Skip to content

feat(persona)!: reshape persona_genomes to chat-data-only#67

Merged
enriquephl merged 6 commits into
devfrom
feat/persona-genomes-chat-data-only
Jun 1, 2026
Merged

feat(persona)!: reshape persona_genomes to chat-data-only#67
enriquephl merged 6 commits into
devfrom
feat/persona-genomes-chat-data-only

Conversation

@enriquephl

Copy link
Copy Markdown
Member

Strips engine.persona_genomes to the fields the chat pipeline actually uses and removes the engine's persona-availability surface entirely. Catalog / availability / creator / avatar become downstream concerns, keyed by genome_id.

What changed

  • Drop is_active (availability gate) and avatar_url (display data) from PersonaGenome and every consumer.
  • Remove GET /comp/personas — the only endpoint exposing genome data over HTTP — plus its DTOs and PersonaRepo::list_active(). Engine now exposes zero genome data over HTTP.
  • Drop the chat-start availability gate: resolve_or_create_session no longer returns 400 "genome is not active". Existence-only — 404 if the genome row is missing.
  • No metadata column added — downstream owns persona catalog/availability in its own store.
  • Migration 0024 (destructive) drops the two columns; docs + example persona TOMLs updated.

Final schema: id, name, system_prompt, tip_personality, art_metadata, created_at.

⚠️ BREAKING

  • GET /comp/personas removed.
  • chat-start no longer rejects "inactive" genomes (downstream gates before sending a genome_id).
  • persona_genomes.is_active and .avatar_url dropped — migration 0024 is destructive; avatar_url is not preserved (export first if a live deployment needs it).

Test plan

  • cargo fmt --all --check clean
  • cargo clippy --workspace --all-targets -- -D warnings clean
  • cargo test --workspace — 421 pass (incl. migration_0024_strips_persona_genomes_to_chat_data)
  • OpenAPI snapshot regenerated, no drift
  • Codex review
  • CI green

Spec: docs/superpowers/specs/2026-06-01-persona-genomes-chat-data-only-design.md

🤖 Generated with Claude Code

enriquephl and others added 6 commits June 1, 2026 22:51
Strip persona_genomes to chat-relevant fields: drop is_active and
avatar_url, remove GET /comp/personas and the availability gate. No
metadata column — downstream owns catalog/availability keyed by
genome_id. Mirrors the 0023 NFT-stack teardown boundary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The personas listing was the only endpoint exposing genome data over
HTTP. engine no longer owns the persona catalog — downstream lists and
gates personas itself, keyed by genome_id. Repoint the auth 401 smoke
test to /comp/chat/{id}/sessions.

BREAKING CHANGE: GET /comp/personas is removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: enriquephl <70942788+enriquephl@users.noreply.github.com>
resolve_or_create_session no longer returns 400 "genome is not active";
a genome row that exists starts a chat (404 if missing). GenomeGate is
trimmed to name only. Downstream gates availability before sending a
genome_id.

BREAKING CHANGE: chat-start no longer rejects "inactive" genomes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: enriquephl <70942788+enriquephl@users.noreply.github.com>
Remove both fields from PersonaGenome and every consumer: GenomeRow,
load_companion, upsert_genome, the seed-personas loader, and all test
fixtures. Genome now carries only chat-relevant data. Schema column
drop follows in the migration.

BREAKING CHANGE: PersonaGenome no longer exposes avatar_url or is_active.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: enriquephl <70942788+enriquephl@users.noreply.github.com>
DESTRUCTIVE migration stripping persona_genomes to chat-relevant
columns. avatar_url data is not preserved — export before applying if a
deployment needs it. Mirrors the 0023 schema-assertion test.

BREAKING CHANGE: persona_genomes.is_active and .avatar_url are dropped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: enriquephl <70942788+enriquephl@users.noreply.github.com>
Remove the deleted personas endpoint from the API reference and READMEs,
and the avatar_url key from the example persona TOML files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: enriquephl <70942788+enriquephl@users.noreply.github.com>
@enriquephl enriquephl merged commit 0ee6c37 into dev Jun 1, 2026
6 checks passed
@enriquephl enriquephl deleted the feat/persona-genomes-chat-data-only branch June 1, 2026 16:24
@enriquephl enriquephl mentioned this pull request Jun 1, 2026
enriquephl added a commit that referenced this pull request Jun 1, 2026
v0.5.2 — persona_genomes chat-data-only reshape (promotes #67).

Drops is_active + avatar_url, removes GET /comp/personas + list_active, drops the chat-start availability gate, adds destructive migration 0024. Version bump 0.5.2-dev → 0.5.2 (workspace + 5 path-dep pins, Cargo.lock, openapi.json, README docker examples).

BREAKING CHANGE: GET /comp/personas removed; chat-start no longer rejects "inactive" genomes; persona_genomes.is_active and .avatar_url dropped (migration 0024, destructive).
enriquephl added a commit that referenced this pull request Jun 3, 2026
* 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(release): v0.6.0

Bump workspace 0.5.3-dev → 0.6.0 (4 Cargo.toml version pins, Cargo.lock,
openapi.json, README docker tags). Promotes the dev track to main.

Highlights since v0.5.2:
- chat-vision image input via vision describe
- chat_input_filter user-input rewrite layer
- insight_extraction events table + OpenRouter audit columns
- geo insight fields + config-driven extraction prompts
- gift_user → tip-only (drop legacy Gift Event + GiftReaction; cleanup migration 0027)
- tip turns reach the model (gift_user prompt fix)
- OpenRouter X-OpenRouter-Categories attribution header + canonical X-OpenRouter-Title

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

---------

Signed-off-by: enriquephl <70942788+enriquephl@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant