* chore(dev): open dev track at 0.4.21-dev + GHCR dual-track tags
dev is the integration branch for new work; it carries 0.4.21-dev and
promotes to 0.4.21 (suffix dropped) on stable release.
release-docker.yml now picks the moving tag by version suffix: -dev cuts
push :{version}-dev + :latest-dev (never :latest); stable cuts push
:{version} + :latest. One workflow, suffix-driven.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: optional chat-reply output filter layer + final-frame status fields (#44)
Opt-in chat-reply output filter (output_filter + [tasks.chat_output_filter]: model/fallback/retry_depth/filter_prompt/trigger/timing) + new SSE final fields (filtered, prompt_injected, tier, retries_chat, retries_filter). Codex P2s addressed (gated-traits trigger, task-level filter token docs).
* docs(readme): ASCII hyphen + drop Chinese glosses in affinity composites (#45)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: tip-aware streaming reply (tips_amount_usd) (#46)
Optional tips_amount_usd on POST /comp/chat/{id}/message/stream: companion always replies (never ghosted) with an amount-aware, tip_personality-flavored prompt fragment. Empty content allowed for standalone tips (persisted as a "(打赏 $N)" marker); PDE rule-0 guard forces Reply with Neutral/Tsundere baseline style; free-form tip_personality injected verbatim. No affinity special-casing, no new endpoint, no migration. Spec: docs/superpowers/specs/2026-05-26-tips-stream-reply-design.md
* chore(dev): open dev track at 0.4.3-dev (#48)
Stylized scheme: 0.4.20/0.4.21 read as 0.4.2 / 0.4.2-1, so the next track
after the 0.4.2x line is 0.4.3 (→ 0.4.3-dev), not 0.4.22.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* docs(readme): GHCR images are amd64-only (#49)
The release-docker workflow builds linux/amd64 only (arm64 + qemu were
dropped as of v0.4.20); README still claimed multi-arch.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat: tip role (gift_user) + chat-reply filter audit columns + prompt_traits metadata (#52)
* docs(spec): tip role (gift_user) + chat-reply filter audit columns
Design for issue #51 plus persisting the chat-reply output filter's
pre-rewrite text and metadata. Bundles into one chat_messages migration:
* metadata JSONB — tip rows carry {tips_amount_usd: X}; BFF history
exposes the structured amount, role flips to gift_user.
* pre_filter_content / filter_model / filter_triggers / f_client_msg_id /
f_generation_id — written only on filtered-success assistant rows.
Supersedes 2026-05-25-chat-output-filter-design.md §2.6 (in-memory-only
original). No DTO surface for the filter audit columns.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(store): migration 0019 — tip metadata + filter audit columns
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(store): upsert_user_message_idempotent takes role + metadata
* feat(store): FilterAudit struct + assistant insert binds 5 audit columns
* refactor(store): FilterAudit.f_generation_id is Option<String>
Allows the SQL NULL to propagate when OpenRouter's filter response
omits generation_id. Avoids an .unwrap_or_default() at the Task 7
call site that would have stored "" for a legitimately-missing value.
* feat(llm): should_filter returns Option<TriggerHits> with hit detail
- Add TriggerHits { random, models, traits } + RandomHit { p, draw }
types (skip_serializing_if = Option::is_none so stored JSONB only
includes fired predicates)
- Change should_filter(…, random_pass: bool) -> bool to
should_filter(…, random_draw: Option<f64>) -> Option<TriggerHits>
- Change turn_level_pass(random_pass: bool, …) signature to
turn_level_pass(random_draw: Option<f64>, …)
- Absent-predicate trait hits recorded as empty vec (nothing to
enumerate when predicate fires on non-presence)
- 5 new tests + should_filter_predicate_combinations updated for new API
- eros-engine-server pipeline/stream.rs still calls old bool API;
that call site is intentionally deferred to Task 7
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(llm): guard random-misuse fallback + empty TriggerHits JSON shape
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(stream): tip path persists role=gift_user + tips_amount_usd metadata
* test(stream): pass role + metadata to upsert + filter_audit: None to AssistantInsert
* feat(stream): filtered-success branch writes FilterAudit (5 columns)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(stream): filter_triggers serialize uses .expect + document MutexGuard drop
* feat(bff): expose tips_amount_usd on history rows
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(spec): note 2026-05-26 supersedes 2026-05-25 §2.6 in-memory-only claim
* chore: cargo fmt + regen openapi
* feat(stream): record kept prompt_traits in chat_messages.metadata on every assistant row
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(pipeline): widen compute_signals role filter to include gift_user (codex P2)
Tip turns persisting as gift_user (PR #52 / spec §3.1) were no longer
counted by compute_signals_for_session, skewing message_count and
hours_since_last_message signals. Widen both queries to
role IN ('user','gift_user'), same pattern as the upsert dedup widening.
Two sqlx::test cases added in pipeline::tests:
- signals_count_includes_gift_user_rows: seeds 1 user + 1 gift_user row,
asserts message_count == 2
- signals_count_user_only_rows: baseline regression for pure user rows
* feat(metadata): record user tier-at-time on chat_messages + lock BFF surface to tips_amount_usd
- companion_stream + drive_chat_burst now include {"tier": "<x>"} in
chat_messages.metadata when the request carried a tier. Reason: tier table
only has the user's CURRENT tier; the row should record what tier they
had at message time.
- BFF history negative test confirms only tips_amount_usd is surfaced;
tier / prompt_traits / raw metadata all stay audit-only.
- Spec §3.4 / §3.5 amended.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(pipeline): narrow signals query to tip-flagged gift_user rows only (codex P2 v2)
Previous fix (5f5c09b) widened too far — it counted all gift_user rows
including legacy in-app-gift rows written by routes/companion.rs:827
via append_message. Those rows lack tip metadata and never counted as
user activity pre-PR. Narrow to:
role = 'user' OR (role = 'gift_user' AND metadata ? 'tips_amount_usd')
so only the new tip-replacing path counts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(v0.5.0): chat_output_filter reasoning + configurable chat retry_depth + model recommendations (#53)
* feat(v0.5.0): reasoning on filter + configurable chat retry_depth
Item 1: Add reasoning: Option<ReasoningConfig> to ResolvedOutputFilter.
resolve_output_filter() now reads it from [tasks.chat_output_filter]
(task-level only, no per-tier override). run_output_filter() in stream.rs
forwards it to the ChatRequest instead of relying on ..Default::default().
Item 2: Add retry_depth: u32 to ResolvedModel. resolve() computes it via
tier > task > default 2 and truncates fallback_model in place before
returning. Removes MAX_STREAM_FALLBACK_DEPTH=3 constant and the
.take(MAX_STREAM_FALLBACK_DEPTH) call from drive_chat_burst — the chain
is now [primary] + the already-capped fallback_model. Default of 2 gives
the same 3-entry chain as before. Tier-overridable.
Six new unit tests cover both items.
* docs(model-config): rewrite chat_output_filter model recommendations
gpt-5.4-nano primary (fast, stable). gemini-3.1-flash / zlm-4.7-flash
fallbacks (real error responses -> fail-open works). Warn against
gpt-4.1-nano (200-with-refusal masks failure) and haiku-4.5 (strict
output alignment refuses to filter).
* feat(v0.5.0): error_handling_config + pseudo-ghost on chat-stream chain exhaustion (#54)
* feat(store): error_handling_config kv table + 10-phrase seed (codex-generated)
Add migration 0020 creating engine.error_handling_config (kind TEXT PK,
payload JSONB) and seeding 10 casual pseudo-ghost phrases for the
chat-stream failure fallback path.
Add ErrorHandlingRepo::pick_chat_stream_fallback_phrase() helper with
rand::seq::SliceRandom-based random selection. Returns None on missing
row, empty array, or DB error so callers can fall back to the raw Error
frame as a last resort.
Three migration-level tests: seed count (exactly 10), picker round-trip
against seed, picker returns None when kind deleted.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(stream): pseudo-ghost fallback on chain exhaustion
When the chat-stream fallback chain exhausts, pick a configured phrase
from engine.error_handling_config and emit Meta + Delta + Done frames
as if the LLM returned a brief reply, instead of an Error frame. The
assistant row is persisted with metadata.fallback_reason='stream_failure'
for audit. Falls back to the original Error frame as a last resort if
the config lookup fails.
outcome.retries_chat is set to chain.len() so the Final frame correctly
reflects all retries exhausted. Both live mode and filtered mode chain-
exhaustion paths are covered.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(spec): error fallback config design
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: Cargo.lock update for rand 0.8 in eros-engine-store
* fix(store): supabase RLS + revoke lockdown on error_handling_config (codex P2)
Mirrors the 0013/0015 pattern: conditional REVOKE ALL from anon/authenticated,
ENABLE ROW LEVEL SECURITY. Defense-in-depth for Supabase deployments that
expose the engine schema via PostgREST. Also strips trailing whitespace
from the spec doc.
* fix(stream): persist pseudo-ghost row with model: None for replay idempotency (codex P2)
Live stream emits Meta with model: None on the pseudo-ghost path.
Persisting model: Some("__fallback_phrase__") meant replay_stream would
feed that sentinel through display_override and surface a different
meta.model than the original stream — a violation of the idempotent
replay contract. Drop the sentinel; metadata.fallback_reason carries
the audit signal.
* fix(stream): pseudo-ghost retries_chat semantics + continues_from link (codex P2)
Two findings from the second codex pass:
1. retries_chat over-reported: chain.len() includes the primary attempt;
the field is documented as fallback retries consumed (0 when primary
served). Fix both call sites to chain.len() - 1, and pass the same
corrected value through to the metadata audit field.
2. continues_from was always None on the pseudo-ghost frame + persisted
row. In live mode, the previous truncated bubble is already persisted
and visible to the client; the pseudo-ghost should link to it so the
replay path stitches the burst into one logical turn. Filtered mode
leaves it None — that path never persists intermediate truncations.
* fix(stream): replace produced list with pseudo-ghost on exhaustion (codex P2)
When live mode exhausts the chain, outcome.produced still held the
failed truncated attempts. Post-process (memory / affinity / insight
extraction) would then run on those partial garbage outputs instead of
the safe fallback phrase the user actually saw — and the old Error
path bypassed post-process entirely, so this was a behavioral
regression introduced by the pseudo-ghost path.
Fix: helper now returns the produced message alongside the frames;
call sites clear outcome.produced and push only the pseudo-ghost
before yielding success frames. Filtered mode never populated produced,
so clear() is a no-op there.
* fix(stream): replay omits meta.model when persisted row.model is None (codex P2)
Live stream emits Meta with model: None on the pseudo-ghost path.
Previous replay_stream code did display(row.model.as_deref().unwrap_or_default())
which under model_name_display_override = true / fixed-string / map.default
configurations would produce a non-None meta.model on replay, breaking
wire-identical idempotent retry.
Fix: only call display(...) when row.model is Some; otherwise emit None
to mirror the live emission. Existing replay tests still pass; the
display-override test continues to assert the Some(model) path correctly.
* docs(spec): document inherited Final-frame replay divergence (codex P2 ack)
Codex flagged that replay_stream emits Final with retries_chat=0, tier=None,
prompt_injected=None on the pseudo-ghost path. That's the same divergence
2026-05-25-chat-output-filter-design.md §2.8 explicitly accepted for every
completed turn — none of these Final-frame fields are persisted, so replay
reconstructs them from current state rather than the original wire shape.
The pseudo-ghost row DOES persist these values in metadata (audit-only).
A future PR can extend replay_stream to read metadata.retries_chat /
metadata.tier / metadata.prompt_traits if wire-identical Final replay
becomes a requirement. Not in scope for this PR.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(v0.5.0): chat_output_filter output validity gate (#55)
* feat(llm): surface finish_reason on non-streaming ChatResponse
Lets callers gate on content_filter (Gemini-style mid-response safety
truncation, also used by OpenAI). Wire-level WireChoice gains the
field; ChatResponse exposes it as Option<String>. Default None for
existing constructors.
* feat(filter): defensive output validity gate + per-model chain walk
run_output_filter no longer trusts any HTTP 200 from the filter LLM.
After each per-model response, run filter_output_invalidity:
- refusal pattern in leading 120 chars (curated list, zh + en)
- response < 80 chars (short-and-refusal-verb OR plain too-short)
- finish_reason = content_filter (Gemini/OpenAI safety blocking)
On any of these, log the rejection and walk to the next model in the
chain. If the whole chain exhausts, return None as before (fail-open:
emit and persist the original reply). retries_filter index reflects
the model that passed validity, not just one that responded 200.
* docs(spec): chat_output_filter output validity gate design
* fix(filter): validity gate matches refusal patterns case-insensitively (codex P2)
Codex caught: original case-sensitive contains check missed common
English refusal variants like 'i'm sorry, but i can't ...' (lowercase
i) or 'as an ai ...' (lowercase a) — both real-world model outputs.
The 200-char-plus apology would slip past the gate and be persisted
as the filtered rewrite, which is exactly what this feature is meant
to prevent.
Fix: lowercase the head (and the short-text body) before contains.
Pattern table moved to lowercase form. char::to_lowercase is
Unicode-aware; CJK code points are unchanged, so Chinese patterns
still match exactly. Added a regression test covering lower / mixed /
upper case English apology shapes.
* feat(filter): record fail-open audit in chat_messages.metadata
When the validity gate rejects every model in the filter chain (or all
models error/timeout), the engine falls open and emits the original
reply — but now also writes a fail-open audit bag into metadata so ops
can count fail-open rate per period and see which models are refusing.
New metadata keys (only present when filter was triggered AND every
model failed):
filter_outcome = "fail_open"
f_client_msg_id = engine-generated ULID for this logical call
filter_attempts[] = [{model, reason}] per chain attempt
Reasons: refusal_pattern / too_short / content_filter / empty / error /
timeout. Trigger-not-fired and filter-not-configured rows stay
metadata-clean (filter_outcome absent), so ops can SELECT * FROM
chat_messages WHERE metadata->>'filter_outcome' = 'fail_open' to find
exactly the failure cases.
run_output_filter now returns Result<RunFilterOutcome, FilterFailOpen>
carrying the per-attempt audit log on the Err side.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: prompt enhancements + scope persistence (#56)
* docs(spec): prompt enhancements + scope persistence design
Adds memory_scope/affinity_scope to chat_messages.metadata (pre-validation on user
rows, resolved on assistant rows) plus a raw prompt_traits audit on the user side
to surface frontend/backend allow-list mismatches.
Rewrites prompt.rs section headers to ASCII brackets (lighter on tokens, easier
to skim), adds a [recent_conversation] block carrying the prior three turn pairs,
and revises the iron rules: new positive-frame ⓪ in English plus a Japanese rewrite
of ③ that lists the actual pronoun and filler-word inventory.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(store): recent_turn_pairs for short-term memory injection
Returns up to N (user_or_gift_user, assistant) content pairs from a session,
filtered by truncated=false and capped at a cutoff timestamp. Used by the
chat pipeline to render [recent_conversation] in the system prompt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(prompt): rename 16 section headers to ASCII brackets
【...】 → [...]. Same section order, same line breaks, same conditional
blocks — string substitution only. Saves a small amount of tokens per
turn and makes the prompt code easier to skim for non-CN readers. Cache
prefix boundary unchanged; per-persona stable-prefix tests still pass.
Also updates the one cross-module test assertion in pipeline/stream.rs
that pinned the old 【刚收到的打赏】 literal, so the full server crate
stays green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: sweep stale 【...】 rustdoc references after header rename
Three doc-comments still named prompt sections by their old Chinese
literal — types.rs PromptTrait, handlers.rs hydrate_user_profile_bullets,
and routes/companion.rs PromptTraitDto. Doc-only fixup, no behavior change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fixup(prompt): finish header rename — openapi snapshot + public docs + test strings
After the 16-header rename:
- regenerate openapi.json so PromptTraitDto.text description reflects
[additional_guidance]; without this the openapi-snapshot CI check fails
- update docs/prompt-traits.{md,zh.md} which still described the layout
using 【附加指引】 / 【擅长话题】 / 【今日情境】 as live header names
- update 3 stale test comment / panic-message strings in prompt.rs that
still narrated the old Chinese labels
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fixup(openapi): regen snapshot cleanly (drop stray cargo log lines)
Prior fixup ran `cargo run > openapi.json` without --quiet/2>/dev/null,
which leaked 3 cargo build lines into the snapshot and broke JSON parse.
This regen uses --quiet so only the printer's JSON reaches the file.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(prompt): [recent_conversation] short-term memory block
build_prompt now takes recent_turns: &[(user, assistant)]. When non-empty,
renders a [recent_conversation] block between [now] and [iron_rules]
showing the last N turn pairs in 用户:X / {name}:Y form. Empty slice
omits the entire block including its header.
Call sites pass &[] for now; population wires up in a follow-up task.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fixup(prompt): empty recent_turns emits "\n" to preserve pre-PR blank line
Byte-stable empty-path: "[now]\n{tc}\n\n---" stays one blank line whether
[recent_conversation] renders or not. Single-char change, no behavior shift.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(prompt): iron rule ⓪ (positive identity) + Japanese rewrite of ③
⓪ "You are a real person ..." prepended for positive-frame identity
reinforcement (it's easier to say what to BE than what NOT to say).
③ rewritten in Japanese — Japanese has a richer pronoun inventory and
filler-word inventory than Chinese, so the rule lists the specific
alternates and quantifies "consecutive several" as "three or more
sentences". Reviewed via codex pass before commit.
Other iron rules ① ② ④ ⑤ ⑥ ⑦ ⑧ untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(stream): persist raw memory_scope / affinity_scope / prompt_traits
User and gift_user rows now carry memory_scope_raw / affinity_scope_raw /
prompt_traits_raw in chat_messages.metadata when the request supplied
them. These hold the pre-validation, pre-resolve frontend payload, so
operators can diff against the post-resolve values on the matching
assistant row (Task 6) to spot allow-list misconfiguration or field-shape
drift between frontend and backend.
Keys are omitted when the source request field is None — JSONB stays sparse.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(stream): persist resolved memory_scope / affinity_scope on assistant rows
build_metadata and the pseudo-ghost fallback now write memory_scope
(snake_case enum string) and affinity_scope (6-bool record) into
chat_messages.metadata for every assistant row. Pairs with the _raw
values written on the matching user/gift_user row to enable a single
metadata->>'...' diff that surfaces frontend/backend allow-list or
shape mismatches.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(handlers): inject [recent_conversation] into per-turn system prompt
handlers.rs now fetches the prior 3 (user|gift_user, assistant) pairs via
ChatRepo::recent_turn_pairs at each build_prompt call site (chat + gift)
and threads them in. Cutoff = Utc::now() so the current-turn user row is
excluded from its own [recent_conversation] block.
Fetch failures degrade to empty (no short-term memory) with a warn-level
log — prompt assembly is non-fatal.
Completes the short-term memory layer: the system prompt now carries
long-term facts ([user_profile]), mid-term memories ([shared_memories]),
and the literal last three exchanges ([recent_conversation]).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* style: cargo fmt
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(handlers): cutoff [recent_conversation] at current user row's sent_at
Codex P2 on PR #56: Utc::now() cutoff is racy under concurrent streams on
the same session — a later already-completed turn could leak into the
current turn's [recent_conversation] block.
Adds ChatRepo::recent_turn_pairs_before_message which subqueries the
current msg's sent_at as the cutoff. handlers.rs threads user_message_id
through build_reply_request / build_gift_request to use it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: silence clippy::too_many_arguments on build_gift_request
The codex P2 fix added user_message_id to build_gift_request, pushing it
to 8 args (over clippy's default 7). build_reply_request stayed at 7 so
needs no allow. Pure attribute addition; no behavior change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(snapshot): companion_insights_snapshot table + cron sweeper (#62)
* docs(superpowers): add v0.5.1 cleanup + snapshot design spec
Three independent slices grouped under one release window:
- §1 drop the NFT-ownership mirror (wallet_links / persona_ownership /
sync_cursors / asset_id / four /s2s/* endpoints / enforce_nft_ownership
gate) — user→wallet binding becomes a downstream concern
- §2 reshape chat_messages.filter_triggers JSONB to predicate
config-as-declared, with a one-shot wipe of legacy audit rows
- §3 add engine.companion_insights_snapshot table + cron sweeper as a
pure write-through history egress for eros-engine-web#181's private
worker (no LLM, no dedupe, no transformation)
Ships as three sequenced PRs on dev (0021 snapshot → 0022 filter →
0023 nft drop), promoted to main as a single chore(release): v0.5.1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(snapshot): add cron crate dep
* feat(snapshot): migration 0021 — companion_insights_snapshot table
Guards the anon/authenticated REVOKEs in pg_roles existence checks
(mirroring 0013) so non-Supabase Postgres — including the sqlx test
DB — skips them; RLS enable runs unconditionally.
* feat(snapshot): InsightRepo::snapshot_all_users
* feat(snapshot): SnapshotConfig + parse_snapshot_config wiring
Adds the SNAPSHOT_DISABLED / SNAPSHOT_CRON / SNAPSHOT_TZ env surface to
ServerConfig. Also threads the field through the companion test_state
helper (disabled). Fields are read once the sweeper lands in the next
task; the transient dead-code warning clears then.
* feat(snapshot): pipeline::snapshot::sweeper + cron unit tests
* feat(snapshot): spawn snapshot sweeper from main.rs
Spawned alongside the dreaming sweeper. OpenAPI snapshot verified
unchanged (no HTTP surface). Full-server boot smoke skipped locally
(needs prod secrets); disabled-path covered by parse_snapshot_config
unit tests + clean compile.
* chore(dev): open dev track at 0.5.1-dev
Bumps the workspace version and the inter-crate path-dep version pins
from 0.4.3-dev to 0.5.1-dev. Rides inside PR1 (squash-merged) rather
than a standalone PR0 — we have a single downstream, so the extra PR
round isn't worth it. README docker tag is a release-time bump, left
untouched here.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(filter): filter_triggers config-as-declared reshape (#63)
* feat(filter): FiredPredicates replaces TriggerHits — config-as-declared shape
* feat(filter): bind guard maps Value::Null filter_triggers to SQL NULL
* feat(filter): migration 0022 — wipe legacy-shape filter_triggers rows
* feat(filter): empty trigger persists SQL NULL filter_triggers
* docs(filter): supersede tip-role spec §4 filter_triggers shape
* chore(filter): regenerate openapi snapshot for 0.5.1-dev
Clears the version drift dev inherited from PR #62 (snapshot said
0.4.3-dev while Cargo is 0.5.1-dev). Diff is version-only — §2 adds no
HTTP surface (filter_triggers is operator-side audit, not in any DTO).
* refactor(filter): wipe migration 0022 on filter_triggers alone
Drop the redundant filter_model IS NOT NULL condition (per final review):
the migration runs at the v0.5.1 upgrade before any new-shape row exists,
so every non-null filter_triggers is legacy. Single-condition form is
strictly safer (catches any legacy row regardless of filter_model) and
matches intent. Spec §2 SQL updated to match.
* feat(teardown)!: remove NFT-ownership stack (BREAKING) (#64)
Drops the user→wallet ownership stack the engine no longer owns: wallet_links / persona_ownership / sync_cursors tables, persona_genomes.asset_id, /s2s/wallets/* + /s2s/ownership/* endpoints, HMAC s2s auth, the marketplace self-heal sync pipeline, the enforce_nft_ownership gate, and MARKETPLACE_SVC_* env wiring. Migration 0023 drops the three tables + the asset_id column. Engine is chat + insights only.
* feat(persona)!: reshape persona_genomes to chat-data-only (#67)
Strip engine.persona_genomes to chat-relevant fields and remove the engine's persona-availability surface. Drops is_active + avatar_url, removes GET /comp/personas + list_active, drops the chat-start availability gate, and adds destructive migration 0024. Catalog/availability/avatar are downstream concerns keyed by genome_id.
BREAKING CHANGE: GET /comp/personas removed; chat-start no longer rejects "inactive" genomes; persona_genomes.is_active and .avatar_url dropped (migration 0024, destructive, avatar_url not preserved).
* chore(dev): reopen 0.5.3-dev
Open the next dev cycle after the v0.5.2 release. Bumps workspace + 5
path-dep pins to 0.5.3-dev, regenerates Cargo.lock + openapi.json.
README docker examples stay at the released 0.5.2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: enriquephl <70942788+enriquephl@users.noreply.github.com>
* feat(input-filter): chat_input_filter user-input rewrite (#70)
Optional, off-by-default user-input rewrite filter (input-side mirror of
chat_output_filter). A global `input_filter` trigger on [tasks.chat_companion]
accepts a bool or a per-turn probability (false / true / 0.8); a meaningless
turn is rewritten by a second LLM (JSON verdict) before chat_companion. Original
stays in `content` (client-visible); rewrite goes to `pre_filter_content`
(model-facing). Reuses the 0019 audit columns — no migration. Fail-open; extraction
reads the original; tipped turns skipped; content-level non-verdicts keep the
original (no chain walk).
* feat(chat-vision): image input via vision describe (#71)
Image input on chat/stream: a single https image_url is described by a vision
model ([tasks.chat_vision]) into a fixed JSON schema {description, ocr_text,
people, scene}, folded into the text-only main chat model's prompt (current turn
+ history). Off by default, fail-open, no SQL migration (rides chat_messages.metadata).
Single image; tip+image rejected. Addresses all codex review findings.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
* fix(server): tip turns reach the model (gift_user prompt fix) + run_stream e2e tests (#73)
* docs(tip-fix): spec for gift_user prompt fix + run_stream e2e tests
Spec A of a two-spec split. Tip turns persist as role='gift_user' but
assemble_chat_request drops gift_user rows, so the current tip turn never
reaches the model and it parrots history (amount-independent). Fix maps
gift_user -> user in the model prompt. Adds two end-to-end #[sqlx::test]s
driving run_stream (tip regression + chat_vision path). No migration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(server): tip (gift_user) turns reach the model instead of parroting
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(server): e2e run_stream coverage for the chat_vision image path
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* style: cargo fmt
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refine(server): gate gift_user prompt inclusion to tip rows (codex P2)
Only gift_user rows carrying tips_amount_usd (tips) are promoted into the
chat prompt; legacy in-app Gift Event rows (a bare gift label, no tip
metadata) stay dropped — matching the signals_count gate in pipeline/mod.rs.
Adds is_tip_row + a unit test (tip promoted / legacy dropped, no gift_user
role on the wire); the e2e regression test now persists tip metadata as
production does. Whether to unify/remove gift_user + legacy Gift Events is
deferred to a discussion issue.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat: insight_extraction events table + OpenRouter audit columns (B1) (#74)
* docs(insight-events): spec for companion_insights_events + OpenRouter audit columns (B1)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(store): migration 0025 — companion_insights_events + affinity audit columns
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(store): OpenRouterCallMeta + InsightEventRepo::record
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat: persist OpenRouter audit trio on companion_affinity_events
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(server): write companion_insights_events rows per insight_extraction call
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* style: cargo fmt
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(server): allow too_many_arguments on persist_affinity (8th arg is audit meta)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat: geo insight fields + extraction-prompt precision + config-driven extraction prompts (B2) (#75)
* docs(spec): geo insight fields + extraction-prompt precision + config-driven extraction prompts (B2)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(store): add location/hometown/nationality to human_insights (migration 0026 + projection)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(store): assert geo fields default to None in missing-fields projection test
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(prompt): geo fields in insights schema + structured-prompt attribution clarity
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(handlers): add geo fields to sample_human_row fixture (compile after HumanInsightsRow grew)
Task 1 added location/hometown/nationality to HumanInsightsRow; the server-crate
test fixture must construct them. Pre-completes Task 3 Step 2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(prompt): assert structured prompt schema embeds geo fields
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(handlers): render geo cluster (所在地/老家/国籍) in both insight bullet renderers
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* style(handlers): rustfmt the geo-render test vec! (CI fmt gate)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(model_config): resolve_insight_extract/resolve_memory_extract (config-driven extraction prompts)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(model_config): pin extraction tasks' inherited retry_depth=2 (vs filter tasks' 1)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(post_process): facts extraction reads system prompt from config (system+user split)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(post_process): clarify the facts-resolve guard comment (gate ships in this change set)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(dreaming): memory extraction reads system prompt from config (system+user split)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(config): ship default insight/memory extraction prompts (anti-attribution, 简体)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(boot): refuse to boot when insight/memory extraction prompts are unset
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(prompt): drop now-false relocation/Traditional-Chinese note (B2 normalized it)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor: remove legacy Gift Event + dead GiftReaction machinery (gift_user → tip-only) (#72) (#76)
* docs(spec): remove legacy Gift Event + dead GiftReaction machinery (gift_user → tip-only)
Resolves the Issue #72 design: tear out the event_gift endpoint and the
confirmed-dead GiftReaction path; gift_user becomes tip-only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(api)!: remove legacy event_gift endpoint (Issue #72)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(companion): clean up event_gift fallout (reserve AppError::Internal, drop dead label_to_string, refresh docs)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(pipeline): gift_user is tip-only — drop is_tip_row gate + simplify signals
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(pipeline): remove dead gift-reaction request/prompt machinery
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(prompt): drop now-dead tip_personality param + orphaned JSONB insight renderer
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(core): remove dead Event::Gift + ActionType::GiftReaction taxonomy
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(store): assistant_action_type only ever 'reply' now (gift_reaction never produced)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(store): cleanup migration for legacy non-tip gift_user rows (0027) (#77)
* docs(spec): cleanup migration for legacy non-tip gift_user rows (#76 follow-up)
Resolves codex's two P2 findings on PR #76 at the data layer: a one-time
migration deleting role='gift_user' rows that lack tips_amount_usd metadata.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(store): cleanup migration deleting legacy non-tip gift_user rows (0027)
Resolves the codex P2 on #76 at the data layer: removes role='gift_user' rows
that lack tips_amount_usd metadata (legacy event_gift rows), so gift_user is
tip-only in data as well as code. Tips and user rows are preserved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(llm): X-OpenRouter-Categories attribution header + canonical X-OpenRouter-Title (#78)
Add OPENROUTER_APP_CATEGORIES as a third optional app-attribution env var,
mirroring OPENROUTER_APP_REFERER / OPENROUTER_APP_TITLE. When set, its value
is sent verbatim as the X-OpenRouter-Categories header (comma-separated
OpenRouter marketplace categories). OpenRouter silently ignores unrecognised
values, so the engine does no validation; an unparseable value is dropped
with a warning, like the other two headers.
Also switch the title header from the legacy X-Title to the current canonical
X-OpenRouter-Title (OpenRouter still accepts X-Title as an alias).
Docs (.env.example, README, README.zh, llm-audit, llm-audit.zh) and the three
attribution tests updated. OpenAPI snapshot unchanged.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(dev): reopen 0.6.1-dev
Open the next dev cycle after the v0.6.0 release. Bumps workspace + 5
path-dep pins to 0.6.1-dev, regenerates Cargo.lock + openapi.json.
README docker examples stay at the released 0.6.0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat: section-presence toggle for insight_extraction / memory_extraction (#81)
* docs(spec): section-presence toggle for insight_extraction / memory_extraction
Disable an extraction task by omitting its [tasks.*_extraction] section:
- section present → filter_prompt required, else boot-fail (today's gate, re-scoped)
- section absent → that extraction is off, engine boots fine (behavior change vs Spec B2)
- dreaming sweeper goes inert when memory_extraction section is absent
- no new config field, no schema change; model stays required
- reasoning on extraction tasks already works end-to-end — documented, not built
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(model_config): validate_extraction_prompts (present ⇒ filter_prompt required)
Section-presence gate helper: a present [tasks.*_extraction] section must carry a
usable filter_prompt; an absent section means that extraction is off. Unit-tested
at the ModelConfig level (no DB needed). Wired into the boot gate next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(server): presence-scoped extraction boot gate
Boot fails only when a [tasks.*_extraction] section is present but its filter_prompt
is blank. An absent section now boots fine (that extraction is off) — behavior change
from Spec B2's mandatory gate. Test asserts the shipped config still passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(dreaming): sweeper inert when memory_extraction section is absent
Early-return before claiming any sessions when resolve_memory_extract() is None,
so an omitted [tasks.memory_extraction] section turns the feature off cleanly
instead of retry-looping the per-row no-stamp path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(example): extraction sections are removable to disable
Comment both [tasks.*_extraction] sections to say: remove the section to turn the
feature off; filter_prompt is required while present. Add a commented
reasoning = { enabled = false } showing the optional force-off.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(model-config): document section-presence extraction toggle
Both language docs: present section ⇒ filter_prompt required (boot-fail otherwise);
absent section ⇒ extraction off. Note the 0.6.x behavior change and the reasoning
three-state. zh prose in 简体中文.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: refresh API reference + fix gift→tip / memory-injection drift (#82)
* docs: refresh API reference + fix gift→tip / memory-injection drift
api-reference (en + zh): drop the removed standalone gift routes
(/event/gift, /gifts) and document tips as a stream-turn field
(tips_amount_usd); add chat-vision image input (image_url); add the new
canonical GET /comp/affinity/{session_id}/event log; add the 5 new SSE
`final` fields (filtered / prompt_injected / tier / retries_chat /
retries_filter) + meta.continues_from; note BFF history tips_amount_usd;
healthz version 0.3.1 → 0.6.0 (+ dynamic note); refresh Source list.
Cross-doc staleness from the gift_user → tip-only refactor and the
memory-injection work:
- architecture: drop GiftReaction action / GiftHandler (now Reply/Ghost/Proactive)
- affinity-model: mark `gift` event_type legacy
- model-config: chat_companion no longer cites removed GiftHandler
- memory-layers: "lazy/write-mostly" → memory is injected per-turn via memory_scope
Docs only; no code or API surface change. New zh prose in 简体中文.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(api-reference): include time_decay in canonical affinity event_type filter
Review nit: the canonical GET /comp/affinity/{session_id}/event filter accepts
message|gift|proactive|ghost|time_decay (time_decay reserved/unwritten). The BFF
route's 4-value list stays as-is (it excludes time_decay).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat: opt-in LLM-based PDE — decision layer + rule guardrails + ghosting kill-switch + audit table (#83)
Opt-in LLM judge for per-turn action (reply_text / ghost / reserved image actions) + free-text inner_state. Off by default → byte-identical to the rule engine. Rule engine demoted to fallback + hard-safety ghost guardrail. ghosting=false kill-switch disables ghosting path-wide. companion_decision_events audit table (migration 0028). Sanitized inner_state, fail-open, best-effort audit.
Spec: docs/superpowers/specs/2026-06-04-llm-based-pde-design.md (dual-reviewed Opus + codex). 505 workspace tests, clippy -D warnings clean, fmt clean. codex PR review: 1 P3 (fixed).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
* feat: global provider exclusion + byte-BPE garble guard (issue #84) (#85)
Spec 1 of issue #84 (provider-quality half):
- Global [defaults].ignore_providers → provider.ignore on every OpenRouter call (chat/stream/vision).
- Byte-BPE garble guard: detect Ġ/Ċ-dense completions (no-whitespace + ≥2 markers + ≥8 chars + ≥3% density; Maltese/pinyin-safe), prefer fallback, salvage only a complete garble (preserving finish_reason), repair before persist so the DB never holds raw glyphs, error-level logging.
- One-off repair runbook (docs, not a migration).
7 rounds of Codex review addressed; 532 tests, clippy/fmt/openapi clean. Reply-quality work deferred to Spec 2.
* feat: AI-companion reply-quality lightweight layer (Spec 2, issue #84) (#86)
* docs: design spec — AI-companion reply-quality lightweight layer (Spec 2)
Six independent changes (one PR): sampling params (top_p + frequency/presence
penalty), dynamic anti-repetition avoid-list from recent assistant turns,
chat-prompt anti-narration/anti-ellipsis/respond-first directives,
memory-extraction specificity (prompt-only), a fixed persona-guard clause
re-appended after system_prompt (don't acknowledge AI / no safety disclaimers),
and recent affinity-event reasons injected as emotional context. Reply-action
layer + memory salience columns deferred.
* feat(llm): resolve top_p/frequency_penalty/presence_penalty (task-level)
* feat(llm): forward top_p/frequency_penalty/presence_penalty to OpenRouter wire (sync + stream)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(server): pass resolved sampling params onto chat ChatRequest
* feat(config): ship opinionated chat sampling defaults (top_p/freq/presence)
* feat(server): add pure overused_openings anti-repetition miner
* fix(server): silence repetition dead_code until pipeline wiring; add edge tests
* feat(store): add recent_assistant_contents fetch for anti-repetition
* feat(store): add recent_emotional_reasons fetch for emotional context
* feat(prompt): add volatile [avoid_repetition] + [emotional_context] sections
Extends build_prompt signature with two new per-turn slice args
(avoid_patterns, emotional_context) and renders them as volatile
sections after the stable cache prefix. Both sections are omitted
when the slice is empty. Updated all 22 existing call sites to pass
empty slices; handlers.rs stub comments mark wiring point for a
later task. Cache-prefix invariant test strengthened with non-empty
new args to confirm the stable-prefix boundary is unaffected.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(prompt): make volatile-section ordering asserts fail loudly on missing marker
* feat(prompt): add anti-self-narration / ellipsis / first-person iron-rule directives
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(prompt): always re-inject constant PERSONA_GUARD after authored head
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(prompt): make PERSONA_GUARD ordering asserts fail loudly on missing marker
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(config): push extraction prompts toward specific, evidenced memories
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(server): wire avoid_repetition + emotional_context into chat prompt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* style: cargo fmt for reply-quality test code
* fix(store): cut recent_emotional_reasons off at the current turn (codex P2)
A concurrent same-session stream could finish post-processing before an
earlier turn assembled its prompt, leaking a later turn's affinity reason
into the earlier [emotional_context]. Add a before_message_id cutoff
(e.created_at < the current user row's sent_at), mirroring
ChatRepo::recent_assistant_contents and the sibling recent-chat queries.
New test covers the future/concurrent-event exclusion.
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore(release): v0.6.1 — strip -dev, regenerate openapi, bump README docker tag
---------
Signed-off-by: enriquephl <70942788+enriquephl@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Six small, independent prompt/quality changes that make companion replies less empty / templated / self-absorbed / out-of-character — no new architecture, no schema migration, no API surface change. Implements
docs/superpowers/specs/2026-06-13-companion-reply-quality-design.md(Spec 2 of the issue #84 follow-up).top_p/frequency_penalty/presence_penalty(Option<f32>, task-level) plumbed config → wire (sync + stream) → pipeline, with opinionated defaults inexamples/model_config.toml(0.9 / 0.4 / 0.2).None⇒ omitted from the wire, so a deployment setting none produces a byte-identical OpenRouter body. Norepetition_penalty(provider-inconsistent, distorts CJK).repetition::overused_openingsmines over-used reply openings from the persona's recent assistant turns (ChatRepo::recent_assistant_contents), injected as a volatile[avoid_repetition]block.我看着…), ellipsis overuse, and consecutive我-openings.memory_extraction/insight_extractionfilter_prompts push for concrete, evidenced memories (good-vs-bad examples); 5-category + anti-attribution + JSON contracts preserved.PERSONA_GUARDre-appended after the persona's authoredsystem_prompt(no AI-acknowledgment / no meta-leakage / no self-censorship), anchored "within all other hard constraints" so it cannot override iron-rule ⑦ (minor-safety).AffinityRepo::recent_emotional_reasons) rendered oldest→newest as a volatile[emotional_context]block.Invariants preserved
PERSONA_GUARDis a constant in the stable prefix; the two new sections are volatile (after the[turn_style]cut). Empty inputs render byte-identically to before.recent_turn_pairs.Test Plan
cargo fmt --all -- --checkcleancargo clippy --workspace --all-targets -- -D warningscleancargo test --workspace --all-features— 554 tests, 0 failures (incl. new unit tests for the miner, both store fetches, the two prompt sections, the iron-rule directives,PERSONA_GUARD, sampling resolve/wire, and cache-prefix invariants)🤖 Generated with Claude Code