chore: back-merge main into dev (v0.7.0 closure) + reopen 0.7.1-dev#136
Merged
Conversation
* 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(dev): reopen 0.6.2-dev
* chore(server): mark eros-engine-server publish = false (GHCR-only binary)
* feat(pde): persona injection + structured output (audit 22 §6) (#90)
紧凑人格注入 build_pde_ctx(复用现有 genome 字段,无迁移)+ PDE 请求 response_format: json_schema(config-gated structured_output 默认开)+ parse-error 走完 fallback 链再回规则引擎。采纳 eros-audit 报告 22 的两条代码建议。Spec: docs/superpowers/specs/2026-06-23-pde-persona-and-structured-output-design.md
* feat(affinity): stamp eval_skip_reason on NULL-audit-trio events (#91)
companion_affinity_events audit trio (model/usage/generation_id) is populated only from a successful affinity_evaluation call; every other turn left it NULL. Stamp context.eval_skip_reason on every NULL-trio row so it is always explainable (short_user_msg/empty_assistant/image_reply/proactive/no_persona_or_affinity/eval_error/eval_timeout/eval_no_generation_id). No backfill, no schema/API change. Includes spec §3b addendum.
* feat: reply_image / reply_text_image image executor (#92)
* docs(spec): reply_image / reply_text_image image executor design
Design for the companion image-reply executor that flips the PDE's
reserved reply_image / reply_text_image actions into real ones
(tasks.chat_image_generation). Mirrors chat_vision in the output
direction; no migration.
Key decisions captured:
- Delivery: relay base64 data-URL in a new SSE Image frame; client
stores it and writes the https URL back (engine persists no bytes).
- Trigger: PDE decides autonomously AND the frontend can force per turn.
- Prompt: deterministic compose (style preset + optional persona
appearance + subject), no extra LLM call; image2image via face_ref_url.
- Model: optional config model/fallback via a unified per-turn candidate
list (per-turn override -> config ModelSpec -> config fallback, dedup
keep-first); empty -> degrade to reply_text.
- Affinity now evaluated on image turns (image_prompt as the
assistant-content proxy for reply_image).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(core): ActionPlan.image_prompt threaded through plan_for
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(llm): StyleKey presets + ResolvedImageGen + resolve_image_gen
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(llm): effective_image_chain unified candidate list + dedup
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(llm): execute_image (modalities + image2image + images[] parse)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(store): merge_assistant_image_meta + set_assistant_image_url (no migration)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(server): image reply request params + validation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(server): update canonical crates/eros-engine-server/openapi.json; drop stray root openapi.json
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(server): Image SSE frame + image FrameActionType variants
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(server): compose_image_prompt + assistant image history fold
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(server): PDE keeps image actions + forced path + image_prompt capture
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(server): image-reply execution arm (generate, relay Image frame, persist metadata.image)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(server): generated-image URL write-back endpoint
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(server): evaluate affinity on image turns (image_prompt proxy for reply_image)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(image-reply): config example, persona appearance, api/model-config docs, 403 + openapi
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(image-reply): clarify failed-image client contract per action shape
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(server): refresh lead_score on reply_text_image turns (text-bearing)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(server): scope image write-back to session (IDOR) + gate image-gen on task block (opt-in)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(server): refresh lead on degraded reply_image + affinity-eval subjectless image turns
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(dev): reopen 0.6.3-dev
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: slim README (en/zh/ja) + audit-driven docs/examples fixes (#95)
* docs: slim README (5 pillars + roadmap) + audit-driven doc/example fixes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: codex polish (en) + refined zh + new README.ja.md, fact-checked
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: full Simplified-Chinese re-sync of model-config.zh.md (#96)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(readme): emoji per pillar + checklist Roadmap (en/zh/ja) (#97)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(server): empty SUPABASE_JWT_SECRET fails fast instead of all-reject boot (#100)
* fix(server): empty SUPABASE_JWT_SECRET fails fast instead of all-reject boot
The boot guard tested whether SUPABASE_JWT_SECRET was *set*, but the legacy
HS256 source is only wired when it is *non-empty*. So `SUPABASE_JWT_SECRET=""`
with no JWKS source slipped past the guard and booted a validator with no
sources — fail-closed (rejects every request) but contradicting the documented
"refuses to boot if no auth source is set" contract, and forcing operators to
debug blanket 401s instead of getting a clear boot error.
Extract the decision into a pure `resolve_legacy_secret` helper that empty-
filters the secret in one place, so the boot guard agrees with what actually
gets wired. Add unit tests for the boundary (no source / empty-secret-no-jwks
bail; jwks-alone, empty-secret-with-jwks, and non-empty-secret-alone pass).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs: clarify empty SUPABASE_JWT_SECRET counts as unset (fail-fast at boot)
Document the boot contract codified in the preceding fix: an empty
SUPABASE_JWT_SECRET is treated as absent, so the server refuses to boot
unless a JWKS source or a non-empty secret is configured.
- .env.example: note the shipped-empty secret counts as unset.
- deploying.md / deploying.zh.md: the compose example wires only the legacy
secret, so call out that an empty value fails fast by design (and how to
switch to JWKS instead).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(llm): support image-only OpenRouter output models (#101) (#102)
* docs(spec): image-only output model support design (#101)
Decouple the image-gen call from text: request modalities ["image"] only so
image-only OpenRouter models stop 404-ing. Records the key finding that text
and image generation are already two separate calls, so the fix collapses to
the modalities change plus removing the dead ImageGenResponse.text field.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(llm): request image-only modality for image-gen (#101)
Image-only OpenRouter models reject modalities ["image","text"] with a 404;
the engine never uses the image model's text, so ask for ["image"] only. This
is backward-compatible for text-capable image models, which still return the
image.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* refactor(llm): drop dead ImageGenResponse.text field (#101)
With image-gen now requesting image-only output, the captured text was already
unused everywhere (reply_image writes empty content; reply_text_image's caption
comes from chat_companion). Remove the field and its extraction.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(model-config): note image-only models are supported (#101)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(server): per-turn image opt-in via the request image block (#101)
Once a fallback was configured under [tasks.chat_image_generation], the image
executor was always available, so a caller could not suppress image generation
for a specific turn — the PDE could return reply_image / reply_text_image on any
turn and it executed. Make the per-request `image` block the opt-in:
image_executor_available = req_image.is_some() && image_chain.is_some()
Omit `image` to turn generation off for the turn (image actions degrade to
reply_text via guard_action); send `image: {}` to enable with the config model.
Mirrors chat_vision, which runs only when image_url is present. force_image and
guard_action already consume this bool, so nothing else moves.
Docs updated: PDE action contract + chat_image_generation activation prose
(model-config.md/.zh) and the image-field opt-in semantics (api-reference.md/.zh).
Spec records the follow-on.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(server): don't advance image round-robin cursor on opted-out turns (#101)
Codex review P2: effective_image_chain calls ModelSpec::select(), which advances
the round-robin cursor. Calling it on every turn meant opted-out / text turns
consumed image-model RR slots, skewing the sequencing of later opted-in image
turns. Resolve the chain only when the caller opted in (req_image present), so
disabled turns have no image-model selection side effects.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* chore(dev): reopen 0.6.4-dev
Open the dev track at 0.6.4-dev after the v0.6.3 release. Workspace + 3 crate
path-dep pins bumped; Cargo.lock + openapi.json regenerated. README docker pull
stays at the released :0.6.3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(pde): emit per-turn image-availability line in judge context (#105)
* docs(spec): PDE image-availability context signal
Thread image_executor_available into build_pde_ctx so the judge can
condition image-action selection on real per-turn availability instead of
proposing blindly. Emits a stable [图片能力] 本轮可发图=是/否 context line.
0.6.4 dev track, no migration, additive.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(pde): emit per-turn image-availability line in judge context
* docs(pde): document the image-availability context line for prompt authors
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(dev): reopen 0.6.5-dev
Open the next dev cycle. Bump workspace + path-dep pins to 0.6.5-dev,
regenerate Cargo.lock + openapi.json. README docker-pull stays at the
just-released 0.6.4.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: enriquephl <70942788+enriquephl@users.noreply.github.com>
* feat(prompt-log): raw prompt disk log for main reply (PROMPT_LOG_DIR) (#108)
* docs(spec): raw prompt disk log for main reply
Design for optionally persisting the fully-assembled main-reply prompt to
a human-readable file per turn, gated by a single PROMPT_LOG_DIR env var
that doubles as the destination (point it at a docker/fly volume). Lives
entirely in eros-engine-server; fire-and-forget so the send path never
blocks on disk IO. No migration, no crates.io surface change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(prompt-log): add PROMPT_LOG_DIR config (ServerConfig.prompt_log_dir)
* feat(prompt-log): add prompt_log module (render/file_name/write_file/spawn_write)
* feat(prompt-log): write reply prompt to disk when PROMPT_LOG_DIR is set
* docs(prompt-log): document PROMPT_LOG_DIR + docker/fly volume usage
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(prompt-log): fmt
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(dev): reopen 0.7.0-dev
Next crates-publishing release is v0.7.0. Bump 0.6.5 -> 0.7.0-dev across
the workspace version strings, Cargo.lock, and openapi.json. README docker
tags stay at the released 0.6.5.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat: per-model deterministic regex output filter (#114)
Adds output_regex on [tasks.chat_companion]: a deterministic, per-model regex
strip of the assistant reply (layer 0 — before client emit, the optional LLM
output_filter, and the memory/insight/affinity extract). Raw reply retained on
pre_filter_content with filter_model "<regex>". Default-off, no store migration,
no HTTP-contract change. Targeted mitigation of the euryale reply_text_image
"[你给对方发送了一张照片:…]" artifact (partial mitigation of #113). Also reopens
the dev line at 0.6.6-dev.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat: image iteration, ref reuse, real aspect ratio, prompt composer (#115)
Phase 1 (engine) of the image-generation overhaul: prior-image transcript marker, PDE image_ref+aspect_ratio, prev_image_url + reference selection, real aspect-ratio (width/height) control, and an optional chat_image_prompt_compose LLM (built-in expand-only default, config-overridable). Backward-compatible; inert until config opts in. 643 tests green.
* fix: break companion reply-collapse feedback loop (#113) (#116)
Stop persisting assistant prose as recallable relationship memory (user-turn-only write, once per turn), filter legacy transcript rows at recall, dedup recalled memories (exact-equality), and retire the redundant gaze-template callout in iron-rule 9. 3 codex P2s fixed during review. No schema/config/API change.
* chore(dev): reopen 0.6.7-dev
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat: reply_to_message_id context anchoring (#123)
Optional reply_to_message_id on the chat stream request rewinds the turn's main history to the quoted message (Latest / Rewind / DropHistory). Metadata-stored, no migration; look-back blocks stay on the new message; idempotency key untouched.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
* feat: image-gen failure diagnostics + composed→original retry (#122) (#124)
* docs: spec for image-gen failure diagnostics + composed→original retry
Combined design for issue #122 (persist metadata.image_failed with the
composed prompt + per-attempt outcomes on total image-chain failure) and
the composed→original fallback retry (model-outer/variant-inner walk when
chat_image_prompt_compose is enabled). Both rewrite execute_image's
contract, so they ship as one PR.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(llm): execute_image records per-attempt outcomes + composed→original walk
New ImageGenError/ImageAttempt/AttemptOutcome/PromptVariant types;
execute_image returns Result<ImageGenResponse, ImageGenError> with
attempts on both paths, and walks model×[composed,original] via
plan_attempts. (Server crate wires up in a later commit.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(store): merge_assistant_metadata_key generic metadata merge
Sibling of merge_assistant_image_meta for arbitrary top-level keys
(e.g. image_failed). Session- and role-scoped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(store): decode metadata as Option in IDOR-guard read-back
Match the happy-path block; avoids a panic if metadata were NULL.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): wire image sites to new execute_image contract
build_image_gen_request gains original_subject (callers pass None for
now); both execute_image sites match Result<_, ImageGenError>. No
behavior change yet.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(server): persist metadata.image_failed on total image-gen failure (#122)
On ChainExhausted, persist the composed prompt + per-attempt outcomes:
text+image merges onto its text row; image-only stashes and attaches to
the fallthrough text row. Success path also records skipped attempts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(server): retry the original PDE prompt after the composed one
When chat_image_prompt_compose rewrote the subject, pass the original
subject so execute_image walks model×[composed,original]. Guarded so a
no-op compose keeps the single-variant walk.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* style: cargo fmt the image-gen diagnostics + retry changes
rustfmt reflow of Task 1/4/5 code (line-width wrapping in openrouter.rs,
stream.rs, chat.rs). No logic change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: expose winning_variant …
bump workspace + path-dep pins 0.7.0 -> 0.7.1-dev; regen Cargo.lock + openapi.json. README docker stays at the released 0.7.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the v0.7.0 release loop: merges
main(the released 0.7.0) back intodevso their histories don't diverge, then reopensdevat0.7.1-dev.Two commits:
chore: back-merge main into dev (v0.7.0 closure)— merge commit (version files resolved to main's released 0.7.0; feature content identical on both sides, so no content conflicts).chore(dev): reopen 0.7.1-dev— bump workspace + path-dep pins to0.7.1-dev, regenCargo.lock+openapi.json. README docker-pull stays at the released0.7.0.Merge with "Create a merge commit" (NOT squash) — squashing would collapse the ancestry and re-introduce the divergence this PR exists to prevent.
🤖 Generated with Claude Code