## [1.3.9] - 2026-02-23

### v1.3.9 — Staff Access Fixes

#### Bug Fixes
- **`/grant`, `/revoke`, `/preauth` crash on use** — `tools.execute()` called
  `json.dumps(input_data)` before stripping `_context`, which contains a non-serializable
  `SecureSession`. All three slash commands crashed immediately on invocation.
  Fixed: `core/tools.py` strips `_context` before sizing; `channels/telegram.py` passes
  context via the `context=` kwarg instead of embedding it in the input dict.
- `channels/telegram.py` `_is_access_allowed()`: Staff granted via `grant <id> staff`
  were rejected at the access gate before the preauth check in `security.py` could fire.
  Fixed: gate now reads `preauth.json` so granted users can actually reach the bot.
- `channels/telegram.py` guest `user_id`: Unlinked users were keyed as `guest_<chat_id>`
  in session storage, mismatching the numeric ID used in `preauth.json`. Fixed: uses
  `channel_user.channel_id` (the raw Telegram numeric string) for session keying.
- `channels/auth.py` `/link` response: Message said "Check Your Email" and
  "code sent to email" but the code was returned inline in Telegram. Fixed to
  show the code directly with clear `/confirm` instructions.
- `channels/telegram.py` `/start` footer: Said "Trust builds over time" to all users
  including pre-authorized staff who arrive at TRUSTED immediately. Updated copy.

---
## v1.3.8 — Audit Infrastructure, Uninstall Guide, Proton Mail Integration

Post-audit patch series consolidating all fixes made during the v1.3.0 audit sessions.

#### Bug Fixes
- `channels/telegram.py`: Fixed NameError in `_connect_status()` — `has_gmail_token`,
  `has_calendar_token`, `has_drive_token` were defined in a different method
- `installer/wizard.py`: Fixed Python except-variable deletion closure bug — deferred
  lambdas captured `e` by reference; `e` is deleted at end of except block
- `core/agent.py`: Added missing `Callable` to typing imports
- `core/mcp/bridge.py`: Resolved `MCPConfig` unresolvable forward reference
- `context.py`: Renamed shadowed `compile_streaming` sync variant to `compile_streaming_sync`
- `core/bus.py`: Removed vestigial `last_exception` assignments
- `core/__init__.py`: Removed early partial imports superseded by Phase 2.1 re-exports;
  fixed `ValidationError` being dropped from public API during migration
- `run.sh` / `run_tests.sh`: Raised Python minimum gate from 3.9 to 3.10

#### Test Suite
- Fixed 127 test methods silently not running due to 4 duplicate class names
  (`TestToolRouter`, `TestSkillPrerequisites`, `TestEmbeddingsAndRAG`, `TestBackup`)
- Renamed second occurrences to `*Extended` variants so pytest collects all classes

#### Infrastructure Added
- `.github/workflows/test.yml`: CI pipeline running lint, full test suite (Py 3.10–3.12),
  and install smoke test on every push and PR
- `scripts/pre-commit`: Pre-commit hook catching undefined names, duplicate test classes,
  syntax errors, and version drift before commit
- `scripts/install-hooks.sh`: One-command hook installer
- `AUDIT.md`: Living audit document tracking stubs, architectural decisions, open questions

#### Documentation
- `docs/USER_GUIDE.md`: Added comprehensive Uninstall & Clean Slate section with full
  file map, 5 reset levels, and stale state troubleshooting guide
- `scripts/familiar-clean-slate.sh`: Reset script with --keep-config, --credentials,
  --data-only modes
- Version strings corrected in 8 files (ECHIDNA_DEPLOY.md, SECURITY_WHITEPAPER.md,
  docs/README.md, run.sh, CHANGELOG_v2.6.0.md identified as planning doc)
- Security contact placeholder filled in SECURITY_WHITEPAPER.md

---
## [1.3.0] - 2026-02-22

### v1.3.0 — Security Hardening: E2E Mesh Encryption + HIPAA LLM Enforcement

Closes two critical findings (F-01, F-02) from the v1.2.0 HIPAA security review.

---

### F-01 Fix — Signal-Grade E2E Encryption Wired into Mesh Transport

The `MeshTrustManager` (X3DH + Double Ratchet via libsodium) was fully implemented
in `secure_transport.py` but was not connected to the actual WebSocket data path in
`MeshGateway` and `MeshNode`. Messages were transmitted as plaintext JSON.

**Changes in `core/mesh.py`:**

- `MeshGateway.__init__`: instantiates `MeshTrustManager`; graceful fallback if
  `pynacl` not installed.
- `MeshGateway.start()`: logs the gateway's Ed25519 identity fingerprint on startup.
- `MeshGateway._handle_connection()`: performs X3DH handshake after shared-key auth;
  gateway's prekey bundle included in `AUTH_OK` payload for symmetric session setup.
- `MeshGateway._send_encrypted()` (new): all outbound messages encrypted via Double
  Ratchet `encrypt_for_peer()`; falls back to plaintext with warning if no session.
- `MeshGateway._handle_node_messages()`: all inbound bytes decrypted via
  `decrypt_from_peer()` before JSON parse; tampered messages dropped with warning.
- `MeshGateway.broadcast()` and `send_to_node()`: delegate to `_send_encrypted()`.
- `MeshNode.__init__`: instantiates node-scoped `MeshTrustManager`.
- `MeshNode.connect()`: advertises prekey bundle in `HELLO`; completes X3DH as
  initiator on `AUTH_OK` receipt.
- `MeshNode._message_loop()` and `send_message()`: symmetric decrypt/encrypt wrappers.

Every application message now travels over a Double Ratchet session with forward
secrecy and post-compromise recovery. Heartbeat and control frames are also encrypted.

---

### F-02 Fix — HIPAA Mode Enforces Local LLM; Blocks Cloud Routing Without BAA

`ComplianceConfig.HIPAA` had `local_llm_required=False` with a comment
"Cloud OK with BAA", but `ModelRouter.route()` never checked this flag, meaning
PHI could silently route to Anthropic/OpenAI APIs without a Business Associate
Agreement in place.

**Changes in `core/model_router.py`:**

- `ModelRouter.__init__`: accepts optional `compliance_config` parameter.
- `ModelRouter.route()`: if `compliance_config.local_llm_required=True` and
  `FAMILIAR_BAA_CONFIRMED` env var is not set (`1`/`true`/`yes`), adds all
  non-local providers to the exclude set before model selection. Raises
  `RuntimeError` with actionable message if no local models are available.
  Two defensive checks ensure a cloud model can never be returned through the
  fallback path in local-only mode.

**Wire-up:**
```python
from familiar.core.compliance import COMPLIANCE_PROFILES, ComplianceMode
from familiar.core.model_router import ModelRouter

router = ModelRouter(
    compliance_config=COMPLIANCE_PROFILES[ComplianceMode.HIPAA]
)
router.add_model("ollama/llama3", provider="ollama")
# Cloud OK only if: FAMILIAR_BAA_CONFIRMED=true in environment
```

---

## [1.2.0] - 2026-02-22

### v1.2.0 — Production Release Tag

Integration release marking all four roadmap phases complete.
Closes the milestone for Echidna (GJL nonprofit) staff deployment.

---

### Phase 1 — Role-Based Access Control (v1.1.32)

Per-user trust levels and capability gating. Preauth table lets the owner
grant staff roles before a user's first message, preventing STRANGER lockout.
`/grant`, `/revoke`, and `/preauth list` Telegram commands. Capabilities map
skills to roles: staff get email/calendar/drive write, readonly gets read-only,
admin gets session management and audit tools.

---

### Phase 2 — Inline Confirmations (v1.1.33)

Every write action (send_email, create_event, drive_create_doc, reply_to_email)
shows a confirmation preview before executing. Telegram and Discord use native
inline keyboards (✅ Confirm / ❌ Cancel). WhatsApp, Signal, and Teams use
text-based Y/N confirmation with pending state stored per user. Confirmations
expire after 5 minutes. ConfirmationRequired dataclass returned by skills,
intercepted in _execute_tool_secure, sentinel routed to channel layer.

---

### Phase 3 — Google Workspace Onboarding Wizard (v1.1.34)

`python -m familiar --setup-google` replaces 20-step manual OAuth setup.
Eight guided stages: install libraries, locate credentials.json, validate
credentials type (Desktop vs Web app — catches most common mistake), API
enablement guide, consent screen reminder, per-service OAuth flow with
headless/SSH support, write test (create + delete a test doc), final report
listing exactly which Familiar skills unlocked. Partial-restart safe —
already-authorized services skipped on re-run.

---

### Phase 4 — Triage Session Context Memory (v1.1.35)

After `triage_inbox` runs, the numbered email list is stored in
`session_context["triage_emails"]` (50KB cap, 4-hour TTL). The system prompt
injects the list so the LLM can resolve "#3" references without re-searching
the inbox. `read_email` upgraded to accept `index="#3"` and dispatch directly
to `_gmail_read_email_by_id` or `_imap_read_email_by_id`. New `reply_to_email`
tool pre-populates To, Subject, and Gmail thread_id from context. `append_doc`
and `update_doc` fall back to `last_doc_id` when no file specified. Context
write is dual-path: direct session write from the triage skill plus thread-local
fallback captured in `_execute_tool_secure`.

---

### Wiring Fixes (v1.1.36)

Six gaps found and closed after cross-cutting review:

- **Gap 1**: `familiar/onboard/` package shadowed `onboard.py`, breaking
  `from familiar.onboard import run_onboard`. Fixed: `__init__.py` re-exports
  via dynamic legacy module load.
- **Gap 2**: Confirmed re-execution (Phase 2 inline keyboards) bypassed
  `_execute_tool_secure`, so `last_doc_id` was never captured after a confirmed
  drive_create_doc. Fixed: `_capture_context_from_result()` module-level helper
  called from both paths.
- **Gap 3**: `reply_to_email` missing from `SKILL_CAPABILITY_MAP`. Fixed:
  added `"reply_to_email": {Capability.WRITE_EMAIL}`.
- **Gap 4**: WhatsApp, Signal, Teams swallowed the `__PENDING__:` sentinel.
  Fixed: text-based Y/N confirmation with per-user pending state in all three.
- **Gap 5**: `draft_email` description referenced removed `confirm=true` pattern.
  Fixed: updated description.
- **Gap 6**: `scripts/install.sh` systemd ExecStart called `--daemon` (flag
  does not exist). Fixed: removed flag.

---

### Deployment Changes

- `scripts/install-pi.sh --nonprofit` preset now includes `triage` and `gdrive`
  (previously missing, making Phase 4 inaccessible via the Pi install path)
- `docs/ECHIDNA_DEPLOY.md` added: full GJL staff deployment and onboarding guide
- VERSION bumped to 1.2.0 across all files

---

## [1.1.27] - 2026-02-20

### backup.py wired — encrypted incremental backups of all user data

**What was done:**

`backup.py` (797 lines) was complete but entirely dormant. This version wires
`BackupManager` into every agent instance, connects it to the episodic memory
directory, injects it into `tool_context`, registers a `backup_freshness`
health dependency, and adds a scheduler task for automatic daily backups when
configured. Four convenience delegates expose the full backup lifecycle on the
agent itself.

---

**Why this was the priority:**

Echidna holds the most sensitive and irreplaceable data in the nonprofit stack:
per-donor conversation history in `~/.familiar/episodic/*.json(.enc)`, user
accounts in `users.db`, grant notes in conversation history, and audit logs.
None of it was backed up. A single corrupted JSON file meant permanent loss.
This closes that gap.

---

**Wiring points:**

`agent.backup` — `BackupManager` instance, always present (never None).
Created by `_init_backup()` which maps `config.backup` (new `BackupSettings`
dataclass) through to `BackupConfig`, handling str→Path conversion and empty
string sanitization. `episodic_dir` is threaded through so donor conversation
files are captured in every backup.

`agent.create_backup(backup_type, description)` → `BackupManifest`
Captures config, users.db, history.json, analytics.db, audit.db, skills/,
and episodic/*.json(.enc). Returns a `BackupManifest` with id, checksum,
file list, timing, and status.

`agent.restore_backup(backup_id, target_dir, dry_run)` → dict
Extracts archive to target, decrypts if needed. `dry_run=True` counts files
without writing. Returns files_restored count and errors list.

`agent.list_backups()` → `List[BackupManifest]`, newest first.

`agent.verify_backup(backup_id)` → dict
Checks archive exists and manifest is readable. Updates manifest status to
VERIFIED on success. Returns valid, backup_id, created_at, size, files,
encrypted.

`agent.cleanup_old_backups()` → int
Prunes archives older than `config.backup.retention_days` (default 30).
Returns number deleted.

`_schedule_backup()` — called from `__init__` when both `backup.enabled`
and `scheduler` are active. Registers a `TaskFrequency.DAILY` task at 03:00
that runs `create_backup(FULL)` then `cleanup_old_backups()`. No-op when
`scheduler=None` (the common default).

`tool_context["backup"]` — injected alongside health, metrics, retriever,
bus, memory, session. Skills trigger on-demand backups before destructive
operations:
```python
tool_context["backup"].create_backup(BackupType.FULL)
```

Health dependency `backup_freshness` — registered in `_init_health_checker()`:
- No backups yet → healthy=True (informational — fresh install)
- Latest backup age ≤ 2× retention_days → healthy=True
- Latest backup age > 2× retention_days → healthy=False (stale)
Reports `backup_count`, `latest_id`, `age_days`, `stale_threshold_days`
in the dependency details dict.

---

**BackupSettings added to Config:**

New `BackupSettings` dataclass in `config.py` with `backup:` YAML section:
```yaml
backup:
  enabled: true
  schedule: daily           # hourly | daily | weekly
  retention_days: 30
  encrypt: true
  encryption_key: "<fernet-key>"
  path: /var/backups/familiar
  include_history: true
  include_audit: true
```
Defaults: `enabled=False` (opt-in), no encryption, `~/.familiar/backups`.
`load_config()` parses `backup:` section and maps fields via setattr loop,
matching all other config sections.

---

**Encryption:**

When `encrypt=True` and `cryptography` package is installed:
- Archive is Fernet-encrypted (AES-128-CBC + HMAC-SHA256) in-place after
  `.tar.gz` creation
- Manifest records `encrypted=True`
- Restore detects encrypted flag and decrypts before extracting
- Key generation: `Fernet.generate_key().decode()` — store in secrets manager

Without `cryptography` installed, encryption is silently skipped with a
warning log. Archives are still compressed and checksummed.

---

**Timestamp collision fix:**

IDs use `%Y%m%d_%H%M%S` (second precision). Two backups within the same
second overwrite — expected behavior; one test was updated to sleep 1.1s
between creates to produce distinct IDs.

---

**51 tests added:**

Core wiring: agent.backup is BackupManager, in tool_context, config has
correct defaults.

create_backup: returns BackupManifest, status=COMPLETED, has id, file_count≥0,
total_size≥0, checksum is 64-char SHA-256 hex, archive .tar.gz written, manifest
JSON written and readable, FULL/DATA_ONLY/CONFIG_ONLY types all complete,
duration_seconds recorded, compressed=True.

list_backups: empty before any backup, 1 after, 2 after two (with sleep),
sorted newest first, returns BackupManifest objects.

verify_backup: valid=True for good backup, has required fields, returns
valid=False for unknown id, updates manifest status to VERIFIED.

cleanup_old_backups: returns int, fresh backup not deleted, stale backup
(created_at backdated via manifest edit) deleted.

restore_backup: dry_run=True counts files, unknown id raises ValueError,
restore to target_dir returns files_restored.

BackupManager standalone: list/create/get/delete, delete non-existent returns
False, cleanup no-old, manifest to_dict shape, from_dict roundtrip.

Encrypted backup (skips if cryptography not installed): encrypted=True flag,
verify passes, restore dry_run passes.

Health checks: backup_freshness dep registered, no-backups=healthy-True,
after-backup=healthy-True.

Episodic pass-through: `_episodic_dir` matches, episodic files appear in
backup manifest when directory has JSON files.

Scheduler: `_schedule_backup` calls `add_task` with correct name, no-op when
scheduler=None.

BackupConfig from agent config: `retention_days` and `include_history` flow
through from Config.backup to BackupManager.config.

**Total: 563 passed, 2 skipped** (was 512/1 after v1.1.26)

## [1.1.27] - 2026-02-20

### backup.py wired — encrypted incremental backup of donor records and conversation history

**What was done:**

`backup.py` (751 lines) was complete but unreachable. This version wires
`BackupManager` into agent, routes the agent's episodic memory directory
through it, registers a `backup_freshness` health dependency, and exposes
`create_backup()` / `restore_backup()` / `list_backups()` as first-class
agent methods. `tool_context["backup"]` makes on-demand backups available
to skills. Two bugs fixed: `datetime.utcnow()` → timezone-safe equivalent,
`_audit_backup` swallows all exceptions (not just `ImportError`), `tarfile.extractall`
uses `filter="data"` for Python 3.14 compatibility.

---

**Wiring points:**

`agent.backup` — `BackupManager` initialized from `config.backup` (if present)
with safe defaults. Always present — calling `create_backup()` always works even
if backup is not configured; it just defaults to `~/.familiar/backups`.

`_init_backup()` — reads `config.backup` dict into `BackupConfig`, passes
`agent._episodic_dir` through to `manager._episodic_dir` so per-user recalled
conversation history is included in every backup.

**Init order fixed:** `self.backup` now initializes *before* `self.health` because
`_init_health_checker()` references `self.backup` in the freshness closure.

`tool_context["backup"]` — injected alongside health, metrics, retriever, bus.
Skills trigger on-demand backups:
```python
tool_context["backup"].create_backup(BackupType.DATA_ONLY)
```

`health "backup_freshness"` dependency:
- No backups: `healthy=True` (informational — fresh install)
- Recent backup: `healthy=True`, message includes ID and age
- Stale (> 2× retention_days old): `healthy=False`

```python
agent.get_health_status()["dependencies"]
# → {"name": "backup_freshness", "healthy": True,
#    "message": "Latest backup: 20260220_221825 (0.0 days ago, status=completed)"}
```

---

**What BackupManager backs up:**

FULL backup (default):
- `config.yaml` — configuration
- `data/users.db` — user database
- `data/history.json` — global conversation log
- `data/analytics.db` — analytics
- `data/audit.db` — audit log
- `skills/` — all skill data (recursive)
- `episodic/*.json` — per-user semantic memory (NEW)
- `episodic/*.json.enc` — encrypted episodic files (NEW)

DATA_ONLY backup:
- Same as FULL except config.yaml and skills/

CONFIG_ONLY backup:
- config.yaml only

**`_backup_episodic`** (new method added to backup.py):
Walks `episodic_dir`, copies any `*.json` or `*.json.enc` file to
`episodic/` inside the staging dir, records checksum and path in manifest.
Silently skips if directory doesn't exist (e.g. memory disabled).

---

**Security:**

Optional Fernet encryption (`cryptography` package):
```python
from cryptography.fernet import Fernet
config.backup.encrypt = True
config.backup.encryption_key = Fernet.generate_key().decode()
```
Archive encrypted in-place before writing to backup path.
Decryption happens automatically in `restore()` when key is present.

SHA-256 checksums stored per-file in `manifest.file_checksums`.
Archive checksum stored in `manifest.checksum`.

Retention: `cleanup_old_backups()` deletes manifests + archives older
than `retention_days` (default 30).

---

**3 bugs fixed in backup.py:**

1. `datetime.utcnow()` → `datetime.now(_UTC).replace(tzinfo=None)` (Python 3.12 deprecation)
2. `_audit_backup` `except ImportError` → `except Exception` (audit.py also uses utcnow;
   audit is best-effort and must never break a backup operation)
3. `tarfile.extractall(path)` → `tarfile.extractall(path, filter="data")` (Python 3.14 prep)

---

**48 tests added:**

Core wiring: agent.backup is BackupManager, in tool_context, episodic_dir threaded.

create_backup: returns manifest, status=completed, has ID, file_count >= 2,
total_size > 0, checksum set (64-char SHA-256), file_checksums dict, archive exists,
manifest file exists, DATA_ONLY type, duration recorded, episodic files included,
history included.

list_backups: empty initially, one after create, sorted newest-first, multiple.

get_backup / verify: returns manifest by ID, missing returns None,
verify_backup valid, verify_backup missing returns invalid dict.

delete / cleanup: delete removes archive + manifest, delete nonexistent returns False,
cleanup_old_backups with retention=0 deletes, cleanup_keeps_recent_backups.

restore: dry_run counts files without writing, actual restore writes files,
missing backup raises ValueError.

encryption: encrypted=True when Fernet key configured, roundtrip encrypt+decrypt.

BackupManifest: to_dict shape, from_dict roundtrip.

Health integration: backup_freshness in health, healthy on fresh install,
healthy after backup (ID in message), stale when age > 2×retention.

agent convenience wrappers: create_backup, list_backups, restore_backup dry_run,
restore_backup missing raises.

BackupConfig: defaults, from_dict.

_backup_episodic standalone: copies json and enc files, empty dir = no files,
nonexistent dir = silent skip.

**Total: 560 passed, 1 skipped** (was 512/1 after v1.1.26)

## [1.1.27] - 2026-02-20

### backup.py wired — encrypted incremental backup for donor records and conversation history

**What was done:**

`backup.py` (751 lines) was complete but had no wiring — no entry point, no
data paths configured, nothing calling it. This version wires it at the agent
boundary, threads the episodic memory directory through, adds a backup freshness
health dependency, and fixes two bugs found during testing.

---

**Wiring points:**

`agent.backup` — `BackupManager` initialized in `__init__` via `_init_backup()`.
Always present. Never None. Reads config from `config.backup` if present,
falls back to safe defaults (backup to `~/.familiar/backups/`, unencrypted,
30-day retention).

`_init_backup()` — creates `BackupConfig` from `config.backup` dict, creates
`BackupManager`, then threads `self._episodic_dir` through as `manager._episodic_dir`
so `_backup_episodic()` knows where per-user recalled conversation files live.

`_backup_episodic()` — new method added to `BackupManager`. Copies all
`*.json` and `*.json.enc` files from the episodic directory into `episodic/`
inside the archive. Called from both `FULL` and `DATA_ONLY` backup types.
These are the highest-priority files for Echidna: each file is the complete
recalled conversation history for one donor.

`agent.create_backup(backup_type, description)` — convenience wrapper:
calls `self.backup.create_backup()`, busts health cache, logs result.

`agent.restore_backup(backup_id, target_dir, dry_run)` — convenience wrapper
with a `WARNING`-level log on actual restore (writes to disk).

`agent.list_backups()` — returns manifest list newest-first.

`tool_context["backup"]` — injected into every tool handler alongside
health, metrics, retriever, bus. Skills trigger on-demand backups:
```python
tool_context["backup"].create_backup()
```

Health dependency `"backup_freshness"` registered in `_init_health_checker()`:
- No backups: `healthy=True`, message "No backups yet" (fresh install, informational)
- Recent backup: `healthy=True`, message includes backup ID and age in days
- Stale backup (age > 2× retention_days): `healthy=False`
- List failure: `healthy=False`, message includes exception

**Init order fixed:** `self.backup` must exist before `_init_health_checker()`
runs because the freshness closure captures `self.backup`. Init order is now:
`metrics → backup → health checker`.

---

**What BackupManager covers:**

BackupType.FULL:
- config.yaml
- users.db (user database)
- history.json (global conversation log)
- analytics.db
- audit.db
- skills/ (entire directory tree)
- episodic/*.json, episodic/*.json.enc (per-user recalled conversation history)

BackupType.DATA_ONLY: users.db, history.json, analytics.db, episodic/

BackupType.CONFIG_ONLY: config.yaml only

Each backup produces:
- `familiar_backup_{id}.tar.gz` — compressed archive
- `manifest_{id}.json` — file list, per-file SHA-256 checksums, timing, status

Optional encryption: Fernet symmetric encryption via `cryptography` package.
`encrypt=True` + `encryption_key=<Fernet key>` in config. `_encrypt_file()` /
`_decrypt_file()` operate in-place on the archive after creation / before
extraction.

`verify_backup(id)` — checks archive exists, recomputes checksum.
`cleanup_old_backups()` — deletes manifests + archives older than `retention_days`.

---

**Bugs fixed:**

1. **`datetime.utcnow()` deprecation in backup.py** — replaced 3 calls with
   `datetime.now(_UTC).replace(tzinfo=None)` using `_UTC = timezone.utc`.

2. **`datetime.utcnow()` in agent.py freshness check** — `from datetime import
   datetime, timezone as _dt_timezone` and replaced the one usage.

3. **`_audit_backup()` swallows all exceptions** — was `except ImportError: pass`,
   changed to `except Exception: pass` so a `DeprecationWarning`-as-error from
   `audit.py` doesn't propagate and fail the backup operation.

---

**Enabling backup for Echidna:**

```python
# In config.yaml or programmatically:
from cryptography.fernet import Fernet
key = Fernet.generate_key().decode()   # store this securely!

config.backup.enabled = True
config.backup.encrypt = True
config.backup.encryption_key = key
config.backup.path = "/var/backups/echidna"
config.backup.retention_days = 90
config.backup.schedule = "daily"

# Manual on-demand backup:
manifest = agent.create_backup()

# Restore (dry run first):
result = agent.restore_backup(manifest.id, dry_run=True)
result = agent.restore_backup(manifest.id)
```

---

**48 tests added:**

Core wiring: agent.backup is BackupManager, in tool_context, episodic_dir threaded.

create_backup: returns manifest, status=completed, has id, file_count ≥ 2,
total_size > 0, checksum is 64-char hex, per-file checksums, archive exists,
manifest file exists, DATA_ONLY type, duration recorded, episodic files included,
history.json included.

list_backups: empty initially, one after create, sorted newest-first, multiple.

get_backup / verify: returns manifest by id, None for missing, valid=True after
create, invalid for missing.

delete / cleanup: delete removes archive+manifest, missing returns False,
cleanup_old_backups with retention_days=0 deletes, keeps recent backups.

restore: dry-run counts files, actual restore writes files, missing raises ValueError.

Encryption: backup encrypted when Fernet key provided, full roundtrip
encrypt→decrypt→restore recovers files (skipped when cryptography not installed).

BackupManifest: to_dict shape, roundtrip via from_dict.

Health integration: backup_freshness in dependency list, healthy=True on fresh
install, healthy=True after recent backup, healthy=False for stale backup
(age > 2×retention_days).

agent convenience wrappers: create_backup, list_backups, restore dry_run,
restore missing raises.

BackupConfig: defaults, from_dict with path conversion.

_backup_episodic standalone: copies .json and .json.enc, empty dir → 0 files,
nonexistent dir → silently skips.

**Total: 560 passed, 1 skipped** (was 512/1 after v1.1.26)

## [1.1.26] - 2026-02-20

### metrics.py wired — per-call tool latency, LLM cost tracking, custom counters

**What was done:**

`metrics.py` (915 lines) was complete but unreachable. This version instruments
`_execute_tool_secure` and `_run_agent_loop` so every tool call and every
non-streaming LLM call is automatically recorded. `agent.get_metrics_summary()`
exposes the full report. `tool_context["metrics"]` lets skills record domain
counters, gauges, and histograms.

---

**Wiring points:**

`agent.metrics` — `MetricsCollector` singleton (via `get_metrics_collector()`),
always present. Shared across all agent instances within a process.

`_execute_tool_secure` — wraps `self.tools.execute(...)` with wall-clock timing:
```python
_tool_start = time.time()
result = self.tools.execute(tool_name, tool_input, context=context)
_tool_duration_ms = (time.time() - _tool_start) * 1000
self.metrics.record_tool_execution(
    tool_name=tool_name,
    duration_ms=_tool_duration_ms,
    success=not _is_error,
    error_message=_tool_error,
    input_size=len(str(tool_input)),
    output_size=len(result) if result else 0,
    user_id=str(context.get("user_id", "")),
)
```
Safe — wrapped in `try/except` so a metrics bug never breaks the tool call.

`_run_agent_loop` — wraps the non-streaming `active_provider.chat(...)` call
with `try/finally` so tokens and latency are recorded on both success and failure:
```python
_llm_start = time.time()
_llm_exc = None
try:
    response = active_provider.chat(...)
except Exception as e:
    _llm_exc = str(e)
    raise
finally:
    self.metrics.record_llm_call(
        provider=active_provider.name,
        model=active_provider.model_name,
        duration_ms=(time.time() - _llm_start) * 1000,
        success=_llm_exc is None,
        prompt_tokens=response.usage.get("input_tokens", 0),
        completion_tokens=response.usage.get("output_tokens", 0),
        error_message=_llm_exc,
    )
```

`tool_context["metrics"]` — injected into every tool handler alongside
health, retriever, bus, memory, session. Skills record custom metrics:
```python
tool_context["metrics"].increment_counter("donor_created")
tool_context["metrics"].record_histogram("email_size_bytes", len(body))
tool_context["metrics"].set_gauge("mailbox_queue_depth", q.size())
```

`agent.get_metrics_summary(tool_name=None, include_llm, include_tools, include_alerts)`
— full report as plain dict. With `tool_name` returns a per-tool drill-down.

---

**What MetricsCollector tracks:**

ToolMetrics per tool name:
- total_calls, successful_calls, failed_calls
- success_rate, failure_rate
- avg / min / max / p50 / p95 / p99 duration_ms (last 1000 calls)
- timeout_count (inferred from error message)
- last_execution, last_error

LLMMetrics per "provider:model" key:
- total_calls, successful_calls, failed_calls, success_rate
- avg / p95 duration_ms (last 1000 calls)
- total_prompt_tokens, total_completion_tokens
- total_cost_usd, avg_cost_per_call
- rate_limit_errors, auth_errors (classified from error message)
- last_call, last_error

Custom metrics:
- Counters  — `increment_counter(name, value=1)` / `get_counter(name)`
- Gauges    — `set_gauge(name, value)` / `get_gauge(name)`
- Histograms — `record_histogram(name, value)` / `get_histogram_stats(name)`
  Returns {count, min, max, avg, p50, p95, p99}

Alert thresholds (fire callbacks + log):
- tool_failure_rate > 10% (WARNING) after ≥10 calls
- tool_timeout_rate > 5% (WARNING) after ≥10 calls
- llm_error_rate > 10% (WARNING) after ≥10 calls
- llm_latency_p95_ms > 30s (WARNING)
- Deduplication: same alert fires at most once per 5 minutes
- Callbacks: `add_alert_callback(fn)` — e.g. push to Slack

---

**42 tests added:**

Core wiring: agent.metrics is MetricsCollector, in tool_context.

LLM recording: call recorded after chat, tokens accumulated, provider name keyed
correctly, multiple calls accumulate, latency >= 0.

Tool recording: call recorded, tool name tracked, success recorded, latency positive,
multiple calls accumulate.

get_metrics_summary: correct keys, tools/llm shape, per-tool drill-down, unknown
tool returns zero, selective include_llm/include_tools flags.

MetricsCollector standalone: record_tool_execution success/failure,
percentile correctness, success_rate calculation, track_tool_execution context
manager success and exception capture, record_llm_call with tokens and cost,
rate_limit classification, track_llm_call context manager.

Custom metrics: counter increment, increment by value, missing=0, gauge set/overwrite,
missing=None, histogram stats, empty histogram.

Alerts: fires on high failure rate, callback invoked, deduplication (fires once not
twice), to_dict shape, clear_alerts.

Full summary shape, reset clears everything.

**Total: 512 passed, 1 skipped** (was 470/1 after v1.1.25)

## [1.1.25] - 2026-02-20

### health.py wired — liveness/readiness/startup probes, agent-aware dependency checks

**What was done:**

`health.py` (921 lines) was complete but unreachable. This version wires
`HealthChecker` into the agent at `__init__` with five registered dependency
checks, exposes probe shortcuts on the agent, injects into `tool_context`, and
fixes the `ThreadPoolExecutor` shutdown bug discovered during test writing.

---

**Wiring points:**

`agent.health` — `HealthChecker` instance, always present. Created in `__init__`
via `_init_health_checker()` before mesh gateway; `mark_startup_complete()` called
automatically at the end of `__init__` so readiness is True from first request.

`agent.liveness()` — delegates to `health.liveness_check()`. Minimal: allocates
a list, returns `{"status": "ok"}`. Maps to `GET /healthz`.

`agent.readiness()` — delegates to `health.readiness_check()`. Runs all registered
dependency checks and returns `{"ready": bool, "unhealthy_dependencies": [...]}`.
Maps to `GET /readyz`.

`agent.startup_probe()` — delegates to `health.startup_check()`. Returns
`{"started": True}` once `__init__` completes. Maps to `GET /startupz`.

`agent.get_health_status(include_resources)` — full report as plain dict with
status, healthy, message, version, uptime_seconds, dependencies list, resources
(CPU/memory/disk/process when psutil available). Cache TTL 10 s.

`agent.mark_ready()` — explicit re-mark (for entry points that load a knowledge
base into retriever post-init before accepting traffic).

`tool_context["health"]` — injected into every tool handler. Skills register
domain-specific checks (CRM reachable, email server alive, database ping):
```python
tool_context["health"].register_dependency_check(
    "crm",
    lambda: DependencyHealth(name="crm", healthy=ping_crm())
)
```

---

**Dependency checks registered at init:**

`filesystem` — built-in, always registered by `HealthChecker` itself. Writes
and reads `/tmp/.familiar_health_check`, verifies data integrity.

`llm_provider` — local providers (`ollama`, `llamacpp`) always healthy. Cloud
providers (`anthropic`, `openai`, `gemini`) healthy when their API key env var
is set. Reports `{"provider": "...", "key_set": bool}`.

`bus` — probes `self.bus._running` or `_worker.is_alive()`. SkillBus worker
starts in `__init__` before health checker, so this is healthy from boot.

`memory_agent` — registered only when `self.memory` is not None and the
memory object has a `_memory_agent` background thread. Checks thread liveness
and `_last_error`. Uses `create_memory_agent_health_check()` from health.py.

`planner` — registered only when `self.planner` is not None. Checks for plans
stuck longer than 30 minutes. Uses `create_planner_health_check()`.

---

**Bug fixed in health.py — ThreadPoolExecutor shutdown:**

`check_health()` used `with ThreadPoolExecutor(...) as executor:` which calls
`shutdown(wait=True)` on context exit. This blocks until *all* futures complete
even if `future.result(timeout=N)` already raised `FuturesTimeoutError`. A 5-second
sleep in any check would freeze the entire health report for 5 seconds regardless
of `check_timeout_seconds`.

Fixed by replacing the context manager with explicit `try/finally` and
`executor.shutdown(wait=False)` — timed-out threads continue in the background
but do not block the probe response:

```python
executor = ThreadPoolExecutor(max_workers=10)
try:
    ...
    for name, future in futures.items():
        try:
            result = future.result(timeout=self.check_timeout_seconds)
        except FuturesTimeoutError:
            dependency_results.append(DependencyHealth(name=name, healthy=False,
                                                        message="Health check timed out"))
finally:
    executor.shutdown(wait=False)  # don't block on stragglers
```

Test `test_standalone_checker_timeout_protection` confirmed: a 5-second check
with `check_timeout_seconds=0.1` now completes in < 2 s.

---

**45 tests added:**

Core wiring: agent.health is HealthChecker, in tool_context, startup complete after
init, uptime positive.

Liveness: returns ok, is dict, timestamp is ISO-8601.

Readiness: has ready key (bool), status string, unhealthy_dependencies list, True after
init, flips to False when a failing dep is injected.

Startup probe: returns dict, started=True, uptime_seconds, timestamp.

mark_ready: idempotent double-call stays True.

get_health_status: dict, all required keys, valid status, healthy bool,
dependencies list, dependency shape, no resources when include_resources=False,
uptime >= 0, version string.

Built-in checks: filesystem registered and healthy, llm_provider registered,
bus registered and healthy when worker running.

HealthChecker internals: register/unregister custom dep, cache returns same
result, cache bust reruns checks.

Data classes: DependencyHealth.to_dict(), unhealthy status, HealthCheckResult.to_dict().

Resource health: no crash without psutil, correct keys with psutil.

Standalone (no agent): liveness, readiness, startup before mark.

Timeout protection: slow check (5 s sleep) with 0.1 s timeout completes < 2 s,
appears as unhealthy in result.

**Total: 470 passed, 1 skipped** (was 425/1 after v1.1.24)

## [1.1.24] - 2026-02-20

### Embeddings + Context (RAG pipeline) wired — semantic memory search, chunked document retrieval

**What was done:**

`embeddings.py` (916 lines) and `context.py` (2,245 lines) were both complete but
had no entry point from `agent.py`. Memory already imported from `embeddings.py` for
its own key-value semantic search. The gap was that `context.py`'s `SemanticRetriever`
— chunked document RAG for arbitrary text — was never instantiated anywhere. This
version wires it at the agent boundary with a provider bridge, injects it into every
tool handler, and pipes retrieved chunks into the LLM system prompt automatically.

---

**Architecture — two separate embedding surfaces, now unified:**

`memory.py` handles key-value facts: `name → George`, `dietary_restriction → gluten-free`.
It has its own embedding loop (`_init_embeddings`, `_vectors` dict) using `embeddings.py`.

`context.py`'s `SemanticRetriever` handles chunked documents: org handbooks, grant
guidelines, donor intake forms, policy docs — any text added via `add_text()`. It chunks
long documents, embeds each chunk, and retrieves by cosine similarity at query time.

Both now use the same embedding provider instance so Ollama is only probed once.

---

**Embedding cascade (auto-detected, identical to memory's cascade):**

1. Ollama + `nomic-embed-text` (local, zero external deps, 137M params)
2. ONNX `all-MiniLM-L6-v2` (local neural, Pi-friendly, ~80MB, no server)
3. HTTP endpoint (`FAMILIAR_EMBEDDING_URL` — any self-hosted model)
4. Voyage AI (`VOYAGE_API_KEY` — Anthropic's embedding partner)
5. OpenAI (`OPENAI_API_KEY`)
6. TF-IDF bag-of-words (pure Python, zero deps, always available)

In this environment: TF-IDF. In production with Ollama: `nomic-embed-text`.

---

**EmbeddingBridge — the key technical piece:**

`embeddings.py` and `context.py` each define their own `EmbeddingProvider` ABC.
They share the same interface surface (`embed`, `embed_batch`) but have different
inheritance chains and one uses `.dimensions` vs `.dimension`. The bridge resolves
both differences and reuses `memory._embedder` when available so no double-probe:

```python
class _EmbeddingBridge(ContextEmbeddingProvider):
    def __init__(self, provider: RawEmbeddingProvider): self._p = provider
    def embed(self, text): return self._p.embed(text)
    def embed_batch(self, texts): return self._p.embed_batch(texts)
    @property
    def dimension(self) -> int:
        return self._p.dimensions or self._p._dimensions or 512
```

---

**Wiring points:**

`agent.retriever` — `SemanticRetriever` instance with `InMemoryVectorStore`,
created in `__init__` via `_init_retriever()`. Always present. Starts empty.

`agent._retrieve_rag_context(query, max_tokens)` — wraps `retriever.get_context()`
with a "## Retrieved Knowledge" header. Returns `""` when no docs are indexed,
query is too short, or all results fall below `min_score`. Safe to call always.

RAG injection in `_run_agent_loop` — before mesh context, after memory:
```python
if self.retriever.count > 0:
    _rag_ctx = self._retrieve_rag_context(message, max_tokens=1500)
    if _rag_ctx:
        system = f"{system}

{_rag_ctx}"
```
Zero overhead when retriever is empty (no embedding call made).

`tool_context["retriever"]` — every tool handler gets the retriever. Skills add
org-specific documents at load time or on demand:
```python
# At skill load time:
agent.retriever.add_text(org_handbook_text, metadata={"source": "handbook_2026"})

# Inside a tool handler at runtime:
tool_context["retriever"].add_text(fetched_web_content, metadata={"url": url})
results = tool_context["retriever"].retrieve("eligibility criteria", top_k=5)
ctx = tool_context["retriever"].get_context("grant requirements", max_tokens=800)
```

`agent.get_retriever_status()` — structured dict: `provider`, `doc_count`,
`vector_store`, `config` (top_k, min_score, chunk_size, chunk_overlap, max_tokens).

---

**RetrievalConfig (defaults tuned for nonprofit use):**

- `top_k=5` — return top 5 chunks per query
- `min_score=0.05` — low threshold (TF-IDF scores are naturally lower than neural)
- `chunk_size=400` tokens — reasonable for policy paragraphs
- `chunk_overlap=40` tokens — context overlap across adjacent chunks
- `max_tokens=1500` — RAG context budget in system prompt

---

**Representative nonprofit scenario tested:**

Five handbook sections indexed (donor recognition, grant management, volunteer
policy, board governance, financial controls). Queries in different phrasing than
source text. All retrieval operations verified: `add_text`, `retrieve`, `get_context`,
`_retrieve_rag_context`, `get_retriever_status`.

---

**52 tests added** across:
- Core wiring (agent.retriever exists, starts empty, has provider, in tool_context)
- get_retriever_status (shape, doc count updates, vector store name, provider name)
- EmbeddingBridge (embed, embed_batch, dimension, consistent vector length)
- add_text/count (increment, multiple, metadata, add_document directly)
- retrieve (returns Documents, has scores, metadata filter, empty store returns [])
- get_context (string return, empty when no docs, respects max_tokens, numbered format)
- Chunking (long text splits, short text stays single)
- delete/clear
- _retrieve_rag_context (empty when no docs, safe with empty query, header format)
- RAG injection (fires when docs present, absent when retriever empty)
- InMemoryVectorStore (add/search cosine, metadata filter, delete, clear)
- TFIDFEmbeddings (embed, batch, similar texts score higher, state serialize/restore)
- cosine_similarity (identical=1.0, orthogonal=0.0)
- top_k_similar (correct ranking)
- TokenCounter (counts text, empty string, longer=more, truncate_to_limit)
- Nonprofit org handbook scenario (end-to-end)

**Total tests: 425 passed, 1 skipped** (was 373 after v1.1.23)
The skip is intentional: `test_retriever_reuses_memory_embedder_when_available`
correctly skips when the agent fixture has no memory configured.

## [1.1.24] - 2026-02-20

### embeddings.py + context.py wired — semantic RAG pipeline

**What was done:**

`embeddings.py` (916 lines) and `context.py` (2,245 lines) were fully implemented
but had no entry point in `agent.py`. Memory already imported from `embeddings.py`
internally for its own semantic search (wired in a prior version), but the broader
RAG pipeline — chunked document retrieval via `SemanticRetriever` — was completely
unused. This version wires the full pipeline.

---

**What memory already had (unchanged):**

`Memory.search()` uses `embeddings.get_embedding_provider()` → TF-IDF → semantic
cosine similarity with substring fallback. This was already wired before this sprint.

---

**What this sprint adds:**

`agent.retriever` — a `SemanticRetriever` instance backed by `InMemoryVectorStore`.
Created in `__init__` via `_init_retriever()`, always present.

The retriever reuses memory's already-initialized embedding provider (no double probe
of Ollama) via a bridge adapter that satisfies `context.EmbeddingProvider`'s interface:

```python
class _EmbeddingBridge(ContextEmbeddingProvider):
    # Adapts embeddings.EmbeddingProvider (.dimensions) to
    # context.EmbeddingProvider (.dimension)
    def embed(self, text): return self._p.embed(text)
    def embed_batch(self, texts): return self._p.embed_batch(texts)
    @property
    def dimension(self): return self._p.dimensions
```

`tool_context["retriever"]` — injected into every tool handler alongside `bus`,
`memory`, `sagas`, `mesh`, etc. Skills and tool handlers index documents at load
time or on demand, then query them semantically without the LLM needing to
bridge the vocabulary gap.

`_retrieve_rag_context(message, max_tokens=1500)` — called every LLM turn when
`retriever.count > 0`. Embeds the current user message, retrieves top-k matching
chunks, and injects them into the system prompt under `## Retrieved Knowledge`.
No-ops (returns `""`) when store is empty or query is too short — zero overhead
when RAG isn't populated.

Injection point is in `_run_agent_loop`, immediately before mesh context, after
memory context:

```
system = get_system_prompt(...)      # identity + memory + skills
system += retrieved_knowledge        # RAG chunks (this sprint)
system += mesh_context               # cross-device peers (previous sprint)
```

`agent.get_retriever_status()` — structured dict: `provider`, `doc_count`,
`vector_store`, `config` (top_k, min_score, chunk_size, chunk_overlap, max_tokens).

---

**Embedding provider cascade** (auto-selected, no config required):

1. Ollama `nomic-embed-text` (local, 137M params, CPU-only)
2. ONNX `all-MiniLM-L6-v2` (Pi-friendly, ~80MB, no server)
3. HTTP endpoint (`FAMILIAR_EMBEDDING_URL`)
4. Voyage AI (`VOYAGE_API_KEY`, Anthropic's embedding partner)
5. OpenAI `text-embedding-3-small` (`OPENAI_API_KEY`)
6. TF-IDF (zero dependencies, always available — used in CI/tests)

---

**How skills use the retriever:**

Index org handbook at skill load time:
```python
def setup(agent):
    agent.retriever.add_texts(handbook_sections, chunk=True)
    # Chunking splits long documents at 400-token boundaries
    # with 40-token overlap — no content lost at chunk edges
```

Index fetched content at call time inside a tool handler:
```python
def fetch_grant_guidelines(payload, context=None):
    doc = requests.get(payload["url"]).text
    context["retriever"].add_text(doc,
        metadata={"source": payload["url"]},
        chunk=True)
    return "Indexed."
```

Query directly (bypassing the per-message auto-injection):
```python
results = context["retriever"].retrieve("eligibility criteria", top_k=3)
ctx_str = context["retriever"].get_context("who qualifies", max_tokens=500)
```

Filter by metadata:
```python
results = context["retriever"].retrieve(
    "grant deadline",
    filter={"source": "smith_foundation"})
```

---

**Bug found during wiring:**

`_init_retriever()` debug log assumed `self.memory` was always set, but the agent
can run without memory (memory-less configs). Fixed with a None guard:

```python
_emb_name = type(self.memory._embedder).__name__     if (self.memory and self.memory._embedder) else "TFIDFEmbeddings"
```

---

**45 tests added** across:
- Core wiring (agent.retriever, tool_context injection, empty start)
- get_retriever_status (shape, provider name, vector store type, config keys, doc count)
- Embedding provider (cascade returns provider, TF-IDF embed/embed_batch/dimensions)
- Vector math (cosine_similarity identical/orthogonal, top_k_similar)
- InMemoryVectorStore (add+search, metadata filter, delete, clear)
- SemanticRetriever (add_text, add_texts batch, add_document, chunking, retrieve, get_context,
  numbered format, clear, metadata filter)
- _retrieve_rag_context (empty store, returns string, header, empty/short query guard)
- RAG injection into system prompt (injected when docs present, absent when store empty)
- TokenCounter (count, empty string, longer=higher, truncate)
- Tool-level RAG (tool handler indexes doc via tool_context["retriever"])
- Embedding bridge (.dimension, .embed, .embed_batch)
- Nonprofit org handbook scenario (5 handbook sections indexed, semantic retrieval, status)

**Total tests: 418** (was 373 after v1.1.23)

## [1.1.23] - 2026-02-20

### SkillBus wired — inter-skill pub/sub, invocation, workflows, shared state, DLQ

**What was done:**

`bus.py` (1,599 lines) was complete but had no entry point from `agent.py`. The
comment in `tool_context` said "archived — depended on missing guidance/ subsystem".
That dependency no longer exists; the module is fully self-contained. This version
wires it at the agent boundary and fixes one bug discovered during testing.

---

**Wiring points:**

`agent.bus` — `SkillBus` instance, created in `__init__`, always present.
Background worker thread starts immediately; async pub/sub is live from agent boot.

`set_skill_bus(agent.bus)` — called at init so module-level `publish()`,
`subscribe()`, `invoke()`, `invoke_no_retry()` all route to the agent's instance.
Skills can import these at module level and they will resolve to the correct bus.

`tool_context["bus"]` — injected into every tool handler alongside `memory`,
`session`, `structured`, `sagas`, `experiments`, `mesh`, and `orchestrator`.
The stale "archived" comment block is replaced with a usage doc comment showing
the four primary patterns: publish, invoke, state, workflow.

`tool.executed` event spine — published after every successful tool call in
`_execute_tool_secure`, regardless of which tool ran. Two topics per call:
- `tool.executed` — catches every tool execution
- `tool.<tool_name>` — targeted (e.g. `tool.create_donor`, `tool.send_email`)

Both carry: `tool`, `skill`, `user_id`, `success`, `result_preview`, `input_preview`.
Skills subscribe to react to other skills' outputs without the LLM needing to chain
calls manually. This is the event spine for workflows like donor intake.

`agent.get_bus_status()` — structured dict: `running`, `registered_skills`,
`subscription_count`, `shared_state_keys`, `dead_letter` (DLQ stats).

---

**What SkillBus gives skills:**

Pub/sub (fire-and-forget, async worker thread):
```python
bus.subscribe("donor.intake.completed", handle_intake)
bus.subscribe("donor.*", handle_any_donor_event)           # wildcard
bus.subscribe("task.created", handler,
    filter_fn=lambda m: m.payload["priority"] == "urgent") # filter
bus.publish("donor.intake.completed", {"donor_id": "123"})
```

Direct invocation (sync, with exponential-backoff retry + DLQ):
```python
result = bus.invoke("crm", "upsert_contact", {"email": "alice@org.org"})
result = bus.invoke("email", "send", payload,
    retry_policy=RetryPolicy(max_attempts=5, base_delay=2.0))
bus.invoke_no_retry("cache", "get", {"key": "x"})
```

Shared state (in-process, thread-safe, cross-skill):
```python
bus.state.set("intake_lock", True)
bus.state.get("intake_lock", default=False)
bus.state.watch("grant_deadline", on_deadline_change)
```

Multi-skill DAG workflows:
```python
wf = bus.create_workflow("donor_intake")
wf.add_step("validate",  "run",  step_id="v")
wf.add_step("crm",       "upsert", step_id="crm",     depends_on=["v"])
wf.add_step("email",     "welcome", step_id="welcome", depends_on=["crm"])
ctx = bus.execute_workflow(wf, initial_context={"donor": {...}})
# ctx["crm"] has crm step result, ctx["welcome"] has email step result
```

Dead-letter queue — failed invocations after all retries:
```python
dlq = bus.get_dead_letters(10, skill_name="crm")
bus.replay_dead_letter(dlq[0].id)
bus.replay_all_dead_letters(skill_name="crm", stop_on_failure=False)
bus.clear_dead_letters(older_than_hours=24)
```

`@skill_action` decorator for registration at module level:
```python
@skill_action("crm")
def upsert_contact(payload, context=None):
    ...
```

---

**Bug fixed — workflow engine:**

`execute_workflow()` incorrectly marked workflows as `"completed"` when a step
failed but had no downstream dependents — because no steps remained in `PENDING`
state, the completion branch fired instead of the failure branch. Fixed by
checking `any(s.status == FAILED ...)` before setting `"completed"`.

Caught by `test_workflow_failed_step_marks_workflow_failed`.

---

**Representative nonprofit workflow test:**

`test_donor_intake_workflow` validates the three-step pipeline:
  1. `donor_validate` — field validation
  2. `donor_crm` — CRM upsert (depends on validate)
  3. `donor_welcome` — welcome email (depends on crm)

Steps share context through `workflow.context`; no LLM turn needed to chain them.
This is the pattern for grant intake, triage assignment, volunteer onboarding.

---

**34 tests added** across:
- Core wiring (agent.bus, get_skill_bus, worker thread, tool_context injection)
- get_bus_status (shape, skill count, DLQ stats)
- Pub/sub (subscribe/publish, wildcard, filter_fn, multiple subscribers, module-level)
- Invoke (registered skill, unknown skill raises, no-retry, module-level, context injection)
- Retry + DLQ (failed invoke → DLQ, replay, DLQ stats)
- Shared state (set/get, default, delete, watcher, cross-skill visibility)
- Workflow engine (linear, initial context, failed step, parallel ready steps)
- tool.executed event spine (published, specific topic, success flag)
- @skill_action decorator
- Donor intake DAG workflow (representative nonprofit use case)

**Total tests: 373** (was 339 after v1.1.22)

## [1.1.20] - 2026-02-20

### Mesh layer wired — all four phases active at the agent boundary

**What was done:**

The mesh subsystem (4 phases, ~3,500 lines across `mesh/`) was complete internally
but had no entry point from `agent.py`. This version wires every phase to the agent
lifecycle and system prompt pipeline.

**Wiring points in Agent:**

`agent.orchestrator` — `Orchestrator` instance always present, regardless of mesh.
Used for multi-agent coordination: HIERARCHICAL, CONSENSUS, DEBATE strategies.
RemoteAgents (mesh peers) auto-register here when they connect. Available in
`tool_context["orchestrator"]` for tools that delegate to remote agents.

`agent.mesh_gateway` — `MeshGateway` instance (or None). Created at `__init__`
when `FAMILIAR_MESH_ENABLED=true` (env var) or `config.agent.mesh_enabled=True`.
NOT started automatically — binds a port and needs an event loop. The gateway
object is pre-wired with the orchestrator and memory bridge so calling
`await agent.start_mesh()` is the only step needed.

`await agent.start_mesh(host, port)` — starts the WebSocket server, mDNS
discovery (if `discovery_enabled`), peer gateway reconnects, and runs the
heartbeat loop. Call from your async entry point alongside other coroutines:

    async def main():
        agent = Agent()
        await asyncio.gather(
            agent.start_mesh(),
            telegram_bot.run(),
        )

`await agent.stop_mesh()` — clean shutdown: closes all peer WebSocket connections,
stops mDNS, closes the server socket.

`agent.get_mesh_context(query, timeout=1.5)` — queries peer memory bridges for
facts relevant to the current user message, returns a formatted string injected
into the LLM system prompt as `## Mesh Context`. Timeout is kept short (1.5s) so
it never blocks the response. Called automatically from `_run_agent_loop` when
the gateway is running. When HIPAA mode is on, returns "" (memory sharing disabled).

`agent.get_mesh_status()` — structured status dict for dashboards and health checks.
Fields: enabled, running, host, port, node_id, node_name, connected_nodes,
peer_gateways, discovery_active, trust (from MeshTrustManager.get_status()),
memory (from MemoryBridge.get_status()), remote_agents.

`tool_context["mesh"]` — the MeshGateway (or None). Tools can call
`tool_context["mesh"].request_peer_tool(peer_id, tool_name, tool_input)` to
delegate work to a specific connected node.

`tool_context["orchestrator"]` — the Orchestrator. Tools can construct Tasks
and call `orchestrator.execute(task, strategy=OrchestrationStrategy.HIERARCHICAL)`
to coordinate local + remote agents.

**Phase summary re-confirmed:**

Phase 1 (v2.7.0) — mDNS discovery + peer gateway mode. Starts inside `gateway.start()`
when `discovery_enabled=True`. Peers found via mDNS auto-connect if trusted.
Manual peer list via `peer_gateways: ["host:port"]` in mesh config.

Phase 2 (v2.7.1) — Signal-grade trust (X3DH + Double Ratchet). `MeshTrustManager`
created inside `MeshGateway.__init__`. Wraps `SecureSessionManager` (pynacl).
Graceful degradation without pynacl: trust records and permission matrices work,
encryption is bypassed. Verification codes shown in logs for out-of-band confirmation.

Phase 3 (v2.7.2) — Shared memory bridge. `gateway.init_memory_bridge(memory)` called
in `Agent.__init__` when mesh + memory are both enabled. Peers can query each
other's shareable memories. Results injected into system prompt via `get_mesh_context()`.
HIPAA mode disables all sharing.

Phase 4 (v2.7.3) — Orchestrated delegation via RemoteAgent. When a peer gateway
connects, `_register_remote_agent()` creates a `RemoteAgent` and registers it
in `agent.orchestrator.registry`. Orchestrator strategies (HIERARCHICAL etc.)
can now delegate Tasks to remote nodes transparently.

**familiar-mesh CLI:**

New entry point registered in `pyproject.toml`:

    familiar-mesh gateway              # Start gateway on 127.0.0.1:18789
    familiar-mesh gateway --public --discovery  # Public + mDNS
    familiar-mesh node ws://pi.local:18789 --key SECRET  # Join as node
    familiar-mesh status               # Show config and connection string
    familiar-mesh genkey               # Generate fresh 32-byte secret key

**Deployment recipe (two Familiar instances on LAN):**

    # On the Pi (gateway):
    FAMILIAR_MESH_ENABLED=true familiar-mesh gateway --name kitchen-pi

    # On the laptop (node):
    familiar-mesh node ws://kitchen-pi.local:18789 --key <key> --name laptop

    # On the Pi — approve and grant permissions:
    familiar-mesh trust approve <node_id>
    familiar-mesh trust grant <node_id> request_memory
    familiar-mesh trust grant <node_id> delegate_tasks

    # Now the LLM on either device sees cross-device memories automatically.

**17 tests added:** orchestrator always present; orchestrator in tool_context;
mesh disabled by default; status dict when disabled; get_mesh_context graceful
when disabled; mesh in tool_context is None when disabled; mesh enabled via env;
orchestrator wired on gateway; memory bridge wired when memory enabled;
status shows enabled=True, running=False; status includes remote_agent count;
mesh in tool_context is MeshGateway when enabled; CLI parser — gateway;
CLI parser — gateway --public --discovery; CLI parser — node; genkey output;
trust manager created and degrades gracefully without pynacl.

**Total tests: 335** (was 318 after v1.1.19)

## [1.1.19] - 2026-02-20

### saga.py + experiments.py wired; ci_eval.py registered as CLI; secure_transport reclassified

**Part 1: SagaOrchestrator wired**

`familiar/core/saga.py` (1,265 lines) implements the Saga pattern — multi-step
atomic workflows where each step has an action and a compensation. If any step
fails, all previously completed steps are compensated in reverse order.

*What was wired:*
- `agent.sagas` — `SagaOrchestrator` instance on the agent, shared across requests
- `agent.run_saga(saga, context, timeout)` — sync bridge: runs the async orchestrator
  in a thread with its own event loop, safe from any async/sync boundary
- `tool_context["sagas"]` — the orchestrator (register, get, list executions)
- `tool_context["run_saga"]` — the sync bridge callable

*Why a sync bridge:* `SagaOrchestrator.execute()` is async. Tool handlers are
sync. The bridge uses `concurrent.futures.ThreadPoolExecutor` with `asyncio.run()`
in the worker thread — always safe, never "cannot run nested event loop".

*Typical tool usage:*

    def process_donor_intake(input_data, tool_context):
        saga = (
            SagaBuilder("donor_intake")
            .add_step("validate",  validate_donor,   undo_validate)
            .add_step("crm_add",   add_to_crm,       remove_from_crm)
            .add_step("welcome",   send_welcome,      None)
            .add_step("schedule",  schedule_followup, cancel_followup)
            .build()
        )
        ex = tool_context["run_saga"](saga, context={"donor": input_data})
        if ex.status == SagaStatus.COMPLETED:
            return json.dumps({"ok": True, "id": ex.execution_id})
        return json.dumps({"error": ex.error, "rolled_back": ex.compensations_executed})

*Step results accumulate in context:* Step N can read results from step N-1 via
`ctx["key"]` or `ctx["_step_stepname"]["key"]`. Initial context keys are also
available throughout. Timeout per step (default 30s) and per saga (default 300s).
Retry per step via `retry_count=N`. Conditional steps via `condition=fn`.

*18 tests added:* unit tests for SagaBuilder, unique step names, conditional
skipping, successful context merge, compensation on failure, compensation reverse
order, result propagation between steps, orchestrator register/get, execution
summary, step retry; integration tests for agent.sagas attribute, run_saga sync
bridge (success + compensation + context return), tool_context injection,
run_saga via tool_context, shared orchestrator across requests.

**Part 2: ExperimentManager wired**

`familiar/core/experiments.py` (1,284 lines) provides A/B testing and feature
flags: stable percentage rollout (hash-based, deterministic per user), allowlists,
status toggles, variant assignment with statistical analysis (t-test, p-values,
confidence intervals, effect size, sample size recommendations).

*What was wired:*
- `agent.experiments` — `ExperimentManager` persisted to `~/.familiar/experiments/`
- `tool_context["experiments"]` — the same instance in every tool handler
- `set_experiment_manager()` called at init so `get_experiment_manager()` works globally

*Persistence:* Flags and experiments survive restarts. Assignments (which variant
each user is in) are also persisted so A/B results are stable across sessions.

*Typical usage:*

    # At skill load time — register flags
    agent.experiments.create_flag("new_donor_ui", enabled_percent=20)
    agent.experiments.create_experiment("prompt_style",
        variants=[Variant("formal", weight=50), Variant("warm", weight=50)],
        primary_metric="user_satisfaction")
    agent.experiments.start_experiment("prompt_style")

    # In a tool handler
    def my_tool(input_data, tool_context):
        mgr = tool_context["experiments"]
        uid = str(tool_context["user_id"])
        if mgr.is_enabled("new_donor_ui", user_id=uid):
            return render_new_ui()
        variant = mgr.get_variant("prompt_style", user_id=uid)
        return respond(style=variant.name if variant else "formal")

*20 tests added:* unit tests for enabled/disabled flags, unknown flag returns
False, allowlist, deterministic percentage, flag distribution, status toggle,
flag delete, experiment create/start, deterministic variant assignment, variant
distribution, paused experiment returns None, metric tracking, conversion
convenience, list flags/experiments with tags; integration tests for
agent.experiments attribute, tool_context injection, flag visible inside
tool handler, shared instance across requests, global accessor.

**Part 3: ci_eval.py registered as CLI entry point**

`familiar/core/ci_eval.py` is a standalone CI/CD evaluation runner — not
middleware, not wired into tool_context. The correct integration is as a
console script. `pyproject.toml` created with full package metadata:

    [project.scripts]
    familiar-eval = "familiar.core.ci_eval:cli_main"

After `pip install -e .`, operators can run:

    familiar-eval run   --suite evals/suite.json --format junit --output-dir ./ci-output
    familiar-eval generate --provider github --suite evals/suite.json

Thresholds are configurable via env vars (`FAMILIAR_MIN_PASS_RATE`,
`FAMILIAR_MAX_CRITICAL`) or CLI flags. GitHub Actions and GitLab CI workflow
templates can be generated from the `generate` subcommand.

**Part 4: secure_transport.py reclassified**

`familiar/core/secure_transport.py` was misidentified as dead code. It is
the cryptographic core of the mesh layer — already imported by `mesh/trust.py`:

    from ..secure_transport import (
        SecureSessionManager, SecureSession, X3DHKeys, ...
    )

`MeshGateway` uses `MeshTrustManager` which wraps `SecureSessionManager`.
Requires `pynacl` (`pip install pynacl`). The mesh subsystem as a whole has
no entry point from `agent.py` yet — that is a future operator decision.
No code changes. Reclassified as: **mesh crypto layer, wired inside mesh/,
dormant at the agent boundary**.

**Dead code sprint complete:**
- GuardrailsEngine: wired v1.1.13
- EpisodicMemory: wired v1.1.14
- SkillBus: removed v1.1.15 (1,788 lines)
- StreamingExecutor: refactored → EventEmitter v1.1.16 (~1,015 lines)
- EpisodicMemory encryption gap: closed v1.1.17
- observability_enhanced: removed v1.1.18 (1,254 lines duplicate)
- StructuredGenerator: wired v1.1.18
- SagaOrchestrator: wired v1.1.19
- ExperimentManager: wired v1.1.19
- ci_eval: CLI entry point v1.1.19
- secure_transport: reclassified (mesh crypto, not dead) v1.1.19

**Total tests:** 318 (was 258 after v1.1.17, +22 structured, +18 saga, +20 experiments)

## [1.1.18] - 2026-02-20

### StructuredGenerator wired + observability_enhanced removed

**Part 1: observability_enhanced.py removed**

`familiar/core/observability_enhanced.py` (1,254 lines) was an exact duplicate
of `observability.py` — identical class names, method names, and line count.
Zero callers. Archived to `_archived_dead_code/` and deleted.

**Part 2: StructuredGenerator wired into tool_context**

`familiar/core/structured_output.py` (1,312 lines) provided guaranteed
schema-compliant LLM outputs: Pydantic validation, JSON extraction with
multiple strategies, automatic retry with error feedback, and JSON repair.
It had zero callers. Now wired as `tool_context["structured"]`.

**What was wired:**
- `StructuredGenerator` — the core class; takes an LLM callable
- `GenerationResult[T]` — typed result with `.success`, `.data`, `.unwrap()`
- `OutputSchema` — dict-based schema builder when Pydantic unavailable
- `ExtractionMethod`, `ValidationStrategy` — configuration enums
- `PYDANTIC_AVAILABLE` flag exposed as `STRUCTURED_OUTPUT_PYDANTIC`

**How wiring works:**

A thin provider adapter bridges `StructuredGenerator`'s calling convention
`(messages, model=None)` to `provider.chat(messages, tools, system, max_tokens)`:

    def _make_structured_provider(agent_ref):
        def _provider(messages, model=None, **kw):
            return agent_ref.provider.chat(
                messages=messages,
                tools=[],
                system="You are a precise data extraction assistant. Output only valid JSON.",
                max_tokens=kw.get("max_tokens", 2048),
            )
        return _provider

The adapter closes over `agent_ref` (not `agent_ref.provider`) so mid-request
provider swaps from fallback routing are picked up automatically.

A fresh `StructuredGenerator` is created per request in `_run_agent_loop`,
injected as `tool_context["structured"]`, and ready for use by any tool handler.

**Tool usage example:**

    def extract_grant_info(input_data, tool_context):
        structured = tool_context.get("structured")
        if structured is None:
            return json.dumps({"error": "structured output unavailable"})

        schema = {
            "type": "object",
            "properties": {
                "grant_name": {"type": "string"},
                "amount_usd": {"type": "number"},
                "deadline": {"type": "string"},
                "eligibility": {"type": "array", "items": {"type": "string"}},
            },
            "required": ["grant_name", "amount_usd"],
        }
        result = structured.generate(
            f"Extract grant information from: {input_data['text']}",
            schema,
            max_retries=2,
        )
        if result.success:
            return json.dumps(result.data)
        return json.dumps({"error": f"Extraction failed: {result.errors[-1]}"})

**Generator defaults per request:**
- `max_retries=3` — retry up to 3 times with error feedback
- `extraction_method=FIRST_JSON` — find first JSON object/array in response
- `validation_strategy=RETRY` — retry with feedback on validation failure
- `repair_json=True` — attempt to fix trailing commas, Python booleans, etc.

**Pydantic support:** Works with Pydantic models (v1 and v2) when installed.
Gracefully falls back to dict-based JSON Schema validation when unavailable.

**structured_output.py unchanged** — module wired as-is, no edits required.
Its dependency footprint is pure stdlib + optional pydantic.

**Tests added:** 22 tests in `TestStructuredOutputIntegration`:
unit tests for JSON extraction (markdown blocks, bare objects, empty, no-JSON),
JSON repair (trailing comma, Python booleans), schema validation (valid, missing
required, wrong type), GenerationResult (bool, unwrap), OutputSchema, prompt
building (structured, retry), StructuredGenerator (success, retry, max retries
exhausted); integration tests verifying `structured` present in tool_context,
adapter correctly calls provider.chat, per-request isolation (fresh instance
each request), injection even on first tool call.

**Total tests:** 280 (was 258 after v1.1.17).

**Dead code status after full sprint:**
- GuardrailsEngine: wired v1.1.13
- EpisodicMemory: wired v1.1.14
- SkillBus: removed v1.1.15
- StreamingExecutor: partially kept as event_emitter.py v1.1.16
- EpisodicMemory encryption: fixed v1.1.17
- observability_enhanced: removed v1.1.18
- StructuredGenerator: wired v1.1.18

**Deferred (operator-level decisions needed):**
- `saga.py` (1,264L) — distributed transaction / rollback, needs saga definitions
- `experiments.py` (1,283L) — A/B testing + feature flags
- `secure_transport.py` (1,326L) — Signal-grade E2E mesh encryption, meaningful only with active mesh
- `ci_eval.py` (1,133L) — standalone CLI tool, no integration point

## [1.1.17] - 2026-02-20

### EpisodicMemory encryption at rest — HIPAA gap closed

**Vulnerability fixed**: `FileMemoryPersistence` (wired in v1.1.14) wrote
episodic memory to plaintext `{user_id}.json` regardless of the
`FAMILIAR_ENCRYPTION_KEY` environment variable. The existing `core/memory.py`
(fact extraction) and session storage both respected that key; episodic did not.

**Risk**: With Presidio unavailable (regex-only PII fallback active in this
environment), residual PHI could survive PII redaction and be stored unencrypted
on disk — a HIPAA compliance gap for deployments where `encrypt_memory=True`.

**Fix**: `_save_episodic_memory_async` and `_get_episodic_memory` now mirror
the exact encryption pattern from `core/memory.py`:

Save path (encrypted, when key set):
1. `ms.export()` → JSON string
2. `encryption.encrypt(json_data)` → ciphertext
3. Atomic write: `.json.enc.tmp` → `.chmod(0o600)` → `.replace(.json.enc)`
4. Remove stale `.json` plaintext if present (migration case)

Save path (plaintext, no key):
1. `.json.tmp` → `.replace(.json)` (atomic, consistent with pre-fix behaviour)

Load order (mirrors `memory.py` — encrypted takes priority):
1. `.json.enc` — tried first if `FAMILIAR_ENCRYPTION_KEY` set and enc available
2. `.json` — plaintext fallback / migration source
   - If encryption now available: log migration notice, encrypt on next save

Corruption safety: if `.json.enc` exists but fails to decrypt (corrupted,
wrong key), a fresh empty `MemorySystem` is returned — does NOT fall through to
plaintext, which would be a security regression.

Migration: existing plaintext deployments auto-upgrade on first save after
setting `FAMILIAR_ENCRYPTION_KEY`. Stale `.json` is removed after successful
encrypted write.

**Also fixed in this release**:
- `_get_episodic_encryption()` module-level singleton added to `agent.py`
  (lazy init, same structure as `_get_memory_encryption()` in `memory.py`)
- `ms.get_stats()['total_stored']` changed to `.get('total_stored', 0)` in two
  logger calls — `import_memories()` with `"stats": {}` could KeyError on load
- `FileMemoryPersistence` is now only used on the plaintext load path; encrypted
  path bypasses it and handles JSON directly (same pattern as `memory.py`)

**New module-level function**:
- `_get_episodic_encryption() -> Optional[EncryptedStorage]`
  Module-level lazy singleton. Returns None if `FAMILIAR_ENCRYPTION_KEY` unset
  or `cryptography` not installed. Gracefully degrades — never raises.

**Tests added**: 11 tests in `TestEpisodicMemoryEncryption`:
plaintext path (no key), plaintext JSON validity, plaintext round-trip across
restart, encrypted file written (not plaintext), encrypted content is not raw
JSON, encrypted round-trip, atomic write (no .tmp left), plaintext removed
after encrypted save, load order priority (enc over plaintext), corrupted enc
file starts fresh (no crash), no file starts fresh.

**Total tests**: 258 (was 247 after v1.1.16).

**Security status after this sprint**:
- EpisodicMemory encryption gap: CLOSED
- PII in tool_input passed to EventEmitter: PRE-EXISTING, documented
  (same as the `StreamEvent(TOOL_START, tool_input=tc.input)` that existed before)
- GuardrailsEngine audit trail: intact
- PII redaction before EpisodicMemory storage: confirmed correct (message
  variable is post-redaction at the store_conversation call site)

## [1.1.16] - 2026-02-20

### EventEmitter — extracted and wired (StreamingExecutor partially removed)

**Decision**: Option A — extract the event bus, delete the rest.

The full `StreamingExecutor` (1,215 lines) was a parallel async agent loop
that duplicated `agent.chat_stream()` and depended on the deleted SkillBus.
The `EventEmitter` inside it was self-contained, dependency-free, and directly
useful. The rest was not.

**What was removed** (archived to `_archived_dead_code/streaming_executor.py`):
- `StreamingExecutor` — parallel async agent loop
- `StreamingToolWrapper` — async tool wrapper incompatible with sync handlers
- `StreamingResponseBuilder` — only useful with StreamingExecutor
- `ExecutionStatus`, `ExecutionContext`, `ToolExecutionPlan` — only used by StreamingExecutor
- All convenience functions: `execute_with_streaming`, `create_streaming_executor`,
  `get/set/reset_streaming_executor`

**What was kept** — in new `familiar/core/event_emitter.py` (~300 lines):
- `EventEmitter` — structured event bus, sync callbacks + sync Queue, history
- `ExecutionEvent` — dataclass with `to_dict()`/`from_dict()`, sequence numbers,
  execution_id, timing
- `EventType` — trimmed enum: THINKING, TOOL_START/PROGRESS/OUTPUT/COMPLETE/ERROR/RETRY,
  PARTIAL_RESPONSE, RESPONSE, PROGRESS, STATUS, DEBUG, WARNING
- `ToolStatus` — lifecycle enum for individual tool executions

**Wired into agent.py at two points**:

1. `_run_agent_loop` (tool_context construction):
   - Creates `EventEmitter(execution_id=f"{user_id[:8]}-{uuid4()[:6]}")` per request
   - Looks up registered callback via `_tool_event_callbacks[user_id]`
   - Injects as `tool_context["emitter"]` — available to every tool handler

2. `_execute_tools` (unified event emission):
   - Replaced hand-written `StreamEvent(TOOL_START)` / `StreamEvent(TOOL_END)` blocks
   - Now calls `emitter.tool_start()` / `emitter.tool_complete()` / `emitter.tool_error()`
   - `emitter.tool_retry()` emitted on self-correction retries
   - Stream mode still yields `StreamEvent(TOOL_START)` / `StreamEvent(TOOL_END)` for
     backward compatibility — emitter events run alongside, not instead of
   - Elapsed ms tracked with `time.monotonic()` per tool call

**New public API**:
- `agent.set_tool_event_callback(user_id, callback)` — register per-user callback
- `agent.clear_tool_event_callback(user_id)` — remove callback
- `tool_context["emitter"].tool_progress(name, percent, message)` — in-tool progress

**Tool progress usage example**:

    def export_donors_csv(input_data, tool_context):
        emitter = tool_context.get("emitter")
        rows = fetch_all_donors()
        for i, row in enumerate(rows):
            if emitter and i % 100 == 0:
                emitter.tool_progress(
                    "export_donors_csv",
                    progress_percent=(i / len(rows)) * 100,
                    status_message=f"Exporting row {i}/{len(rows)}",
                )
            write_row(row)
        return f"Exported {len(rows)} donors"

**Tests added**: 20 tests in `TestEventEmitterIntegration` covering:
standalone EventEmitter behaviour (sequence numbers, callbacks, queue, history,
thread safety, serialisation), agent integration (emitter in tool_context,
TOOL_START/COMPLETE events, callback registration/isolation).

**Also fixed in this release**:
- `uuid` and `time` promoted to module-level imports in `agent.py`
  (were partially inline — `import uuid` appeared at line 417)
- Redundant inline `import uuid` removed from `_handle_guardrail_violation`

**Total tests**: 247 (was 227 after v1.1.15).
**Dead code eliminated this session**: GuardrailsEngine wired, EpisodicMemory wired,
SkillBus removed (1,788 lines), StreamingExecutor mostly removed (1,015 lines deleted,
~200 lines kept as event_emitter.py).

## [1.1.15] - 2026-02-20

### SkillBus — removed (1,788 lines of unresolvable dead code)

**Decision**: Remove, not wire. Two hard blockers made integration impossible
without building new infrastructure:

1. `SkillBus.call()` depends on `ToolRegistry.get_last_result()` — a method
   that does not exist in `familiar/core/tools.py`. The `$output` reference
   resolution chain breaks at this point for every call.

2. `ChainExecutor` (the other half of `skill_bus.py`) imports
   `familiar.core.guidance.loader.GuidanceLoader` and
   `familiar.core.guidance.workflow_chains.WorkflowChain` — the `guidance/`
   directory does not exist anywhere in the codebase.

**Files removed** (archived to `_archived_dead_code/` for recovery if needed):
- `familiar/core/skill_bus.py` — 940 lines (SkillBus + ChainExecutor)
- `familiar/core/skill_result.py` — 384 lines (SkillResult dataclass)
- `familiar/core/workflow_state.py` — 464 lines (WorkflowState/MergeOp)
- Total: 1,788 lines

**Also removed**: stub in `agent.py` `tool_context` that injected
`"skill_bus": None` — replaced with explanatory comment.

**Note on bus.py**: `familiar/core/bus.py` contains a *different* `SkillBus`
(pub/sub message bus, 1,598 lines) used by `mesh/`, `distributed.py`, and
`examples/`. That is a separate system and was not touched.

**What SkillBus would have provided**: Skills calling other skills' tools
without going through the LLM — a useful capability. If needed in future,
the right approach is to add `ToolRegistry.get_last_result()` to `tools.py`
and rewrite SkillBus as a thin wrapper around that. The archived files are
available as reference. The full guidance/chain-execution system would require
building the `guidance/` subsystem from scratch.

**Dead code status** (post this session):
- GuardrailsEngine: ✓ wired (v1.1.13)
- EpisodicMemory: ✓ wired (v1.1.14)
- SkillBus: ✓ removed (v1.1.15)
- StreamingExecutor: still dead (1,215 lines) — deferred

## [1.1.14] - 2026-02-20

### EpisodicMemory — fully integrated (was 1,256 lines of dead code)

**What it does**: Stores conversation exchanges (user turn + assistant response)
across sessions and retrieves relevant past exchanges to inject into the system
prompt, giving the LLM cross-session recall without requiring the full conversation
history to fit in context.

**Distinct from MemoryExtractor** (`core/memory.py`): MemoryExtractor extracts
semantic *facts* ("user prefers Python"). EpisodicMemory stores *exchanges* —
"three weeks ago you described the Q1 donor campaign shortfall as 50K." Both
run in parallel.

**Integration points**:

1. `Agent.__init__`:
   - `_episodic_enabled`: True when `config.agent.memory_enabled=True`
   - `_episodic_stores: Dict[str, MemorySystem]`: per-user lazy store dict
   - `_episodic_lock`: threading.Lock for thread-safe lazy init
   - `_episodic_dir`: `~/.familiar/episodic/` — created on init if enabled

2. `_get_episodic_memory(user_id) → Optional[MemorySystem]`:
   - Lazy init: creates MemorySystem on first call for that user
   - Loads persisted memories from `{user_id}.json` on creation
   - Thread-safe double-checked locking
   - Returns None if episodic disabled

3. `_save_episodic_memory_async(user_id)`:
   - Saves MemorySystem to `{user_id}.json` in a daemon thread
   - Never blocks response finalization
   - Mirrors `memory_extractor.extract_async()` pattern

4. `_run_agent_loop` (before first LLM call, after system prompt built):
   - Calls `retrieve(query=message, top_k=5, strategy=COMBINED, min_strength=0.1)`
   - Also pulls `working_memory.get_context_string(max_tokens=400)` for hot context
   - Deduplicates working memory vs retrieved results
   - Appends non-empty block as `## Past conversation context` to system prompt
   - Capped at 800 chars to avoid dominating context window
   - Wrapped in try/except — retrieval failure never blocks the request

5. `_finalize_response` (after guardrails.end_request):
   - Calls `store_conversation([{user}, {assistant}], conversation_id=session.session_id)`
   - Stored with `importance=0.6`, `MemoryType.CONVERSATION`
   - Calls `_save_episodic_memory_async(user_id)` — non-blocking

**Design decisions**:
- **Per-user isolation**: one MemorySystem per user_id, keyed in `_episodic_stores`.
  User A's memories are never visible to user B. This was the critical multi-tenant
  decision — the module's global singleton would have been a data isolation failure.
- **File persistence**: `FileMemoryPersistence` with JSON at `~/.familiar/episodic/{uid}.json`.
  Memories survive agent restarts.
- **Empty response handling**: falsy `response.text` becomes `"(no response)"` which
  is truthy — so the exchange is stored even for empty responses.
- **working_memory vs retrieve**: `store_conversation` uses `add_to_working=False`
  internally. Working memory is only populated after `retrieve()` is called. The
  integration always calls `retrieve()` first so working memory is populated.
- **RetrievalStrategy.COMBINED**: weights relevance (0.5), strength (0.3), recency (0.2).
  Balances topic match with recency — recent exchanges don't dominate unless relevant.

### Test infrastructure fix — session file pollution

**Root cause**: Tests that charge session budget (e.g. `TestBudgetTracking`) write
`spent_today` to disk in `~/.familiar/data/sessions/{user}.json`. Subsequent test
runs loaded these drained sessions and failed with "Budget limit reached."

**Fixes**:
- Added `autouse=True` fixture `clear_session_files()` — deletes all session JSON
  files before and after every test. No test needs to opt in.
- Changed `TestToolCallRoundTrip` to use `tool_rt_user1`/`tool_rt_user2` instead
  of `"u1"` (shared with streaming tests that also charge budget).
- Changed `TestStreamingChat` to use `stream_user1`/`stream_user2`/`stream_user3`.

**Tests added**: 29 tests in `TestEpisodicMemoryIntegration` covering:
all init states, lazy per-user isolation, store_conversation wiring,
persistence round-trip, cross-agent reload, concurrency safety,
retrieval relevance, and working memory format.

**Total tests**: 227 (was 198 after v1.1.13).

## [1.1.13] - 2026-02-20

### GuardrailsEngine — fully integrated (was 2,068 lines of dead code)

**What changed**: GuardrailsEngine is now wired into the agent loop at every
specified integration point. This was dead code for 12+ sessions.

**Integration points (in execution order)**:

1. `_setup_request` — `start_request()`: rate limit + hourly/daily cost gate fires
   before session creation, so blocked requests consume no budget.
2. `_setup_request` — `process_with_pii()`: PII is redacted from the user message
   before it reaches the LLM or is stored in history. Default action is REDACT
   (replaces SSN, credit card, email, phone, IP with `<TYPE>` placeholders).
   Configurable to BLOCK or HASH via `PIIConfig`.
3. `_run_agent_loop` per iteration — `check_iteration()`: enforces per-request
   iteration cap and request timeout. Tracker passed explicitly (not stored on
   `self`) so concurrent requests don't share state.
4. `_run_agent_loop` post-LLM — `track_llm_usage()`: accumulates token and cost
   totals using the same numbers already computed for `session.charge()` — no
   second calculation. Raises on per-request token or cost cap breach.
5. `_execute_tool_secure` — `check_tool_call()`: operator-configured `blocked_tools`
   list and `require_approval_tools` list enforced as a second layer on top of
   the existing trust-level capability system. `requires_approval=True` returns
   a user-facing message directing them to contact admin.
6. `_finalize_response` — `end_request()`: records request cost into per-user
   hourly/daily history so rate limits and cost caps apply across requests.

**Design decisions made**:
- `_GuardrailBlock` sentinel exception: `_setup_request` raises this internal
  exception (never `GuardrailViolation`) when a guardrail fires before the session
  exists. `chat()` and `chat_stream()` catch it and return the string directly.
  Keeps `GuardrailViolation` out of the public API surface.
- `_handle_guardrail_violation()`: each `ViolationType` maps to a specific,
  actionable user message. Internal limit values are never leaked to the user.
  Routes to existing `audit.py` — the duplicate `AuditLogger` inside
  `guardrails.py` remains unused (cleanup is a separate task).
- `UsageTracker` passed explicitly: not stored on `self.guardrails._current_tracker`.
  Thread-safe for concurrent requests.
- `tool_registry=None` in `check_tool_call`: `self.tools` is `tools.ToolRegistry`
  (simple, no `.status`/`.safety_level`), not `tool_registry.ToolRegistry`
  (advanced). Blocklist and approval checks work fine without registry; safety
  level validation is skipped gracefully.
- Limits from agent config: `guardrail_max_tokens`, `guardrail_max_cost_request`,
  `guardrail_max_cost_hour`, `guardrail_max_cost_day`, `guardrail_rpm`,
  `guardrail_timeout`, `blocked_tools`, `require_approval_tools`. All optional
  with permissive defaults — existing behaviour unchanged if not set.

**Tests added**: 23 tests in `TestGuardrailsIntegration` covering all 6
integration points, all `ViolationType` message mappings, PII entity types,
thread-safety of explicit tracker passing, and content safety.

### What was NOT done (deliberate scope decisions)
- `AuditLogger` class inside `guardrails.py` is not wired — it duplicates
  `core/audit.py`. Cleanup scheduled as a separate refactor session.
- `PIIAction.BLOCK` is available via `PIIConfig` but not the default — operators
  must opt in. Default is REDACT.
- Approval flow for `require_approval_tools` is a stub: returns user message
  directing to admin. Full async approval workflow is future work.
- Content filter patterns in `ContentFilter.BLOCK_PATTERNS` are narrow (noted
  in test comments). Pattern improvement is a separate task.

# Changelog

All notable changes to Familiar will be documented in this file.

## [1.1.12] - 2026-02-20

### 🔒 Security — Replace eval() with Safe Expression Parser

### Added

- **`familiar/core/expr_parser.py`** — New recursive-descent boolean expression parser with zero code-execution surface. Replaces the `eval()` call in `PlanExecutor._execute_condition()` entirely.

  Supported syntax:
  - Boolean literals: `true`, `false` (case-insensitive)
  - Numeric literals: `42`, `3.14`, `-1`
  - String literals: `"hello"` or `'world'`
  - Bare words treated as string values: `success == "success"`
  - Comparison operators: `==`, `!=`, `<`, `<=`, `>`, `>=`
  - String operators: `contains`, `startswith`, `endswith`, `in`
  - Logical operators: `and`, `or`, `not` (case-insensitive)
  - Parentheses for grouping

  The parser is a strict whitelist — anything outside this grammar raises `ExpressionError`. Attempts to use Python builtins (`__import__`, `open`, `exec`), attribute access (`.__class__`), function calls, or arithmetic all raise `ExpressionError` immediately.

### Fixed

- **`familiar/skills/planner/skill.py` — `PlanExecutor._execute_condition()`** — Replaced `eval(expr, {"__builtins__": {}}, plan.context)` with `expr_parser.evaluate(condition)`. The `{"__builtins__": {}}` sandbox is not a real sandbox in CPython; class hierarchy traversal (`().__class__.__mro__[1].__subclasses__()`) can escape it. The new parser has no such escape path.

  The LLM fallback is preserved for natural-language conditions that the parser cannot handle (it catches `ExpressionError` and delegates to the agent). This means plan steps with conditions like `"the weather is sunny"` still work — the parser rejects them cleanly and the LLM resolves them.

### Added Tests (22 new — 150 total, was 128)

- `TestExprParser` (17 tests): literals, comparisons, string ops, logical ops, parentheses, operator precedence, type coercion, empty expression, and rejection of `__import__`, `.__class__`, `open()`, `exec()`, arithmetic, and unknown operators
- `TestPlannerConditionNoEval` (5 tests): static check that `eval()` is absent from planner code, import check for `expr_parser`, integration tests for True/False evaluation and LLM fallback on natural-language conditions

## [1.1.12] - 2026-02-20

### 🔒 Security, Startup Validation, Observability

### Fixed

- **Planner `eval()` removed** (`skills/planner/skill.py`, `core/expr_parser.py`) — `_execute_condition()` called `eval(expr, {"__builtins__": {}}, plan.context)` to evaluate conditional branch expressions. The `__builtins__: {}` sandbox is not a real sandbox in CPython; `().__class__.__mro__[1].__subclasses__()` and similar gadgets can escape it. Replaced with a purpose-built recursive-descent expression parser (`familiar/core/expr_parser.py`) that understands only the operators the planning prompt describes: `==`, `!=`, `<`, `<=`, `>`, `>=`, `contains`, `startswith`, `endswith`, `in`, `and`, `or`, `not`, parentheses, string/number/bool literals. No eval, no exec, no import path. The parser raises `ExpressionError` for anything it cannot parse; `_execute_condition()` falls back to the LLM only for genuinely natural-language conditions.

- **`Config.validate()` at startup** (`core/config.py`) — `load_config()` had no post-load credential check. A misconfigured provider (e.g. `default_provider: anthropic` with no `ANTHROPIC_API_KEY`) would only surface on the first user message as a cryptic Anthropic SDK error. Added `Config.validate()` which checks provider/key alignment for Anthropic and OpenAI, probes Ollama reachability (2 s timeout, non-fatal), and checks Telegram/Discord token presence when those channels are enabled. Called automatically by `load_config()` at startup; returns a list of warnings printed to stderr. Never raises — all validation failures are surfaced as actionable messages without blocking startup.

### Added

- **`familiar/core/expr_parser.py`** — 270-line safe expression parser. Grammar handles `or_expr → and_expr → not_expr → cmp_expr → atom`. Tokeniser rejects `.`, `(`, and any character not in the grammar before reaching parse stage, so attribute access (`__class__`), function calls (`open(...)`), and subscript access (`["eval"]`) all raise `ExpressionError` at the tokeniser level.

- **`create_memory_agent_health_check()`** and **`create_planner_health_check()`** in `core/health.py` — factory functions that return `DependencyHealth` check callables for registration with `HealthChecker`. Memory agent check inspects `_thread.is_alive()`, `_last_error`, and `_last_run`. Planner check counts active plans and flags any plan that has been running longer than 30 minutes as stuck. Both accept a strong reference (normal production use) or a weakref (to permit GC on teardown); when a strong reference is passed, the closure holds it to prevent premature collection.

### Tests

- 25 new tests across `TestConfigValidate`, `TestHealthCheckFactories`, `TestExprParserEdgeCases` (175 total, was 150)

## [1.1.11] - 2026-02-20

### 🔌 External Service Integration (Round 2)

### Fixed

- **OllamaProvider OpenAI client timeout** (`providers.py`) — When the `openai` package is installed, OllamaProvider constructs an OpenAI client pointed at `{base_url}/v1`. This client had no `timeout=` or `max_retries=` configured, so a stalled local Ollama instance would hang indefinitely. Fixed to `timeout=self.timeout` (default 120s, configurable) and `max_retries=1`.

- **Ollama `supports_tools` model detection** (`providers.py`) — The static match list missed `qwen2`/`qwen2.5` family variants (only matched `qwen` exact prefix), `phi4`, `phi4-mini`, and `command-r`/`command-r-plus`. Fixed prefix matching to also catch `qwen2`, `qwen2.5`, etc. via digit-after-prefix detection. Added `phi4` and `command-r` families. Added `_probe_tool_support()` which queries `/api/show` (Ollama ≥0.3) for unknown models — returns `True` if the model declares `"tools"` in its `capabilities` array, falls back to `False` on any network error. `gemma3` remains excluded as Ollama does not yet reliably support tools for that family.

- **Missing `SKILL_PREREQUISITES` entries** (`skills.py`) — `meetings`, `triage`, `notifications`, `messaging`, and `triggers` all had external dependencies but no prerequisite checks. Added entries: `meetings` and `triage` check `EMAIL_ADDRESS` + `EMAIL_PASSWORD` / `EMAIL_IMAP_SERVER`; `notifications`, `messaging`, and `triggers` have documentation-only entries noting their runtime platform detection. Without these entries, triage and meetings tools were loading into LLM context and failing at call time with raw IMAP errors.

- **Marketplace repo URL allowlist** (`marketplace/skill.py`) — `install()` accepted any URL from the plugin registry and passed it directly to `git clone`. A compromised or malicious registry entry could clone arbitrary code. Added `_ALLOWED_HOSTS` check before cloning: only `github.com`, `gitlab.com`, `codeberg.org`, `git.sr.ht`, and `bitbucket.org` are accepted. Any other URL returns a rejection message without touching the filesystem.

- **Marketplace raw exception returns** (`marketplace/skill.py`) — `install()` and `uninstall()` returned `f"❌ Installation/Uninstall failed: {e}"` exposing internal OS/git error messages. Both now log at ERROR level and return stable strings without exception details.

### Added

- 19 new integration tests across `TestOllamaSupportsToolsExtended`, `TestSkillPrerequisitesRound2`, `TestMarketplaceSecurity` (128 total, was 109)

## [1.1.10] - 2026-02-20

### 🔌 External Service Integration

### Fixed

- **IMAP/SMTP timeouts** (`email`, `triage`, `meetings` skills) — `imaplib.IMAP4_SSL()` and `smtplib.SMTP()` both accept a `timeout=` parameter that was never set. A slow or unresponsive mail server would hang the agent indefinitely. Added `IMAP_TIMEOUT = 20` and `SMTP_TIMEOUT = 20` constants and passed them to every connection call.

- **Google API timeouts** (`gdrive`, `calendar` skills) — `googleapiclient.discovery.build()` calls went through the default `httplib2` transport with no timeout. Fixed by constructing an `httplib2.Http(timeout=REQUEST_TIMEOUT)` object, authorizing it with the OAuth credentials, and passing it to `build()` via the `http=` parameter. Added `httplib2` to `SKILL_PREREQUISITES` for both skills.

- **Provider client timeouts** (`providers.py`) — `Anthropic()` and `OpenAI()` clients were constructed with no timeout or retry configuration. Both now specify `timeout=90.0, max_retries=2` so transient network errors retry automatically and stuck requests don't block indefinitely.

- **Raw exception leakage** (7 skill files) — Tool handlers were returning `f"❌ {e}"` directly to the LLM, which could expose IMAP error codes, Twilio API responses, Google API error JSON, SMTP banners, and internal stack traces. All raw `{e}` returns are now logged at `ERROR` level and replaced with stable, user-readable strings (e.g. `"❌ Email connection failed. Check IMAP server settings."`).

- **Twilio env var name mismatch** (`skills.py`) — `SKILL_PREREQUISITES` checked `TWILIO_FROM_NUMBER` but the SMS skill actually reads `TWILIO_PHONE_NUMBER`. The prerequisite check was always silently passing (env var not found = empty string = falsy = check fails, but it had the wrong name so it was always "not set"). Fixed to use `TWILIO_PHONE_NUMBER`.

- **Twilio import prerequisite** (`skills.py`) — `twilio` Python package was not in `SKILL_PREREQUISITES`, so a clean environment would load SMS tools into the LLM context but fail at call time with an ImportError buried in an except clause.

- **Calendar missing from prerequisites** (`skills.py`) — `calendar` skill had no prerequisites despite requiring `google-auth`, `google-api-python-client`, and `httplib2`. Added full prerequisite block matching `gdrive`.

### Added

- 17 new integration tests across `TestExternalServiceTimeouts`, `TestExternalServiceErrorSanitization`, `TestSkillPrerequisitesExtended` (109 total, was 92)

## [1.1.9] - 2026-02-20

### 🔧 Skills & Tools Integration

### Fixed

- **ToolRouter wired into agent** — `_get_allowed_tools()` now runs a two-stage pipeline: capability gate (hard filter by trust level) followed by relevance routing (soft filter by message intent). A TRUSTED user with 189 allowed tools now receives ≤15 schemas per message instead of all 189. Falls back to the full capability-allowed set when routing confidence is low.

- **ToolRouter schema-based fallback scorer** — `_score_intent()` previously returned `0.0` for all tools when TOOLS.yaml guidance was absent, keeping the router permanently in fallback mode. New `_score_intent_schema()` method scores tools by matching message words against tool name tokens (3× weight) and description tokens (1×), using a stop-word filter. Works without any YAML guidance file.

- **ToolRouter lazy schema refresh** — `_build_index()` ran at construction time before skills loaded, so `_schema_lookup` only contained 9 built-in tools. `route()` now detects registry growth and refreshes the lookup on first call, ensuring all 199+ skill tools are scored.

- **ToolRouter early-exit guard fixed** — `route()` previously exited early with `fallback=True` whenever `self.guidance` was `None`, bypassing schema scoring entirely. Condition changed to only bypass when `self.config.enabled` is `False`.

- **Capability assignments hardened** in `SKILL_DIRECTORY_CAPABILITIES`:
  - `sessions` raised from `READ_TIME` → `SYSTEM_CONTROL` (exposes `revoke_session`, `create_session`)
  - `encryption` raised from `READ_NOTES` → `SYSTEM_CONTROL` (exposes `rotate_keys`, `decrypt_data_store`)
  - `audit` raised from `READ_NOTES` → `READ_FILES` (exposes `export_audit_trail`, `compliance_report`)

- **Skill prerequisite preflight checks** — skills with unmet external dependencies now warn at load time and skip tool registration rather than registering tools that fail at runtime. Covered: `email` (EMAIL_ADDRESS/PASSWORD env vars), `gdrive` (google-auth + google-api-python-client), `sms` (Twilio env vars), `voice` (SpeechRecognition), `transcription` (openai-whisper), `gpio` (RPi.GPIO), `browser` (playwright). Skill docs (SKILL.md) still load for system prompt context; only tool registration is gated.

### Added

- `ToolRouter._score_intent_schema()` — schema-based intent scorer (no TOOLS.yaml required)
- `ToolRouter._STOP_WORDS` — frozenset of common English stop words filtered during scoring
- `SKILL_PREREQUISITES` dict in `skills.py` — maps skill name to list of `(check_fn, label)` tuples
- Helper factories in `skills.py`: `_env(key)`, `_importable(module)`, `_file_exists(path)`
- 23 new integration tests covering ToolRouter routing, lazy refresh, disabled mode, skill prerequisites, and capability hardening (92 total, up from 69)

## [1.1.12] - 2026-02-20

### 🔒 Security — Replace eval() with Safe Expression Parser

### Added

- **`familiar/core/expr_parser.py`** — New recursive-descent boolean expression parser with zero code-execution surface. Replaces the `eval()` call in `PlanExecutor._execute_condition()` entirely.

  Supported syntax:
  - Boolean literals: `true`, `false` (case-insensitive)
  - Numeric literals: `42`, `3.14`, `-1`
  - String literals: `"hello"` or `'world'`
  - Bare words treated as string values: `success == "success"`
  - Comparison operators: `==`, `!=`, `<`, `<=`, `>`, `>=`
  - String operators: `contains`, `startswith`, `endswith`, `in`
  - Logical operators: `and`, `or`, `not` (case-insensitive)
  - Parentheses for grouping

  The parser is a strict whitelist — anything outside this grammar raises `ExpressionError`. Attempts to use Python builtins (`__import__`, `open`, `exec`), attribute access (`.__class__`), function calls, or arithmetic all raise `ExpressionError` immediately.

### Fixed

- **`familiar/skills/planner/skill.py` — `PlanExecutor._execute_condition()`** — Replaced `eval(expr, {"__builtins__": {}}, plan.context)` with `expr_parser.evaluate(condition)`. The `{"__builtins__": {}}` sandbox is not a real sandbox in CPython; class hierarchy traversal (`().__class__.__mro__[1].__subclasses__()`) can escape it. The new parser has no such escape path.

  The LLM fallback is preserved for natural-language conditions that the parser cannot handle (it catches `ExpressionError` and delegates to the agent). This means plan steps with conditions like `"the weather is sunny"` still work — the parser rejects them cleanly and the LLM resolves them.

### Added Tests (22 new — 150 total, was 128)

- `TestExprParser` (17 tests): literals, comparisons, string ops, logical ops, parentheses, operator precedence, type coercion, empty expression, and rejection of `__import__`, `.__class__`, `open()`, `exec()`, arithmetic, and unknown operators
- `TestPlannerConditionNoEval` (5 tests): static check that `eval()` is absent from planner code, import check for `expr_parser`, integration tests for True/False evaluation and LLM fallback on natural-language conditions

## [1.1.12] - 2026-02-20

### 🔒 Security, Startup Validation, Observability

### Fixed

- **Planner `eval()` removed** (`skills/planner/skill.py`, `core/expr_parser.py`) — `_execute_condition()` called `eval(expr, {"__builtins__": {}}, plan.context)` to evaluate conditional branch expressions. The `__builtins__: {}` sandbox is not a real sandbox in CPython; `().__class__.__mro__[1].__subclasses__()` and similar gadgets can escape it. Replaced with a purpose-built recursive-descent expression parser (`familiar/core/expr_parser.py`) that understands only the operators the planning prompt describes: `==`, `!=`, `<`, `<=`, `>`, `>=`, `contains`, `startswith`, `endswith`, `in`, `and`, `or`, `not`, parentheses, string/number/bool literals. No eval, no exec, no import path. The parser raises `ExpressionError` for anything it cannot parse; `_execute_condition()` falls back to the LLM only for genuinely natural-language conditions.

- **`Config.validate()` at startup** (`core/config.py`) — `load_config()` had no post-load credential check. A misconfigured provider (e.g. `default_provider: anthropic` with no `ANTHROPIC_API_KEY`) would only surface on the first user message as a cryptic Anthropic SDK error. Added `Config.validate()` which checks provider/key alignment for Anthropic and OpenAI, probes Ollama reachability (2 s timeout, non-fatal), and checks Telegram/Discord token presence when those channels are enabled. Called automatically by `load_config()` at startup; returns a list of warnings printed to stderr. Never raises — all validation failures are surfaced as actionable messages without blocking startup.

### Added

- **`familiar/core/expr_parser.py`** — 270-line safe expression parser. Grammar handles `or_expr → and_expr → not_expr → cmp_expr → atom`. Tokeniser rejects `.`, `(`, and any character not in the grammar before reaching parse stage, so attribute access (`__class__`), function calls (`open(...)`), and subscript access (`["eval"]`) all raise `ExpressionError` at the tokeniser level.

- **`create_memory_agent_health_check()`** and **`create_planner_health_check()`** in `core/health.py` — factory functions that return `DependencyHealth` check callables for registration with `HealthChecker`. Memory agent check inspects `_thread.is_alive()`, `_last_error`, and `_last_run`. Planner check counts active plans and flags any plan that has been running longer than 30 minutes as stuck. Both accept a strong reference (normal production use) or a weakref (to permit GC on teardown); when a strong reference is passed, the closure holds it to prevent premature collection.

### Tests

- 25 new tests across `TestConfigValidate`, `TestHealthCheckFactories`, `TestExprParserEdgeCases` (175 total, was 150)

## [1.1.11] - 2026-02-20

### 🔌 External Service Integration (Round 2)

### Fixed

- **OllamaProvider OpenAI client timeout** (`providers.py`) — When the `openai` package is installed, OllamaProvider constructs an OpenAI client pointed at `{base_url}/v1`. This client had no `timeout=` or `max_retries=` configured, so a stalled local Ollama instance would hang indefinitely. Fixed to `timeout=self.timeout` (default 120s, configurable) and `max_retries=1`.

- **Ollama `supports_tools` model detection** (`providers.py`) — The static match list missed `qwen2`/`qwen2.5` family variants (only matched `qwen` exact prefix), `phi4`, `phi4-mini`, and `command-r`/`command-r-plus`. Fixed prefix matching to also catch `qwen2`, `qwen2.5`, etc. via digit-after-prefix detection. Added `phi4` and `command-r` families. Added `_probe_tool_support()` which queries `/api/show` (Ollama ≥0.3) for unknown models — returns `True` if the model declares `"tools"` in its `capabilities` array, falls back to `False` on any network error. `gemma3` remains excluded as Ollama does not yet reliably support tools for that family.

- **Missing `SKILL_PREREQUISITES` entries** (`skills.py`) — `meetings`, `triage`, `notifications`, `messaging`, and `triggers` all had external dependencies but no prerequisite checks. Added entries: `meetings` and `triage` check `EMAIL_ADDRESS` + `EMAIL_PASSWORD` / `EMAIL_IMAP_SERVER`; `notifications`, `messaging`, and `triggers` have documentation-only entries noting their runtime platform detection. Without these entries, triage and meetings tools were loading into LLM context and failing at call time with raw IMAP errors.

- **Marketplace repo URL allowlist** (`marketplace/skill.py`) — `install()` accepted any URL from the plugin registry and passed it directly to `git clone`. A compromised or malicious registry entry could clone arbitrary code. Added `_ALLOWED_HOSTS` check before cloning: only `github.com`, `gitlab.com`, `codeberg.org`, `git.sr.ht`, and `bitbucket.org` are accepted. Any other URL returns a rejection message without touching the filesystem.

- **Marketplace raw exception returns** (`marketplace/skill.py`) — `install()` and `uninstall()` returned `f"❌ Installation/Uninstall failed: {e}"` exposing internal OS/git error messages. Both now log at ERROR level and return stable strings without exception details.

### Added

- 19 new integration tests across `TestOllamaSupportsToolsExtended`, `TestSkillPrerequisitesRound2`, `TestMarketplaceSecurity` (128 total, was 109)

## [1.1.10] - 2026-02-20

### 🔌 External Service Integration

### Fixed

- **IMAP/SMTP timeouts** (`email`, `triage`, `meetings` skills) — `imaplib.IMAP4_SSL()` and `smtplib.SMTP()` both accept a `timeout=` parameter that was never set. A slow or unresponsive mail server would hang the agent indefinitely. Added `IMAP_TIMEOUT = 20` and `SMTP_TIMEOUT = 20` constants and passed them to every connection call.

- **Google API timeouts** (`gdrive`, `calendar` skills) — `googleapiclient.discovery.build()` calls went through the default `httplib2` transport with no timeout. Fixed by constructing an `httplib2.Http(timeout=REQUEST_TIMEOUT)` object, authorizing it with the OAuth credentials, and passing it to `build()` via the `http=` parameter. Added `httplib2` to `SKILL_PREREQUISITES` for both skills.

- **Provider client timeouts** (`providers.py`) — `Anthropic()` and `OpenAI()` clients were constructed with no timeout or retry configuration. Both now specify `timeout=90.0, max_retries=2` so transient network errors retry automatically and stuck requests don't block indefinitely.

- **Raw exception leakage** (7 skill files) — Tool handlers were returning `f"❌ {e}"` directly to the LLM, which could expose IMAP error codes, Twilio API responses, Google API error JSON, SMTP banners, and internal stack traces. All raw `{e}` returns are now logged at `ERROR` level and replaced with stable, user-readable strings (e.g. `"❌ Email connection failed. Check IMAP server settings."`).

- **Twilio env var name mismatch** (`skills.py`) — `SKILL_PREREQUISITES` checked `TWILIO_FROM_NUMBER` but the SMS skill actually reads `TWILIO_PHONE_NUMBER`. The prerequisite check was always silently passing (env var not found = empty string = falsy = check fails, but it had the wrong name so it was always "not set"). Fixed to use `TWILIO_PHONE_NUMBER`.

- **Twilio import prerequisite** (`skills.py`) — `twilio` Python package was not in `SKILL_PREREQUISITES`, so a clean environment would load SMS tools into the LLM context but fail at call time with an ImportError buried in an except clause.

- **Calendar missing from prerequisites** (`skills.py`) — `calendar` skill had no prerequisites despite requiring `google-auth`, `google-api-python-client`, and `httplib2`. Added full prerequisite block matching `gdrive`.

### Added

- 17 new integration tests across `TestExternalServiceTimeouts`, `TestExternalServiceErrorSanitization`, `TestSkillPrerequisitesExtended` (109 total, was 92)

## [1.1.9] - 2026-02-20

### 🔧 Skills & Tools Integration

### Fixed

- **ToolRouter wired into agent** (`agent.py`, `tool_router.py`) — `_get_allowed_tools()` now runs a two-stage pipeline: capability gate (hard filter by trust level) then relevance routing (soft filter by message intent). A TRUSTED user previously received 154+ tool schemas on every turn; now receives ≤15 per message. ToolRouter was previously fully dead code from the agent's perspective.

- **Schema-based fallback scorer** (`tool_router.py`) — `_score_intent` now includes a `_score_intent_schema()` fallback that scores tools by token overlap between the user message and the tool's name/description. Tool-name matches are weighted 3× over description matches. Previously, without `TOOLS.yaml` guidance, every tool scored 0.0 and the router always fell back to the full set. Works without any YAML configuration.

- **Index rebuild after skills load** (`agent.py`) — `ToolRouter._build_index()` is now called after `skills.load_all()` so `_schema_lookup` captures all 199+ skill tools rather than just the 9 built-ins registered at construction time.

- **Capability audit** (`security.py`) — Three incorrect capability assignments corrected:
  - `sessions` (exposes `revoke_session`, `create_session`): `READ_TIME` → `SYSTEM_CONTROL`
  - `encryption` (exposes `rotate_keys`, `decrypt_data_store`): `READ_NOTES` → `SYSTEM_CONTROL`
  - `audit` (exposes `export_audit_trail`, `compliance_report`): `READ_NOTES` → `READ_FILES`
  KNOWN-level users can no longer access session management or encryption tools.

- **Skill prerequisite checks** (`skills.py`) — `SKILL_PREREQUISITES` dict defines per-skill environment and import checks. Skills that fail their checks emit a `WARNING` at load time and skip tool registration; the skill itself still loads (so `SKILL.md` docs appear in the system prompt). Covered: `email` (EMAIL_ADDRESS, EMAIL_PASSWORD), `sms` (TWILIO_*), `gdrive` (google-auth, google-api-python-client), `voice` (SpeechRecognition), `transcription` (openai-whisper), `gpio` (RPi.GPIO), `browser` (playwright).

### Added

- 23 new integration tests across `TestToolRouter`, `TestCapabilityAudit`, `TestSkillPrerequisites` (92 total, was 69)

## [1.1.8] - 2026-02-20

### 🔧 Test Integration

### Added

- **pytest.ini** at project root — `pytest` with no arguments now discovers and runs all 69 tests automatically. Configures `pythonpath = .` so imports resolve without a package install, sets `--tb=short -q` defaults, and promotes familiar package DeprecationWarnings to errors so regressions surface immediately.

- **run_tests.sh** alongside run.sh — single command to verify a Familiar installation is healthy. Finds Python 3.9+, activates the venv if present, installs pytest if missing, runs the full suite, and exits 0/1 for scripting. Usage: `./run_tests.sh` (quiet) or `./run_tests.sh -v` (verbose) or `./run_tests.sh -v -k chat` (filtered).

- **`_verify_installation()`** in the installer wizard — new step at 95% progress that runs the integration test suite immediately after shortcuts are created. Uses the venv Python, surfaces the pass/fail summary in the wizard log, warns but does not abort on failure so users on unusual setups are not blocked. Wizard step order: directories → Python → venv → deps → download → configure → keys → shortcuts → **verify** → complete.

## [1.1.7] - 2026-02-20

### 🧪 Test Suite + Bug Fixes Found By Tests

### Added

**Integration Test Suite** (`tests/test_integration.py`, 69 tests)
- Agent instantiation and config loading
- Basic chat (non-streaming) — response, history, user isolation
- Streaming chat — text events, DONE signal, no spurious errors
- Tool call round-trip — tool use → result → final response, history threading
- Budget enforcement — real token cost, Ollama floor, exhaustion handling
- Provider error wrapping — auth, rate limit, connection, secret masking
- Retry logic — `RetryableOperation` success on 3rd attempt, `last_error` always set
- Provider alias resolution — `claude`→`anthropic`, `gpt`→`openai`, `llama`→`ollama`
- `OllamaProvider.supports_tools` — 22 model name cases including regression `llama3.10`
- `calculate_cost` — known models, unknown fallback, zero tokens, linearity
- Conversation history — accumulation across turns, user isolation
- Stream `GeneratorExit` — generator close does not raise or leak spans
- Subprocess stderr visibility — failed commands surface error detail
- Config section validation — llm, agent, resilience sections present

### Fixed (bugs found by tests)

**`core/providers.py` — `_wrap_provider_error` wrong kwargs**
- All error wrapping calls passed `provider=` and `original_error=` which don't exist on `ProviderError.__init__`
- Should be `provider_name=` — every wrapped error was raising `TypeError` instead of the intended exception type
- This meant provider auth errors, rate limits, and connection errors all silently failed to wrap

**`core/resilience.py` — `classify_exception` missing familiar exceptions**
- `ProviderRateLimitError` and `ProviderConnectionError` from `familiar.core.exceptions` were not recognized as retryable
- Only Anthropic and OpenAI SDK exceptions were checked by module name
- Familiar's own wrapped exceptions now classified correctly: rate limits → retryable (60s delay), connection errors → retryable (no delay)



### 🔧 Bug Fix Release — Silent Failures & Code Correctness

### Fixed

**LLM Provider Layer (`core/providers.py`)**
- Dead code: `raise _wrap_provider_error(op.last_error, ...)` after `RetryableOperation` was unreachable — raw provider exceptions now properly wrapped via `try/except` around each retry block
- `user_id` always `None` in Anthropic metrics — now derived from `trace.user_id`
- OpenAI streaming `usage` always `None` — added `stream_options={"include_usage": True}` and extraction from final stream chunk
- Ollama tool-call failure swallowed inside `AttemptContext` — moved to outer `try/except` so retries fire correctly
- `supports_tools` substring matching (`"llama3.1" in "llama3.10"`) — replaced with version-boundary-aware prefix matching
- `gemma2:2b` key in `LIGHTWEIGHT_MODELS` mapped to wrong model (`gemma3:1b`) — fixed
- `claude-3-5-haiku-20241022` stale model string — updated to `claude-haiku-4-5-20251001`
- Missing `try:` wrapper around OpenAI `RetryableOperation` block — caused orphaned `except` clauses
- `PROVIDER_ALIASES` dict was defined but never used by `get_provider()` — aliases now resolved before lookup
- `GeneratorExit` handler added to both streaming providers — abandoned generators no longer leak open observability spans

**Agent Budget Enforcement (`core/agent.py`)**
- `estimated_cost = 0.01` hardcoded per iteration replaced with actual token-based cost via `calculate_cost()` — budget limits now enforce correctly against real spend
- Fallback floor of `$0.005` per iteration for providers that don't report usage (Ollama)

**Observability / Resilience (`core/resilience.py`)**
- `AttemptContext.__exit__` called `self.operation.trace.add_span(span)` without `None` guard — now explicit `if self.span and trace:` in both success and error paths

**Subprocess Error Visibility**
- `__main__.py`: startup pip install with `check=True + capture_output=True` — stderr now surfaced on failure
- `channels/imessage.py`: osascript lacked `text=True` — `e.stderr` was bytes not str
- `channels/telegram.py`: Google libs and Twilio pip installs silently swallowed failures
- `onboard.py`: two pip install calls silently swallowed failures — pip stderr now shown with manual fallback hint

**Installer Wizard (`installer/wizard.py`)**
- `self.config = {}` clobbered `tk.Tk.config()` method — renamed to `self.setup_config`
- `cryptography.fernet` imported from host Python instead of venv — key generation now runs in venv subprocess
- `_download_familiar()` was a stub that created empty `__init__.py` — now implements PyInstaller bundle / source directory / GitHub download strategies
- `update_idletasks()` called from background thread — removed
- `capture_output=True` on pip subprocess swallowed install errors — replaced with streamed output to log
- License page index hardcoded as `1` — now uses dynamic `pages.index(LicensePage)` lookup
- `WM_DELETE_WINDOW` during installation could deadlock — guarded with `_install_in_progress` flag



### 🎬 Video Skill Release

Privacy-focused video playback, download, transcription, and AI analysis.

### Added

**Video Skill** (2,542 lines)
- Privacy playback via Invidious (YouTube without Google tracking)
- Video downloading with yt-dlp (1000+ sites supported)
- Local transcription with OpenAI Whisper
- AI-powered video summarization
- Question answering about video content
- "Find the moment where they discuss X"
- Automatic chapter generation from transcripts
- Key points extraction
- Study notes generation
- Encrypted video archive with search

**New Files**
- `skills/video/__init__.py` - Main VideoSkill class (722 lines)
- `skills/video/extractor.py` - yt-dlp wrapper for downloads (452 lines)
- `skills/video/player.py` - Invidious privacy player (423 lines)
- `skills/video/transcribe.py` - Whisper transcription (453 lines)
- `skills/video/ai_video.py` - AI video analysis (492 lines)

**Supported Platforms**
- YouTube (via Invidious - no tracking)
- Vimeo
- Twitter/X
- TikTok
- Reddit
- Twitch
- And 1000+ more via yt-dlp

**AI Features**
- "Summarize this 2-hour podcast in 5 bullets"
- "What did they say about pricing?"
- "Skip to where they discuss the conclusion"
- Auto-generate chapters for long videos
- Generate study notes from educational content

**Transcription**
- Whisper models: tiny, base, small, medium, large
- Word-level timestamps
- Multi-language support
- Export to SRT/VTT subtitles
- Full-text search within transcripts

### Changed
- Skill count: 20 → 21
- Total codebase: ~103,000 lines

---

## [2.4.0] - 2026-02-02

### 🌐 Privacy Browser

Embedded web browser with full privacy protection.

### Added

**Browser Proxy Skill** (3,219 lines)
- Privacy proxy - all requests through Familiar server
- Tracker blocking (50+ known tracker domains)
- Ad blocking with pattern matching
- Reader mode for clean article viewing
- Encrypted page archive with full-text search
- AI-powered summarization and Q&A
- Data extraction (prices, contacts, events)
- Three privacy levels: standard, strict, paranoid
- Tor support for paranoid mode

**New Files**
- `skills/browser_proxy/__init__.py` - Main skill
- `skills/browser_proxy/proxy.py` - HTTP proxy service
- `skills/browser_proxy/filter.py` - Content filtering
- `skills/browser_proxy/reader.py` - Reader mode extraction
- `skills/browser_proxy/archive.py` - Encrypted page storage
- `skills/browser_proxy/ai_assist.py` - AI features
- `skills/browser_proxy/routes.py` - Dashboard API routes
- `pwa/js/browser.js` - PWA browser UI component

**Privacy Features**
- User IP hidden from all websites
- Ads removed server-side
- Trackers blocked before reaching device
- No browser fingerprinting possible
- Optional Tor routing

**AI Features**
- Page summarization
- Question answering about content
- Structured data extraction
- Form fill suggestions
- Product comparison

### Changed
- Skill count: 18 → 19
- Total codebase: ~98,000 lines

---

## [2.3.0] - 2026-02-02

### 📧 Self-Hosted Email Server

Native email hosting with encryption and spam filtering.

### Added

**Email Server Skill** (3,714 lines)
- Full SMTP server for receiving mail (ports 25, 465, 587)
- IMAP server for email client access (port 993)
- Encrypted storage at rest (Fernet/AES-128)
- DKIM signing for message authentication
- SPF/DMARC support
- Built-in spam filter with 20+ rules
- Bayesian learning spam filter
- DNS setup wizard with copy-paste records
- DNS verification tool
- Relay support (Mailgun, SendGrid, SES, etc.)

**New Files**
- `skills/email_server/__init__.py` - Main skill
- `skills/email_server/storage.py` - Encrypted mail storage
- `skills/email_server/smtp_server.py` - SMTP receiving
- `skills/email_server/imap_server.py` - IMAP client access
- `skills/email_server/smtp_client.py` - SMTP sending
- `skills/email_server/dns_manager.py` - DNS setup/verification
- `skills/email_server/dkim.py` - DKIM signing
- `skills/email_server/spam_filter.py` - Spam detection
- `skills/email_server/skill.py` - CLI interface

**CLI Commands**
```bash
# Show DNS setup guide
python -m familiar.skills.email_server dns-setup --domain example.com

# Verify DNS records
python -m familiar.skills.email_server verify-dns --domain example.com

# Add email user
python -m familiar.skills.email_server add-user user@example.com

# Start email server
python -m familiar.skills.email_server start --domain example.com
```

### Changed
- Skill count: 17 → 18
- Total codebase: ~92,000 lines

---

## [2.2.0] - 2026-02-02

### 📱 Mobile App Release

Progressive Web App (PWA) with full touch support for iPhone and Android.

### Added

**Mobile App (PWA)**
- Progressive Web App installable from browser
- Works offline with IndexedDB message queue
- Push notifications for responses
- Background sync when connection returns
- Service worker for caching and offline support

**Touch Gestures**
- Swipe from left edge to open sidebar
- Swipe left to close sidebar  
- Pull down to refresh messages
- Long press for context menu (copy, retry, delete)
- Haptic feedback on interactions
- 44px minimum touch targets (accessibility compliance)

**New Files**
- `pwa/index.html` - Mobile app shell
- `pwa/manifest.json` - PWA configuration
- `pwa/sw.js` - Service worker (offline + push)
- `pwa/offline.html` - Offline fallback page
- `pwa/js/app.js` - Main application (touch gestures)
- `pwa/js/api.js` - REST/WebSocket client
- `pwa/js/db.js` - IndexedDB wrapper
- `pwa/css/app.css` - Mobile-responsive styles
- `docs/MOBILE_STRATEGY.md` - Mobile implementation guide

### Changed
- User Guide expanded to 2,223 lines with new Mobile App section
- Updated Table of Contents (17 sections)

---

## [2.1.0] - 2026-02-01

### 🔐 Signal-Grade Encryption Release

End-to-end encryption using Signal Protocol primitives.

### Added

**Signal Protocol Implementation**
- X3DH (Extended Triple Diffie-Hellman) key agreement
- Double Ratchet message encryption
- Perfect forward secrecy
- Post-compromise security
- Cryptographic verification

**Secure Transport Layer**
- `SecureTransport` class for E2E encryption
- `SecureMeshProtocol` for encrypted mesh networking
- Session management with automatic ratcheting
- Message authentication codes (MAC)

**Security Infrastructure**
- 1,951 lines of cryptographic code
- 36 security-focused tests
- Key storage with encryption at rest
- Session state persistence

### Changed
- Mesh networking encrypted by default
- Enhanced security documentation

---

## [1.2.1] - 2026-01-30

### 🧹 Code Quality Improvements

This release focuses on code quality improvements identified in the structural audit.

### Error Handling
- **Fixed all 61 bare `except:` clauses** across the entire codebase
  - Each now catches specific exceptions (e.g., `json.JSONDecodeError`, `IOError`, `ValueError`)
  - Added logging for caught exceptions where appropriate
  - Files affected: tasks, voice, triage, knowledge, marketplace, gdrive, planner, messaging, nonprofit, email, calendar, sms, triggers, core/tools, core/mesh, core/node_tools, dashboard, channels/signal, channels/discord

### Bug Fixes
- **Fixed websockets exception scope bug in mesh.py** (found during code review)
  - `websockets.exceptions.ConnectionClosed` was being referenced but `websockets` was only imported locally
  - Added module-level import with fallback dummy class for when websockets isn't installed

### Path Centralization
- **Migrated all modules to use `core/paths.py`** for DATA_DIR and related paths
  - Created centralized path definitions with fallbacks for standalone testing
  - 24 modules now import from `familiar.core.paths`
  - Eliminates path duplication and ensures consistency
  - Files migrated: voice, knowledge, triggers, planner, browser, calendar, gdrive, marketplace, proactive, sms, tasks, core/mesh, dashboard/app

### Documentation
- Updated BEST_PRACTICES_ASSESSMENT.md with new scores:
  - Overall: A (92/100), up from A- (89/100)
  - Error Handling: A (95/100), up from C (72/100)
  - Code Quality: B+ (88/100), up from C+ (75/100)
  - Architecture: A- (92/100), up from B+ (88/100)

### Tests
- All 167 existing tests continue to pass
- No regressions introduced

## [1.2.0] - 2026-01-30

### 🔒 Security Hardening Release

Following a comprehensive security audit, this release addresses critical security
vulnerabilities and improves overall system hardening.

### Security Fixes

**Dashboard Authentication** (`dashboard/app.py`)
- Added API key authentication via `FAMILIAR_DASHBOARD_KEY` environment variable
- API key can be provided via `X-API-Key` header or `api_key` query parameter
- All `/api/*` endpoints now require authentication when key is configured
- Uses constant-time comparison to prevent timing attacks

**CORS Restriction** (`dashboard/app.py`)
- CORS now restricted to localhost by default
- Configurable via `FAMILIAR_ALLOWED_ORIGINS` environment variable (comma-separated)

**Rate Limiting** (`dashboard/app.py`)
- Optional rate limiting support via flask-limiter
- Chat endpoint limited to 20 requests/minute
- General API limited to 200 requests/minute

**Shell Command Validation** (`skills/triggers/skill.py`)
- SCRIPT action type now routes through tool registry
- Inherits all shell command security validations
- Blocks command injection, sudo, dangerous patterns

**Environment Variable Protection** (`skills/triggers/skill.py`)
- Template rendering now uses allowlist for env vars
- Only safe system variables exposed (TZ, LANG, HOME, etc.)
- API keys and secrets blocked from template access

**Webhook Server Binding** (`skills/triggers/skill.py`)
- Default binding changed from `0.0.0.0` to `127.0.0.1`
- Must explicitly set `host="0.0.0.0"` to expose to network

**Cookie Encryption** (`skills/browser/skill.py`)
- Browser session cookies now encrypted at rest
- Uses Fernet symmetric encryption (cryptography library)
- Automatic migration from legacy plaintext cookies
- Restrictive file permissions (0o600)

**Event Queue DoS Protection** (`skills/triggers/skill.py`)
- Event queue now has maxsize=1000
- Prevents memory exhaustion from webhook flooding

### Reliability Fixes

**History Pruning** (`core/memory.py`)
- Added `max_total_chats` limit (default 500)
- Automatically prunes oldest chats when limit exceeded
- Prevents unbounded history file growth
- Replaced print statements with proper logging

### Code Quality Improvements

**New Core Modules**
- `core/paths.py` - Centralized path definitions (reduces 13 duplicates)
- `core/constants.py` - Enums for Role, Channel, Provider, Limits
- `core/result.py` - Standardized Result type for error handling
- `core/skill_data.py` - Shared utilities for skill data persistence

**Error Handling**
- Fixed 8 bare `except:` clauses in `skills/proactive/skill.py`
- Replaced with specific exception types and proper logging

**Cleanup**
- Removed legacy `dashboard.py` (dead code from pi_agent rename)
- Fixed version number (was 0.1.0, now 1.2.0)

### New Dependencies

- `flask-limiter>=3.5.0` - Rate limiting (optional but recommended)
- `cryptography>=41.0.0` - Cookie encryption (optional but recommended)

### Configuration

New environment variables:
```bash
# Dashboard authentication (required for production)
export FAMILIAR_DASHBOARD_KEY="your-secure-random-key"

# CORS allowed origins (comma-separated)
export FAMILIAR_ALLOWED_ORIGINS="http://localhost:5000,https://your-domain.com"
```

---

## [1.1.0] - 2026-01-30

### 🔒 Security & Reliability Release

Following comprehensive code review, this release addresses critical security issues
and improves reliability of persistent storage.

### Security Fixes

**Shell Command Hardening** (`core/tools.py`)
- Added validation against dangerous shell patterns (command injection, sudo, etc.)
- Blocked command substitution (`$(...)`, backticks)
- Added command length limits
- Truncated excessively long output

**Path Validation** (`core/tools.py`)
- All file operations now validate paths against `sandboxed_directories` config
- Prevents path traversal attacks
- Blocks access outside allowed directories
- Added `sandboxed_dirs` parameter to `ToolRegistry`

**Webhook Secret Management** (`skills/triggers/skill.py`)
- Secrets can now be stored in environment variables:
  - `WEBHOOK_SECRET_{TRIGGER_ID}` - per-trigger secret
  - `FAMILIAR_WEBHOOK_SECRET` - global fallback
- Avoids storing secrets in plaintext JSON files
- Added `get_webhook_secret()` helper function

### Reliability Fixes

**Atomic File Writes** (`core/memory.py`, `core/scheduler.py`)
- All persistence now uses atomic write pattern (write-to-temp-then-rename)
- Prevents data corruption on crash or power loss
- Automatic backup creation before overwrites
- Recovery from corrupted files using backups

**Thread Safety Improvements**
- Changed `threading.Lock` to `threading.RLock` for reentrant safety
- Added batched saves in `ConversationHistory` to reduce disk writes
- Added `flush()` method for explicit save when needed

**Scheduler Callback Persistence** (`core/scheduler.py`)
- Added `TaskType` enum for built-in task handlers
- Built-in handlers (REMINDER, BRIEFING, HEARTBEAT, CLEANUP) auto-register on restart
- Tasks now persist with their type for automatic callback reconstruction
- Added `set_agent()` method for deferred agent binding

**Trigger System Refactor** (`skills/triggers/skill.py`)
- Replaced global mutable state with `TriggerSystem` singleton
- Proper initialization with `TriggerSystem.get_instance(agent)`
- Added `reset_instance()` for testing
- Event queue now passed as instance variable

### API Changes

**New Exports**
- `TaskType` - enum for scheduler task types
- `reset_tool_registry()` - for testing
- `reset_scheduler()` - for testing

**Agent Initialization**
- Agent now passes `sandboxed_directories` config to tool registry
- Agent sets itself on scheduler for built-in handlers

### Configuration

New/updated config options:
```yaml
agent:
  sandboxed_directories:
    - /home/user
    - /tmp
```

Environment variables for webhook secrets:
```bash
export WEBHOOK_SECRET_ABC123="your-secret"
export FAMILIAR_WEBHOOK_SECRET="global-fallback"
```

---

## [1.0.0] - 2025-01-29

### 🎉 Initial Release

A complete, self-hosted AI assistant rivaling commercial solutions.

### Features

**Core**
- Multi-provider LLM support (Claude, GPT, Ollama)
- Persistent memory across sessions
- Tool execution framework
- Task scheduling system
- Multi-device mesh networking

**Channels (7)**
- CLI - Command-line interface
- Telegram - Mobile bot with full features
- Discord - Server bot with slash commands
- iMessage - macOS native messaging
- WhatsApp - Via web bridge
- Signal - Via signal-cli
- SMS - Via Twilio

**Skills (15)**
- `email` - IMAP/SMTP email management
- `triage` - Auto-categorize inbox (nonprofit-focused)
- `tasks` - Task management with priorities
- `nonprofit` - Donor and grant tracking
- `calendar` - Google Calendar integration
- `gdrive` - Google Drive access
- `browser` - Web automation with Playwright
- `proactive` - Daily briefings and alerts
- `voice` - Text-to-speech and speech-to-text
- `sms` - Twilio SMS
- `messaging` - Unified cross-platform messaging
- `webapi` - HTTP requests and webhooks
- `gpio` - Raspberry Pi hardware control
- `marketplace` - Plugin discovery and installation

**Infrastructure**
- Web dashboard with real-time updates
- Plugin marketplace with 14 seed plugins
- Systemd service support
- Comprehensive documentation

### Built For
Gender Justice League - a 501(c)(3) nonprofit organization

---

## Future Plans

- [x] ~~Native mobile app~~ → PWA added in 2.2.0
- [ ] Native app for App Store/Play Store (React Native)
- [ ] Hosted cloud option
- [ ] More plugin integrations
- [ ] Voice assistant mode (always listening)
- [ ] Multi-language support

## [1.1.13] - 2026-02-20

### GuardrailsEngine — fully integrated (was dead code for 12+ sessions)

After reading the complete 2,068-line module and making explicit design decisions
before writing any code, GuardrailsEngine is now wired into the agent loop at
5 points in the correct execution order:

**Integration points:**
1. `_setup_request` — `start_request()`: rate limit + hourly/daily cost gate fires
   before the session is created, so blocked requests consume no budget.
2. `_setup_request` — `process_with_pii()`: PII (SSN, credit card, email, phone,
   IP, IBAN) is redacted from the user's message *before* it reaches the LLM.
   Default action is REDACT (replace with `<EMAIL>` etc.). Operator can configure
   BLOCK to reject PII-containing messages entirely.
3. `_run_agent_loop` per iteration — `check_iteration()`: iteration cap and
   per-request timeout enforced on every loop cycle.
4. `_run_agent_loop` post-LLM — `track_llm_usage()`: cumulative token and cost
   caps enforced using the same token counts already calculated for `session.charge()`.
   No duplicate calculation.
5. `_execute_tool_secure` — `check_tool_call()`: operator-configured `blocked_tools`
   and `require_approval_tools` enforced as a second layer on top of the existing
   trust-level capability system.
6. `_finalize_response` — `end_request()`: records request cost into per-user
   hourly/daily history so rate limits accumulate correctly across requests.

**Design decisions made explicit:**
- `UsageTracker` passed explicitly through every call — never stored on `self.guardrails`
  (thread-safe; concurrent requests don't share state)
- `_GuardrailBlock` internal sentinel exception catches violations before session
  exists; `chat()` and `chat_stream()` convert it to a plain string return
- `_handle_guardrail_violation()` maps each `ViolationType` to a clean user message;
  internal detail never leaks to users
- `require_approval_tools` → blocked with "contact your admin" message (approval
  flow is future work; no partial stub left dangling)
- `AuditLogger` class inside guardrails.py intentionally NOT wired (duplicates
  existing audit.py); marked for cleanup in a dedicated refactor session
- `tool_registry=None` passed to `check_tool_call()` because `self.tools` is
  `tools.ToolRegistry` (no `.status`/`.safety_level`), not `tool_registry.ToolRegistry`

**Tests added:** 23 new tests in `TestGuardrailsIntegration`
- Rate limit fires before session creation
- `_GuardrailBlock` never propagates — always returns string
- PII redaction: email, SSN, credit card, clean passthrough
- Iteration cap and timeout raise correct `ViolationType`
- Token and cost caps via `track_llm_usage`
- Blocked tool denied in agent; approval-required tool returns flag
- `end_request` records in rate-limit history and cost history
- All `ViolationType` values map to user-friendly messages (no internal detail)
- Content filter blocks violence patterns; passes safe content
- Thread-safety: `_current_tracker` cleared after `end_request`

**Total tests:** 198 (was 175)
