ai-memory v0.8.0

Hook pipeline (Track G — 27 lifecycle events)

v0.7.0 ships a programmable extension surface that fires on every substrate lifecycle point. Hooks return one of Allow, Modify(delta), Deny{reason, code}, or AskUser{prompt, options, default}. Default off — a v0.7.0 install with no hooks.toml behaves identically to v0.6.4 at the lifecycle layer.

Configuration

[[hook]]
event = "post_store"
command = "/usr/local/bin/auto-link-detector"
priority = 100
timeout_ms = 5000
mode = "daemon"          # daemon | exec (optional; default per event class)
enabled = true
namespace = "team/*"     # glob match (today: non-empty string accepted)
fail_mode = "open"       # open (default) | closed

Fields (src/hooks/config.rs:174-190):

27-event matrix

The 20 baseline events:

Event Phase Class Fires on
pre_store / post_store write Write memory_store, memory_update (when content changes)
pre_recall / post_recall read Read memory_recall, family-loader recall
pre_search / post_search read Read memory_search
pre_delete / post_delete write Write memory_delete
pre_promote / post_promote write Write tier promotion (manual + auto)
pre_link / post_link write Write memory_link
pre_consolidate / post_consolidate write Write memory_consolidate
pre_governance_decision / post_governance_decision gate Write governance pipeline
on_index_eviction maintenance Index HNSW eviction
pre_archive write Write archive-on-GC + manual archive
pre_transcript_store / post_transcript_store write Transcript transcript sidechain writes

The 5 grand-slam additions:

Event Track Class Fires on
pre_recall_expand G10 HotPath query-expansion synthesise step
pre_reflect / post_reflect Recursive-learning Task 6/8 Write memory_reflect
pre_compaction / on_compaction_rollback L1-7 Write curator compaction pipeline

The 2 v0.8.0 Pillar-1 additions:

Event Track Class Fires on
pre_signal_send v0.8.0 #1709 Write before a signed coordination signal (memory_signal_send) is persisted
post_signal_ack v0.8.0 #1709 Write (notify-only) after a coordination signal is acknowledged (memory_signal_ack)

The discriminator strings (snake_case of the variant names via #[serde(rename_all = "snake_case")]) and the HookEvent enum live at src/hooks/events.rs:91; the canonical wire shapes for every event’s payload (MemoryDelta, RecallQuery, SearchResult, ReflectDelta, CompactionDelta, …) start right after the enum (≈src/hooks/events.rs:235) and span the rest of the module.

Decision-class semantics

Every hook returns a HookDecision (src/hooks/decision.rs:87):

is_pre_event (src/hooks/decision.rs:344) is the canonical predicate for “may this event return Modify” — the chain runner rejects Modify decisions on post_* events.

Per-class deadline budgets

The chain runner reads event_class(event) (src/hooks/timeouts.rs:137) at fire entry and computes a wall-clock ceiling on the entire chain. Per-hook budgets are derived by per_hook_budget_ms (src/hooks/timeouts.rs:264) and shrink monotonically as earlier hooks consume time:

Class Deadline Events
Write 5,000 ms store/delete/promote/link/consolidate/governance/archive/reflect/compaction
Read 2,000 ms recall/search
Index 1,000 ms on_index_eviction
Transcript 5,000 ms pre_transcript_store, post_transcript_store
HotPath 50 ms pre_recall_expand (only inhabitant today)

The HotPath ceiling is the v0.6.3 recall p95 budget — a hook that can’t return a decision in 50ms cannot be wired on the read path without blowing SLO. The class deadline is the whole-chain ceiling; individual hook timeout_ms values may be smaller. A hook’s effective per-call budget is min(timeout_ms, remaining_chain_ms).

When per_hook_budget_ms returns None, the chain has already exhausted its class deadline before this hook even fired. The runner increments the process-wide timeout_violations_total counter (src/hooks/timeouts.rs:306-313) and handles the missed hook per its fail_mode (open → treated as Allow; closed → chain Deny). The doctor surface reads this counter for the “did we trip a budget since boot” panel.

Hot-path constraint

post_recall and post_search default to mode = "daemon" (src/hooks/config.rs:157). The v0.6.3 recall p95 budget is 50 ms; the daemon subprocess keeps the hook chain off the synchronous fork/exec path. mode = "exec" is permitted for these events but requires the explicit setting — the default is intentionally biased toward latency-preserving behavior.

pre_recall_expand defaults to daemon for the same reason but is classed as HotPath rather than Read (50 ms whole-chain ceiling vs 2 s), so a misconfigured exec-mode expansion hook still cannot park the recall path for a full second.

Hot-reload (SIGHUP)

spawn_reload_task (src/hooks/config.rs:424) listens for SIGHUP on Linux/macOS and atomically swaps the chain’s config snapshot (a shared Arc<HookConfigSnapshot>, i.e. RwLock<Vec<HookConfig>>). Read-side dispatch resolves the snapshot once per fire, so a reload mid-fire never tears: any in-flight chain finishes against the old config; new chains see the new config. On non-Unix targets the function is a no-op (src/hooks/config.rs:473).

Race window discussion. Between the operator’s kill -HUP <pid> and the chain snapshot swap there is a sub-millisecond window where a new chain fire may have already loaded the pre-swap snapshot. This is intentional — the alternative (locking writers out of the chain during reload) would convert hot-reload into a brief outage on a busy daemon. The operator-visible consequence: a single in-flight chain may run against the pre-reload config even after SIGHUP is delivered. Verification of the swap is via the tracing::info! emitted by the reload task (“hooks: reloaded config on SIGHUP”) — if the operator wants strict observability they grep for the line before treating the reload as effective.

On parse failure (TOML error, validation error, missing file) the reload task logs an error and keeps the previous config (src/hooks/config.rs:456). The daemon never reloads to an empty config because of operator typo — silent hook removal would be a security regression.

Security hardening

Tests

Pinned by tests/hooks_executor_test.rs, tests/hooks_hot_reload.rs, tests/hooks_pre_recall.rs, tests/hooks_timeout_budget.rs, tests/g3_hooks_stderr_drain.rs, tests/g11_auto_link_detector.rs.

Operator workflow

  1. Author the helper binary. Use the auto-link-detector as the reference (tools/auto-link-detector/src/main.rs). Speak JSON-RPC over stdin/stdout for mode = "daemon"; one-shot exec for mode = "exec".
  2. Drop the binary on PATH and chmod +x.
  3. Edit ~/.config/ai-memory/hooks.toml with the row schema above.
  4. Reload with kill -HUP $(pgrep -f 'ai-memory mcp') or restart the daemon. The reload task logs hooks: reloaded config on SIGHUP on success.
  5. Verify the reload landed via the hooks: reloaded config on SIGHUP log line (it carries the loaded hook count). The capabilities hooks block (memory_capabilities over MCP, e.g. printf '<JSON-RPC tools/call>' | ai-memory mcp --profile full) reports hook_events_count (27) and registered_count — it does not enumerate per-event hook rows.

Tuning guidance

Recommended priority bands:

Recommended timeout_ms per event class:

Class Recommended timeout_ms Rationale
Write 500-2000 Most hooks finish in <100ms; budget gives headroom for an Ollama call or a network lookup.
Read 200-1000 Read path is hot; keep budgets tight.
HotPath (pre_recall_expand) 30-45 Class ceiling is 50ms; leave 5-20ms headroom for chain overhead.
Index 100-500 Background loop; small payloads.
Transcript 500-2000 Same as Write; transcript payloads can be larger.

For deployment sizes:

Troubleshooting

Symptom Likely cause Diagnostic recipe
Hook not firing Namespace mismatch, enabled = false, or config not loaded Confirm the hooks: reloaded config on SIGHUP line reported a non-zero hook count (or restart and watch boot logs). Then RUST_LOG=ai_memory::hooks=debug and watch the chain’s per-hook warn/debug lines.
Hook fires but result ignored Returned Modify on a post_* event Check decision.rs:344 is_pre_eventModify is only valid on pre-events. Daemon log carries the rejection reason.
No hook log lines on any write No hooks.toml, or zero matching rows (an empty chain is a silent no-op returning Allow) This is the expected v0.6.4-equivalent behavior. Confirms hooks aren’t quietly firing.
Recall p95 regressed after enabling hook Hook is mode = "exec" on a hot-path event Switch to mode = "daemon". If already daemon, reduce timeout_ms and inspect helper-binary tracing for the slow path.
timeout_violations_total growing A hook’s class deadline tripping Compare to per-hook ExecutorMetrics (src/hooks/executor.rs:530) to identify the slow hook; widen its timeout_ms (cap is 30s) or migrate work off the synchronous path.
Daemon-mode hook respawn loop Helper binary panics on framed stdin Inspect daemon log for the hook spawn failed for <command> error. Fix the helper, redeploy, SIGHUP. The chain fails open in the meantime (per fail_mode = "open" default).
Reload didn’t pick up new hook TOML parse error Look for hooks: SIGHUP reload failed; keeping previous config in the log. Validate the file with cat ~/.config/ai-memory/hooks.toml | toml --check (or taplo lint).

Operator runbook (3am procedures)

A hook is denying every write — substrate appears stuck.

  1. Set RUST_LOG=ai_memory::hooks=debug (pkill -USR2 ai-memory if you have the dynamic-log signal wired; otherwise restart).
  2. Inspect the refused callers’ error responses — a hook Deny short-circuits the chain and surfaces {reason, code} directly to the caller (ChainResult::Deny); fail-mode conversions additionally log hooks: chain hook errored; fail_mode=closed, denying lines naming the hook command and event.
  3. To unblock: edit ~/.config/ai-memory/hooks.toml, set enabled = false on the offending row, kill -HUP. Confirm via the hooks: reloaded config on SIGHUP log line.
  4. RCA after the bleeding stops — replay the captured payload against the helper binary on the command line (daemon-mode helpers speak framed JSON-RPC on stdin/stdout, so a single-request replay is a printf | helper one-liner).

Reload appears to have hung.

SIGHUP reload is async and idempotent. If you don’t see the hooks: reloaded config on SIGHUP log line within ~1s, the most likely cause is a TOML parse error — look for the warning line. Re-issue kill -HUP after fixing the file. If the daemon process itself is unresponsive, fall through to standard restart procedure (scripts/dogfood-rebuild.sh documents the live-binary swap dance).

Hot-path latency regressed; suspect a hook.

  1. Review ~/.config/ai-memory/hooks.toml (and the loaded-count on the last hooks: reloaded config on SIGHUP line) — confirm which events have hooks attached.
  2. Temporarily disable hot-path hooks (enabled = false on post_recall / post_search / pre_recall_expand rows), SIGHUP.
  3. Re-measure recall p95. If recovered, the hook was the cause.
  4. Look at per-hook ExecutorMetrics for the trip — usually the helper binary blocked on an upstream (Ollama, network). Move the work async or relax the SLO.

Migration

A v0.6.4 → v0.7.0 install with no hooks.toml is a no-op at the lifecycle layer (an empty chain returns Allow without spawning anything). To opt in, follow the operator workflow above. To verify hooks are NOT firing on a write path, set RUST_LOG=ai_memory::hooks=debug and confirm the absence of per-hook chain log lines on writes.

See also: docs/MIGRATION_v0.7.md §”Hook pipeline (opt-in)”, the canonical inventory in docs/internal/v070-feature-inventory.md §”Feature: Hook pipeline (Track G, 25 events)”, the SSE approval pipeline that consumes AskUser decisions at docs/k10-sse-approvals.md, the transcript-store hook reference at docs/sidechain-transcripts.md, the K8 quotas substrate that gates write events after hook decisions at docs/k8-quotas.md, the federation hardening that applies the same hook chain to inbound peer writes at docs/federation.md, and the signed-events chain that records every governance-gated write at docs/signed-events-v4.md.