Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions .github/workflows/release-docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,15 @@ jobs:
else
ref="${{ github.ref_name }}"
fi
version="${ref#v}"
echo "ref=$ref" >> "$GITHUB_OUTPUT"
echo "version=${ref#v}" >> "$GITHUB_OUTPUT"
echo "version=$version" >> "$GITHUB_OUTPUT"
# Dev cuts (e.g. v0.4.21-dev) move :latest-dev and never touch :latest;
# stable cuts move :latest.
case "$version" in
*-dev*) echo "moving=latest-dev" >> "$GITHUB_OUTPUT" ;;
*) echo "moving=latest" >> "$GITHUB_OUTPUT" ;;
esac

- uses: actions/checkout@v4
with:
Expand All @@ -51,7 +58,7 @@ jobs:
push: true
tags: |
ghcr.io/etherfunlab/eros-engine:${{ steps.ref.outputs.version }}
ghcr.io/etherfunlab/eros-engine:latest
ghcr.io/etherfunlab/eros-engine:${{ steps.ref.outputs.moving }}
cache-from: type=gha
cache-to: type=gha,mode=max
labels: |
Expand Down
9 changes: 5 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/*"]

[workspace.package]
version = "0.4.20"
version = "0.4.21"
edition = "2021"
license = "AGPL-3.0-only"
repository = "https://github.com/etherfunlab/eros-engine"
Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,10 @@ Each chat session carries a six-dimensional relationship vector. The six axes
| `patience` | 0.0 to 1.0 | Tolerance for low-effort or repeated messages. |
| `tension` | 0.0 to 1.0 | Push-pull, friction, and playful resistance. |

Two **composite scores** summarize this vector for prompt-shaping — each is the mean of a disjoint triplet of axes (`warmth` is rescaled to 01 first):
Two **composite scores** summarize this vector for prompt-shaping — each is the mean of a disjoint triplet of axes (`warmth` is rescaled to 0-1 first):

- **bond** (朋友感, how close it feels) = mean(`warmth`, `intimacy`, `tension`)
- **chemistry** (来电感, how charged it feels) = mean(`trust`, `intrigue`, `patience`)
- **bond** (how close it feels) = mean(`warmth`, `intimacy`, `tension`)
- **chemistry** (how charged it feels) = mean(`trust`, `intrigue`, `patience`)

Updates are smoothed with exponential moving average (EMA), so the persona does not jump between emotional states. `intrigue`, `patience`, and `tension` also decay or recover with real time. The smoothing strength is set by `EMA_INERTIA` (default `0.8`): each turn applies only `1 − inertia` of the evaluated delta, so a higher value makes the relationship build (and cool) more slowly — in effect a difficulty dial — while `0` applies every delta in full.

Expand Down Expand Up @@ -133,7 +133,7 @@ eros-engine-llm = "0.4" # only if you want the OpenRouter + Voyage clients
Multi-arch (`linux/amd64`, `linux/arm64`) images for `eros-engine-server` are published to GitHub Container Registry on every `v*` tag:

```bash
docker pull ghcr.io/etherfunlab/eros-engine:0.4.1
docker pull ghcr.io/etherfunlab/eros-engine:0.4.21
# or track the latest tagged release
docker pull ghcr.io/etherfunlab/eros-engine:latest
```
Expand All @@ -142,7 +142,7 @@ Minimal run (you bring Postgres + your own `.env`):

```bash
docker run --rm -p 8080:8080 --env-file .env \
ghcr.io/etherfunlab/eros-engine:0.4.1 serve
ghcr.io/etherfunlab/eros-engine:0.4.21 serve
```

The `docker/Dockerfile` is the same artifact used to build this image. Deploy it on any container host.
Expand Down
82 changes: 82 additions & 0 deletions crates/eros-engine-core/src/pde.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,27 @@ const GHOST_DELTA_TENSION: f64 = 0.05;
///
/// Phase 2: rules only. Phase 6 adds the LLM fallback path.
pub fn decide(input: &DecisionInput) -> ActionPlan {
// 0. Tip on a user message — always reply, never ghost. Tone is driven by
// tip_personality (injected into the prompt downstream); the ReplyStyle
// here is only a baseline / fallback. Affinity deltas stay normal.
if let Event::UserMessage {
tips_amount_usd: Some(_),
..
} = &input.event
{
let reply_style = match input.persona.genome.tip_personality.as_deref() {
Some(_) => ReplyStyle::Neutral,
None => ReplyStyle::Tsundere,
};
return ActionPlan {
action_type: ActionType::Reply,
reply_style,
affinity_deltas: predict_reply_deltas(input),
energy_cost: ENERGY_COST_REPLY,
context_hints: vec![],
};
}

// 1. Gift event — deterministic
if matches!(input.event, Event::Gift { .. }) {
return ActionPlan {
Expand Down Expand Up @@ -211,6 +232,20 @@ mod tests {
tier: None,
memory_scope: Default::default(),
affinity_scope: Default::default(),
tips_amount_usd: None,
}
}

fn tip_msg(amount: f64) -> Event {
Event::UserMessage {
content: String::new(),
message_id: Uuid::new_v4(),
prompt_traits: Vec::new(),
audit: None,
tier: None,
memory_scope: Default::default(),
affinity_scope: Default::default(),
tips_amount_usd: Some(amount),
}
}

Expand All @@ -236,6 +271,53 @@ mod tests {
assert_eq!(plan.reply_style, ReplyStyle::Excited);
}

#[test]
fn test_tip_forces_reply_even_when_ghost_signals_present() {
// Same affinity that drives test_ghost_threshold_triggers_ghost_action.
let mut affinity = base_affinity();
affinity.intrigue = 0.05;
affinity.patience = 0.05;
affinity.tension = 0.5;
let input = DecisionInput {
event: tip_msg(20.0),
affinity,
persona: persona_with_tip(None),
signals: base_signals(),
};
let plan = decide(&input);
assert_eq!(
plan.action_type,
ActionType::Reply,
"a tip must never be ghosted"
);
}

#[test]
fn test_tip_reply_style_neutral_when_personality_present() {
let input = DecisionInput {
event: tip_msg(20.0),
affinity: base_affinity(),
persona: persona_with_tip(Some("傲娇")),
signals: base_signals(),
};
let plan = decide(&input);
assert_eq!(plan.action_type, ActionType::Reply);
assert_eq!(plan.reply_style, ReplyStyle::Neutral);
}

#[test]
fn test_tip_reply_style_tsundere_when_personality_absent() {
let input = DecisionInput {
event: tip_msg(20.0),
affinity: base_affinity(),
persona: persona_with_tip(None),
signals: base_signals(),
};
let plan = decide(&input);
assert_eq!(plan.action_type, ActionType::Reply);
assert_eq!(plan.reply_style, ReplyStyle::Tsundere);
}

#[test]
fn test_ghost_threshold_triggers_ghost_action() {
let mut affinity = base_affinity();
Expand Down
22 changes: 22 additions & 0 deletions crates/eros-engine-core/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ pub enum Event {
/// Defaults to `bond` when absent.
#[serde(default)]
affinity_scope: AffinityScope,
/// Optional caller-supplied tip amount in USD. When `Some`, this turn
/// is a tip: the PDE forces a reply (never ghost) and the reply prompt
/// gets a tip fragment. `None` for normal messages.
#[serde(default)]
tips_amount_usd: Option<f64>,
},
Gift {
gift_id: Uuid,
Expand Down Expand Up @@ -224,6 +229,23 @@ mod tests {
}
}

#[test]
fn event_user_message_defaults_tips_amount_to_none() {
let raw = r#"{"UserMessage":{"content":"hi","message_id":"00000000-0000-0000-0000-000000000001"}}"#;
let ev: Event = serde_json::from_str(raw).expect("legacy body deserialises");
match ev {
Event::UserMessage {
tips_amount_usd, ..
} => {
assert!(
tips_amount_usd.is_none(),
"missing field must default to None"
);
}
_ => panic!("expected UserMessage"),
}
}

#[test]
fn chat_response_defaults_audit_fields_to_none() {
let r = ChatResponse {
Expand Down
2 changes: 1 addition & 1 deletion crates/eros-engine-llm/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ keywords = ["companion", "openrouter", "voyage", "embeddings", "llm"]
categories = ["api-bindings"]

[dependencies]
eros-engine-core = { path = "../eros-engine-core", version = "0.4.20" }
eros-engine-core = { path = "../eros-engine-core", version = "0.4.21" }
serde = { workspace = true }
serde_json = { workspace = true }
reqwest = { workspace = true }
Expand Down
Loading
Loading