Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -0,0 +1,29 @@
[package]
name = "nomifun-companion"
version.workspace = true
edition.workspace = true
[dependencies]
nomifun-common.workspace = true
nomi-redact.workspace = true
nomi-memory.workspace = true
nomifun-db.workspace = true
nomifun-api-types.workspace = true
nomifun-realtime.workspace = true
nomifun-ai-agent.workspace = true
nomifun-extension.workspace = true
nomifun-auth.workspace = true
nomifun-conversation.workspace = true
axum.workspace = true
tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
sqlx.workspace = true
chrono.workspace = true
tracing.workspace = true
async-trait.workspace = true
zip.workspace = true
reqwest.workspace = true
[dev-dependencies]
tempfile.workspace = true
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,243 @@
//! Persisted companion configuration: opt-in collection switches, learning model,
//! persona, appearance and quiet-hours. Stored as `config.json` under the companion
//! dir with atomic temp+rename writes (same pattern as cron skill files).
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
/// The roster character every companion falls back to when none is configured.
pub(crate) const DEFAULT_CHARACTER: &str = "mochi";
/// Which event sources the user has opted into collecting. The work-event
/// sources all default OFF; `companion_dialogues` (direct conversations with the
/// companions) defaults ON — talking to the companion is itself the opt-in.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct CollectConfig {
pub chat_user_messages: bool,
pub chat_assistant_replies: bool,
pub requirements: bool,
pub cron_runs: bool,
pub conversation_lifecycle: bool,
pub terminal_sessions: bool,
/// Tool-call capture from owner work sessions: tool NAME + normalized param
/// SHAPE only (sorted top-level arg keys + JSON types), never values. The
/// primary mining signal for skill self-evolution (design §5.1).
pub tool_calls: bool,
/// Companion-dialogue capture: owner messages + companion replies inside companion
/// (companion / channel-master) conversations. The field-level serde
/// default keeps it ON for legacy `config.json` files written before the
/// field existed.
#[serde(default = "default_true")]
pub companion_dialogues: bool,
}
fn default_true() -> bool {
true
}
impl Default for CollectConfig {
fn default() -> Self {
Self {
chat_user_messages: false,
chat_assistant_replies: false,
requirements: false,
cron_runs: false,
conversation_lifecycle: false,
terminal_sessions: false,
tool_calls: false,
companion_dialogues: true,
}
}
}
impl CollectConfig {
/// Whether any of the opt-in *work-event* sources is enabled (UI
/// onboarding hint). Deliberately excludes `companion_dialogues`, which is on
/// by default and would make this vacuously true.
pub fn any_enabled(&self) -> bool {
self.chat_user_messages
|| self.chat_assistant_replies
|| self.requirements
|| self.cron_runs
|| self.conversation_lifecycle
|| self.terminal_sessions
|| self.tool_calls
}
}
/// The model used for learning runs + companion chat.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct ModelConfig {
pub provider_id: String,
pub model: String,
}
impl ModelConfig {
pub fn is_configured(&self) -> bool {
!self.provider_id.is_empty() && !self.model.is_empty()
}
}
/// Scheduled learning settings.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct LearnConfig {
pub enabled: bool,
/// Minutes between learning runs.
pub interval_minutes: u32,
}
impl Default for LearnConfig {
fn default() -> Self {
Self {
enabled: false,
interval_minutes: 60,
}
}
}
/// Desktop-companion appearance + notification behaviour.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct AppearanceConfig {
/// Whether the desktop companion window should be visible.
pub companion_enabled: bool,
/// Which character renders in the companion window (see the UI character
/// roster: mochi/ink/roux/pixel/bolt/boo). Unknown values fall back to
/// the default character on the renderer side.
pub character: String,
/// Saved companion window position (physical px), if the user dragged it.
pub companion_x: Option<i32>,
pub companion_y: Option<i32>,
/// Quiet hours "HH:mm" — within this window the companion only accrues badges
/// and never pops bubbles. Empty strings disable quiet hours.
pub quiet_start: String,
pub quiet_end: String,
}
impl Default for AppearanceConfig {
fn default() -> Self {
Self {
companion_enabled: false,
character: DEFAULT_CHARACTER.into(),
companion_x: None,
companion_y: None,
quiet_start: String::new(),
quiet_end: String::new(),
}
}
}
/// Persona settings injected into the chat/learn system prompts.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct PersonaConfig {
/// One of `lively` | `calm` | `sassy`.
pub preset: String,
/// Free-form extra persona instructions appended by the user.
pub custom: String,
}
impl Default for PersonaConfig {
fn default() -> Self {
Self {
preset: "lively".into(),
custom: String::new(),
}
}
}
/// The full persisted companion configuration.
///
/// LEGACY: this is the pre-multi-companion single-config shape, kept only so boot
/// can read an old `companion/nomi/config.json` and migrate it into the new
/// per-companion [`crate::profile::CompanionProfileConfig`] + shared
/// [`crate::profile::SharedCompanionConfig`] split. Do not extend it.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct CompanionConfig {
pub collect: CollectConfig,
pub model: ModelConfig,
pub learn: LearnConfig,
pub appearance: AppearanceConfig,
pub persona: PersonaConfig,
}
impl CompanionConfig {
pub fn config_path(companion_dir: &Path) -> PathBuf {
companion_dir.join("config.json")
}
/// Load from `{companion_dir}/config.json`, falling back to defaults when the
/// file is missing or unreadable (a corrupt config must never brick boot).
pub fn load(companion_dir: &Path) -> Self {
crate::fsio::load_json_or_default(&Self::config_path(companion_dir))
}
/// Atomically persist to `{companion_dir}/config.json` (unique temp file +
/// rename, so two concurrent saves can never rename each other's
/// half-written temp into place).
pub fn save(&self, companion_dir: &Path) -> std::io::Result<()> {
crate::fsio::save_json_atomic(companion_dir, "config.json", self)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_and_default_on_missing() {
let dir = tempfile::tempdir().unwrap();
let loaded = CompanionConfig::load(dir.path());
assert_eq!(loaded, CompanionConfig::default());
assert!(!loaded.collect.any_enabled());
let mut cfg = CompanionConfig::default();
cfg.collect.chat_user_messages = true;
cfg.model.provider_id = "prov_x".into();
cfg.model.model = "claude-fable-5".into();
cfg.learn.enabled = true;
cfg.save(dir.path()).unwrap();
let again = CompanionConfig::load(dir.path());
assert_eq!(again, cfg);
assert!(again.model.is_configured());
}
#[test]
fn corrupt_config_falls_back_to_default() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(CompanionConfig::config_path(dir.path()), "{not json").unwrap();
assert_eq!(CompanionConfig::load(dir.path()), CompanionConfig::default());
}
#[test]
fn legacy_collect_json_defaults_companion_dialogues_on() {
// Stored configs written before the field existed must come back ON.
let legacy: CollectConfig = serde_json::from_str(r#"{"chat_user_messages":true}"#).unwrap();
assert!(legacy.companion_dialogues);
assert!(legacy.chat_user_messages);
// …and an explicit false is respected.
let off: CollectConfig = serde_json::from_str(r#"{"companion_dialogues":false}"#).unwrap();
assert!(!off.companion_dialogues);
// Full legacy config.json on disk (no companion_dialogues key) roundtrips
// through the file loader with the field defaulted ON.
let dir = tempfile::tempdir().unwrap();
std::fs::write(
CompanionConfig::config_path(dir.path()),
r#"{"collect":{"requirements":true}}"#,
)
.unwrap();
let loaded = CompanionConfig::load(dir.path());
assert!(loaded.collect.companion_dialogues);
assert!(loaded.collect.requirements);
// companion_dialogues is excluded from the work-event onboarding hint.
assert!(CollectConfig::default().companion_dialogues);
assert!(!CollectConfig::default().any_enabled());
}
}
@@ -0,0 +1,154 @@
//! WS push events for the companion domain. Same shape as `CronEventEmitter`:
//! a thin wrapper over the global `EventBroadcaster`.
use std::sync::Arc;
use nomifun_api_types::WebSocketMessage;
use nomifun_realtime::EventBroadcaster;
#[derive(Clone)]
pub struct CompanionEventEmitter {
broadcaster: Arc<dyn EventBroadcaster>,
}
impl CompanionEventEmitter {
pub fn new(broadcaster: Arc<dyn EventBroadcaster>) -> Self {
Self { broadcaster }
}
fn broadcast<T: serde::Serialize>(&self, event_name: &str, payload: &T) {
let value = match serde_json::to_value(payload) {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, event_name, "failed to serialize companion event");
return;
}
};
self.broadcaster.broadcast(WebSocketMessage::new(event_name, value));
}
/// 把 `companion_id` 合并进结构体序列化出的对象顶层后广播。用于 learn/evolve
/// 产出的"应由单个伙伴呈现"的事件(沿用 emit_companion_updated 的对象合并手法)。
fn broadcast_scoped<T: serde::Serialize>(&self, event_name: &str, companion_id: &str, payload: &T) {
let mut map = match serde_json::to_value(payload) {
Ok(serde_json::Value::Object(map)) => map,
Ok(other) => {
let mut m = serde_json::Map::new();
m.insert("value".into(), other);
m
}
Err(e) => {
tracing::warn!(error = %e, event_name, "failed to serialize scoped companion event");
return;
}
};
map.insert("companion_id".into(), serde_json::Value::String(companion_id.to_owned()));
self.broadcaster.broadcast(WebSocketMessage::new(event_name, serde_json::Value::Object(map)));
}
pub fn emit_suggestion_created(&self, companion_id: &str, suggestion: &crate::store::CompanionSuggestion) {
self.broadcast_scoped("companion.suggestion-created", companion_id, suggestion);
}
/// A suggestion was accepted/dismissed. Lets every open surface (panel,
/// desktop bubble, console) drop the now-decided card live instead of
/// leaving a stale `new` snapshot that 404s on the next decide. Payload is
/// the decided suggestion (carries `id` + new `status`).
pub fn emit_suggestion_decided(&self, suggestion: &crate::store::CompanionSuggestion) {
self.broadcast("companion.suggestion-decided", suggestion);
}
pub fn emit_learn_started(&self, companion_id: &str) {
self.broadcast("companion.learn-started", &serde_json::json!({ "companion_id": companion_id }));
}
pub fn emit_learn_finished(&self, companion_id: &str, run: &crate::store::CompanionLearnRun) {
self.broadcast_scoped("companion.learn-finished", companion_id, run);
}
pub fn emit_mood_changed(&self, companion_id: &str, mood: &str) {
self.broadcast("companion.mood-changed", &serde_json::json!({ "companion_id": companion_id, "mood": mood }));
}
/// Shared (cross-companion) config changed. Same event name the legacy single
/// config used; the payload carries `"scope": "shared"` so listeners can
/// tell it apart from per-companion profile updates.
pub fn emit_shared_config_updated(&self, config: &crate::profile::SharedCompanionConfig) {
let mut payload = match serde_json::to_value(config) {
Ok(serde_json::Value::Object(map)) => map,
_ => serde_json::Map::new(),
};
payload.insert("scope".into(), serde_json::Value::String("shared".into()));
self.broadcast("companion.config-updated", &serde_json::Value::Object(payload));
}
/// One companion's profile changed. `"scope"` is the companion id, the rest of the
/// payload is the full profile. `"companion_id"` is also set explicitly so listeners
/// that key off it (useCompanions) don't have to fall back to parsing `scope`.
pub fn emit_companion_updated(&self, companion_id: &str, profile: &crate::profile::CompanionProfileConfig) {
let mut payload = match serde_json::to_value(profile) {
Ok(serde_json::Value::Object(map)) => map,
_ => serde_json::Map::new(),
};
payload.insert("scope".into(), serde_json::Value::String(companion_id.to_owned()));
payload.insert("companion_id".into(), serde_json::Value::String(companion_id.to_owned()));
self.broadcast("companion.config-updated", &serde_json::Value::Object(payload));
}
pub fn emit_companion_created(&self, profile: &crate::profile::CompanionProfileConfig) {
// Wire shape matches the frontend ICompanionCreatedEvent { companion_id, profile }.
// The raw profile carries `id` but no `companion_id`, so useCompanions.refreshOne
// (which reads evt.companion_id) silently no-op'd on the incremental roster add.
self.broadcast(
"companion.created",
&serde_json::json!({ "companion_id": profile.id.clone(), "profile": profile }),
);
}
pub fn emit_companion_deleted(&self, companion_id: &str) {
self.broadcast("companion.deleted", &serde_json::json!({ "companion_id": companion_id }));
}
/// A memory was created outside a learn run (companion-chat save_memory
/// tool or manual add) — lets open UIs refresh counters live.
pub fn emit_memory_created(&self, memory: &crate::store::CompanionMemory) {
self.broadcast("companion.memory-created", memory);
}
/// A memory's content/scope/pin/status was edited. Lets every open surface
/// (memories tab, desktop bubble, second window) reflect the edit live
/// instead of holding a stale snapshot.
pub fn emit_memory_updated(&self, memory: &crate::store::CompanionMemory) {
self.broadcast("companion.memory-updated", memory);
}
/// A memory was hard-deleted. Payload carries the `id` so listeners can drop
/// the row without a refetch.
pub fn emit_memory_deleted(&self, id: &str) {
self.broadcast("companion.memory-deleted", &serde_json::json!({ "id": id }));
}
/// A skill draft was auto-generated and is awaiting review.
pub fn emit_skill_drafted(&self, companion_id: &str, skill_name: &str) {
self.broadcast(
"companion.skill-drafted",
&serde_json::json!({ "companion_id": companion_id, "skill_name": skill_name }),
);
}
/// A skill was accepted/activated — the companion just "learned" it.
pub fn emit_skill_learned(&self, companion_id: &str, skill_name: &str) {
self.broadcast(
"companion.skill-learned",
&serde_json::json!({ "companion_id": companion_id, "skill_name": skill_name }),
);
}
/// A skill was auto-archived by the decay pass (unused too long).
pub fn emit_skill_archived(&self, companion_id: &str, skill_name: &str) {
self.broadcast(
"companion.skill-archived",
&serde_json::json!({ "companion_id": companion_id, "skill_name": skill_name }),
);
}
}
@@ -0,0 +1,281 @@
//! 真实重水合源(design 2026-06-23):会话库 `messages` 表 = 内容唯一事实源。
//!
//! 给定 wire `conversation_id`(= `conversations.id` 的十进制字符串),按 [`TranscriptAnchor`]
//! 框出窗口,把消息转成**脱敏**转录喂给 drafter。装配见 `service::attach_companion`(会话服务
//! 晚于伴随服务构建,故晚装配)。走仓储层 `get_messages`(user 无关,绕开 list_messages 的
//! 鉴权与 type 过滤)。会话不存在/为空 → `None`(drafter 降级回工具名步骤)。
use std::collections::HashSet;
use std::sync::Arc;
use async_trait::async_trait;
use nomifun_common::AppError;
use nomifun_db::{IConversationRepository, SortOrder};
use crate::evolution::transcript::{TranscriptAnchor, TranscriptSource, TranscriptTurn};
/// 单条文本/参数/结果脱敏后的字符上限(控转录体量)。
const FIELD_CHARS: usize = 600;
/// 单会话最多取多少条消息来框窗口(防超大会话拖垮起草)。
const MAX_FETCH: u32 = 1000;
pub struct ConversationTranscriptSource {
repo: Arc<dyn IConversationRepository>,
}
impl ConversationTranscriptSource {
pub fn new(repo: Arc<dyn IConversationRepository>) -> Self {
Self { repo }
}
}
/// 一行消息解析后的最小投影。
struct Parsed {
ty: String,
position: String,
content: serde_json::Value,
/// tool_call/acp_tool_call 的 call_id(用于按锚精确框窗)。
call_id: Option<String>,
}
/// 脱敏 + 截断(secrets 永不入转录;过长字段裁断带省略号)。
fn redact_clip(s: &str) -> String {
let red = nomi_redact::redact_secrets_owned(s.to_owned());
if red.chars().count() <= FIELD_CHARS {
red
} else {
let head: String = red.chars().take(FIELD_CHARS).collect();
format!("{head}")
}
}
/// 从 tool_call/acp_tool_call 的 content 提取 (name, args, result)。
fn extract_tool(ty: &str, content: &serde_json::Value) -> Option<(String, Option<String>, Option<String>)> {
match ty {
"tool_call" => {
let name = content.get("name").and_then(|v| v.as_str())?.to_owned();
let args = content
.get("args")
.or_else(|| content.get("input"))
.filter(|v| !v.is_null())
.map(|v| v.to_string());
let result = content.get("output").and_then(|v| v.as_str()).map(|s| s.to_owned());
Some((name, args, result))
}
"acp_tool_call" => {
let upd = content.get("update")?;
let name = upd.get("title").and_then(|v| v.as_str()).unwrap_or("tool").to_owned();
let args = upd.get("raw_input").filter(|v| !v.is_null()).map(|v| v.to_string());
let result = upd.get("raw_output").filter(|v| !v.is_null()).map(|v| v.to_string());
Some((name, args, result))
}
_ => None,
}
}
#[async_trait]
impl TranscriptSource for ConversationTranscriptSource {
async fn window(&self, anchor: &TranscriptAnchor) -> Result<Option<Vec<TranscriptTurn>>, AppError> {
// wire id = conversations.id 的十进制串(无独立公开 id 列);非数字 → 无法定位。
let Ok(conv_id) = anchor.conversation_id.parse::<i64>() else {
return Ok(None);
};
let page = self
.repo
.get_messages(conv_id, 1, MAX_FETCH, SortOrder::Asc)
.await
.map_err(|e| AppError::Internal(format!("rehydrate get_messages: {e}")))?;
if page.items.is_empty() {
return Ok(None); // 会话已删/为空
}
let parsed: Vec<Parsed> = page
.items
.iter()
.filter(|r| !r.hidden)
.map(|r| {
let content: serde_json::Value = serde_json::from_str(&r.content).unwrap_or(serde_json::Value::Null);
let call_id = match r.r#type.as_str() {
"tool_call" => content.get("call_id").and_then(|v| v.as_str()).map(|s| s.to_owned()),
"acp_tool_call" => content
.get("update")
.and_then(|u| u.get("tool_call_id"))
.and_then(|v| v.as_str())
.map(|s| s.to_owned()),
_ => None,
};
Parsed { ty: r.r#type.clone(), position: r.position.clone().unwrap_or_default(), content, call_id }
})
.collect();
if parsed.is_empty() {
return Ok(None);
}
// 框窗口:call_id 命中优先(挖矿/示范都带 call_ids);命中为空则退回整段(capped)。
let want: HashSet<&str> = anchor.call_ids.iter().map(|s| s.as_str()).collect();
let hits: Vec<usize> = if want.is_empty() {
Vec::new()
} else {
parsed
.iter()
.enumerate()
.filter(|(_, p)| p.call_id.as_deref().map(|c| want.contains(c)).unwrap_or(false))
.map(|(i, _)| i)
.collect()
};
let (lo, hi) = if hits.is_empty() {
(0, parsed.len() - 1) // 无精确命中 → 整段(已 capped 到 MAX_FETCH)
} else {
let lo = hits.iter().min().copied().unwrap().saturating_sub(anchor.pad_turns);
let hi = (hits.iter().max().copied().unwrap() + anchor.pad_turns).min(parsed.len() - 1);
(lo, hi)
};
let mut turns = Vec::new();
for p in &parsed[lo..=hi] {
match p.ty.as_str() {
"text" if p.position == "right" => {
if let Some(t) = p.content.get("content").and_then(|v| v.as_str()).filter(|s| !s.trim().is_empty()) {
turns.push(TranscriptTurn::user(redact_clip(t)));
}
}
"text" => {
if let Some(t) = p.content.get("content").and_then(|v| v.as_str()).filter(|s| !s.trim().is_empty()) {
turns.push(TranscriptTurn::assistant(redact_clip(t)));
}
}
"tool_call" | "acp_tool_call" => {
if let Some((name, args, result)) = extract_tool(&p.ty, &p.content) {
turns.push(TranscriptTurn::tool(
name,
args.map(|a| redact_clip(&a)),
result.map(|r| redact_clip(&r)),
));
}
}
_ => {} // thinking/tips 跳过(对起草是噪声)
}
}
Ok(Some(turns))
}
}
#[cfg(test)]
mod tests {
use super::*;
use nomifun_common::now_ms;
use nomifun_db::models::{ConversationRow, MessageRow};
use nomifun_db::{init_database_memory, SqliteConversationRepository};
fn conv_row() -> ConversationRow {
ConversationRow {
id: 0, // ignored on insert (autoincrement)
user_id: "system_default_user".into(), // seeded by init_database_memory (FK)
name: "t".into(),
r#type: "gemini".into(),
extra: "{}".into(),
model: None,
status: Some("finished".into()),
source: None,
channel_chat_id: None,
pinned: false,
pinned_at: None,
cron_job_id: None,
created_at: now_ms(),
updated_at: now_ms(),
}
}
fn text_msg(conv: i64, content: &str, position: &str, ts: i64) -> MessageRow {
MessageRow {
id: format!("msg-{position}-{ts}"),
conversation_id: conv,
msg_id: None,
r#type: "text".into(),
content: serde_json::json!({ "content": content }).to_string(),
position: Some(position.into()),
status: Some("finish".into()),
hidden: false,
created_at: ts,
}
}
fn tool_msg(conv: i64, call_id: &str, args: serde_json::Value, output: &str, ts: i64) -> MessageRow {
MessageRow {
id: format!("msg-{call_id}"),
conversation_id: conv,
msg_id: None,
r#type: "tool_call".into(),
content: serde_json::json!({
"call_id": call_id, "name": "grep", "args": args, "status": "completed", "output": output
})
.to_string(),
position: Some("left".into()),
status: Some("finish".into()),
hidden: false,
created_at: ts,
}
}
async fn repo_with_conv() -> (Arc<SqliteConversationRepository>, i64) {
let db = init_database_memory().await.unwrap();
let repo = Arc::new(SqliteConversationRepository::new(db.pool().clone()));
let id = repo.create(&conv_row()).await.unwrap();
(repo, id)
}
/// 守门:重水合命中 → 真实 user/tool/assistant 内容入转录;secret 脱敏;thinking/hidden 排除。
#[tokio::test]
async fn rehydrates_window_and_redacts_secrets() {
let (repo, conv) = repo_with_conv().await;
repo.insert_message(&text_msg(conv, "把日志里的错误改掉", "right", 1)).await.unwrap();
repo.insert_message(&tool_msg(
conv,
"tc-1",
serde_json::json!({ "pattern": "ERROR", "key": "sk-ABCDEFGHIJ0123456789xyz" }),
"命中 3 处",
2,
))
.await
.unwrap();
repo.insert_message(&text_msg(conv, "改好了", "left", 3)).await.unwrap();
let mut hidden = text_msg(conv, "隐藏内容", "left", 4);
hidden.hidden = true;
repo.insert_message(&hidden).await.unwrap();
let mut thinking = text_msg(conv, "内心独白", "left", 5);
thinking.r#type = "thinking".into();
repo.insert_message(&thinking).await.unwrap();
let src = ConversationTranscriptSource::new(repo.clone());
let anchor = TranscriptAnchor {
conversation_id: conv.to_string(),
start_ts: 0,
end_ts: 0,
pad_turns: 2,
call_ids: vec!["tc-1".into()],
};
let turns = src.window(&anchor).await.unwrap().expect("conversation present");
let rendered = crate::evolution::render_transcript(&turns, 600).join("\n");
assert!(rendered.contains("把日志里的错误改掉"), "user content: {rendered}");
assert!(rendered.contains("grep"), "tool name: {rendered}");
assert!(rendered.contains("命中 3 处"), "tool result: {rendered}");
assert!(rendered.contains("改好了"), "assistant content: {rendered}");
// 脱敏:secret 永不入转录(纵深防御要害)。
assert!(!rendered.contains("sk-ABCDEFGHIJ"), "secret leaked: {rendered}");
assert!(rendered.contains("[REDACTED_SECRET]"), "redaction marker missing: {rendered}");
// thinking/hidden 是噪声,不入转录。
assert!(!rendered.contains("内心独白"), "thinking leaked: {rendered}");
assert!(!rendered.contains("隐藏内容"), "hidden leaked: {rendered}");
}
/// 守门:非数字 id / 不存在的会话 → None(drafter 降级,不报错)。
#[tokio::test]
async fn missing_or_nonnumeric_conversation_returns_none() {
let (repo, _conv) = repo_with_conv().await;
let src = ConversationTranscriptSource::new(repo);
let nonnumeric = TranscriptAnchor { conversation_id: "conv_abc".into(), ..Default::default() };
assert!(src.window(&nonnumeric).await.unwrap().is_none(), "non-numeric id → None");
let gone = TranscriptAnchor { conversation_id: "999999".into(), ..Default::default() };
assert!(src.window(&gone).await.unwrap().is_none(), "missing conversation → None");
}
}
@@ -0,0 +1,858 @@
//! EvolutionEngine — 后台技能进化循环(design §5)。
//!
//! 镜像 `crate::learner::Learner` 的 tick/cursor/run_lock 脚手架,但独立调度:
//! 挖矿(`miner`,确定性)→ 起草(one_shot)→ 评审(one_shot)→ 物化为待审草稿 SKILL.md
//! + `create_skill` 建议卡。失败只记进 `EvolveRun` + `tracing::warn!`**绝不 `emit_error`**
//! (后台副任务红线)。蒸馏走 `CompanionCompleter`(选 model,非 agent)。
use std::path::PathBuf;
use std::sync::Arc;
use nomifun_common::{AppError, generate_prefixed_id, now_ms};
use nomifun_extension::constants::SKILL_MANIFEST_FILE;
use nomifun_extension::skill_service::{self, SkillDraftInput, SkillPaths, SkillScope};
use tokio::sync::Mutex;
use crate::collector::{SharedConfig, read_events_since};
use crate::events::CompanionEventEmitter;
use crate::evolution::miner::{mine_patterns, mine_reflection_candidates, MinedPattern};
use crate::evolution::prompt::{self, DraftOutput};
use crate::evolution::transcript::{render_transcript, TranscriptAnchor, TranscriptSource};
use crate::learner::CompanionCompleter;
use crate::registry::CompanionRegistry;
use crate::store::{CompanionSkill, CompanionStore};
const MAX_EVENTS_PER_RUN: usize = 500;
const TICK_SECONDS: u64 = 60;
const DRAFT_MAX_TOKENS: u32 = 1200;
const CRITIC_MAX_TOKENS: u32 = 256;
/// 一次最多起草几个新技能(避免单轮爆量骚扰)。
const MAX_DRAFTS_PER_RUN: usize = 3;
/// 任务后反思的最小步数门槛(单会话工具序列折叠后 ≥ 此值才作反思候选)。
const REFLECT_MIN_STEPS: usize = 4;
/// 重水合转录行的单行字符上限(控 drafter 上下文成本)。
const DRAFT_LINE_CHARS: usize = 240;
/// 喂给 drafter 的转录行数上限(窗口可能跨多轮)。
const DRAFT_MAX_LINES: usize = 40;
/// 一次进化运行的小结(P1 仅返回,不落表)。
#[derive(Debug, Clone)]
pub struct EvolveRun {
pub id: String,
pub started_at: i64,
pub finished_at: Option<i64>,
pub status: String,
pub events_processed: i64,
pub patterns_found: i64,
pub drafts_created: i64,
pub error: Option<String>,
}
pub struct EvolutionEngine {
pub companion_dir: PathBuf,
pub config: SharedConfig,
pub store: CompanionStore,
pub registry: Arc<CompanionRegistry>,
pub completer: Arc<dyn CompanionCompleter>,
pub emitter: CompanionEventEmitter,
pub skill_paths: Arc<SkillPaths>,
/// 重水合源(会话库 = 唯一内容源)。`start()` 时为 Noop(会话库晚于伴随服务装配,
/// 见 `attach_companion`),装配后经 [`set_transcript`] 换成真实适配器。未装配/会话已删
/// → 起草降级回工具名步骤。`std::sync::RwLock` 因 `attach_companion` 非 async;读出 Arc
/// 即刻 drop guard,绝不跨 await 持锁。
pub transcript: std::sync::RwLock<Arc<dyn TranscriptSource>>,
/// 与 Learner 各自独立的再入守卫。
pub run_lock: Arc<Mutex<()>>,
}
impl EvolutionEngine {
/// 晚装配重水合源(会话库适配器在伴随服务之后构建)。
pub fn set_transcript(&self, src: Arc<dyn TranscriptSource>) {
*self.transcript.write().expect("transcript lock poisoned") = src;
}
/// 为 `anchor` 重水合一段脱敏转录,渲染成 drafter 上下文行。无源/会话已删/锚为空 →
/// 空(drafter 仅凭工具名步骤起草——优雅降级,绝不阻塞)。
async fn rehydrate_lines(&self, anchor: &TranscriptAnchor) -> Vec<String> {
if anchor.conversation_id.is_empty() {
return Vec::new();
}
let src = { self.transcript.read().expect("transcript lock poisoned").clone() };
match src.window(anchor).await {
Ok(Some(turns)) => {
let mut lines = render_transcript(&turns, DRAFT_LINE_CHARS);
lines.truncate(DRAFT_MAX_LINES);
lines
}
Ok(None) => Vec::new(),
Err(e) => {
tracing::debug!(error = %e, "transcript rehydration failed; drafting from steps only");
Vec::new()
}
}
}
/// 启动周期 tick 循环。
pub fn spawn(self: Arc<Self>) {
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(TICK_SECONDS));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
interval.tick().await;
let (enabled, interval_minutes) = {
let cfg = self.config.read().await;
(cfg.evolve.enabled, cfg.evolve.interval_minutes.max(5) as i64)
};
if !enabled {
continue;
}
let last_run = self.store.get_state_i64("last_evolve_ts").await.unwrap_or(0);
if now_ms() - last_run < interval_minutes * 60_000 {
continue;
}
if let Err(e) = self.run_once().await {
tracing::warn!(error = %e, "companion evolution run failed");
}
}
});
}
/// 一次进化运行。失败绝不 emit_error;状态写进返回的 EvolveRun。
pub async fn run_once(&self) -> Result<EvolveRun, AppError> {
let Ok(_guard) = self.run_lock.try_lock() else {
return Err(AppError::Conflict("an evolution run is already in progress".into()));
};
let started_at = now_ms();
// 先 stamp,崩溃/失败也不会让 60s 调度热循环。
self.store.set_state("last_evolve_ts", &started_at.to_string()).await?;
// Skill health/decay pass (P5 T1-B): runs every evolution tick, before the
// model-configured gate, so unused mined skills fade even when no draft is produced.
// Fire-and-forget; never emit_error. Emits skill-archived for live UI refresh.
let (half_life, archive_threshold) = {
let cfg = self.config.read().await;
(cfg.evolve.skill_half_life_days, cfg.evolve.skill_archive_threshold)
};
if let Ok(n) = self.store.decay_skills(half_life, archive_threshold).await {
if n > 0 {
let owner = {
let did = { self.config.read().await.default_companion_id.clone() };
self.registry.resolve_default(&did).await
};
self.emitter.emit_skill_archived(&owner, "");
}
}
let (model, min_count, min_distinct, reflect_enabled, auto_activate, auto_threshold) = {
let cfg = self.config.read().await;
// One model for the whole flywheel: fall back to the learn model when no
// dedicated evolve model is configured, so default-on works out of the box
// once the user has set the shared learning model.
let model = if cfg.evolve.model.is_configured() {
cfg.evolve.model.clone()
} else {
cfg.learn.model.clone()
};
(
model,
cfg.evolve.min_pattern_count,
cfg.evolve.min_distinct_sessions,
cfg.evolve.reflect_enabled,
cfg.evolve.auto_activate,
cfg.evolve.auto_threshold,
)
};
let mut run = EvolveRun {
id: generate_prefixed_id("evr"),
started_at,
finished_at: None,
status: "ok".into(),
events_processed: 0,
patterns_found: 0,
drafts_created: 0,
error: None,
};
if !model.is_configured() {
run.status = "model_unconfigured".into();
run.finished_at = Some(now_ms());
return Ok(run);
}
// 自生成技能必须归属某个伙伴(伙伴级专属成长)。默认体解析复用 registry 单一事实源。
let owner = {
let did = { self.config.read().await.default_companion_id.clone() };
self.registry.resolve_default(&did).await
};
if owner.is_empty() {
run.status = "no_companion".into();
run.finished_at = Some(now_ms());
return Ok(run);
}
let cursor = self.store.get_state_i64("evolve_cursor_ts").await?;
let (events, _truncated) = read_events_since(&self.companion_dir, cursor, MAX_EVENTS_PER_RUN);
if events.is_empty() {
run.status = "no_events".into();
run.finished_at = Some(now_ms());
return Ok(run);
}
run.events_processed = events.len() as i64;
let new_cursor = events.last().map(|e| e.ts).unwrap_or(cursor);
let patterns = mine_patterns(&events, min_count, min_distinct);
run.patterns_found = patterns.len() as i64;
// Candidates = repeated patterns first, then (if enabled) single complex sessions
// for post-task reflection. Reflection candidates have distinct_sessions=1 → low
// confidence → always reviewed, never auto-activated.
let mut candidates = patterns;
if reflect_enabled {
candidates.extend(mine_reflection_candidates(&events, REFLECT_MIN_STEPS, MAX_DRAFTS_PER_RUN));
}
let mut provider_failed = false;
for p in candidates {
if run.drafts_created as usize >= MAX_DRAFTS_PER_RUN {
break;
}
match self
.process_candidate(&p, &owner, &model.provider_id, &model.model, min_distinct, auto_activate, auto_threshold)
.await
{
Ok(true) => run.drafts_created += 1,
Ok(false) => {}
Err(e) => {
// Provider failure: terminate the run and keep the cursor for retry.
run.error = Some(e.to_string());
provider_failed = true;
break;
}
}
}
// provider 失败:保 cursor(下轮重试该批);否则推进。
if provider_failed {
if run.status == "ok" {
run.status = "error".into();
}
} else {
self.store.set_state("evolve_cursor_ts", &new_cursor.to_string()).await?;
}
run.finished_at = Some(now_ms());
Ok(run)
}
/// Process one candidate (mined pattern or reflection) through draft→critic→materialize.
/// Returns `Ok(true)` if a skill was produced (draft or auto-activated), `Ok(false)` if
/// skipped (rejected/already-drafted/critic-reject/invalid/disk-fail), and `Err` ONLY on
/// provider failure (the caller terminates the run and keeps the cursor). Never `emit_error`.
#[allow(clippy::too_many_arguments)]
async fn process_candidate(
&self,
p: &MinedPattern,
owner: &str,
provider_id: &str,
model: &str,
min_distinct: usize,
auto_activate: bool,
auto_threshold: f64,
) -> Result<bool, AppError> {
// Skip rejected (negative-sample) or already-drafted signatures.
if self.store.is_signature_rejected(&p.signature).await.unwrap_or(false) {
return Ok(false);
}
if matches!(self.store.pattern_status(&p.signature).await.unwrap_or(None).as_deref(), Some("drafted")) {
return Ok(false);
}
let anchor = p.example_event_ids.first().cloned().unwrap_or_default();
let _ = self.store.bump_pattern(&p.signature, owner, &anchor, now_ms()).await;
// Draft (1 retry). A completer error → Err (caller terminates + keeps cursor).
// Rehydrate the real (redacted) transcript window for this pattern so the drafter
// sees actual how-to, not just tool names; degrades to steps-only when unavailable.
let context = self.rehydrate_lines(&p.anchor).await;
let draft_user = prompt::build_draft_prompt(p, &context);
let mut draft: Option<DraftOutput> = None;
for attempt in 0..2 {
match self.completer.complete(provider_id, model, prompt::DRAFT_SYSTEM, &draft_user, DRAFT_MAX_TOKENS).await {
Ok(raw) => match prompt::parse_draft_output(&raw) {
Ok(d) if !d.name.trim().is_empty() && !d.description.trim().is_empty() => {
draft = Some(d);
break;
}
Ok(_) => tracing::debug!(attempt, "evolution draft missing name/description"),
Err(e) => tracing::debug!(attempt, error = %e, "evolution draft unparseable"),
},
Err(e) => return Err(e),
}
}
let Some(draft) = draft else { return Ok(false) };
// Critic.
let critic_user = prompt::build_critic_prompt(&draft, p);
let approved = match self.completer.complete(provider_id, model, prompt::CRITIC_SYSTEM, &critic_user, CRITIC_MAX_TOKENS).await {
Ok(raw) => prompt::parse_critic_output(&raw).map(|v| v.approve).unwrap_or(false),
Err(e) => return Err(e),
};
// Mark drafted (approved or not) so the same signature isn't re-judged every run.
self.store.mark_pattern_status(&p.signature, "drafted").await.ok();
if !approved {
return Ok(false);
}
let name = sanitize_skill_name(&draft.name);
if name.is_empty() {
return Ok(false);
}
let scope = SkillScope::Companion(owner.to_owned());
// Evolve-in-place: if a near-identically-named active/draft skill exists, MERGE into it
// (improve + version bump) instead of creating a near-duplicate (P5 T2-A). Provider error
// → Err (terminate); any other failure degrades to the normal create path below.
if let Ok(Some(existing)) = self.store.find_similar_skill(owner, &name).await {
if let Ok(Some(row)) = self.store.get_skill(owner, &existing).await {
let draft_dir = row.status == "draft";
if let Ok(dir) = skill_service::skill_dir_for(&self.skill_paths, &scope, &existing, draft_dir) {
if let Ok(existing_body) = tokio::fs::read_to_string(dir.join(SKILL_MANIFEST_FILE)).await {
let merge_user = prompt::build_merge_prompt(&existing_body, &draft, p);
match self.completer.complete(provider_id, model, prompt::MERGE_SYSTEM, &merge_user, DRAFT_MAX_TOKENS).await {
Ok(raw) => {
if let Ok(merged) = prompt::parse_draft_output(&raw) {
if !merged.description.trim().is_empty() && !merged.body.trim().is_empty() {
let merged_input = SkillDraftInput {
name: existing.clone(),
description: merged.description,
when_to_use: merged.when_to_use,
allowed_tools: None,
paths: None,
body: merged.body,
};
let md = skill_service::build_skill_md(&merged_input);
if skill_service::write_skill(&self.skill_paths, &scope, draft_dir, &existing, &md).await.is_ok() {
let _ = self.store.bump_skill_version(owner, &existing).await;
self.emitter.emit_skill_learned(owner, &existing);
self.store.mark_pattern_status(&p.signature, "drafted").await.ok();
return Ok(true);
}
}
}
}
Err(e) => return Err(e),
}
}
}
}
// merge attempt failed softly → fall through to normal create.
}
let input = SkillDraftInput {
name: name.clone(),
description: draft.description.clone(),
when_to_use: draft.when_to_use.clone(),
allowed_tools: None,
paths: None,
body: draft.body.clone(),
};
let confidence = ((p.distinct_sessions as f64) / ((min_distinct + 2) as f64)).clamp(0.3, 0.95);
// High-confidence auto-activation only when the user opted in AND confidence clears
// the bar (repetition-derived; single-session reflections never reach it).
let auto = auto_activate && confidence >= auto_threshold;
if let Err(e) = skill_service::create_skill(&self.skill_paths, &scope, /* draft= */ !auto, &input).await {
tracing::warn!(error = %e, skill = %name, "evolution failed to write skill");
return Ok(false);
}
let now = now_ms();
let skill = CompanionSkill {
skill_name: name.clone(),
scope_kind: "companion".into(),
scope_companion_id: owner.to_owned(),
status: if auto { "active".into() } else { "draft".into() },
source: "mined".into(),
confidence,
provenance: p.example_event_ids.clone(),
strength: 1.0,
version: 1,
superseded_by: None,
usage_count: 0,
last_used_at: None,
created_at: now,
updated_at: now,
signature: p.signature.clone(),
};
if let Err(e) = self.store.insert_skill(&skill).await {
tracing::warn!(error = %e, "evolution failed to insert skill row");
return Ok(false);
}
if auto {
// Auto-activated: no review card, but emit skill-learned so the UI toasts and
// the skill shows as active (the user can still archive it — "see + undo").
self.emitter.emit_skill_learned(owner, &name);
} else {
let action = serde_json::json!({
"type": "create_skill",
"name": name,
"companion_id": owner,
"signature": p.signature,
});
let title = format!("我学会了一个新技能:{name}");
let body = format!("你做过「{}」这套操作,我把它固化成了技能,采纳后我就能自动帮你做。", draft.description);
if let Ok(created) = self.store.insert_suggestion("create_skill", &title, &body, Some(&action)).await {
self.emitter.emit_suggestion_created(&owner, &created);
}
self.emitter.emit_skill_drafted(owner, &name);
}
Ok(true)
}
/// On-demand "learn by demonstration" (P5 T2-B): draft a skill from a single demonstrated
/// tool-name sequence, bypassing the miner/dedup/critic (the user is deliberately teaching).
/// Always a reviewable draft, `source="demonstrated"` (never decays, never auto-activates).
/// `anchor` rehydrates the real session transcript for richer drafting (whole-conversation
/// window from the caller); degrades to steps-only when unavailable.
/// Returns the drafted skill name, or `None` if the model produced nothing usable.
pub async fn draft_from_episode(
&self,
steps: Vec<String>,
anchor: TranscriptAnchor,
owner: &str,
) -> Result<Option<String>, AppError> {
if steps.len() < 2 || owner.is_empty() {
return Ok(None);
}
let model = {
let cfg = self.config.read().await;
if cfg.evolve.model.is_configured() { cfg.evolve.model.clone() } else { cfg.learn.model.clone() }
};
if !model.is_configured() {
return Err(AppError::BadRequest("尚未配置学习模型".into()));
}
let p = MinedPattern {
signature: crate::evolution::tool_call_signature(&steps),
steps: steps.clone(),
count: 1,
distinct_sessions: 1,
example_event_ids: vec![],
anchor,
};
let context = self.rehydrate_lines(&p.anchor).await;
let draft_user = prompt::build_draft_prompt(&p, &context);
let mut draft: Option<DraftOutput> = None;
for _ in 0..2 {
match self.completer.complete(&model.provider_id, &model.model, prompt::DRAFT_SYSTEM, &draft_user, DRAFT_MAX_TOKENS).await {
Ok(raw) => {
if let Ok(d) = prompt::parse_draft_output(&raw) {
if !d.name.trim().is_empty() && !d.description.trim().is_empty() {
draft = Some(d);
break;
}
}
}
Err(e) => return Err(e),
}
}
let Some(draft) = draft else { return Ok(None) };
let name = sanitize_skill_name(&draft.name);
if name.is_empty() {
return Ok(None);
}
let input = SkillDraftInput {
name: name.clone(),
description: draft.description.clone(),
when_to_use: draft.when_to_use.clone(),
allowed_tools: None,
paths: None,
body: draft.body.clone(),
};
let scope = SkillScope::Companion(owner.to_owned());
skill_service::create_skill(&self.skill_paths, &scope, true, &input)
.await
.map_err(|e| AppError::Internal(format!("write demonstrated skill: {e}")))?;
let now = now_ms();
self.store
.insert_skill(&CompanionSkill {
skill_name: name.clone(),
scope_kind: "companion".into(),
scope_companion_id: owner.to_owned(),
status: "draft".into(),
source: "demonstrated".into(),
confidence: 0.5,
provenance: vec![],
strength: 1.0,
version: 1,
superseded_by: None,
usage_count: 0,
last_used_at: None,
created_at: now,
updated_at: now,
signature: String::new(),
})
.await?;
let action = serde_json::json!({ "type": "create_skill", "name": name, "companion_id": owner, "signature": "" });
let title = format!("我学会了你示范的技能:{name}");
let body = format!("照你示范的「{}」整理成了技能,采纳后我就能复用。", draft.description);
if let Ok(created) = self.store.insert_suggestion("create_skill", &title, &body, Some(&action)).await {
self.emitter.emit_suggestion_created(&owner, &created);
}
self.emitter.emit_skill_drafted(owner, &name);
Ok(Some(name))
}
}
/// 归一化技能名 → kebab-case 合法目录名(create_skill 再过 validate_filename)。
/// 全非 ASCII(无可用字符)→ 空串,调用方跳过。
fn sanitize_skill_name(raw: &str) -> String {
let mut s: String = raw
.trim()
.to_lowercase()
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '-' })
.collect();
while s.contains("--") {
s = s.replace("--", "-");
}
s.trim_matches('-').chars().take(64).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::collector::{CollectedEvent, append_event};
use crate::evolution::transcript::test_util::StubTranscript;
use crate::evolution::transcript::{NoopTranscriptSource, TranscriptTurn};
use crate::profile::SharedCompanionConfig;
use nomifun_realtime::BroadcastEventBus;
use tokio::sync::RwLock;
/// 按 system 提示区分起草/评审两次调用。
struct ScriptedCompleter {
draft: String,
approve: bool,
}
#[async_trait::async_trait]
impl CompanionCompleter for ScriptedCompleter {
async fn complete(&self, _p: &str, _m: &str, system: &str, _u: &str, _t: u32) -> Result<String, AppError> {
if system == prompt::DRAFT_SYSTEM {
Ok(self.draft.clone())
} else {
Ok(format!("{{\"approve\":{}}}", self.approve))
}
}
}
/// Records every draft `user` prompt so tests can assert what the drafter actually saw.
struct CapturingCompleter {
draft: String,
approve: bool,
draft_prompts: Arc<tokio::sync::Mutex<Vec<String>>>,
}
#[async_trait::async_trait]
impl CompanionCompleter for CapturingCompleter {
async fn complete(&self, _p: &str, _m: &str, system: &str, user: &str, _t: u32) -> Result<String, AppError> {
if system == prompt::DRAFT_SYSTEM {
self.draft_prompts.lock().await.push(user.to_owned());
Ok(self.draft.clone())
} else {
Ok(format!("{{\"approve\":{}}}", self.approve))
}
}
}
fn test_skill_paths(dir: &std::path::Path) -> Arc<SkillPaths> {
Arc::new(SkillPaths {
data_dir: dir.to_path_buf(),
user_skills_dir: dir.join("skills"),
cron_skills_dir: dir.join("cron/skills"),
builtin_skills_dir: dir.join("builtin-skills"),
builtin_rules_dir: dir.join("rules"),
assistant_rules_dir: dir.join("assistant-rules"),
assistant_skills_dir: dir.join("assistant-skills"),
})
}
fn seed_tool_calls(dir: &std::path::Path) {
let base = now_ms();
let mut k = 0i64;
for conv in ["c1", "c2", "c3"] {
for tool in ["grep", "read", "edit"] {
k += 1;
append_event(
dir,
&CollectedEvent {
ts: base + k,
source: "tool_calls".into(),
name: "tool.call".into(),
data: serde_json::json!({"name": tool, "conversation_id": conv, "call_id": format!("{conv}-{tool}")}),
},
)
.unwrap();
}
}
}
async fn make_engine(dir: &std::path::Path, draft: &str, approve: bool) -> (EvolutionEngine, String) {
make_engine_with(dir, Arc::new(ScriptedCompleter { draft: draft.to_owned(), approve })).await
}
async fn make_engine_with(dir: &std::path::Path, completer: Arc<dyn CompanionCompleter>) -> (EvolutionEngine, String) {
let mut config = SharedCompanionConfig::default();
config.evolve.enabled = true;
config.evolve.model.provider_id = "prov_t".into();
config.evolve.model.model = "test-model".into();
config.evolve.min_pattern_count = 3;
config.evolve.min_distinct_sessions = 2;
let registry = Arc::new(CompanionRegistry::scan(dir.join("companions"), dir.join("shared")));
let companion = registry.create("测试", "ink").await.unwrap();
config.default_companion_id = companion.id.clone();
let engine = EvolutionEngine {
companion_dir: dir.to_path_buf(),
config: Arc::new(RwLock::new(config)),
store: CompanionStore::open_memory().await.unwrap(),
registry,
completer,
emitter: CompanionEventEmitter::new(Arc::new(BroadcastEventBus::new(16))),
skill_paths: test_skill_paths(dir),
transcript: std::sync::RwLock::new(Arc::new(NoopTranscriptSource)),
run_lock: Arc::new(Mutex::new(())),
};
(engine, companion.id)
}
#[tokio::test]
async fn run_once_mines_drafts_and_suggests() {
let dir = tempfile::tempdir().unwrap();
seed_tool_calls(dir.path());
let draft = r#"{"name":"grep-read-edit","description":"查找并修改代码","when_to_use":"改 bug 时","body":"步骤"}"#;
let (engine, cid) = make_engine(dir.path(), draft, true).await;
let run = engine.run_once().await.unwrap();
assert_eq!(run.status, "ok");
assert!(run.patterns_found >= 1, "expected a mined pattern");
assert_eq!(run.drafts_created, 1);
// 注册表一条 draft 技能
let skills = engine.store.list_skills(&cid, false).await.unwrap();
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].status, "draft");
assert_eq!(skills[0].source, "mined");
// 一条 create_skill 建议卡
let sugs = engine.store.list_suggestions(Some("new"), 10).await.unwrap();
assert_eq!(sugs.len(), 1);
assert_eq!(sugs[0].kind, "create_skill");
// 草稿 SKILL.md 落盘
let draft_md = dir.path().join("skills/_drafts").join(&cid).join("grep-read-edit/SKILL.md");
assert!(draft_md.exists(), "draft SKILL.md missing at {}", draft_md.display());
// cursor 推进;二次运行无新事件
assert!(engine.store.get_state_i64("evolve_cursor_ts").await.unwrap() > 0);
let run2 = engine.run_once().await.unwrap();
assert_eq!(run2.drafts_created, 0);
}
#[tokio::test]
async fn run_once_skips_when_model_unconfigured() {
let dir = tempfile::tempdir().unwrap();
seed_tool_calls(dir.path());
let (engine, _) = make_engine(dir.path(), "{}", true).await;
engine.config.write().await.evolve.model = Default::default();
let run = engine.run_once().await.unwrap();
assert_eq!(run.status, "model_unconfigured");
}
#[tokio::test]
async fn run_once_critic_reject_creates_no_skill() {
let dir = tempfile::tempdir().unwrap();
seed_tool_calls(dir.path());
let draft = r#"{"name":"x","description":"d","body":"b"}"#;
let (engine, cid) = make_engine(dir.path(), draft, false).await;
let run = engine.run_once().await.unwrap();
assert_eq!(run.drafts_created, 0);
assert_eq!(engine.store.list_skills(&cid, false).await.unwrap().len(), 0);
}
#[tokio::test]
async fn evolve_falls_back_to_learn_model_when_unconfigured() {
let dir = tempfile::tempdir().unwrap();
seed_tool_calls(dir.path());
let draft = r#"{"name":"gre","description":"d","when_to_use":"w","body":"b"}"#;
let (engine, _cid) = make_engine(dir.path(), draft, true).await;
{
let mut cfg = engine.config.write().await;
cfg.evolve.model = Default::default(); // no dedicated evolve model
cfg.learn.model.provider_id = "prov_t".into(); // learn model configured
cfg.learn.model.model = "test-model".into();
}
let run = engine.run_once().await.unwrap();
assert_ne!(run.status, "model_unconfigured", "should fall back to the learn model");
assert_eq!(run.drafts_created, 1);
}
fn seed_repeated(dir: &std::path::Path, convs: &[&str], tools: &[&str]) {
let base = now_ms();
let mut k = 0i64;
for conv in convs {
for tool in tools {
k += 1;
append_event(
dir,
&CollectedEvent {
ts: base + k,
source: "tool_calls".into(),
name: "tool.call".into(),
data: serde_json::json!({"name": tool, "conversation_id": conv, "call_id": format!("{conv}-{tool}-{k}")}),
},
)
.unwrap();
}
}
}
#[tokio::test]
async fn high_confidence_pattern_auto_activates_when_enabled() {
let dir = tempfile::tempdir().unwrap();
// 4 distinct sessions repeating the same 3-step pattern → confidence ≥ 0.85.
seed_repeated(dir.path(), &["c1", "c2", "c3", "c4"], &["grep", "read", "edit"]);
let draft = r#"{"name":"auto-skill","description":"d","when_to_use":"w","body":"b"}"#;
let (engine, cid) = make_engine(dir.path(), draft, true).await;
engine.config.write().await.evolve.auto_activate = true;
let run = engine.run_once().await.unwrap();
assert_eq!(run.drafts_created, 1);
let skills = engine.store.list_skills(&cid, false).await.unwrap();
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].status, "active", "high-confidence pattern should auto-activate");
assert!(dir.path().join("skills/companion").join(&cid).join("auto-skill").join("SKILL.md").exists());
// auto path emits no review card
assert!(engine.store.list_suggestions(Some("new"), 10).await.unwrap().is_empty());
}
#[tokio::test]
async fn reflection_drafts_single_complex_session_and_never_auto_activates() {
let dir = tempfile::tempdir().unwrap();
// one session, a long non-repeating tool sequence (5 steps) → reflection candidate.
seed_repeated(dir.path(), &["solo"], &["grep", "read", "edit", "write", "bash"]);
let draft = r#"{"name":"reflect-skill","description":"d","when_to_use":"w","body":"b"}"#;
let (engine, cid) = make_engine(dir.path(), draft, true).await;
// even with auto on, a single-session reflection (distinct=1, low confidence) stays a draft.
engine.config.write().await.evolve.auto_activate = true;
let run = engine.run_once().await.unwrap();
assert_eq!(run.drafts_created, 1);
let skills = engine.store.list_skills(&cid, false).await.unwrap();
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].status, "draft", "single-session reflection must be reviewed, not auto-activated");
}
struct VersioningCompleter;
#[async_trait::async_trait]
impl CompanionCompleter for VersioningCompleter {
async fn complete(&self, _p: &str, _m: &str, system: &str, _u: &str, _t: u32) -> Result<String, AppError> {
if system == prompt::DRAFT_SYSTEM {
Ok(r#"{"name":"grep-read-edit-flow","description":"d","when_to_use":"w","body":"new"}"#.into())
} else if system == prompt::CRITIC_SYSTEM {
Ok(r#"{"approve":true}"#.into())
} else {
// MERGE_SYSTEM
Ok(r#"{"name":"grep-read-edit","description":"merged desc","when_to_use":"w","body":"merged body"}"#.into())
}
}
}
#[tokio::test]
async fn evolve_improves_similar_skill_in_place_not_duplicate() {
let dir = tempfile::tempdir().unwrap();
seed_repeated(dir.path(), &["c1", "c2", "c3"], &["grep", "read", "edit"]);
let (engine, cid) = make_engine_with(dir.path(), Arc::new(VersioningCompleter)).await;
// Pre-existing active skill whose name the new draft ("grep-read-edit-flow") is similar to.
let input = SkillDraftInput {
name: "grep-read-edit".into(),
description: "原始".into(),
when_to_use: None,
allowed_tools: None,
paths: None,
body: "old".into(),
};
skill_service::create_skill(&engine.skill_paths, &SkillScope::Companion(cid.clone()), false, &input).await.unwrap();
let now = now_ms();
engine
.store
.insert_skill(&CompanionSkill {
skill_name: "grep-read-edit".into(),
scope_kind: "companion".into(),
scope_companion_id: cid.clone(),
status: "active".into(),
source: "mined".into(),
confidence: 0.7,
provenance: vec![],
strength: 1.0,
version: 1,
superseded_by: None,
usage_count: 0,
last_used_at: None,
created_at: now,
updated_at: now,
signature: "old-sig".into(),
})
.await
.unwrap();
engine.run_once().await.unwrap();
let skills = engine.store.list_skills(&cid, false).await.unwrap();
// No duplicate created; the similar existing skill was improved in place + version bumped.
assert_eq!(skills.len(), 1, "should evolve in place, not duplicate");
assert_eq!(skills[0].skill_name, "grep-read-edit");
assert_eq!(skills[0].version, 2, "version should bump on evolve-in-place");
}
#[tokio::test]
async fn draft_from_episode_creates_demonstrated_draft() {
let dir = tempfile::tempdir().unwrap();
let draft = r#"{"name":"demo-flow","description":"d","when_to_use":"w","body":"b"}"#;
let (engine, cid) = make_engine(dir.path(), draft, true).await;
let name = engine
.draft_from_episode(vec!["grep".into(), "read".into(), "edit".into()], TranscriptAnchor::default(), &cid)
.await
.unwrap();
assert_eq!(name.as_deref(), Some("demo-flow"));
let skills = engine.store.list_skills(&cid, false).await.unwrap();
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].source, "demonstrated", "demonstrated skills are exempt from decay");
assert_eq!(skills[0].status, "draft", "demonstration always produces a reviewable draft");
}
/// 守门:重水合命中 → drafter 看到真实(脱敏)转录内容,而非仅工具名。
#[tokio::test]
async fn process_candidate_drafts_from_rehydrated_transcript() {
let dir = tempfile::tempdir().unwrap();
seed_tool_calls(dir.path());
let draft = r#"{"name":"grep-read-edit","description":"d","when_to_use":"w","body":"b"}"#;
let seen = Arc::new(tokio::sync::Mutex::new(Vec::new()));
let completer = Arc::new(CapturingCompleter { draft: draft.into(), approve: true, draft_prompts: seen.clone() });
let (engine, _cid) = make_engine_with(dir.path(), completer).await;
engine.set_transcript(Arc::new(StubTranscript::with(vec![
TranscriptTurn::user("把日志里的错误找出来改掉"),
TranscriptTurn::tool("grep", Some("pattern=ERROR".into()), Some("命中 3 处".into())),
])));
engine.run_once().await.unwrap();
let prompts = seen.lock().await;
let dp = prompts.iter().find(|p| p.contains("可复用技能")).expect("a draft prompt was issued");
assert!(dp.contains("实际操作过程"), "rehydrated transcript section missing: {dp}");
assert!(dp.contains("把日志里的错误找出来改掉"), "user content missing: {dp}");
assert!(dp.contains("命中 3 处"), "tool result missing: {dp}");
}
/// 守门:悬空指针(无源,默认 Noop)→ 降级回工具名步骤,不报错、照常起草、无转录段。
#[tokio::test]
async fn process_candidate_degrades_when_transcript_missing() {
let dir = tempfile::tempdir().unwrap();
seed_tool_calls(dir.path());
let draft = r#"{"name":"grep-read-edit","description":"d","when_to_use":"w","body":"b"}"#;
let seen = Arc::new(tokio::sync::Mutex::new(Vec::new()));
let completer = Arc::new(CapturingCompleter { draft: draft.into(), approve: true, draft_prompts: seen.clone() });
let (engine, cid) = make_engine_with(dir.path(), completer).await; // transcript stays Noop
let run = engine.run_once().await.unwrap();
assert!(run.drafts_created >= 1, "must still draft from steps alone");
let prompts = seen.lock().await;
let dp = prompts.iter().find(|p| p.contains("可复用技能")).expect("a draft prompt was issued");
assert!(!dp.contains("实际操作过程"), "degraded draft must carry no transcript section: {dp}");
// The pattern steps still drive the draft.
assert!(dp.contains("grep"), "steps still present: {dp}");
let skills = engine.store.list_skills(&cid, false).await.unwrap();
assert_eq!(skills.len(), 1);
}
}
@@ -0,0 +1,359 @@
//! 重复挖矿器(确定性,无 LLM)。
//!
//! 从采集的工具调用事件里挖出"做过多次的多步套路"。只看**工具名序列**(绝不看参数值,
//! 秘密永不入签名),按对话分组 → 折叠连续重复 → 滑窗聚合 → 跨对话计 distinct →
//! 极大窗去重(长窗优先,丢掉被更长且覆盖度不低的窗包含的短窗)。100% 单元可测。
use std::collections::{BTreeMap, BTreeSet};
use crate::collector::CollectedEvent;
use crate::evolution::transcript::TranscriptAnchor;
/// 窗口前后各保留的轮数(给 drafter 上下文);随锚带给重水合层。
const ANCHOR_PAD_TURNS: usize = 2;
/// 一个被挖出的、值得固化为技能的多步套路。
#[derive(Debug, Clone, PartialEq)]
pub struct MinedPattern {
/// 稳定签名(工具名序列),= [`tool_call_signature`]。
pub signature: String,
/// 归一化工具名序列(多步套路的步骤)。
pub steps: Vec<String>,
/// 跨所有会话的总出现次数。
pub count: i64,
/// 出现该套路的不同会话数。
pub distinct_sessions: usize,
/// 几个代表性 event_id(用于技能溯源 provenance)。
pub example_event_ids: Vec<String>,
/// 一个代表性实例的重水合定位锚(会话 + 时间窗 + call_ids)。空 conversation_id
/// = 无法重水合(drafter 降级回工具名步骤)。
pub anchor: TranscriptAnchor,
}
/// 多步套路的窗口长度边界:至少 2 步,至多 5 步(更长的多为一次性长链,固化价值低)。
const MIN_STEPS: usize = 2;
const MAX_STEPS: usize = 5;
/// 归一化工具名序列 → 稳定签名。工具名不含 `\u{1f}`(单元分隔符),故 join 即稳定且无碰撞。
pub fn tool_call_signature(steps: &[String]) -> String {
steps.join("\u{1f}")
}
/// 从工具调用事件挖掘重复多步套路。
///
/// - `events`oldest-first;只消费 `source == "tool_calls"` 的 `data.{name, conversation_id, call_id}`。
/// - `min_count`:同一签名跨所有会话的总出现次数下限。
/// - `min_distinct_sessions`:出现该签名的不同会话数下限。
///
/// 返回去重后的极大套路,长窗优先。
pub fn mine_patterns(events: &[CollectedEvent], min_count: i64, min_distinct_sessions: usize) -> Vec<MinedPattern> {
// 1) 按对话分组,保序收集 (tool_name, call_id, ts)。
let mut by_conv: BTreeMap<String, Vec<(String, String, i64)>> = BTreeMap::new();
for ev in events {
if ev.source != "tool_calls" {
continue;
}
let name = ev.data.get("name").and_then(|n| n.as_str()).unwrap_or("");
if name.is_empty() {
continue;
}
let conv = ev
.data
.get("conversation_id")
.and_then(|c| c.as_str())
.map(|s| s.to_owned())
.or_else(|| ev.data.get("conversation_id").and_then(|c| c.as_i64()).map(|n| n.to_string()))
.unwrap_or_default();
if conv.is_empty() {
continue;
}
let call_id = ev.data.get("call_id").and_then(|c| c.as_str()).unwrap_or("").to_owned();
by_conv.entry(conv).or_default().push((name.to_owned(), call_id, ev.ts));
}
// 2) 每对话:折叠连续重复 → 序列;滑窗 [MIN_STEPS, MAX_STEPS] 聚合签名。
struct Agg {
steps: Vec<String>,
count: i64,
sessions: BTreeSet<String>,
examples: Vec<String>,
/// 首个观察到的实例锚(一个代表性会话窗口)。
anchor: Option<TranscriptAnchor>,
}
let mut agg: BTreeMap<String, Agg> = BTreeMap::new();
for (conv, calls) in &by_conv {
// 折叠连续重复(同一工具连刷多次算一步),保留首次的 (call_id, ts)。
let mut seq: Vec<(String, String, i64)> = Vec::new();
for (name, eid, ts) in calls {
if seq.last().map(|(n, _, _)| n == name).unwrap_or(false) {
continue;
}
seq.push((name.clone(), eid.clone(), *ts));
}
let names: Vec<String> = seq.iter().map(|(n, _, _)| n.clone()).collect();
let n = names.len();
if n < MIN_STEPS {
continue;
}
for len in MIN_STEPS..=MAX_STEPS.min(n) {
for start in 0..=(n - len) {
let window = &names[start..start + len];
let sig = tool_call_signature(window);
let entry = agg.entry(sig).or_insert_with(|| Agg {
steps: window.to_vec(),
count: 0,
sessions: BTreeSet::new(),
examples: Vec::new(),
anchor: None,
});
entry.count += 1;
entry.sessions.insert(conv.clone());
// 首个实例 → 锚(代表性会话窗口)。
if entry.anchor.is_none() {
let slice = &seq[start..start + len];
entry.anchor = Some(TranscriptAnchor {
conversation_id: conv.clone(),
start_ts: slice.first().map(|(_, _, t)| *t).unwrap_or(0),
end_ts: slice.last().map(|(_, _, t)| *t).unwrap_or(0),
pad_turns: ANCHOR_PAD_TURNS,
call_ids: slice.iter().filter(|(_, e, _)| !e.is_empty()).map(|(_, e, _)| e.clone()).collect(),
});
}
if entry.examples.len() < 8 {
if let Some((_, eid, _)) = seq.get(start) {
if !eid.is_empty() && !entry.examples.contains(eid) {
entry.examples.push(eid.clone());
}
}
}
}
}
}
// 3) 阈值过滤。
let mut survivors: Vec<MinedPattern> = agg
.into_iter()
.filter(|(_, a)| a.count >= min_count && a.sessions.len() >= min_distinct_sessions)
.map(|(sig, a)| MinedPattern {
signature: sig,
steps: a.steps,
count: a.count,
distinct_sessions: a.sessions.len(),
example_event_ids: a.examples,
anchor: a.anchor.unwrap_or_default(),
})
.collect();
// 4) 极大窗去重:长窗优先;丢掉被某个更长且覆盖度 >= 自身的已留签名包含的短窗。
survivors.sort_by(|x, y| {
y.steps
.len()
.cmp(&x.steps.len())
.then(y.distinct_sessions.cmp(&x.distinct_sessions))
.then(x.signature.cmp(&y.signature))
});
let mut kept: Vec<MinedPattern> = Vec::new();
for p in survivors {
let subsumed = kept.iter().any(|k| {
k.steps.len() > p.steps.len()
&& k.distinct_sessions >= p.distinct_sessions
&& is_contiguous_subsequence(&p.steps, &k.steps)
});
if !subsumed {
kept.push(p);
}
}
kept
}
/// `needle` 是否为 `haystack` 的连续子序列。
fn is_contiguous_subsequence(needle: &[String], haystack: &[String]) -> bool {
if needle.is_empty() || needle.len() > haystack.len() {
return false;
}
haystack.windows(needle.len()).any(|w| w == needle)
}
/// 反思候选的会话序列长度上限(签名不无限膨胀)。
const MAX_REFLECT_STEPS: usize = 8;
/// 任务后反思候选(design §5.5):把"单个会话里一长串多步操作"整体作为一个候选——
/// 即使只出现一次,也在一次复杂任务后反思是否值得固化。每会话至多一条,折叠连续重复后
/// 长度 ≥ `min_steps`(取前 [`MAX_REFLECT_STEPS`] 步作签名),`distinct_sessions=1`
/// (故其 confidence 低、永远走人审,不会被高置信自动激活)。最多返回 `max` 条。
pub fn mine_reflection_candidates(events: &[CollectedEvent], min_steps: usize, max: usize) -> Vec<MinedPattern> {
let mut by_conv: BTreeMap<String, Vec<(String, String, i64)>> = BTreeMap::new();
for ev in events {
if ev.source != "tool_calls" {
continue;
}
let name = ev.data.get("name").and_then(|n| n.as_str()).unwrap_or("");
if name.is_empty() {
continue;
}
let conv = ev
.data
.get("conversation_id")
.and_then(|c| c.as_str())
.map(|s| s.to_owned())
.or_else(|| ev.data.get("conversation_id").and_then(|c| c.as_i64()).map(|n| n.to_string()))
.unwrap_or_default();
if conv.is_empty() {
continue;
}
let call_id = ev.data.get("call_id").and_then(|c| c.as_str()).unwrap_or("").to_owned();
by_conv.entry(conv).or_default().push((name.to_owned(), call_id, ev.ts));
}
let mut out = Vec::new();
for (conv, calls) in &by_conv {
if out.len() >= max {
break;
}
let mut seq: Vec<(String, String, i64)> = Vec::new();
for (name, eid, ts) in calls {
if seq.last().map(|(n, _, _)| n == name).unwrap_or(false) {
continue;
}
seq.push((name.clone(), eid.clone(), *ts));
}
if seq.len() < min_steps {
continue;
}
let take = seq.len().min(MAX_REFLECT_STEPS);
let taken = &seq[..take];
let names: Vec<String> = taken.iter().map(|(n, _, _)| n.clone()).collect();
let examples: Vec<String> =
taken.iter().take(8).filter_map(|(_, e, _)| if e.is_empty() { None } else { Some(e.clone()) }).collect();
let anchor = TranscriptAnchor {
conversation_id: conv.clone(),
start_ts: taken.first().map(|(_, _, t)| *t).unwrap_or(0),
end_ts: taken.last().map(|(_, _, t)| *t).unwrap_or(0),
pad_turns: ANCHOR_PAD_TURNS,
call_ids: taken.iter().filter(|(_, e, _)| !e.is_empty()).map(|(_, e, _)| e.clone()).collect(),
};
out.push(MinedPattern {
signature: tool_call_signature(&names),
steps: names,
count: 1,
distinct_sessions: 1,
example_event_ids: examples,
anchor,
});
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn tool_event(conv: &str, name: &str, call_id: &str, ts: i64) -> CollectedEvent {
CollectedEvent {
ts,
source: "tool_calls".to_owned(),
name: "tool.call".to_owned(),
data: json!({ "name": name, "conversation_id": conv, "call_id": call_id }),
}
}
/// 三个会话各做一遍 [grep, read, edit] → 恰好一个套路(极大窗去重掉子窗)。
#[test]
fn mines_repeated_three_step_pattern_once() {
let mut events = Vec::new();
let mut ts = 0;
for conv in ["conv-1", "conv-2", "conv-3"] {
for (i, tool) in ["grep", "read", "edit"].iter().enumerate() {
ts += 1;
events.push(tool_event(conv, tool, &format!("{conv}-{i}"), ts));
}
}
let patterns = mine_patterns(&events, 3, 2);
assert_eq!(patterns.len(), 1, "expected exactly one maximal pattern, got {patterns:?}");
assert_eq!(patterns[0].steps, vec!["grep".to_string(), "read".into(), "edit".into()]);
assert!(patterns[0].count >= 3);
assert!(patterns[0].distinct_sessions >= 2);
// 签名只含工具名,绝无参数/秘密。
assert!(!patterns[0].signature.contains("SECRET"));
assert_eq!(patterns[0].signature, "grep\u{1f}read\u{1f}edit");
// 锚指向一个代表性会话窗口(供重水合定位"那一段")。
let a = &patterns[0].anchor;
assert!(["conv-1", "conv-2", "conv-3"].contains(&a.conversation_id.as_str()), "anchor conv: {a:?}");
assert!(a.start_ts > 0 && a.end_ts >= a.start_ts, "anchor ts bounds: {a:?}");
assert_eq!(a.call_ids.len(), 3, "3 步窗 → 3 个 call_id: {a:?}");
assert!(a.call_ids.iter().all(|c| c.starts_with(&a.conversation_id)), "call_ids 同会话: {a:?}");
}
/// 反思候选也带定位锚(单会话整段)。
#[test]
fn reflection_candidate_carries_anchor() {
let mut events = Vec::new();
for (i, tool) in ["a", "b", "c", "d", "e"].iter().enumerate() {
events.push(tool_event("sess-x", tool, &format!("sess-x-{i}"), (i as i64) + 10));
}
let cands = mine_reflection_candidates(&events, 4, 3);
assert_eq!(cands.len(), 1);
let a = &cands[0].anchor;
assert_eq!(a.conversation_id, "sess-x");
assert_eq!(a.start_ts, 10);
assert!(a.end_ts >= a.start_ts);
assert!(!a.call_ids.is_empty());
}
/// 只出现在单个会话的序列被排除(distinct_sessions < 阈值)。
#[test]
fn excludes_single_session_sequences() {
let mut events = Vec::new();
// 反复出现但只在一个会话里 → distinct_sessions = 1
for i in 0..5 {
events.push(tool_event("only-conv", "foo", &format!("a{i}"), i * 2));
events.push(tool_event("only-conv", "bar", &format!("b{i}"), i * 2 + 1));
}
let patterns = mine_patterns(&events, 2, 2);
assert!(patterns.is_empty(), "single-session pattern must be excluded, got {patterns:?}");
}
/// 连续重复同一工具被折叠为一步(不会把 [grep,grep,read] 当成三步套路)。
#[test]
fn collapses_consecutive_duplicates() {
let mut events = Vec::new();
let mut ts = 0;
for conv in ["c1", "c2"] {
for tool in ["grep", "grep", "grep", "read"] {
ts += 1;
events.push(tool_event(conv, tool, &format!("{conv}-{ts}"), ts));
}
}
let patterns = mine_patterns(&events, 2, 2);
assert_eq!(patterns.len(), 1);
assert_eq!(patterns[0].steps, vec!["grep".to_string(), "read".into()]);
}
/// 非 tool_calls 来源被忽略。
#[test]
fn ignores_non_tool_call_sources() {
let events = vec![CollectedEvent {
ts: 1,
source: "companion_dialogues".to_owned(),
name: "chat".to_owned(),
data: json!({ "name": "whatever" }),
}];
assert!(mine_patterns(&events, 1, 1).is_empty());
}
/// 单个长会话 → 一条反思候选(distinct_sessions=1);过短会话被排除。
#[test]
fn reflection_candidate_from_single_long_session() {
let mut events = Vec::new();
let mut ts = 0;
for tool in ["grep", "read", "edit", "write"] {
ts += 1;
events.push(tool_event("solo", tool, &format!("e{ts}"), ts));
}
events.push(tool_event("short", "ls", "x", 100)); // 1-step session: excluded
let cands = mine_reflection_candidates(&events, 4, 5);
assert_eq!(cands.len(), 1);
assert_eq!(cands[0].steps, vec!["grep".to_string(), "read".into(), "edit".into(), "write".into()]);
assert_eq!(cands[0].distinct_sessions, 1);
}
}
@@ -0,0 +1,18 @@
//! 桌面伙伴自进化引擎(design §5)。
//!
//! 独立于轻量记忆学习器(`crate::learner`)的后台管线:从采集的工具调用事件里
//! 挖出重复多步套路(`miner`,确定性无 LLM),起草 + 评审成 SKILL.md`prompt` +
//! `engine``one_shot_completion`),物化为待审草稿 + `create_skill` 建议卡。
pub mod conversation_transcript;
pub mod engine;
pub mod miner;
pub mod prompt;
pub mod transcript;
pub use conversation_transcript::ConversationTranscriptSource;
pub use engine::{EvolutionEngine, EvolveRun};
pub use miner::{mine_patterns, mine_reflection_candidates, tool_call_signature, MinedPattern};
pub use transcript::{
render_transcript, NoopTranscriptSource, ToolTrace, TranscriptAnchor, TranscriptSource, TranscriptTurn, TurnRole,
};
@@ -0,0 +1,176 @@
//! 技能起草器 / 评审器的提示词与严格 JSON 解析(design §5.2 / §5.3)。
//!
//! 两个阶段都走 `one_shot_completion(tools:[])`(选 model,不切 agent)。解析容错完全
//! 镜像 `crate::prompt::{parse_learn_output, extract_json_object}`:容忍 ```json 围栏与
//! 周围散文,抽最外层 `{...}`。
use serde::Deserialize;
use super::miner::MinedPattern;
/// 起草器输出:一份技能的 frontmatter 字段 + 正文。
#[derive(Debug, Clone, Deserialize)]
pub struct DraftOutput {
#[serde(default)]
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub when_to_use: Option<String>,
#[serde(default)]
pub body: String,
}
/// 评审器裁决。
#[derive(Debug, Clone, Deserialize)]
pub struct CriticVerdict {
#[serde(default)]
pub approve: bool,
#[serde(default)]
pub reason: Option<String>,
}
/// 起草器 system:只产 JSON,禁围栏/散文,给精确骨架。
pub const DRAFT_SYSTEM: &str = "你是技能起草器。用户反复做某套多步操作,你要把它固化成一个可复用技能(SKILL.md)。\
只输出一个 JSON 对象,不要任何解释、不要代码围栏。字段:\n\
{\"name\":\"kebab-case 短名\",\"description\":\"一句话说明这个技能做什么(必填,非空)\",\
\"when_to_use\":\"什么情况下该用它(一句话)\",\"body\":\"## 步骤\\n1. ...\\n2. ... 的 markdown 操作手册\"}\n\
要求:name 只含小写字母数字和连字符;description 必须非空;body 给出可照做的步骤。";
/// 评审器 system:判断草稿是否一个足够通用、可复用的好技能。
pub const CRITIC_SYSTEM: &str = "你是技能评审器。判断给定技能草稿是否一个足够通用、可复用、安全的好技能。\
只输出一个 JSON 对象,不要解释、不要围栏:\n\
{\"approve\":true|false,\"reason\":\"一句话理由\"}\n\
拒绝条件:过于具体只适用一次、description 空洞、含危险/破坏性操作而无防护、与常识矛盾。";
/// 合并/演化 system:给定一个已有技能正文和一份新证据,产出改进后的同名技能(升版本)。
pub const MERGE_SYSTEM: &str = "你是技能演化器。已有一个技能,又观察到相关的新做法。\
把两者合并成一份**改进版**技能,保留原优点、补充新步骤、去重。只输出一个 JSON 对象,不要解释、不要围栏:\n\
{\"name\":\"沿用原 kebab-case 名\",\"description\":\"一句话说明(必填,非空)\",\"when_to_use\":\"何时用\",\"body\":\"改进后的 markdown 操作手册\"}";
/// 起草提示:给模型工具序列 + 真实操作转录(已脱敏,可空),要它产出技能字段。
pub fn build_draft_prompt(p: &MinedPattern, transcript: &[String]) -> String {
let steps = p.steps.join("");
let mut s = format!(
"主人在 {} 个不同会话里反复做了这套 {} 步操作(共 {} 次):\n{}\n\n",
p.distinct_sessions,
p.steps.len(),
p.count,
steps
);
if !transcript.is_empty() {
s.push_str("这是其中一次的实际操作过程(已脱敏,据此提炼可复用的做法,不要照抄一次性细节):\n");
for r in transcript.iter().take(40) {
s.push_str("- ");
s.push_str(r);
s.push('\n');
}
s.push('\n');
}
s.push_str("把它固化成一个可复用技能。按 system 要求只输出 JSON。");
s
}
/// 评审提示:给模型草稿 + 来源套路。
pub fn build_critic_prompt(d: &DraftOutput, p: &MinedPattern) -> String {
format!(
"技能草稿:\nname: {}\ndescription: {}\nwhen_to_use: {}\nbody:\n{}\n\n来源:主人在 {} 个会话重复了 {} 次。\n按 system 要求只输出 JSON 裁决。",
d.name,
d.description,
d.when_to_use.as_deref().unwrap_or(""),
d.body,
p.distinct_sessions,
p.count
)
}
/// 合并提示:给模型已有技能正文 + 新证据,要它产出改进版。
pub fn build_merge_prompt(existing_body: &str, draft: &DraftOutput, p: &MinedPattern) -> String {
format!(
"已有技能正文:\n{}\n\n新观察到的相关做法(步骤: {}):\n{}\n\n请合并成改进版(沿用原名),按 system 要求只输出 JSON。",
existing_body,
p.steps.join(""),
draft.body
)
}
/// 解析起草器输出(容忍围栏/散文)。
pub fn parse_draft_output(raw: &str) -> Result<DraftOutput, String> {
let cleaned = extract_json_object(raw).ok_or_else(|| "no JSON object found in draft output".to_owned())?;
serde_json::from_str(cleaned).map_err(|e| format!("invalid draft JSON: {e}"))
}
/// 解析评审器输出。
pub fn parse_critic_output(raw: &str) -> Result<CriticVerdict, String> {
let cleaned = extract_json_object(raw).ok_or_else(|| "no JSON object found in critic output".to_owned())?;
serde_json::from_str(cleaned).map_err(|e| format!("invalid critic JSON: {e}"))
}
/// 抽最外层 `{...}`(与 `crate::prompt::extract_json_object` 同语义)。
fn extract_json_object(raw: &str) -> Option<&str> {
let start = raw.find('{')?;
let end = raw.rfind('}')?;
if end <= start {
return None;
}
Some(&raw[start..=end])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_plain_and_fenced_draft() {
let plain = r#"{"name":"weekly-report","description":"汇总周报","when_to_use":"周五","body":"步骤:\n1. 收集"}"#;
let d = parse_draft_output(plain).unwrap();
assert_eq!(d.name, "weekly-report");
assert_eq!(d.description, "汇总周报");
let fenced = format!("好的:\n```json\n{plain}\n```\n以上。");
let d2 = parse_draft_output(&fenced).unwrap();
assert_eq!(d2.name, "weekly-report");
}
#[test]
fn empty_description_draft_still_parses() {
// 解析层不拒空 description(由 create_skill/critic 后续拒绝),仅保证可解析。
let d = parse_draft_output(r#"{"name":"x","description":"","body":"y"}"#).unwrap();
assert_eq!(d.description, "");
}
#[test]
fn malformed_draft_errors() {
assert!(parse_draft_output("not json at all").is_err());
assert!(parse_draft_output(r#"{"name": }"#).is_err());
}
#[test]
fn parses_critic_verdict() {
let approve = parse_critic_output(r#"{"approve":true,"reason":"通用"}"#).unwrap();
assert!(approve.approve);
let reject = parse_critic_output("裁决如下 {\"approve\":false} 完毕").unwrap();
assert!(!reject.approve);
// 缺字段走 serde default → approve=false
let missing = parse_critic_output(r#"{"reason":"x"}"#).unwrap();
assert!(!missing.approve);
}
#[test]
fn build_prompts_include_steps() {
let p = MinedPattern {
signature: "grep\u{1f}read".into(),
steps: vec!["grep".into(), "read".into()],
count: 4,
distinct_sessions: 3,
example_event_ids: vec![],
anchor: Default::default(),
};
let dp = build_draft_prompt(&p, &["在仓库里查 TODO".to_string()]);
assert!(dp.contains("grep → read"));
assert!(dp.contains("3 个不同会话"));
let d = DraftOutput { name: "x".into(), description: "d".into(), when_to_use: None, body: "b".into() };
let cp = build_critic_prompt(&d, &p);
assert!(cp.contains("name: x"));
}
}
@@ -0,0 +1,202 @@
//! 重水合原语(design 2026-06-23 采集接缝重构)。
//!
//! 技能起草需要"真实做法",但采集器只存候选索引(工具形状 + 锚点,无内容)。
//! 内容的**唯一事实源**是会话库(`nomifun-conversation` 的 messages 表,永久 durable)。
//! 起草时按 [`TranscriptAnchor`] 定向拉取"那一段"转录,脱敏后喂给 drafter,**用完即弃,
//! 绝不落 companion 库**。会话被删 → `window` 返回 `None`,调用方降级回工具名步骤。
use async_trait::async_trait;
use nomifun_common::AppError;
/// 转录窗口的定位锚:一个代表性会话 + 时间区间(call_id 辅助精确命中)。
/// `conversation_id` 为空或时间区间无法定位 → 无法重水合(降级)。
#[derive(Debug, Clone, Default, PartialEq)]
pub struct TranscriptAnchor {
/// 代表性会话(wire 形式 conversation_id,采集器即以字符串存)。
pub conversation_id: String,
/// 窗口首/末工具调用的采集 ts(毫秒);用于在会话里框出"那一段"。
pub start_ts: i64,
pub end_ts: i64,
/// 窗口前后各额外保留的轮数(给 drafter 上下文)。
pub pad_turns: usize,
/// 窗口内工具 call_id(精确命中辅助;时间区间为主)。
pub call_ids: Vec<String>,
}
/// 一条转录消息的角色(由 messages.type+position 推导:right=user, left=assistant)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TurnRole {
User,
Assistant,
Tool,
}
/// 工具调用痕迹(名 + 参数 + 结果摘要),**调用方负责脱敏后再放入**。
#[derive(Debug, Clone, PartialEq)]
pub struct ToolTrace {
pub name: String,
pub args: Option<String>,
pub result: Option<String>,
}
/// 一条转录消息(内容应已脱敏)。
#[derive(Debug, Clone, PartialEq)]
pub struct TranscriptTurn {
pub role: TurnRole,
pub text: String,
pub tool: Option<ToolTrace>,
}
impl TranscriptTurn {
pub fn user(text: impl Into<String>) -> Self {
Self { role: TurnRole::User, text: text.into(), tool: None }
}
pub fn assistant(text: impl Into<String>) -> Self {
Self { role: TurnRole::Assistant, text: text.into(), tool: None }
}
pub fn tool(name: impl Into<String>, args: Option<String>, result: Option<String>) -> Self {
Self {
role: TurnRole::Tool,
text: String::new(),
tool: Some(ToolTrace { name: name.into(), args, result }),
}
}
}
/// 只读的转录来源:按 [`TranscriptAnchor`] 拉取相关窗口。
///
/// 实现者(P-D 的 `ConversationTranscriptSource`)走会话库仓储层
/// `get_messages(conv_id)`user 无关),把 wire conversation_id 解析为 i64
/// 读 full content(非 compact),脱敏后返回。会话已删/无法定位 → `Ok(None)`。
#[async_trait]
pub trait TranscriptSource: Send + Sync {
async fn window(&self, anchor: &TranscriptAnchor) -> Result<Option<Vec<TranscriptTurn>>, AppError>;
}
/// 兜底源:永远返回 `None`(会话库未装配前 / 测试)。drafter 据此降级回工具名步骤。
pub struct NoopTranscriptSource;
#[async_trait]
impl TranscriptSource for NoopTranscriptSource {
async fn window(&self, _anchor: &TranscriptAnchor) -> Result<Option<Vec<TranscriptTurn>>, AppError> {
Ok(None)
}
}
/// 单行字符截断(与 collector 同风格,末尾省略号)。
fn clip(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_owned()
} else {
let head: String = s.chars().take(max).collect();
format!("{head}")
}
}
/// 把转录窗口渲染成喂给 drafter 的文本行(每条消息一行;工具带参/果摘要)。
/// 每行截断到 `max_chars_per_line`;空白消息丢弃。
pub fn render_transcript(turns: &[TranscriptTurn], max_chars_per_line: usize) -> Vec<String> {
let mut out = Vec::new();
for turn in turns {
let line = match (&turn.role, &turn.tool) {
(TurnRole::Tool, Some(t)) => {
let mut s = format!("工具 {}", t.name);
if let Some(a) = t.args.as_deref().filter(|a| !a.trim().is_empty()) {
s.push_str(&format!("({})", clip(a, max_chars_per_line / 2)));
}
if let Some(r) = t.result.as_deref().filter(|r| !r.trim().is_empty()) {
s.push_str(&format!("{}", clip(r, max_chars_per_line / 2)));
}
s
}
(TurnRole::User, _) => {
let t = turn.text.trim();
if t.is_empty() {
continue;
}
format!("用户:{}", clip(t, max_chars_per_line))
}
(TurnRole::Assistant, _) => {
let t = turn.text.trim();
if t.is_empty() {
continue;
}
format!("助手:{}", clip(t, max_chars_per_line))
}
(TurnRole::Tool, None) => continue,
};
out.push(line);
}
out
}
#[cfg(test)]
pub(crate) mod test_util {
//! Shared test stub. Constructed by `engine.rs` tests (P-C); the
//! `dead_code` allow keeps the P-A-only build clean before that lands.
#![allow(dead_code)]
use super::*;
use std::sync::Arc;
use tokio::sync::Mutex;
/// 测试桩:返回预置窗口,并记录被请求的锚(验证三路确实经重水合)。
pub(crate) struct StubTranscript {
pub turns: Option<Vec<TranscriptTurn>>,
pub seen: Arc<Mutex<Vec<TranscriptAnchor>>>,
}
impl StubTranscript {
pub fn with(turns: Vec<TranscriptTurn>) -> Self {
Self { turns: Some(turns), seen: Arc::new(Mutex::new(Vec::new())) }
}
pub fn missing() -> Self {
Self { turns: None, seen: Arc::new(Mutex::new(Vec::new())) }
}
}
#[async_trait]
impl TranscriptSource for StubTranscript {
async fn window(&self, anchor: &TranscriptAnchor) -> Result<Option<Vec<TranscriptTurn>>, AppError> {
self.seen.lock().await.push(anchor.clone());
Ok(self.turns.clone())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn renders_user_assistant_tool_lines_and_drops_empty() {
let turns = vec![
TranscriptTurn::user("帮我把这批图压缩"),
TranscriptTurn::assistant(" "), // 空白 → 丢弃
TranscriptTurn::tool("imagemin", Some("dir=assets".into()), Some("压了 12 张".into())),
TranscriptTurn::assistant("已完成"),
];
let lines = render_transcript(&turns, 200);
assert_eq!(lines.len(), 3, "空白助手行应被丢弃: {lines:?}");
assert!(lines[0].starts_with("用户:"));
assert!(lines[1].starts_with("工具 imagemin"));
assert!(lines[1].contains("dir=assets"));
assert!(lines[1].contains("→ 压了 12 张"));
assert!(lines[2].starts_with("助手:"));
}
#[test]
fn clips_long_lines() {
let long = "x".repeat(500);
let lines = render_transcript(&[TranscriptTurn::user(long)], 100);
assert_eq!(lines.len(), 1);
assert!(lines[0].chars().count() <= "用户:".chars().count() + 100 + 1);
assert!(lines[0].ends_with('…'));
}
#[tokio::test]
async fn noop_source_returns_none() {
let src = NoopTranscriptSource;
let got = src.window(&TranscriptAnchor::default()).await.unwrap();
assert!(got.is_none());
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,364 @@
//! DIY custom-figure storage for one companion (spec: DIY custom companion figure §3).
//!
//! Two-phase upload: the frontend first lands the processed cutout image
//! under the OS temp upload root via `POST /api/fs/upload`, then this module
//! validates the temp file and atomically installs it as
//! `{companions_dir}/{companion_id}/figure.webp` — so a delete-companion `remove_dir_all`
//! cleans the figure up together with the profile.
//!
//! Validation mirrors `nomifun-requirement`'s attachment ingest: the source
//! must canonicalize to a path inside the upload root (`{temp_dir}/nomifun`,
//! symlink-safe prefix check), carry a WebP or PNG magic number, stay within
//! [`FIGURE_MAX_BYTES`], and measure at most [`FIGURE_MAX_DIM`] pixels per
//! side (spec §3). The install is atomic (unique temp + rename, the
//! crate-wide pattern from [`crate::fsio`]).
use std::path::Path;
use nomifun_common::AppError;
/// File name of the stored figure inside `{companions_dir}/{companion_id}/`. Always
/// `.webp`: the frontend matting pipeline encodes WebP; a transparent PNG
/// passed through keeps its original bytes under this name (the serve
/// handler picks the real Content-Type via [`content_type_of`]).
pub const FIGURE_FILE: &str = "figure.webp";
/// Hard cap on the stored figure size. The generic upload endpoint allows
/// 30MB, but a processed cutout (long edge ≤ 2048) has no business being
/// larger than this.
pub const FIGURE_MAX_BYTES: u64 = 10 * 1024 * 1024;
/// Hard cap on either pixel dimension (spec §3: ≤4096×4096).
pub const FIGURE_MAX_DIM: u32 = 4096;
/// Only files inside this root may be ingested — `POST /api/fs/upload` lands
/// here (the same constraint as requirement attachments).
fn upload_root() -> std::path::PathBuf {
std::env::temp_dir().join("nomifun")
}
/// True when `bytes` starts with a WebP (`RIFF????WEBP`) or PNG
/// (`\x89PNG\r\n\x1a\n`) magic number.
fn has_image_magic(bytes: &[u8]) -> bool {
let webp = bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP";
let png = bytes.starts_with(b"\x89PNG\r\n\x1a\n");
webp || png
}
/// MIME type for serving stored figure bytes, decided by magic number.
/// Anything that passed [`has_image_magic`] is PNG or WebP; default WebP.
pub fn content_type_of(bytes: &[u8]) -> &'static str {
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
"image/png"
} else {
"image/webp"
}
}
/// Pixel dimensions parsed straight from the PNG / WebP container headers
/// (no decoder dependency). `None` when the header is truncated, malformed,
/// or an unknown WebP flavor — callers must reject such files.
fn image_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
// PNG: signature (8) + IHDR length (4) + "IHDR" (4), then BE u32 w/h.
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
if bytes.len() < 24 || &bytes[12..16] != b"IHDR" {
return None;
}
let w = u32::from_be_bytes(bytes[16..20].try_into().ok()?);
let h = u32::from_be_bytes(bytes[20..24].try_into().ok()?);
return Some((w, h));
}
// WebP: RIFF header (12), then the first chunk fourcc picks the flavor;
// every chunk payload starts at byte 20 (fourcc 4 + chunk size 4).
if bytes.len() < 16 || &bytes[0..4] != b"RIFF" || &bytes[8..12] != b"WEBP" {
return None;
}
match &bytes[12..16] {
// Extended: canvas w/h as LE 24-bit minus-one fields at payload +4.
b"VP8X" if bytes.len() >= 30 => {
let le24 = |b: &[u8]| u32::from(b[0]) | u32::from(b[1]) << 8 | u32::from(b[2]) << 16;
Some((le24(&bytes[24..27]) + 1, le24(&bytes[27..30]) + 1))
}
// Lossless: signature byte 0x2F, then 14+14 bits minus-one in a LE u32.
b"VP8L" if bytes.len() >= 25 && bytes[20] == 0x2F => {
let b = u32::from_le_bytes(bytes[21..25].try_into().ok()?);
Some(((b & 0x3FFF) + 1, ((b >> 14) & 0x3FFF) + 1))
}
// Lossy: 3-byte frame tag, start code 9D 01 2A, then LE u16 w/h
// (low 14 bits each; the top 2 bits are upscaling hints).
b"VP8 " if bytes.len() >= 30 && bytes[23..26] == [0x9D, 0x01, 0x2A] => {
let w = u16::from_le_bytes(bytes[26..28].try_into().ok()?) & 0x3FFF;
let h = u16::from_le_bytes(bytes[28..30].try_into().ok()?) & 0x3FFF;
Some((u32::from(w), u32::from(h)))
}
_ => None,
}
}
/// Validate an uploaded figure source (the two-phase-upload temp file) and
/// return its bytes, ready to install. Shared by the per-companion figure
/// ([`ingest_figure`]) and the decoupled figure library
/// ([`crate::figures`]): the source must canonicalize inside the upload
/// sandbox, stay within [`FIGURE_MAX_BYTES`] / [`FIGURE_MAX_DIM`], and carry a
/// real WebP or PNG magic number.
pub fn validate_figure_source(source_path: &Path) -> Result<Vec<u8>, AppError> {
// Resolve symlinks/`..` first, then prefix-check against the equally
// canonicalized upload root (macOS /var → /private/var must not bypass).
let canonical = std::fs::canonicalize(source_path).map_err(|e| {
AppError::BadRequest(format!(
"cannot resolve figure source '{}': {e}",
source_path.display()
))
})?;
let inside_root = std::fs::canonicalize(upload_root()).is_ok_and(|root| canonical.starts_with(&root));
if !inside_root {
return Err(AppError::Forbidden(format!(
"figure source '{}' is outside the allowed sandbox",
source_path.display()
)));
}
// Size gate before reading, so an oversized file never enters memory.
let size = std::fs::metadata(&canonical)
.map_err(|e| AppError::Internal(format!("stat figure source: {e}")))?
.len();
if size > FIGURE_MAX_BYTES {
return Err(AppError::BadRequest(format!(
"figure file is too large: {size} bytes (max {FIGURE_MAX_BYTES})"
)));
}
let bytes =
std::fs::read(&canonical).map_err(|e| AppError::Internal(format!("read figure source: {e}")))?;
if !has_image_magic(&bytes) {
return Err(AppError::BadRequest("figure file is not a WebP or PNG image".into()));
}
let (width, height) =
image_dimensions(&bytes).ok_or_else(|| AppError::BadRequest("无法解析图像尺寸".into()))?;
if width > FIGURE_MAX_DIM || height > FIGURE_MAX_DIM {
return Err(AppError::BadRequest(format!(
"图像尺寸 {width}x{height} 超出上限 {FIGURE_MAX_DIM}x{FIGURE_MAX_DIM}"
)));
}
Ok(bytes)
}
/// Validate `source_path` and atomically install its bytes as
/// `{companions_dir}/{companion_id}/figure.webp`.
///
/// The caller owns the companion-existence gate (the service 404s unknown companions
/// before calling this, so `companion_id` is always a registry-vetted id).
pub fn ingest_figure(companions_dir: &Path, companion_id: &str, source_path: &Path) -> Result<(), AppError> {
let bytes = validate_figure_source(source_path)?;
crate::fsio::save_bytes_atomic(&companions_dir.join(companion_id), FIGURE_FILE, &bytes)
.map_err(|e| AppError::Internal(format!("save companion figure: {e}")))
}
/// The stored figure bytes plus their mtime in unix seconds (the serve
/// handler's ETag input). `None` when this companion has no figure.
pub fn read_figure(companions_dir: &Path, companion_id: &str) -> Option<(Vec<u8>, u64)> {
let path = companions_dir.join(companion_id).join(FIGURE_FILE);
let mtime = std::fs::metadata(&path)
.ok()?
.modified()
.ok()?
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_secs();
let bytes = std::fs::read(&path).ok()?;
Some((bytes, mtime))
}
#[cfg(test)]
mod tests {
use super::*;
/// A unique scratch dir inside the allowed upload root
/// (`{temp_dir}/nomifun`) — figure sources must live under it.
fn upload_scratch() -> tempfile::TempDir {
let root = upload_root();
std::fs::create_dir_all(&root).unwrap();
tempfile::Builder::new()
.prefix("figure-test-")
.tempdir_in(root)
.unwrap()
}
/// A real 7×5 lossless WebP (VP8L) generated with PIL — passes both the
/// magic check and dimension parsing.
fn webp_bytes() -> Vec<u8> {
vec![
0x52, 0x49, 0x46, 0x46, 0x1E, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50,
0x38, 0x4C, 0x11, 0x00, 0x00, 0x00, 0x2F, 0x06, 0x00, 0x01, 0x00, 0x07, 0x50, 0x8A,
0x2A, 0xD4, 0xA3, 0xFF, 0x81, 0x88, 0xE8, 0x7F, 0x00, 0x00,
]
}
/// A real 7×5 RGBA PNG generated with PIL.
fn png_bytes() -> Vec<u8> {
vec![
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
0x44, 0x52, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x05, 0x08, 0x06, 0x00, 0x00,
0x00, 0x89, 0x9A, 0xF6, 0xD8, 0x00, 0x00, 0x00, 0x15, 0x49, 0x44, 0x41, 0x54, 0x78,
0x9C, 0x63, 0xE4, 0x12, 0x91, 0x6B, 0x60, 0xC0, 0x01, 0x98, 0x70, 0x49, 0xD0, 0x50,
0x12, 0x00, 0x6B, 0x56, 0x00, 0xC6, 0xD1, 0x14, 0x3D, 0x99, 0x00, 0x00, 0x00, 0x00,
0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
]
}
/// A real 12×9 lossy WebP (`VP8 `) generated with PIL.
fn lossy_webp_bytes() -> Vec<u8> {
vec![
0x52, 0x49, 0x46, 0x46, 0x3A, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50,
0x38, 0x20, 0x2E, 0x00, 0x00, 0x00, 0xF0, 0x01, 0x00, 0x9D, 0x01, 0x2A, 0x0C, 0x00,
0x09, 0x00, 0x01, 0x40, 0x26, 0x25, 0xA0, 0x02, 0x74, 0xBA, 0x01, 0xF8, 0x00, 0x04,
0xC8, 0x00, 0x00, 0xFE, 0xAE, 0x17, 0xFF, 0x36, 0x04, 0x0C, 0xD0, 0xFA, 0x60, 0xFF,
0xD2, 0x6C, 0xF1, 0x36, 0x78, 0x9B, 0x3E, 0x39, 0x80, 0x00,
]
}
/// Header of a real 21×13 extended WebP (VP8X + ALPH, PIL lossy RGBA) —
/// `image_dimensions` only reads the first 30 bytes.
fn vp8x_header_bytes() -> Vec<u8> {
vec![
0x52, 0x49, 0x46, 0x46, 0x68, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50,
0x38, 0x58, 0x0A, 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x14, 0x00, 0x00, 0x0C,
0x00, 0x00,
]
}
#[test]
fn image_dimensions_parses_real_samples() {
assert_eq!(image_dimensions(&png_bytes()), Some((7, 5)));
assert_eq!(image_dimensions(&webp_bytes()), Some((7, 5)));
assert_eq!(image_dimensions(&lossy_webp_bytes()), Some((12, 9)));
assert_eq!(image_dimensions(&vp8x_header_bytes()), Some((21, 13)));
// Valid magic but garbage payload must parse to nothing.
assert_eq!(image_dimensions(b"RIFF\x10\x00\x00\x00WEBPVP8 fake-payload"), None);
assert_eq!(image_dimensions(b"\x89PNG\r\n\x1a\nrest-of-png"), None);
}
#[test]
fn content_type_follows_magic() {
assert_eq!(content_type_of(&png_bytes()), "image/png");
assert_eq!(content_type_of(&webp_bytes()), "image/webp");
assert_eq!(content_type_of(&lossy_webp_bytes()), "image/webp");
}
#[test]
fn ingest_accepts_webp_and_installs_atomically() {
let upload = upload_scratch();
let companions = tempfile::tempdir().unwrap();
let source = upload.path().join("cutout.webp");
std::fs::write(&source, webp_bytes()).unwrap();
ingest_figure(companions.path(), "companion_a", &source).unwrap();
let companion_dir = companions.path().join("companion_a");
assert_eq!(std::fs::read(companion_dir.join(FIGURE_FILE)).unwrap(), webp_bytes());
// Exactly the figure — no half-written temp file left behind.
assert_eq!(std::fs::read_dir(&companion_dir).unwrap().count(), 1);
// PNG passes too (transparent originals skip re-encoding).
let png = upload.path().join("cutout.png");
std::fs::write(&png, png_bytes()).unwrap();
ingest_figure(companions.path(), "companion_b", &png).unwrap();
assert!(companions.path().join("companion_b").join(FIGURE_FILE).exists());
}
#[test]
fn ingest_rejects_fake_magic() {
let upload = upload_scratch();
let companions = tempfile::tempdir().unwrap();
let source = upload.path().join("fake.webp");
std::fs::write(&source, b"GIF89a definitely not webp bytes").unwrap();
let err = ingest_figure(companions.path(), "companion_a", &source).unwrap_err();
assert!(matches!(err, AppError::BadRequest(_)), "unexpected error: {err}");
assert!(!companions.path().join("companion_a").join(FIGURE_FILE).exists());
}
#[test]
fn ingest_rejects_oversized_dimensions() {
let upload = upload_scratch();
let companions = tempfile::tempdir().unwrap();
// Hand-built IHDR claiming 4097×100 (one past FIGURE_MAX_DIM).
let mut bytes = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0DIHDR".to_vec();
bytes.extend_from_slice(&4097u32.to_be_bytes());
bytes.extend_from_slice(&100u32.to_be_bytes());
let source = upload.path().join("wide.png");
std::fs::write(&source, &bytes).unwrap();
let err = ingest_figure(companions.path(), "companion_a", &source).unwrap_err();
match &err {
AppError::BadRequest(msg) => {
assert!(msg.contains("4097x100"), "message lacks actual size: {msg}");
}
other => panic!("unexpected error: {other}"),
}
assert!(!companions.path().join("companion_a").join(FIGURE_FILE).exists());
}
#[test]
fn ingest_rejects_unparseable_dimensions() {
let upload = upload_scratch();
let companions = tempfile::tempdir().unwrap();
// Valid WebP magic, but the VP8 payload has no key-frame start code.
let source = upload.path().join("opaque.webp");
std::fs::write(&source, b"RIFF\x10\x00\x00\x00WEBPVP8 fake-payload").unwrap();
let err = ingest_figure(companions.path(), "companion_a", &source).unwrap_err();
match &err {
AppError::BadRequest(msg) => assert!(msg.contains("无法解析图像尺寸"), "msg: {msg}"),
other => panic!("unexpected error: {other}"),
}
assert!(!companions.path().join("companion_a").join(FIGURE_FILE).exists());
}
#[test]
fn ingest_rejects_oversized_file() {
let upload = upload_scratch();
let companions = tempfile::tempdir().unwrap();
// Valid magic so the rejection is attributable to size alone.
let mut bytes = webp_bytes();
bytes.resize(FIGURE_MAX_BYTES as usize + 1, 0);
let source = upload.path().join("huge.webp");
std::fs::write(&source, &bytes).unwrap();
let err = ingest_figure(companions.path(), "companion_a", &source).unwrap_err();
assert!(matches!(err, AppError::BadRequest(_)), "unexpected error: {err}");
assert!(!companions.path().join("companion_a").join(FIGURE_FILE).exists());
}
#[test]
fn ingest_rejects_source_outside_upload_root() {
// tempdir() lands directly in temp_dir(), NOT under {temp_dir}/nomifun.
let outside = tempfile::tempdir().unwrap();
let companions = tempfile::tempdir().unwrap();
let source = outside.path().join("escape.webp");
std::fs::write(&source, webp_bytes()).unwrap();
let err = ingest_figure(companions.path(), "companion_a", &source).unwrap_err();
assert!(matches!(err, AppError::Forbidden(_)), "unexpected error: {err}");
assert!(!companions.path().join("companion_a").join(FIGURE_FILE).exists());
}
#[test]
fn read_returns_bytes_and_mtime() {
let upload = upload_scratch();
let companions = tempfile::tempdir().unwrap();
assert!(read_figure(companions.path(), "companion_a").is_none());
let source = upload.path().join("cutout.webp");
std::fs::write(&source, webp_bytes()).unwrap();
ingest_figure(companions.path(), "companion_a", &source).unwrap();
let (bytes, mtime) = read_figure(companions.path(), "companion_a").unwrap();
assert_eq!(bytes, webp_bytes());
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
assert!(mtime > 0 && mtime <= now + 60, "mtime {mtime} not near now {now}");
}
}
@@ -0,0 +1,286 @@
//! Decoupled custom-figure **library**: figures live independently of any companion,
//! so a user can create/import a figure up-front (from the 电子伙伴 home page)
//! before a companion exists, reuse one figure across several companions, and pick a saved
//! figure when creating/editing a companion.
//!
//! Storage (shared, under the backend data dir):
//! `{figures_dir}/{figure_id}.webp` — the processed cutout image bytes
//! `{figures_dir}/index.json` — `{ figures: [FigureMeta, …] }`
//!
//! Ingest reuses [`crate::figure::validate_figure_source`] (same sandbox +
//! magic + size + dimension checks as the per-companion path). Index read-modify-write
//! is serialized by the caller ([`crate::service::CompanionService`] holds the lock),
//! so these functions stay pure over `figures_dir`.
use std::path::Path;
use nomifun_common::{AppError, generate_prefixed_id, now_ms};
use serde::{Deserialize, Serialize};
use crate::profile::HeadBox;
const INDEX_FILE: &str = "index.json";
/// Cap on a figure's display name (chars). Generous; just stops abuse.
const MAX_NAME_CHARS: usize = 40;
/// One library figure. Mirrors `FigureMeta` in the UI (`characters/types.ts`).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FigureMeta {
/// Stable id `figure_…` (cross-device, per the primary-key terminal state).
pub id: String,
/// User-facing label.
pub name: String,
/// width / height of the cutout image.
pub aspect: f32,
pub head_box: HeadBox,
/// Desk size tier: "s" | "m" | "l".
pub size_tier: String,
/// Creation time, unix milliseconds.
pub created_at: i64,
}
/// Editable library-figure metadata. Image bytes, id, aspect and created_at stay immutable.
#[derive(Debug, Clone, Default)]
pub struct FigureUpdate {
pub name: Option<String>,
pub head_box: Option<HeadBox>,
pub size_tier: Option<String>,
}
#[derive(Default, Serialize, Deserialize)]
struct FigureIndex {
#[serde(default)]
figures: Vec<FigureMeta>,
}
fn image_name(id: &str) -> String {
format!("{id}.webp")
}
/// Reject ids that could escape `figures_dir` (path separators / traversal) or
/// don't look like our minted ids. `read`/`delete` take the id from a URL path
/// param, so this is the trust boundary.
fn is_safe_id(id: &str) -> bool {
id.starts_with("figure_")
&& id.len() <= 80
&& !id.contains('/')
&& !id.contains('\\')
&& !id.contains("..")
&& id.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
fn sanitize_name(raw: &str) -> String {
let trimmed = raw.trim();
let name: String = trimmed.chars().take(MAX_NAME_CHARS).collect();
if name.is_empty() { "自定义形象".to_owned() } else { name }
}
fn normalize_tier(tier: &str) -> String {
match tier {
"s" | "l" => tier.to_owned(),
_ => "m".to_owned(),
}
}
fn load_index(figures_dir: &Path) -> FigureIndex {
crate::fsio::load_json_or_default(&figures_dir.join(INDEX_FILE))
}
fn save_index(figures_dir: &Path, index: &FigureIndex) -> Result<(), AppError> {
crate::fsio::save_json_atomic(figures_dir, INDEX_FILE, index)
.map_err(|e| AppError::Internal(format!("save figure index: {e}")))
}
/// All saved figures, newest first.
pub fn list(figures_dir: &Path) -> Vec<FigureMeta> {
let mut figures = load_index(figures_dir).figures;
figures.sort_by(|a, b| b.created_at.cmp(&a.created_at));
figures
}
/// Ingest a validated upload as a new library figure; returns its metadata.
pub fn create(
figures_dir: &Path,
source_path: &Path,
name: &str,
aspect: f32,
head_box: HeadBox,
size_tier: &str,
) -> Result<FigureMeta, AppError> {
let bytes = crate::figure::validate_figure_source(source_path)?;
let id = generate_prefixed_id("figure");
crate::fsio::save_bytes_atomic(figures_dir, &image_name(&id), &bytes)
.map_err(|e| AppError::Internal(format!("save library figure: {e}")))?;
let meta = FigureMeta {
id: id.clone(),
name: sanitize_name(name),
aspect,
head_box,
size_tier: normalize_tier(size_tier),
created_at: now_ms(),
};
let mut index = load_index(figures_dir);
index.figures.push(meta.clone());
save_index(figures_dir, &index)?;
Ok(meta)
}
/// One figure's image bytes + mtime (unix seconds, the ETag input). `None` for
/// an unknown/invalid id or a missing image file.
pub fn read_image(figures_dir: &Path, id: &str) -> Option<(Vec<u8>, u64)> {
if !is_safe_id(id) {
return None;
}
let path = figures_dir.join(image_name(id));
let mtime = std::fs::metadata(&path)
.ok()?
.modified()
.ok()?
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_secs();
let bytes = std::fs::read(&path).ok()?;
Some((bytes, mtime))
}
/// Rename a figure. Unknown id → 404.
pub fn rename(figures_dir: &Path, id: &str, name: &str) -> Result<FigureMeta, AppError> {
update(figures_dir, id, FigureUpdate { name: Some(name.to_owned()), head_box: None, size_tier: None })
}
/// Update editable figure metadata. Unknown id → 404.
pub fn update(figures_dir: &Path, id: &str, patch: FigureUpdate) -> Result<FigureMeta, AppError> {
if !is_safe_id(id) {
return Err(AppError::NotFound(format!("figure '{id}' not found")));
}
let mut index = load_index(figures_dir);
let entry = index
.figures
.iter_mut()
.find(|f| f.id == id)
.ok_or_else(|| AppError::NotFound(format!("figure '{id}' not found")))?;
if let Some(name) = patch.name {
entry.name = sanitize_name(&name);
}
if let Some(head_box) = patch.head_box {
entry.head_box = head_box;
}
if let Some(size_tier) = patch.size_tier {
entry.size_tier = normalize_tier(&size_tier);
}
let updated = entry.clone();
save_index(figures_dir, &index)?;
Ok(updated)
}
/// Delete a figure (image + index entry). Idempotent: a missing image still
/// drops the index entry. Unknown id → 404.
pub fn remove(figures_dir: &Path, id: &str) -> Result<(), AppError> {
if !is_safe_id(id) {
return Err(AppError::NotFound(format!("figure '{id}' not found")));
}
let mut index = load_index(figures_dir);
let before = index.figures.len();
index.figures.retain(|f| f.id != id);
if index.figures.len() == before {
return Err(AppError::NotFound(format!("figure '{id}' not found")));
}
save_index(figures_dir, &index)?;
// Best-effort image removal — the index no longer references it either way.
let _ = std::fs::remove_file(figures_dir.join(image_name(id)));
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn upload_scratch() -> tempfile::TempDir {
let root = std::env::temp_dir().join("nomifun");
std::fs::create_dir_all(&root).unwrap();
tempfile::Builder::new().prefix("figlib-test-").tempdir_in(root).unwrap()
}
/// A real 7×5 lossless WebP (VP8L), same bytes the figure.rs tests use.
fn webp_bytes() -> Vec<u8> {
vec![
0x52, 0x49, 0x46, 0x46, 0x1E, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50,
0x38, 0x4C, 0x11, 0x00, 0x00, 0x00, 0x2F, 0x06, 0x00, 0x01, 0x00, 0x07, 0x50, 0x8A,
0x2A, 0xD4, 0xA3, 0xFF, 0x81, 0x88, 0xE8, 0x7F, 0x00, 0x00,
]
}
fn make_source(upload: &tempfile::TempDir, file: &str) -> std::path::PathBuf {
let p = upload.path().join(file);
std::fs::write(&p, webp_bytes()).unwrap();
p
}
#[test]
fn create_list_read_rename_delete_roundtrip() {
let upload = upload_scratch();
let figs = tempfile::tempdir().unwrap();
let dir = figs.path();
let hb = HeadBox { x: 0.3, y: 0.0, w: 0.4, h: 0.4 };
let a = create(dir, &make_source(&upload, "a.webp"), "阿狸", 0.7, hb.clone(), "l").unwrap();
let b = create(dir, &make_source(&upload, "b.webp"), "", 1.0, hb.clone(), "bogus").unwrap();
assert!(a.id.starts_with("figure_"));
assert_eq!(a.name, "阿狸");
assert_eq!(a.size_tier, "l");
assert_eq!(b.name, "自定义形象"); // empty → default
assert_eq!(b.size_tier, "m"); // bogus tier → m
// newest first
let listed = list(dir);
assert_eq!(listed.len(), 2);
assert!(listed.iter().any(|f| f.id == a.id));
assert!(listed.iter().any(|f| f.id == b.id));
if b.created_at > a.created_at {
assert_eq!(listed[0].id, b.id);
}
// image readable
let (bytes, _) = read_image(dir, &a.id).unwrap();
assert_eq!(bytes, webp_bytes());
// rename
let renamed = rename(dir, &a.id, "新名字").unwrap();
assert_eq!(renamed.name, "新名字");
assert_eq!(list(dir).iter().find(|f| f.id == a.id).unwrap().name, "新名字");
// update editable framing metadata without touching immutable image/aspect.
let updated_head = HeadBox { x: 0.1, y: 0.2, w: 0.5, h: 0.6 };
let updated = update(
dir,
&a.id,
FigureUpdate { name: Some("新取景".to_owned()), head_box: Some(updated_head.clone()), size_tier: Some("s".to_owned()) },
)
.unwrap();
assert_eq!(updated.name, "新取景");
assert_eq!(updated.aspect, a.aspect);
assert_eq!(updated.created_at, a.created_at);
assert_eq!(updated.head_box, updated_head);
assert_eq!(updated.size_tier, "s");
assert_eq!(list(dir).iter().find(|f| f.id == a.id).unwrap().head_box, updated_head);
// delete drops index + image
remove(dir, &a.id).unwrap();
assert_eq!(list(dir).len(), 1);
assert!(read_image(dir, &a.id).is_none());
assert!(remove(dir, &a.id).is_err()); // already gone → 404
}
#[test]
fn rejects_unsafe_ids() {
let figs = tempfile::tempdir().unwrap();
assert!(read_image(figs.path(), "../escape").is_none());
assert!(read_image(figs.path(), "figure_../x").is_none());
assert!(read_image(figs.path(), "notaprefix").is_none());
assert!(rename(figs.path(), "../x", "n").is_err());
assert!(update(figs.path(), "../x", FigureUpdate { name: Some("n".into()), head_box: None, size_tier: None }).is_err());
assert!(remove(figs.path(), "figure_a/b").is_err());
}
}
@@ -0,0 +1,50 @@
//! Shared file-IO helpers for the companion domain's small JSON config files:
//! atomic temp+rename writes and lenient reads that fall back to `Default`
//! (a corrupt config must never brick boot).
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use serde::Serialize;
use serde::de::DeserializeOwned;
/// Process-wide sequence shared by every atomic save in this crate, so two
/// concurrent saves (even of different config types into the same dir) can
/// never collide on a temp name and rename each other's half-written temp
/// into place.
static SAVE_SEQ: AtomicU64 = AtomicU64::new(0);
/// Atomically persist `value` as pretty JSON to `{dir}/{file}`: write a
/// uniquely-named temp file, then rename it over the target. On any error
/// the temp file is removed.
pub(crate) fn save_json_atomic(dir: &Path, file: &str, value: &impl Serialize) -> std::io::Result<()> {
let raw = serde_json::to_string_pretty(value).expect("companion config types serialize");
save_bytes_atomic(dir, file, raw.as_bytes())
}
/// Atomically persist raw `bytes` to `{dir}/{file}` (the same unique-temp +
/// rename pattern as [`save_json_atomic`] — both draw temp names from
/// [`SAVE_SEQ`] so concurrent saves into one dir can never collide).
pub(crate) fn save_bytes_atomic(dir: &Path, file: &str, bytes: &[u8]) -> std::io::Result<()> {
std::fs::create_dir_all(dir)?;
let path = dir.join(file);
let seq = SAVE_SEQ.fetch_add(1, Ordering::Relaxed);
let tmp = dir.join(format!(".{file}.tmp.{}.{seq}", std::process::id()));
let result = std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, &path));
if result.is_err() {
let _ = std::fs::remove_file(&tmp);
}
result
}
/// Load JSON from `path`, falling back to `T::default()` when the file is
/// missing or unreadable.
pub(crate) fn load_json_or_default<T: DeserializeOwned + Default>(path: &Path) -> T {
match std::fs::read_to_string(path) {
Ok(raw) => serde_json::from_str(&raw).unwrap_or_else(|e| {
tracing::warn!(error = %e, path = %path.display(), "companion json unreadable; using defaults");
T::default()
}),
Err(_) => T::default(),
}
}
@@ -0,0 +1,23 @@
//! Gamification helpers shared by status reporting and the learner.
//! (The legacy in-crate chat loop was replaced by companion threads — real
//! `type='nomi'` conversations driven by the full agent engine; see
//! `companion.rs`.)
/// Level curve: Lv = floor(sqrt(xp/100)) + 1.
pub fn level_for_xp(xp: i64) -> i64 {
((xp.max(0) as f64 / 100.0).sqrt() as i64) + 1
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn level_curve() {
assert_eq!(level_for_xp(0), 1);
assert_eq!(level_for_xp(99), 1);
assert_eq!(level_for_xp(100), 2);
assert_eq!(level_for_xp(400), 3);
assert_eq!(level_for_xp(1600), 5);
}
}
@@ -0,0 +1,493 @@
//! The scheduled learning loop: every tick, if enabled and due, read new
//! collected events, run one LLM distillation call, and apply the output
//! (memories / reinforcement / supersedes / suggestions / mood / diary).
use std::path::PathBuf;
use std::sync::Arc;
use nomifun_ai_agent::nomi_config;
use nomifun_ai_agent::{one_shot_completion, resolve_provider_config, user_message};
use nomifun_common::{AppError, generate_prefixed_id, now_ms};
use nomifun_db::IProviderRepository;
use tokio::sync::Mutex;
use crate::collector::{SharedConfig, read_events_since};
use crate::events::CompanionEventEmitter;
use crate::prompt::{self, LEARN_MAX_TOKENS};
use crate::registry::CompanionRegistry;
use crate::store::{MemoryFilter, CompanionLearnRun, CompanionStore};
const MAX_EVENTS_PER_RUN: usize = 300;
const TICK_SECONDS: u64 = 60;
/// After this many consecutive scheduled runs fail to parse, the batch is
/// abandoned (cursor advanced) instead of re-burning tokens forever.
const PARSE_FAIL_GIVE_UP_RUNS: i64 = 3;
/// LLM seam so tests can run the learner without a live provider.
/// (Companion chat runs on the real agent engine; this trait only serves
/// the scheduled learning distillation calls.)
#[async_trait::async_trait]
pub trait CompanionCompleter: Send + Sync {
async fn complete(&self, provider_id: &str, model: &str, system: &str, user: &str, max_tokens: u32)
-> Result<String, AppError>;
}
/// Production completer: provider row → nomi Config → one-shot completion.
pub struct LiveCompanionCompleter {
pub provider_repo: Arc<dyn IProviderRepository>,
pub encryption_key: [u8; 32],
pub workspace: PathBuf,
}
impl LiveCompanionCompleter {
async fn resolve(&self, provider_id: &str, model: &str) -> Result<nomi_config::config::Config, AppError> {
resolve_provider_config(
&self.provider_repo,
&self.encryption_key,
provider_id,
model,
&self.workspace,
)
.await
}
}
#[async_trait::async_trait]
impl CompanionCompleter for LiveCompanionCompleter {
async fn complete(
&self,
provider_id: &str,
model: &str,
system: &str,
user: &str,
max_tokens: u32,
) -> Result<String, AppError> {
let cfg = self.resolve(provider_id, model).await?;
one_shot_completion(&cfg, system, vec![user_message(user)], max_tokens).await
}
}
pub struct Learner {
pub companion_dir: PathBuf,
pub config: SharedConfig,
pub store: CompanionStore,
/// Companion roster: learn-run XP is a shared achievement granted to every companion.
pub registry: Arc<CompanionRegistry>,
pub completer: Arc<dyn CompanionCompleter>,
pub emitter: CompanionEventEmitter,
/// Re-entrancy guard shared between the tick loop and "run now".
pub run_lock: Arc<Mutex<()>>,
}
impl Learner {
/// Spawn the periodic tick loop.
pub fn spawn(self: Arc<Self>) {
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(TICK_SECONDS));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
interval.tick().await;
let (enabled, interval_minutes) = {
let cfg = self.config.read().await;
(cfg.learn.enabled, cfg.learn.interval_minutes.max(5) as i64)
};
if !enabled {
continue;
}
let last_run = self.store.get_state_i64("last_learn_ts").await.unwrap_or(0);
if now_ms() - last_run < interval_minutes * 60_000 {
continue;
}
if let Err(e) = self.run_once().await {
tracing::warn!(error = %e, "companion scheduled learn run failed");
}
}
});
}
/// One learning run. Returns the persisted run record.
pub async fn run_once(&self) -> Result<CompanionLearnRun, AppError> {
let Ok(_guard) = self.run_lock.try_lock() else {
return Err(AppError::Conflict("a learn run is already in progress".into()));
};
let started_at = now_ms();
// Stamp first so a crashed/failed run doesn't hot-loop the scheduler.
self.store.set_state("last_learn_ts", &started_at.to_string()).await?;
let model = { self.config.read().await.learn.model.clone() };
let mut run = CompanionLearnRun {
id: generate_prefixed_id("plr"),
started_at,
finished_at: None,
status: "ok".into(),
events_processed: 0,
memories_added: 0,
suggestions_added: 0,
error: None,
summary: None,
};
if !model.is_configured() {
run.status = "model_unconfigured".into();
run.finished_at = Some(now_ms());
self.store.insert_learn_run(&run).await?;
return Ok(run);
}
let cursor = self.store.get_state_i64("learn_cursor_ts").await?;
let (events, truncated) = read_events_since(&self.companion_dir, cursor, MAX_EVENTS_PER_RUN);
if events.is_empty() {
run.status = "no_events".into();
run.finished_at = Some(now_ms());
self.store.insert_learn_run(&run).await?;
return Ok(run);
}
run.events_processed = events.len() as i64;
let new_cursor = events.last().map(|e| e.ts).unwrap_or(cursor);
// 选项A:共享学习产出只由「默认体」窗口呈现,避免 N 个伙伴窗口同时弹气泡(提示风暴)。
let target = {
let did = { self.config.read().await.default_companion_id.clone() };
self.registry.resolve_default(&did).await
};
self.emitter.emit_learn_started(&target);
// Existing-memory digest for reinforcement/conflict matching, plus
// the pending suggestions so the model can avoid re-raising them.
let existing = self
.store
.list_memories(&MemoryFilter {
status: Some("active".into()),
limit: 120,
..Default::default()
})
.await?;
let pending_suggestions = self.store.list_suggestions(Some("new"), 50).await.unwrap_or_default();
let event_lines: Vec<String> = events
.iter()
.map(|e| serde_json::to_string(e).unwrap_or_default())
.collect();
let user_prompt = prompt::build_learn_prompt(&existing, &pending_suggestions, &event_lines, truncated);
// One retry on parse failure (the model occasionally wraps in prose).
let mut parsed = None;
let mut last_err = String::new();
let mut provider_failed = false;
for attempt in 0..2 {
match self
.completer
.complete(&model.provider_id, &model.model, prompt::LEARN_SYSTEM, &user_prompt, LEARN_MAX_TOKENS)
.await
{
Ok(raw) => match prompt::parse_learn_output(&raw) {
Ok(out) => {
parsed = Some(out);
break;
}
Err(e) => {
last_err = e;
tracing::debug!(attempt, error = %last_err, "companion learn output unparseable");
}
},
Err(e) => {
last_err = e.to_string();
provider_failed = true;
break; // provider failure: don't burn a retry
}
}
}
let Some(output) = parsed else {
run.status = "error".into();
run.error = Some(last_err);
run.finished_at = Some(now_ms());
// Provider failure is transient: keep the cursor so the same
// events retry once the provider recovers. Parse failure is the
// model misformatting — retry the batch a few scheduled runs,
// then advance past it so a consistently-confused model can't
// re-burn tokens on the same batch forever.
if !provider_failed {
let streak = self.store.get_state_i64("learn_parse_fail_streak").await.unwrap_or(0) + 1;
if streak >= PARSE_FAIL_GIVE_UP_RUNS {
self.store.set_state("learn_cursor_ts", &new_cursor.to_string()).await?;
self.store.set_state("learn_parse_fail_streak", "0").await?;
tracing::warn!(events = run.events_processed, "companion learn batch abandoned after repeated parse failures");
} else {
self.store.set_state("learn_parse_fail_streak", &streak.to_string()).await?;
}
}
self.store.insert_learn_run(&run).await?;
self.emitter.emit_learn_finished(&target, &run);
return Ok(run);
};
let _ = self.store.set_state("learn_parse_fail_streak", "0").await;
// Apply: decay first, then reinforce/supersede/insert.
let _ = self.store.decay_memories().await;
self.store.reinforce_memories(&output.reinforce_ids).await?;
self.store.archive_memories(&output.supersede_ids).await?;
let prior_active = self.store.count_memories("active").await.unwrap_or(0);
for m in &output.memories {
if self.store.find_similar_active(&m.kind, &m.content).await?.is_some() {
continue;
}
self.store
.insert_memory(&m.kind, &m.content, &m.tags, m.importance, "learn")
.await?;
run.memories_added += 1;
}
// First-preference milestone: the moment nomi visibly "gets" you.
if prior_active == 0 && run.memories_added > 0 {
let milestone = self
.store
.insert_suggestion(
"insight",
"nomi 学会了关于你的第一条记忆!",
"我开始懂你了,快来记忆页看看吧~",
Some(&serde_json::json!({"type": "navigate", "to": "/nomi?tab=memories"})),
)
.await?;
run.suggestions_added += 1;
self.emitter.emit_suggestion_created(&target, &milestone);
}
for s in output.suggestions.iter().take(3) {
// Insert-side dedup backstop: even when the model ignores the
// "don't repeat pending suggestions" rule, a similar status='new'
// suggestion blocks the duplicate. The hit is not silently
// dropped: the existing suggestion is touched (created_at bumped)
// so repeated evidence re-floats it instead of vanishing.
if let Some(existing_id) = self.store.find_similar_suggestion(&s.kind, &s.title, &s.body).await? {
if let Err(e) = self.store.touch_suggestion(&existing_id).await {
tracing::warn!(error = %e, suggestion_id = %existing_id, "companion learn failed to touch duplicate suggestion");
}
continue;
}
let created = self
.store
.insert_suggestion(&s.kind, &s.title, &s.body, s.action.as_ref())
.await?;
run.suggestions_added += 1;
self.emitter.emit_suggestion_created(&target, &created);
}
if let Some(mood) = &output.mood {
self.store.set_state("mood", mood).await?;
self.emitter.emit_mood_changed(&target, mood);
}
run.summary = output.diary;
// XP: 1 per event + 5 per new memory — a shared achievement, granted
// to every companion in the roster (spec ruling 2: the family grows
// together on the shared learning loop).
let _ = self
.store
.add_xp_all(
&self.registry.ids().await,
run.events_processed + run.memories_added * 5,
)
.await;
self.store.set_state("learn_cursor_ts", &new_cursor.to_string()).await?;
run.finished_at = Some(now_ms());
self.store.insert_learn_run(&run).await?;
self.emitter.emit_learn_finished(&target, &run);
Ok(run)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::collector::{CollectedEvent, append_event};
use crate::profile::SharedCompanionConfig;
use nomifun_api_types::WebSocketMessage;
use nomifun_realtime::BroadcastEventBus;
use tokio::sync::RwLock;
struct CannedCompleter(String);
#[async_trait::async_trait]
impl CompanionCompleter for CannedCompleter {
async fn complete(&self, _p: &str, _m: &str, _s: &str, _u: &str, _t: u32) -> Result<String, AppError> {
Ok(self.0.clone())
}
}
/// Learner over a temp dir with one registered companion (so the shared XP
/// grant has someone to land on). Returns the learner + that companion's id.
async fn make_learner(dir: &std::path::Path, reply: &str) -> (Learner, String) {
let mut config = SharedCompanionConfig::default();
config.learn.model.provider_id = "prov_t".into();
config.learn.model.model = "test-model".into();
let registry = Arc::new(CompanionRegistry::scan(dir.join("companions"), dir.join("shared")));
let companion = registry.create("测试宠", "ink").await.unwrap();
let learner = Learner {
companion_dir: dir.to_path_buf(),
config: Arc::new(RwLock::new(config)),
store: CompanionStore::open_memory().await.unwrap(),
registry,
completer: Arc::new(CannedCompleter(reply.to_owned())),
emitter: CompanionEventEmitter::new(Arc::new(BroadcastEventBus::new(16))),
run_lock: Arc::new(Mutex::new(())),
};
(learner, companion.id)
}
fn seed_event(dir: &std::path::Path) {
append_event(
dir,
&CollectedEvent {
ts: now_ms(),
source: "chat_user_messages".into(),
name: "message.userCreated".into(),
data: serde_json::json!({"content": "帮我看看 Rust 编译错误"}),
},
)
.unwrap();
}
#[tokio::test]
async fn run_once_applies_learn_output() {
let dir = tempfile::tempdir().unwrap();
seed_event(dir.path());
let reply = r#"{"memories":[{"kind":"profile","content":"主人是 Rust 工程师","importance":0.9}],
"suggestions":[{"kind":"insight","title":"洞察","body":"最近常调编译错误"}],
"mood":"content","diary":"今天陪主人修了 bug"}"#;
let (learner, companion_id) = make_learner(dir.path(), reply).await;
let run = learner.run_once().await.unwrap();
assert_eq!(run.status, "ok");
assert_eq!(run.events_processed, 1);
assert_eq!(run.memories_added, 1);
// 1 real suggestion + 1 first-memory milestone
assert_eq!(run.suggestions_added, 2);
assert_eq!(learner.store.get_state("mood").await.unwrap().unwrap(), "content");
assert!(learner.store.get_state_i64("learn_cursor_ts").await.unwrap() > 0);
// Shared XP grant lands on every registered companion (1 event + 1*5).
assert_eq!(learner.store.get_companion_state_i64(&companion_id, "xp").await.unwrap(), 6);
assert_eq!(learner.store.get_state_i64("xp").await.unwrap(), 0);
// Cursor advanced: a second run sees no events.
let run2 = learner.run_once().await.unwrap();
assert_eq!(run2.status, "no_events");
}
#[tokio::test]
async fn run_once_skips_duplicate_pending_suggestions() {
let dir = tempfile::tempdir().unwrap();
seed_event(dir.path());
let reply = r#"{"suggestions":[{"kind":"insight","title":"最近常调编译错误","body":"建议看看构建脚本"}]}"#;
let (learner, _) = make_learner(dir.path(), reply).await;
let run1 = learner.run_once().await.unwrap();
assert_eq!(run1.suggestions_added, 1);
assert_eq!(learner.store.count_suggestions("new").await.unwrap(), 1);
let first = &learner.store.list_suggestions(Some("new"), 10).await.unwrap()[0];
let (first_id, first_created_at) = (first.id.clone(), first.created_at);
// Same model output over a new event batch: the pending suggestion
// blocks the duplicate, and the dedup hit touches it (created_at
// bumped) instead of silently dropping the repeated evidence.
// (Sleep keeps the new event's ms timestamp past the advanced
// cursor and guarantees a strictly larger touch timestamp.)
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
seed_event(dir.path());
let run2 = learner.run_once().await.unwrap();
assert_eq!(run2.status, "ok");
assert_eq!(run2.suggestions_added, 0);
assert_eq!(learner.store.count_suggestions("new").await.unwrap(), 1);
let touched = &learner.store.list_suggestions(Some("new"), 10).await.unwrap()[0];
assert_eq!(touched.id, first_id, "dedup must keep the existing suggestion");
assert!(
touched.created_at > first_created_at,
"dedup hit must touch the existing suggestion ({} -> {})",
first_created_at,
touched.created_at
);
// Once decided, the same suggestion may be raised again.
let pending = learner.store.list_suggestions(Some("new"), 10).await.unwrap();
learner.store.decide_suggestion(&pending[0].id, false).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
seed_event(dir.path());
let run3 = learner.run_once().await.unwrap();
assert_eq!(run3.suggestions_added, 1);
}
#[tokio::test]
async fn run_once_records_error_on_garbage_output() {
let dir = tempfile::tempdir().unwrap();
seed_event(dir.path());
let (learner, _) = make_learner(dir.path(), "我不会输出 JSON").await;
let run = learner.run_once().await.unwrap();
assert_eq!(run.status, "error");
assert!(run.error.is_some());
}
#[tokio::test]
async fn run_once_skips_when_model_unconfigured() {
let dir = tempfile::tempdir().unwrap();
seed_event(dir.path());
let (learner, _) = make_learner(dir.path(), "{}").await;
learner.config.write().await.learn.model = Default::default();
let run = learner.run_once().await.unwrap();
assert_eq!(run.status, "model_unconfigured");
}
#[derive(Default)]
struct RecordingBroadcaster {
events: std::sync::Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
}
impl nomifun_realtime::EventBroadcaster for RecordingBroadcaster {
fn broadcast(&self, e: WebSocketMessage<serde_json::Value>) {
self.events.lock().unwrap().push(e);
}
}
#[tokio::test]
async fn learn_events_scoped_to_default_companion() {
let dir = tempfile::tempdir().unwrap();
seed_event(dir.path());
let reply = r#"{"memories":[{"kind":"profile","content":"主人是 Rust 工程师","importance":0.9}],
"suggestions":[{"kind":"insight","title":"洞察","body":"最近常调编译错误"}],
"mood":"content","diary":"今天陪主人修了 bug"}"#;
let mut config = SharedCompanionConfig::default();
config.learn.model.provider_id = "prov_t".into();
config.learn.model.model = "test-model".into();
let registry = Arc::new(CompanionRegistry::scan(dir.path().join("companions"), dir.path().join("shared")));
let _a = registry.create("", "ink").await.unwrap();
let b = registry.create("", "ink").await.unwrap();
config.default_companion_id = b.id.clone(); // 默认体 = 乙
let bc = Arc::new(RecordingBroadcaster::default());
let learner = Learner {
companion_dir: dir.path().to_path_buf(),
config: Arc::new(RwLock::new(config)),
store: CompanionStore::open_memory().await.unwrap(),
registry,
completer: Arc::new(CannedCompleter(reply.to_owned())),
emitter: CompanionEventEmitter::new(bc.clone()),
run_lock: Arc::new(Mutex::new(())),
};
learner.run_once().await.unwrap();
let events = bc.events.lock().unwrap().clone();
for name in [
"companion.suggestion-created",
"companion.mood-changed",
"companion.learn-finished",
"companion.learn-started",
] {
let evs: Vec<_> = events.iter().filter(|e| e.name == name).collect();
assert!(!evs.is_empty(), "expected at least one {name} event");
for e in evs {
assert_eq!(
e.data.get("companion_id").and_then(|v| v.as_str()),
Some(b.id.as_str()),
"{name} 必须 scope 到默认体 乙"
);
}
}
}
}
@@ -0,0 +1,73 @@
//! `nomifun-companion` — the desktop-companion domain: a roster of companions sharing one
//! memory hub (opt-in event collection + scheduled LLM learning that distills
//! events into memories + suggestions), per-companion persona companion chats over
//! the real agent engine, and the companion config/status API surface.
//!
//! Layering: `profile` is the per-companion/shared config split (`config` keeps the
//! legacy single-companion shape for migration only); `registry` is the companion roster;
//! `store` owns the shared sqlite db under `{data_dir}/companion/shared/`;
//! `collector` taps the global event bus and appends JSONL event files;
//! `learner` is the periodic LLM distillation loop; `companion` is the
//! per-companion companion chat; `service` bundles them; `routes`/`state` are the
//! API surface; `migrate` lifts a legacy `companion/nomi/` install into the split.
pub mod collector;
pub mod companion;
pub mod config;
pub mod events;
pub mod evolution;
pub mod export;
pub mod figure;
pub mod figures;
mod fsio;
pub mod gamify;
pub mod learner;
pub mod matting_model;
pub mod migrate;
pub mod profile;
pub mod prompt;
pub mod registry;
pub mod routes;
pub mod service;
pub mod skill_sink;
pub mod state;
pub mod store;
pub use config::CompanionConfig;
pub use events::CompanionEventEmitter;
pub use figures::FigureMeta;
pub use profile::{CustomFigureMeta, HeadBox, CompanionProfileConfig, CompanionWindowConfig, SharedLearnConfig, SharedCompanionConfig};
pub use registry::CompanionRegistry;
pub use routes::{companion_public_routes, companion_routes};
pub use service::CompanionService;
pub use state::CompanionRouterState;
pub use store::CompanionStore;
/// Legacy single-companion directory (under the backend data dir). Kept only so
/// boot can detect and migrate a pre-multi-companion install; new code must use
/// [`COMPANION_SHARED_REL_DIR`] / [`COMPANION_COMPANIONS_REL_DIR`].
pub const COMPANION_REL_DIR: &str = "companion/nomi";
/// Shared multi-companion artifacts (under the backend data dir): shared
/// `config.json`, `events/*.jsonl`, `memory.db`.
pub const COMPANION_SHARED_REL_DIR: &str = "companion/shared";
/// Per-companion profile roots (under the backend data dir): one
/// `{COMPANION_COMPANIONS_REL_DIR}/{companion_id}/config.json` per companion.
pub const COMPANION_COMPANIONS_REL_DIR: &str = "companion/companions";
/// 伙伴工作区树根(`{data_dir}/companion/workspaces`):与 home 目录解耦的、
/// 见名知意的每伙伴工作目录所在(`{seq}_{净化名}`)。home 目录因注册表扫描约束
/// (目录名==id)不可改名,故工作区另放此树,由 `extra.workspace` 指向。
pub const COMPANION_WORKSPACES_REL_DIR: &str = "companion/workspaces";
/// Cached ML assets shared across companions (under the backend data dir): the
/// MODNet matting model is proxied here once and served from `127.0.0.1`
/// (see [`matting_model`]) so the webview never hits a remote origin or the
/// 30 s in-worker download timeout that made DIY figures unusable.
pub const COMPANION_MODELS_REL_DIR: &str = "companion/models";
/// Shared custom-figure library (under the backend data dir): reusable figures
/// decoupled from any single companion — `{id}.webp` + `index.json` (see
/// [`figures`]).
pub const COMPANION_FIGURES_REL_DIR: &str = "companion/figures";
@@ -0,0 +1,169 @@
//! MODNet matting-model proxy: download the ML cutout model **once** from an
//! upstream mirror, cache it on disk, and let the webview fetch it from the
//! local backend (`GET /api/companion/matting-model`).
//!
//! Why this exists — the DIY custom-figure flow was "根本用不了": the renderer
//! used to lazy-download the 25 MB model directly from `huggingface.co` inside
//! the matting Web Worker, wrapped in a 30 s timeout that also covered the
//! download. The download alone takes ~36 s on a good connection (and never
//! completes behind the GFW), so the first attempt always timed out, fell back
//! to heuristic flood-fill, and dead-ended any real photo at `MATTE_FAILED`.
//!
//! Moving acquisition to the backend fixes the root cause: the webview always
//! reaches `127.0.0.1` (no remote origin, no CORS, no GFW for the local hop),
//! the model is fetched once and persisted to disk (survives restarts), and the
//! upstream fetch can try a China-friendly mirror before huggingface.
use std::path::{Path, PathBuf};
use nomifun_common::AppError;
use tokio::sync::Mutex;
/// Cached model filename under `{data_dir}/companion/models/`.
pub const MODEL_FILENAME: &str = "modnet.onnx";
/// Upstream sources, tried in order. `hf-mirror.com` is the standard
/// China-friendly HuggingFace mirror; `huggingface.co` is the canonical
/// fallback for everyone else. Same path on both.
const UPSTREAMS: &[&str] = &[
"https://hf-mirror.com/Xenova/modnet/resolve/main/onnx/model.onnx",
"https://huggingface.co/Xenova/modnet/resolve/main/onnx/model.onnx",
];
/// Sanity floor: the real model is ~25 MB. Anything smaller is an error page
/// or a truncated transfer — reject it so we don't cache garbage that bricks
/// inference forever.
const MIN_VALID_BYTES: u64 = 8 * 1024 * 1024;
/// Ceiling guard against a mirror that streams something absurd.
const MAX_VALID_BYTES: u64 = 64 * 1024 * 1024;
/// Connect timeout per upstream attempt. The *total* transfer is intentionally
/// uncapped — a slow 25 MB download must be allowed to finish (that uncapped
/// completion is the whole point of moving it off the worker's 30 s timer).
const CONNECT_TIMEOUT_SECS: u64 = 15;
fn is_valid_size(len: u64) -> bool {
(MIN_VALID_BYTES..=MAX_VALID_BYTES).contains(&len)
}
/// Return the on-disk path to the cached model, downloading it from an upstream
/// mirror on first use. Concurrency-safe: a `lock` serializes first-time
/// downloads so N concurrent callers trigger exactly one fetch (double-checked
/// against the disk both before and after acquiring the lock).
pub async fn ensure_model(models_dir: &Path, lock: &Mutex<()>) -> Result<PathBuf, AppError> {
let path = models_dir.join(MODEL_FILENAME);
// Fast path: already cached and plausibly intact — no lock, no network.
if let Ok(meta) = tokio::fs::metadata(&path).await
&& is_valid_size(meta.len())
{
return Ok(path);
}
// Slow path: serialize so concurrent first-hits share one download.
let _guard = lock.lock().await;
// Re-check under the lock: a racing caller may have just finished.
if let Ok(meta) = tokio::fs::metadata(&path).await
&& is_valid_size(meta.len())
{
return Ok(path);
}
tokio::fs::create_dir_all(models_dir)
.await
.map_err(|e| AppError::Internal(format!("create models dir: {e}")))?;
let client = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
.build()
.map_err(|e| AppError::Internal(format!("build http client: {e}")))?;
let mut last_err = String::from("no upstream attempted");
for url in UPSTREAMS {
match download_one(&client, url).await {
Ok(bytes) if is_valid_size(bytes.len() as u64) => {
write_atomic(&path, &bytes).await?;
tracing::info!(url, bytes = bytes.len(), "matting model cached");
return Ok(path);
}
Ok(bytes) => {
last_err = format!("{url}: implausible size {} bytes", bytes.len());
tracing::warn!(url, bytes = bytes.len(), "matting model upstream returned implausible size; trying next");
}
Err(e) => {
last_err = format!("{url}: {e}");
tracing::warn!(url, error = %e, "matting model upstream failed; trying next");
}
}
}
Err(AppError::Internal(format!(
"无法获取抠图模型(所有上游均失败): {last_err}"
)))
}
async fn download_one(client: &reqwest::Client, url: &str) -> Result<Vec<u8>, String> {
let res = client.get(url).send().await.map_err(|e| e.to_string())?;
if !res.status().is_success() {
return Err(format!("HTTP {}", res.status()));
}
res.bytes().await.map(|b| b.to_vec()).map_err(|e| e.to_string())
}
/// Write to a sibling temp file then rename, so a crashed/partial download
/// never leaves a half-written model at the real path.
async fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), AppError> {
let tmp = path.with_extension("onnx.partial");
tokio::fs::write(&tmp, bytes)
.await
.map_err(|e| AppError::Internal(format!("write model temp: {e}")))?;
tokio::fs::rename(&tmp, path)
.await
.map_err(|e| AppError::Internal(format!("commit model: {e}")))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn size_validation_bounds() {
assert!(!is_valid_size(0));
assert!(!is_valid_size(1024)); // an error page
assert!(is_valid_size(25 * 1024 * 1024)); // the real model
assert!(!is_valid_size(128 * 1024 * 1024)); // absurd
}
#[tokio::test]
async fn ensure_returns_cached_without_network() {
let dir = tempfile::tempdir().unwrap();
let models = dir.path().join("models");
std::fs::create_dir_all(&models).unwrap();
// Seed a plausibly-sized file so ensure_model takes the fast path and
// never touches the network.
let path = models.join(MODEL_FILENAME);
std::fs::write(&path, vec![0u8; MIN_VALID_BYTES as usize + 1]).unwrap();
let lock = Mutex::new(());
let got = ensure_model(&models, &lock).await.unwrap();
assert_eq!(got, path);
}
#[tokio::test]
async fn ensure_ignores_undersized_cache_and_then_fails_offline_cleanly() {
let dir = tempfile::tempdir().unwrap();
let models = dir.path().join("models");
std::fs::create_dir_all(&models).unwrap();
// A truncated/garbage cache must NOT be served; with no network the
// call fails cleanly (it does not return the bad file).
std::fs::write(models.join(MODEL_FILENAME), b"not a model").unwrap();
let lock = Mutex::new(());
// We can't guarantee offline in CI, so only assert the undersized file
// is never returned as-is: either it re-downloads a valid model, or it
// errors — but it never returns the 11-byte path content.
if let Ok(p) = ensure_model(&models, &lock).await {
let len = std::fs::metadata(&p).unwrap().len();
assert!(is_valid_size(len), "must not serve undersized cache");
}
}
}
@@ -0,0 +1,709 @@
//! One-shot migration from the legacy single-companion layout (`companion/nomi/`) to the
//! multi-companion split: shared artifacts (db + events + shared config) move to
//! `companion/shared/`, and the old identity/persona/window settings become the
//! first companion profile — named "Nomi" — under `companion/companions/{id}/`.
//!
//! Runs at boot, before the store/registry open. Idempotency is keyed on two
//! things only: no legacy dir, or a `.migrated` marker in the legacy dir. An
//! existing `shared` dir deliberately does *not* short-circuit — without the
//! marker it can only be the debris of a partially failed earlier attempt,
//! and the migration must re-run rather than silently strand (or lose) the
//! legacy memory. To make that re-run safe, all products are built in a
//! staging dir first and the legacy originals are only deleted after the
//! marker is written.
//!
//! Two crash windows shape the re-run rules:
//!
//! * **Window 1 — stale legacy must not overwrite fresh shared.** If the
//! marker write failed after the commit, the session still runs on the
//! committed `shared` db and writes new data. The re-run therefore stages
//! per artifact preferring `shared` over the legacy originals (a committed
//! `shared` is always ⊇ legacy), and adopts the previously minted first
//! companion ("Nomi") instead of generating a fresh id (which would orphan its
//! per-companion rows and leave a ghost duplicate companion).
//! * **Window 2 — never delete the only live copy.** Commit moves a
//! half-built `shared` aside (`companion/.migrating-displaced-<ts>`) instead of
//! removing it, and only sweeps displaced dirs after the marker is
//! written. Symmetrically, the staging-residue cleanup at entry first
//! rescues any artifact whose only copy lives in staging (a crash between
//! "shared displaced" and "staging renamed into place").
use std::path::{Path, PathBuf};
use nomifun_common::{generate_prefixed_id, now_ms};
use crate::config::CompanionConfig;
use crate::profile::{CompanionProfileConfig, CompanionWindowConfig, SharedLearnConfig, SharedCompanionConfig};
/// Marker file written into the legacy dir after a successful migration;
/// its content is the generated first-companion id.
pub(crate) const MIGRATED_MARKER: &str = ".migrated";
/// Carry a pre-rename `{data}/pet` tree forward to `{data}/companion` (and the
/// per-entity `companion/pets` subdir to `companion/companions`), so installs
/// created under the old "pet" naming keep their data — db, figures, per-companion
/// configs — after the pet→companion rename. Runs FIRST in [`CompanionService::start`],
/// before [`migrate_legacy_layout`] and before the store opens. Only renames when
/// the target doesn't already exist (a fresh `companion` dir wins); best-effort —
/// an io error must never brick boot. Companion ids keep their opaque on-disk
/// values (a `pet_…` id stays valid; only new companions mint `companion_…`).
pub fn migrate_pet_dir_to_companion(data_dir: &Path) {
let legacy_pet = data_dir.join("pet");
let companion = data_dir.join("companion");
if legacy_pet.is_dir() && !companion.exists() {
if let Err(e) = std::fs::rename(&legacy_pet, &companion) {
tracing::warn!(error = %e, "migrate '{{data}}/pet' -> '{{data}}/companion' failed; continuing");
return;
}
tracing::info!("migrated legacy '{{data}}/pet' dir to '{{data}}/companion'");
}
let legacy_pets = companion.join("pets");
let companions = companion.join("companions");
if legacy_pets.is_dir() && !companions.exists() {
if let Err(e) = std::fs::rename(&legacy_pets, &companions) {
tracing::warn!(error = %e, "migrate 'companion/pets' -> 'companion/companions' failed; continuing");
}
}
migrate_companion_config_keys(data_dir);
}
/// One-time rewrite of legacy `pet_*` JSON keys in the on-disk companion config
/// files to their `companion_*` names, so window prefs / the default-companion
/// pointer / the seq watermark survive the rename (profile id/name/character/
/// model already use rename-stable keys; memories migrate inside the store).
/// Surgical quoted-key replacement only — opaque `id` values (`pet_…`) are left
/// intact. Best-effort and idempotent (the legacy keys are gone after one run).
fn migrate_companion_config_keys(data_dir: &Path) {
fn rewrite_keys(path: &Path, pairs: &[(&str, &str)]) {
if !path.is_file() {
return;
}
let Ok(orig) = std::fs::read_to_string(path) else { return };
let mut s = orig.clone();
for (from, to) in pairs {
s = s.replace(from, to);
}
if s != orig {
let _ = std::fs::write(path, s);
}
}
let companion = data_dir.join("companion");
let shared = companion.join("shared");
// Seq watermark: rename the file and its inner key.
let old_seq = shared.join("pet_seq.json");
let new_seq = shared.join("companion_seq.json");
if old_seq.is_file() && !new_seq.exists() {
if let Ok(s) = std::fs::read_to_string(&old_seq) {
let s = s.replace("\"last_pet_seq\"", "\"last_companion_seq\"");
if std::fs::write(&new_seq, s).is_ok() {
let _ = std::fs::remove_file(&old_seq);
}
}
}
rewrite_keys(
&shared.join("config.json"),
&[
("\"default_pet_id\"", "\"default_companion_id\""),
("\"pet_dialogues\"", "\"companion_dialogues\""),
],
);
if let Ok(entries) = std::fs::read_dir(companion.join("companions")) {
for e in entries.flatten() {
if e.path().is_dir() {
rewrite_keys(
&e.path().join("config.json"),
&[
("\"pet_enabled\"", "\"companion_enabled\""),
("\"pet_x\"", "\"companion_x\""),
("\"pet_y\"", "\"companion_y\""),
],
);
}
}
}
}
/// Scratch dir (sibling of `shared`/`companions`) where all migration products are
/// staged before being committed into place. Leftovers from a crashed run
/// are wiped on the next attempt — after rescuing any artifact whose only
/// copy lives there (see [`salvage_unique_staging_copies`]).
const STAGING_REL_DIR: &str = "companion/.migrating";
/// Name prefix (under `companion/`) for the dir a pre-existing `shared` is moved
/// into during commit. Displaced dirs are only deleted after the marker is
/// written; stale ones from crashed runs are swept on the next success.
const DISPLACED_DIR_PREFIX: &str = ".migrating-displaced-";
/// The db plus its WAL sidecars (present only after an unclean shutdown).
/// Always staged as one family keyed on `memory.db`, never mixed across
/// source dirs.
const DB_FILES: [&str; 3] = ["memory.db", "memory.db-wal", "memory.db-shm"];
/// One-shot legacy migration: companion/nomi -> companion/shared + first companion "Nomi".
/// Idempotent and crash-safe via staging:
///
/// 1. Gate: only `legacy` missing or the `.migrated` marker skip the run.
/// 2. Stage: copy (never move) db/WAL/events — per artifact preferring the
/// committed `shared` copy over the legacy original (window 1) — and
/// write the split configs under [`STAGING_REL_DIR`]; the sources stay
/// untouched, so a crash anywhere in this phase restarts cleanly.
/// 3. Commit: displace any pre-existing `shared` aside (window 2: never
/// delete what might be the freshest copy before its replacement is in
/// place), rename the staged trees into `shared`/`companions`, then write the
/// marker.
/// 4. Cleanup: only now delete the legacy db/events copies, the displaced
/// dirs and the staging scratch; failures here are warn-only because the
/// marker already prevents a re-run.
///
/// Returns Some(first_companion_id) only when migration ran.
pub fn migrate_legacy_layout(data_dir: &Path) -> std::io::Result<Option<String>> {
let legacy = data_dir.join(crate::COMPANION_REL_DIR);
let shared = data_dir.join(crate::COMPANION_SHARED_REL_DIR);
let companions = data_dir.join(crate::COMPANION_COMPANIONS_REL_DIR);
if !legacy.exists() || legacy.join(MIGRATED_MARKER).exists() {
return Ok(None);
}
// ----- staging residue: rescue, then wipe -----
let staging = data_dir.join(STAGING_REL_DIR);
if staging.exists() {
salvage_unique_staging_copies(&staging.join("shared"), &shared, &legacy)?;
std::fs::remove_dir_all(&staging)?;
}
let staging_shared = staging.join("shared");
let staging_companions = staging.join("companions");
std::fs::create_dir_all(&staging_shared)?;
std::fs::create_dir_all(&staging_companions)?;
// Copy the db family and the whole events dir into staging — copy, not
// move: the sources must survive until the marker is written. Per
// artifact the committed `shared` copy wins over the legacy original
// (window 1): when a previous run committed but failed to write the
// marker, the session kept writing into `shared`, so the legacy copy is
// stale. The pre-staging implementation's half-moved files are covered
// by the same preference.
let db_src = [&shared, &legacy].into_iter().find(|dir| dir.join(DB_FILES[0]).exists());
if let Some(src) = db_src {
for file in DB_FILES {
let from = src.join(file);
if from.exists() {
std::fs::copy(&from, staging_shared.join(file))?;
}
}
}
let legacy_events = legacy.join("events");
let shared_events = shared.join("events");
let events_src = [&shared_events, &legacy_events].into_iter().find(|dir| dir.exists());
if let Some(src) = events_src {
copy_dir_recursive(src, &staging_shared.join("events"))?;
}
// The registry's seq-watermark state file: only a committed `shared` can
// hold one (the legacy layout predates companion numbering), so a copy here is
// exactly the window-1 carry-over — without it the re-run would reset
// the watermark and let deleted companion numbers be reused. A fresh migration
// has none and starts at 0 (the registry mints the file on first
// allocation).
let seq_state = shared.join(crate::registry::SEQ_STATE_FILE);
if seq_state.exists() {
std::fs::copy(&seq_state, staging_shared.join(crate::registry::SEQ_STATE_FILE))?;
}
// Split the legacy config: collection + learn loop go shared (the learn
// model inherits the old single model), identity/persona/window settings
// become the first companion profile. A re-run adopts the id of an already
// committed first companion (window 1) instead of minting a second one — a
// fresh id would orphan the per-companion rows written meanwhile and leave a
// ghost duplicate "Nomi" in the roster.
let old = CompanionConfig::load(&legacy);
let companion_id = existing_first_companion_id(&companions).unwrap_or_else(|| generate_prefixed_id("companion"));
let shared_cfg = SharedCompanionConfig {
collect: old.collect.clone(),
learn: SharedLearnConfig {
enabled: old.learn.enabled,
interval_minutes: old.learn.interval_minutes,
model: old.model.clone(),
},
default_companion_id: companion_id.clone(),
bridge_to_memory_dir: None,
..Default::default()
};
shared_cfg.save(&staging_shared)?;
let profile = CompanionProfileConfig {
id: companion_id.clone(),
// Window-1 adoption keeps the companion's short number too — rebuilding it
// as None would let the boot backfill renumber an already-numbered
// companion. A freshly minted first companion stays None and is numbered by the
// boot backfill right after the registry scan.
seq: CompanionProfileConfig::load(&companions.join(&companion_id)).seq,
name: "Nomi".into(),
character: old.appearance.character.clone(),
persona: old.persona.clone(),
model: old.model.clone(),
appearance: CompanionWindowConfig {
companion_enabled: old.appearance.companion_enabled,
companion_x: old.appearance.companion_x,
companion_y: old.appearance.companion_y,
quiet_start: old.appearance.quiet_start.clone(),
quiet_end: old.appearance.quiet_end.clone(),
// Legacy installs predate the DIY figure feature.
custom_figure: None,
},
created_at: now_ms(),
};
profile.save(&staging_companions.join(&companion_id))?;
// ----- commit -----
// Window 2: never delete the pre-existing shared dir here — between this
// point and the rename below, a copy of the data could otherwise exist
// only in staging, which the next run's residue wipe would destroy.
// Move it aside instead; displaced dirs are only swept after the marker.
if shared.exists() {
move_path(&shared, &next_displaced_path(data_dir))?;
}
move_path(&staging_shared, &shared)?;
if companions.exists() {
// Defensive: merge just the staged first companion into an existing companions
// tree instead of clobbering it.
let target = companions.join(&companion_id);
if target.exists() {
std::fs::remove_dir_all(&target)?;
}
move_path(&staging_companions.join(&companion_id), &target)?;
} else {
move_path(&staging_companions, &companions)?;
}
std::fs::write(legacy.join(MIGRATED_MARKER), &companion_id)?;
// ----- cleanup (best-effort: the marker already gates re-runs) -----
sweep_displaced_dirs(data_dir);
for file in DB_FILES {
let from = legacy.join(file);
if from.exists() {
if let Err(e) = std::fs::remove_file(&from) {
tracing::warn!("companion migrate: failed to remove legacy {file}: {e}");
}
}
}
if legacy_events.exists() {
if let Err(e) = std::fs::remove_dir_all(&legacy_events) {
tracing::warn!("companion migrate: failed to remove legacy events dir: {e}");
}
}
if let Err(e) = std::fs::remove_dir_all(&staging) {
tracing::warn!("companion migrate: failed to remove staging dir: {e}");
}
Ok(Some(companion_id))
}
/// Window 2 rescue, run before the staging-residue wipe: a crash between
/// "shared displaced" and "staging renamed into place" (or the legacy
/// pre-displacement implementation's `remove_dir_all(shared)` and the same
/// rename) leaves staging holding the *only* copy of the db/events. Any such
/// artifact — present in staging but in neither `shared` nor `legacy` — is
/// moved back into `shared` so the wipe cannot destroy it (the stage phase
/// then picks it up via the shared-first preference). Artifacts that still
/// have a source copy are deliberately left to the wipe: residue from a
/// crashed *stage* phase may be a torn partial copy, and an existing
/// original always wins over it.
fn salvage_unique_staging_copies(staging_shared: &Path, shared: &Path, legacy: &Path) -> std::io::Result<()> {
if staging_shared.join(DB_FILES[0]).exists()
&& !shared.join(DB_FILES[0]).exists()
&& !legacy.join(DB_FILES[0]).exists()
{
std::fs::create_dir_all(shared)?;
for file in DB_FILES {
let from = staging_shared.join(file);
if from.exists() {
move_path(&from, &shared.join(file))?;
}
}
}
let staged_events = staging_shared.join("events");
if staged_events.exists() && !shared.join("events").exists() && !legacy.join("events").exists() {
std::fs::create_dir_all(shared)?;
move_path(&staged_events, &shared.join("events"))?;
}
Ok(())
}
/// Window 1 id adoption: the first companion a previous (marker-less) run already
/// committed under `companions/`. Recognized by its migration-given name "Nomi"
/// with a profile that passes the registry's sanity rule (non-empty id
/// matching its directory). Oldest wins should several qualify.
fn existing_first_companion_id(companions: &Path) -> Option<String> {
let entries = std::fs::read_dir(companions).ok()?;
let mut found: Option<CompanionProfileConfig> = None;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let profile = CompanionProfileConfig::load(&path);
if profile.id.is_empty() || profile.id != entry.file_name().to_string_lossy() || profile.name != "Nomi" {
continue;
}
if found.as_ref().is_none_or(|f| profile.created_at < f.created_at) {
found = Some(profile);
}
}
found.map(|p| p.id)
}
/// A fresh, unoccupied displacement dir under `companion/` for the commit phase.
fn next_displaced_path(data_dir: &Path) -> PathBuf {
let companion_root = data_dir.join("companion");
let base = now_ms();
let mut n = 0u32;
loop {
let candidate = companion_root.join(format!("{DISPLACED_DIR_PREFIX}{base}-{n}"));
if !candidate.exists() {
return candidate;
}
n += 1;
}
}
/// Delete every displacement dir under `companion/` (the current run's and stale
/// ones from crashed runs). Only called after the marker is written, so a
/// failure merely leaves debris behind — warn, never fail the migration.
fn sweep_displaced_dirs(data_dir: &Path) {
let Ok(entries) = std::fs::read_dir(data_dir.join("companion")) else {
return;
};
for entry in entries.flatten() {
if !entry.file_name().to_string_lossy().starts_with(DISPLACED_DIR_PREFIX) {
continue;
}
if let Err(e) = std::fs::remove_dir_all(entry.path()) {
tracing::warn!(dir = %entry.path().display(), "companion migrate: failed to remove displaced dir: {e}");
}
}
}
/// `fs::rename`, falling back to copy + remove when rename fails (e.g. the
/// target lands on another volume, or Windows holds a mapping on the source).
fn move_path(from: &Path, to: &Path) -> std::io::Result<()> {
match std::fs::rename(from, to) {
Ok(()) => Ok(()),
Err(_) => {
if from.is_dir() {
copy_dir_recursive(from, to)?;
std::fs::remove_dir_all(from)
} else {
std::fs::copy(from, to)?;
std::fs::remove_file(from)
}
}
}
}
fn copy_dir_recursive(from: &Path, to: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(to)?;
for entry in std::fs::read_dir(from)? {
let entry = entry?;
let target = to.join(entry.file_name());
if entry.path().is_dir() {
copy_dir_recursive(&entry.path(), &target)?;
} else {
std::fs::copy(entry.path(), &target)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// A legacy `companion/nomi` install: old-format config.json, a fake db file
/// and one events JSONL.
fn build_legacy(data_dir: &Path) {
let legacy = data_dir.join(crate::COMPANION_REL_DIR);
std::fs::create_dir_all(legacy.join("events")).unwrap();
std::fs::write(
legacy.join("config.json"),
serde_json::json!({
"collect": {"chat_user_messages": true, "cron_runs": true},
"model": {"provider_id": "prov_x", "model": "claude-fable-5"},
"learn": {"enabled": true, "interval_minutes": 30},
"appearance": {
"companion_enabled": true,
"character": "ink",
"companion_x": 12,
"companion_y": 34,
"quiet_start": "22:00",
"quiet_end": "08:00"
},
"persona": {"preset": "calm", "custom": "多用颜文字"}
})
.to_string(),
)
.unwrap();
std::fs::write(legacy.join("memory.db"), "fake-db-bytes").unwrap();
std::fs::write(legacy.join("events").join("20260101.jsonl"), "{\"e\":1}\n").unwrap();
}
#[test]
fn migrates_legacy_layout_once() {
let dir = tempfile::tempdir().unwrap();
build_legacy(dir.path());
let legacy = dir.path().join(crate::COMPANION_REL_DIR);
let shared = dir.path().join(crate::COMPANION_SHARED_REL_DIR);
let companions = dir.path().join(crate::COMPANION_COMPANIONS_REL_DIR);
let companion_id = migrate_legacy_layout(dir.path()).unwrap().expect("migration ran");
assert!(companion_id.starts_with("companion_"));
// Shared config: collect + learn inherited, learn model = old model,
// default companion points at the new first companion.
let shared_cfg = SharedCompanionConfig::load(&shared);
assert!(shared_cfg.collect.chat_user_messages);
assert!(shared_cfg.collect.cron_runs);
assert!(!shared_cfg.collect.requirements);
assert!(shared_cfg.learn.enabled);
assert_eq!(shared_cfg.learn.interval_minutes, 30);
assert_eq!(shared_cfg.learn.model.provider_id, "prov_x");
assert_eq!(shared_cfg.learn.model.model, "claude-fable-5");
assert_eq!(shared_cfg.default_companion_id, companion_id);
// First companion profile: named Nomi, everything else from the old config.
let profile = CompanionProfileConfig::load(&companions.join(&companion_id));
assert_eq!(profile.id, companion_id);
// A freshly minted first companion carries no number yet — the boot
// backfill right after the registry scan assigns it.
assert_eq!(profile.seq, None);
assert_eq!(profile.name, "Nomi");
assert_eq!(profile.character, "ink");
assert_eq!(profile.persona.preset, "calm");
assert_eq!(profile.persona.custom, "多用颜文字");
assert_eq!(profile.model.provider_id, "prov_x");
assert!(profile.appearance.companion_enabled);
assert_eq!(profile.appearance.companion_x, Some(12));
assert_eq!(profile.appearance.companion_y, Some(34));
assert_eq!(profile.appearance.quiet_start, "22:00");
assert_eq!(profile.appearance.quiet_end, "08:00");
assert!(profile.created_at > 0);
// Db and events moved (not copied) into shared.
assert_eq!(std::fs::read_to_string(shared.join("memory.db")).unwrap(), "fake-db-bytes");
assert!(!legacy.join("memory.db").exists());
assert_eq!(
std::fs::read_to_string(shared.join("events").join("20260101.jsonl")).unwrap(),
"{\"e\":1}\n"
);
assert!(!legacy.join("events").exists());
// Marker holds the new companion id.
assert_eq!(std::fs::read_to_string(legacy.join(MIGRATED_MARKER)).unwrap(), companion_id);
// Staging scratch dir is gone after a successful run.
assert!(!dir.path().join(STAGING_REL_DIR).exists());
// Second run is a no-op.
assert_eq!(migrate_legacy_layout(dir.path()).unwrap(), None);
}
#[test]
fn no_legacy_dir_is_a_noop() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(migrate_legacy_layout(dir.path()).unwrap(), None);
assert!(!dir.path().join(crate::COMPANION_SHARED_REL_DIR).exists());
assert!(!dir.path().join(crate::COMPANION_COMPANIONS_REL_DIR).exists());
}
#[test]
fn marker_blocks_migration_even_with_legacy_artifacts() {
let dir = tempfile::tempdir().unwrap();
build_legacy(dir.path());
let legacy = dir.path().join(crate::COMPANION_REL_DIR);
std::fs::write(legacy.join(MIGRATED_MARKER), "companion_done").unwrap();
assert_eq!(migrate_legacy_layout(dir.path()).unwrap(), None);
// Nothing was touched: no shared dir, legacy artifacts intact.
assert!(!dir.path().join(crate::COMPANION_SHARED_REL_DIR).exists());
assert!(legacy.join("memory.db").exists());
assert!(legacy.join("events").join("20260101.jsonl").exists());
}
#[test]
fn half_built_shared_without_marker_is_redone() {
// Simulate a partial failure of a previous attempt: the shared dir
// exists (with junk, but no memory.db) and no marker was written.
// The migration must re-run completely instead of skipping, and the
// legacy memory must come through intact.
let dir = tempfile::tempdir().unwrap();
build_legacy(dir.path());
let legacy = dir.path().join(crate::COMPANION_REL_DIR);
let shared = dir.path().join(crate::COMPANION_SHARED_REL_DIR);
std::fs::create_dir_all(&shared).unwrap();
std::fs::write(shared.join("config.json"), "{\"broken\":").unwrap();
// Staging residue from the crashed run must be cleaned up too.
let staging = dir.path().join(STAGING_REL_DIR);
std::fs::create_dir_all(staging.join("shared")).unwrap();
std::fs::write(staging.join("shared").join("memory.db"), "stale-staging-bytes").unwrap();
let companion_id = migrate_legacy_layout(dir.path()).unwrap().expect("migration re-ran");
// Full products, fed from legacy (not from the stale staging residue).
assert_eq!(std::fs::read_to_string(shared.join("memory.db")).unwrap(), "fake-db-bytes");
assert_eq!(
std::fs::read_to_string(shared.join("events").join("20260101.jsonl")).unwrap(),
"{\"e\":1}\n"
);
let shared_cfg = SharedCompanionConfig::load(&shared);
assert_eq!(shared_cfg.default_companion_id, companion_id);
let companions = dir.path().join(crate::COMPANION_COMPANIONS_REL_DIR);
assert_eq!(CompanionProfileConfig::load(&companions.join(&companion_id)).id, companion_id);
// Marker written, legacy db/events cleaned, staging gone.
assert_eq!(std::fs::read_to_string(legacy.join(MIGRATED_MARKER)).unwrap(), companion_id);
assert!(!legacy.join("memory.db").exists());
assert!(!legacy.join("events").exists());
assert!(!staging.exists());
// And the redo is itself final: a third run is a no-op.
assert_eq!(migrate_legacy_layout(dir.path()).unwrap(), None);
}
#[test]
fn half_moved_db_is_salvaged_from_shared() {
// The pre-staging implementation moved memory.db into shared before
// writing the marker. If it crashed in that window, legacy has no db
// but shared does — the re-run must salvage it instead of replacing
// shared with an empty tree.
let dir = tempfile::tempdir().unwrap();
build_legacy(dir.path());
let legacy = dir.path().join(crate::COMPANION_REL_DIR);
let shared = dir.path().join(crate::COMPANION_SHARED_REL_DIR);
std::fs::create_dir_all(shared.join("events")).unwrap();
std::fs::rename(legacy.join("memory.db"), shared.join("memory.db")).unwrap();
std::fs::rename(
legacy.join("events").join("20260101.jsonl"),
shared.join("events").join("20260101.jsonl"),
)
.unwrap();
std::fs::remove_dir_all(legacy.join("events")).unwrap();
let companion_id = migrate_legacy_layout(dir.path()).unwrap().expect("migration re-ran");
assert_eq!(std::fs::read_to_string(shared.join("memory.db")).unwrap(), "fake-db-bytes");
assert_eq!(
std::fs::read_to_string(shared.join("events").join("20260101.jsonl")).unwrap(),
"{\"e\":1}\n"
);
assert_eq!(std::fs::read_to_string(legacy.join(MIGRATED_MARKER)).unwrap(), companion_id);
assert!(!dir.path().join(STAGING_REL_DIR).exists());
}
/// Dirs under `companion/` left by the commit's shared displacement.
fn displaced_dirs(data_dir: &Path) -> Vec<std::path::PathBuf> {
std::fs::read_dir(data_dir.join("companion"))
.unwrap()
.flatten()
.filter(|e| e.file_name().to_string_lossy().starts_with(DISPLACED_DIR_PREFIX))
.map(|e| e.path())
.collect()
}
#[test]
fn rerun_after_marker_write_failure_keeps_fresh_shared_and_reuses_companion_id() {
// Crash window 1: a previous run committed shared + the first companion
// but the marker write failed. The session then kept running on the
// committed shared db and wrote new data; the legacy originals (the
// cleanup never ran) hold only the stale pre-migration state.
let dir = tempfile::tempdir().unwrap();
build_legacy(dir.path());
let legacy = dir.path().join(crate::COMPANION_REL_DIR);
let shared = dir.path().join(crate::COMPANION_SHARED_REL_DIR);
let companions = dir.path().join(crate::COMPANION_COMPANIONS_REL_DIR);
let first_id = migrate_legacy_layout(dir.path()).unwrap().expect("first run");
// Simulate the boot that followed: the registry backfill numbered the
// first companion and advanced its watermark state file under shared.
let mut numbered = CompanionProfileConfig::load(&companions.join(&first_id));
numbered.seq = Some(1);
numbered.save(&companions.join(&first_id)).unwrap();
crate::registry::CompanionSeqState { last_companion_seq: 1 }.save(&shared).unwrap();
// Reconstruct the window: marker gone, stale legacy artifacts still
// in place, fresh post-commit writes in shared.
std::fs::remove_file(legacy.join(MIGRATED_MARKER)).unwrap();
std::fs::write(legacy.join("memory.db"), "fake-db-bytes").unwrap();
std::fs::create_dir_all(legacy.join("events")).unwrap();
std::fs::write(legacy.join("events").join("20260101.jsonl"), "{\"e\":1}\n").unwrap();
std::fs::write(shared.join("memory.db"), "fresh-session-bytes").unwrap();
std::fs::write(shared.join("events").join("20260102.jsonl"), "{\"e\":2}\n").unwrap();
let second_id = migrate_legacy_layout(dir.path()).unwrap().expect("re-ran");
// The fresh shared data wins over the stale legacy copy…
assert_eq!(
std::fs::read_to_string(shared.join("memory.db")).unwrap(),
"fresh-session-bytes"
);
assert_eq!(
std::fs::read_to_string(shared.join("events").join("20260102.jsonl")).unwrap(),
"{\"e\":2}\n"
);
// …and the already-minted first companion is adopted: same id, exactly one
// companion in the roster, no ghost duplicate.
assert_eq!(second_id, first_id);
let companion_dirs: Vec<_> = std::fs::read_dir(&companions).unwrap().flatten().collect();
assert_eq!(companion_dirs.len(), 1);
assert_eq!(CompanionProfileConfig::load(&companions.join(&first_id)).name, "Nomi");
// The adopted companion keeps its short number, and the registry's
// watermark state file is carried into the rebuilt shared dir
// instead of being reset.
assert_eq!(CompanionProfileConfig::load(&companions.join(&first_id)).seq, Some(1));
assert_eq!(crate::registry::CompanionSeqState::load(&shared).last_companion_seq, 1);
assert_eq!(SharedCompanionConfig::load(&shared).default_companion_id, first_id);
assert_eq!(std::fs::read_to_string(legacy.join(MIGRATED_MARKER)).unwrap(), first_id);
// The displaced previous shared was swept after the marker.
assert!(displaced_dirs(dir.path()).is_empty());
assert!(!dir.path().join(STAGING_REL_DIR).exists());
}
#[test]
fn staging_only_db_survives_rerun() {
// Crash window 2 (legacy implementation): the commit removed shared
// and crashed before renaming staging into place — the staged copies
// are the only ones left (the legacy db/events were consumed by an
// even earlier pre-staging attempt). The re-run's residue cleanup
// must rescue them, not wipe them.
let dir = tempfile::tempdir().unwrap();
build_legacy(dir.path());
let legacy = dir.path().join(crate::COMPANION_REL_DIR);
std::fs::remove_file(legacy.join("memory.db")).unwrap();
std::fs::remove_dir_all(legacy.join("events")).unwrap();
let staging = dir.path().join(STAGING_REL_DIR);
std::fs::create_dir_all(staging.join("shared").join("events")).unwrap();
std::fs::write(staging.join("shared").join("memory.db"), "fake-db-bytes").unwrap();
std::fs::write(
staging.join("shared").join("events").join("20260101.jsonl"),
"{\"e\":1}\n",
)
.unwrap();
let companion_id = migrate_legacy_layout(dir.path()).unwrap().expect("migration ran");
let shared = dir.path().join(crate::COMPANION_SHARED_REL_DIR);
assert_eq!(std::fs::read_to_string(shared.join("memory.db")).unwrap(), "fake-db-bytes");
assert_eq!(
std::fs::read_to_string(shared.join("events").join("20260101.jsonl")).unwrap(),
"{\"e\":1}\n"
);
assert_eq!(std::fs::read_to_string(legacy.join(MIGRATED_MARKER)).unwrap(), companion_id);
assert!(!staging.exists());
assert!(displaced_dirs(dir.path()).is_empty());
}
}
@@ -0,0 +1,344 @@
//! Multi-companion configuration split: a per-companion profile (`companion/companions/{id}/config.json`)
//! holding identity/persona/model/window settings, plus a shared config
//! (`companion/shared/config.json`) holding collection switches, the shared learn
//! loop and the default-companion pointer. Both reuse the legacy building blocks
//! from [`crate::config`] and the same atomic temp+rename write pattern.
use std::path::{Path, PathBuf};
use nomifun_common::{generate_prefixed_id, now_ms};
use serde::{Deserialize, Serialize};
use crate::config::{CollectConfig, DEFAULT_CHARACTER, ModelConfig, PersonaConfig};
/// Desktop-companion window settings for one companion — the legacy `AppearanceConfig`
/// minus `character`, which now lives directly on [`CompanionProfileConfig`].
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct CompanionWindowConfig {
/// Whether this companion's desktop window should be visible.
pub companion_enabled: bool,
/// Saved companion window position (physical px), if the user dragged it.
pub companion_x: Option<i32>,
pub companion_y: Option<i32>,
/// Quiet hours "HH:mm" — within this window the companion only accrues badges
/// and never pops bubbles. Empty strings disable quiet hours.
pub quiet_start: String,
pub quiet_end: String,
/// DIY single-image figure metadata (character == "custom"). Absent for
/// roster characters — and omitted from JSON so pre-DIY configs round-trip
/// byte-identical.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub custom_figure: Option<CustomFigureMeta>,
}
/// Head-and-shoulders crop over the figure image in image-fraction coordinates:
/// left `x` and width `w` are fractions of image WIDTH; top `y` and height `h`
/// are fractions of image HEIGHT. `h == 0` marks a legacy square box (created
/// before free-rectangle framing) — the frontend resolves it to `w * aspect`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct HeadBox {
pub x: f32,
pub y: f32,
pub w: f32,
/// Box height as a fraction of image height. `0` ⇒ legacy square (resolved
/// frontend-side to `w * aspect`); `#[serde(default)]` so old configs load.
#[serde(default)]
pub h: f32,
}
/// Metadata for a user-supplied single-image figure (`character == "custom"`),
/// mirrored by `CustomFigureMeta` in the UI (`characters/types.ts`). The image
/// bytes themselves live next to the profile as
/// `{companions_dir}/{companion_id}/{FIGURE_FILE}` (see [`crate::figure`]).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CustomFigureMeta {
/// width / height of the cutout image.
pub aspect: f32,
pub head_box: HeadBox,
/// Desk size tier: "s" | "m" | "l".
pub size_tier: String,
/// Library figure this companion draws from (`figure_…`). When set, the image is
/// served from the shared figure library (`/api/companion/figures/{id}/image`),
/// so one figure can back many companions. Absent for legacy per-companion figures
/// installed before the library (those still serve from
/// `/api/companion/companions/{id}/figure`), keeping old configs byte-identical.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub figure_id: Option<String>,
}
/// Per-companion profile persisted as `companion/companions/{id}/config.json`.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct CompanionProfileConfig {
/// Stable id (`companion_…`). An empty id after `load` means the file was
/// missing/corrupt — callers must discard such profiles.
pub id: String,
/// Display-only short number (`#1`, `#2`, …) for companion lists. Monotonic
/// within this machine — allocated by the registry from its private
/// high-watermark state file (`companion/shared/companion_seq.json`) so a deleted
/// companion's number is never reused. `None` only for profiles written before
/// the seq rollout; the boot scan backfills those.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seq: Option<u64>,
/// Display name chosen by the user.
pub name: String,
/// Which character renders in the companion window (mochi/ink/roux/pixel/bolt/boo).
pub character: String,
pub persona: PersonaConfig,
/// Per-companion companion-chat model (the shared learn loop has its own).
pub model: ModelConfig,
pub appearance: CompanionWindowConfig,
pub created_at: i64,
}
impl CompanionProfileConfig {
/// Fresh profile with a generated id. An empty `character` falls back to
/// the default roster character.
pub fn new(name: &str, character: &str) -> Self {
let character = if character.is_empty() { DEFAULT_CHARACTER } else { character };
Self {
id: generate_prefixed_id("companion"),
// Allocated by the registry under its lock (never here, where no
// watermark is in scope).
seq: None,
name: name.to_owned(),
character: character.to_owned(),
persona: PersonaConfig::default(),
model: ModelConfig::default(),
appearance: CompanionWindowConfig::default(),
created_at: now_ms(),
}
}
pub fn config_path(dir: &Path) -> PathBuf {
dir.join("config.json")
}
/// Load from `{dir}/config.json`, falling back to defaults when the file
/// is missing or unreadable (a corrupt profile must never brick boot).
/// The default has an empty `id` — callers detect and discard it.
pub fn load(dir: &Path) -> Self {
crate::fsio::load_json_or_default(&Self::config_path(dir))
}
/// Atomically persist to `{dir}/config.json` (unique temp file + rename,
/// so two concurrent saves can never rename each other's half-written
/// temp into place).
pub fn save(&self, dir: &Path) -> std::io::Result<()> {
crate::fsio::save_json_atomic(dir, "config.json", self)
}
}
/// Shared learn-loop settings: one schedule + one model distilling events for
/// every companion (the per-companion `model` only drives companion chat).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct SharedLearnConfig {
pub enabled: bool,
/// Minutes between learning runs.
pub interval_minutes: u32,
pub model: ModelConfig,
}
impl Default for SharedLearnConfig {
fn default() -> Self {
Self {
enabled: false,
interval_minutes: 60,
model: ModelConfig::default(),
}
}
}
/// Shared skill-evolution settings (design §6): the background EvolutionEngine
/// mines repeated multi-step tool sequences from real work and drafts them into
/// reviewable skills. Independent schedule/model from the lightweight learner.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct SharedEvolveConfig {
pub enabled: bool,
/// Minutes between evolution runs.
pub interval_minutes: u32,
pub model: ModelConfig,
/// A pattern must occur at least this many times total to be drafted.
pub min_pattern_count: i64,
/// A pattern must appear across at least this many distinct sessions.
pub min_distinct_sessions: usize,
/// Also reflect on single complex work sessions (not just repeated patterns) — design §5.5 任务后反思.
pub reflect_enabled: bool,
/// Auto-activate a drafted skill (skip human review) when confidence ≥ `auto_threshold`.
/// Default off (gated): the user opts into high-confidence auto-activation.
pub auto_activate: bool,
/// Confidence cutoff for `auto_activate` (repetition-derived; single-session reflections stay below it).
pub auto_threshold: f64,
/// Skill strength half-life in days (decay clock = time since last use). Used skills reinforce.
pub skill_half_life_days: f64,
/// Below this strength a mined skill is auto-archived (restorable; manual skills never decay).
pub skill_archive_threshold: f64,
}
impl Default for SharedEvolveConfig {
fn default() -> Self {
Self {
enabled: false,
interval_minutes: 30,
model: ModelConfig::default(),
min_pattern_count: 3,
min_distinct_sessions: 2,
reflect_enabled: true,
auto_activate: false,
auto_threshold: 0.85,
skill_half_life_days: 45.0,
skill_archive_threshold: 0.05,
}
}
}
/// Cross-companion shared configuration persisted as `companion/shared/config.json`.
/// Deliberately user-writable wholesale (full-object `PUT /api/companion/config`),
/// so nothing registry-owned (e.g. the companion-seq watermark, which lives in
/// `companion/shared/companion_seq.json`) may be carried here.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct SharedCompanionConfig {
pub collect: CollectConfig,
pub learn: SharedLearnConfig,
#[serde(default)]
pub evolve: SharedEvolveConfig,
/// Which companion new/unattributed activity defaults to.
pub default_companion_id: String,
/// Opt-in (default None = off): when set to a directory path, companion
/// `save` memories are ALSO mirrored into the nomi agent's file-memory there
/// (the §3.4 "消两库割裂" bridge), so the agent recalls companion-learned
/// facts. Enabling it intentionally surfaces companion memories in agent
/// sessions — that is the feature; default-off keeps the libraries separate.
#[serde(default)]
pub bridge_to_memory_dir: Option<String>,
}
impl SharedCompanionConfig {
pub fn config_path(dir: &Path) -> PathBuf {
dir.join("config.json")
}
/// Load from `{dir}/config.json` (dir is the shared dir), falling back to
/// defaults when the file is missing or unreadable.
pub fn load(dir: &Path) -> Self {
crate::fsio::load_json_or_default(&Self::config_path(dir))
}
/// Atomically persist to `{dir}/config.json` (unique temp file + rename).
pub fn save(&self, dir: &Path) -> std::io::Result<()> {
crate::fsio::save_json_atomic(dir, "config.json", self)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn profile_roundtrip_and_default_on_missing() {
let dir = tempfile::tempdir().unwrap();
let loaded = CompanionProfileConfig::load(dir.path());
assert_eq!(loaded, CompanionProfileConfig::default());
assert!(loaded.id.is_empty()); // caller-discard sentinel
let mut profile = CompanionProfileConfig::new("毛球", "ink");
profile.model.provider_id = "prov_x".into();
profile.model.model = "claude-fable-5".into();
profile.appearance.companion_enabled = true;
profile.save(dir.path()).unwrap();
let again = CompanionProfileConfig::load(dir.path());
assert_eq!(again, profile);
assert!(again.id.starts_with("companion_"));
assert!(again.created_at > 0);
}
#[test]
fn profile_new_falls_back_to_default_character() {
let p = CompanionProfileConfig::new("无名", "");
assert_eq!(p.character, "mochi");
let q = CompanionProfileConfig::new("有名", "boo");
assert_eq!(q.character, "boo");
}
#[test]
fn corrupt_profile_falls_back_to_default() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(CompanionProfileConfig::config_path(dir.path()), "{not json").unwrap();
let loaded = CompanionProfileConfig::load(dir.path());
assert_eq!(loaded, CompanionProfileConfig::default());
assert!(loaded.id.is_empty());
}
#[test]
fn custom_figure_roundtrips_and_stays_absent_for_old_configs() {
let dir = tempfile::tempdir().unwrap();
// A pre-DIY profile (no custom_figure key) deserializes to None and
// serializes without the key (skip_serializing_if).
let mut profile = CompanionProfileConfig::new("自定", "custom");
assert_eq!(profile.appearance.custom_figure, None);
profile.save(dir.path()).unwrap();
let raw = std::fs::read_to_string(CompanionProfileConfig::config_path(dir.path())).unwrap();
assert!(!raw.contains("custom_figure"));
profile.appearance.custom_figure = Some(CustomFigureMeta {
aspect: 0.9444,
head_box: HeadBox { x: 0.321, y: 0.0, w: 0.281, h: 0.3 },
size_tier: "m".into(),
figure_id: None,
});
profile.save(dir.path()).unwrap();
// A None figure_id must not appear in the JSON (old configs stay byte-clean).
let raw_none = std::fs::read_to_string(CompanionProfileConfig::config_path(dir.path())).unwrap();
assert!(!raw_none.contains("figure_id"));
let again = CompanionProfileConfig::load(dir.path());
assert_eq!(again, profile);
let meta = again.appearance.custom_figure.unwrap();
assert_eq!(meta.size_tier, "m");
assert!((meta.head_box.w - 0.281).abs() < f32::EPSILON);
// A library-linked figure_id round-trips.
profile.appearance.custom_figure = Some(CustomFigureMeta {
aspect: 0.9444,
head_box: HeadBox { x: 0.321, y: 0.0, w: 0.281, h: 0.3 },
size_tier: "m".into(),
figure_id: Some("figure_abc".into()),
});
profile.save(dir.path()).unwrap();
let linked = CompanionProfileConfig::load(dir.path());
assert_eq!(linked.appearance.custom_figure.unwrap().figure_id.as_deref(), Some("figure_abc"));
}
#[test]
fn shared_roundtrip_and_default_on_missing() {
let dir = tempfile::tempdir().unwrap();
let loaded = SharedCompanionConfig::load(dir.path());
assert_eq!(loaded, SharedCompanionConfig::default());
assert_eq!(loaded.learn.interval_minutes, 60);
assert!(!loaded.learn.enabled);
let mut cfg = SharedCompanionConfig::default();
cfg.collect.chat_user_messages = true;
cfg.learn.enabled = true;
cfg.learn.model.provider_id = "prov_y".into();
cfg.learn.model.model = "claude-fable-5".into();
cfg.default_companion_id = "companion_abc".into();
cfg.save(dir.path()).unwrap();
let again = SharedCompanionConfig::load(dir.path());
assert_eq!(again, cfg);
assert!(again.learn.model.is_configured());
}
#[test]
fn corrupt_shared_config_falls_back_to_default() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(SharedCompanionConfig::config_path(dir.path()), "[oops").unwrap();
assert_eq!(SharedCompanionConfig::load(dir.path()), SharedCompanionConfig::default());
}
}
@@ -0,0 +1,191 @@
//! Prompt assembly + strict-JSON parsing for learning runs, plus the shared
//! persona flavor text (the companion-chat system prompt lives in
//! `companion::build_companion_system_prompt`).
use serde::Deserialize;
use crate::store::{MEMORY_KINDS, CompanionMemory, CompanionSuggestion};
pub const LEARN_MAX_TOKENS: u32 = 4096;
/// Valid moods the companion can be in (renderer maps each to an animation).
pub const MOODS: [&str; 5] = ["happy", "content", "sleepy", "worried", "excited"];
#[derive(Debug, Deserialize)]
pub struct LearnedMemory {
pub kind: String,
pub content: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default = "default_importance")]
pub importance: f64,
}
fn default_importance() -> f64 {
0.5
}
#[derive(Debug, Deserialize)]
pub struct LearnedSuggestion {
pub kind: String,
pub title: String,
pub body: String,
#[serde(default)]
pub action: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
pub struct LearnOutput {
#[serde(default)]
pub memories: Vec<LearnedMemory>,
#[serde(default)]
pub reinforce_ids: Vec<String>,
#[serde(default)]
pub supersede_ids: Vec<String>,
#[serde(default)]
pub suggestions: Vec<LearnedSuggestion>,
#[serde(default)]
pub mood: Option<String>,
#[serde(default)]
pub diary: Option<String>,
}
pub const LEARN_SYSTEM: &str = r#"你是这台电脑上所有电子伙伴共享的记忆中枢管家。你的任务是阅读主人最近的工作事件记录,提炼出帮助伙伴们"更懂主人"的记忆,并产出对主人有实际帮助的建议。
记忆 kind 只能是:profile(画像,稳定事实) / preference(偏好,风格口味) / knowledge(知识,可复用结论) / episode(事件,带时间的经历) / task(任务线索,未完成事项或口头承诺) / affective(情感,情绪轨迹)。
建议 kind 只能是:guess_question(猜你想问) / create_skill(建议固化为技能) / create_cron(建议定时任务) / unfinished_task(未完成提醒) / insight(洞察) / wellness(健康关怀) / risk(风险提醒,如对话中疑似泄露密钥)。
规则:
1. 只提炼有信息量的内容,宁缺毋滥;每条记忆一句话、自包含、用中文。
2. 若新事件印证了"已有记忆"列表中的某条,把它的 id 放进 reinforce_ids,不要重复生成。
3. 若新事件与某条已有记忆矛盾,生成新记忆并把旧 id 放进 supersede_ids。
4. 建议最多 3 条,必须基于事件证据,不要空泛;可在 action 中给出跳转,格式 {"type":"navigate","to":"/路径"}。
5. mood 从 happy/content/sleepy/worried/excited 中选一个,代表伙伴们读完这些事件后的共同心情。
6. diary 是以伙伴们的第一人称写的一句话日记(中文、简短、温暖),措辞不要绑定任何单一角色名,如"今天主人修了一下午 bug,我们记住了他喜欢先看报错"。
7. 事件 data 中 origin 为 companion/cron/autowork/idmm、或 created_by 为 agent 的内容,是 agent 的自动行为而非主人发言:绝不能据此蒸馏出"主人想要/主人计划/主人提出"类记忆或建议。
8. 事件名 companion.user_message 是主人对伙伴说的话(高价值:偏好/意图/情感都值得提炼);companion.reply 是伙伴自己说的话,只能用作上下文理解,绝不能当作主人的事实、意愿或承诺。
9. 若事件表明某个任务/需求已完成或不再需要,把"已有记忆"中对应的 task 记忆 id 放进 supersede_ids,不要为已完成的事保留或新建 task 记忆。
10. 不要产出与"已有建议"列表语义相同或高度相似的建议。
只输出一个 JSON 对象,不要任何其他文字、不要 markdown 代码围栏:
{"memories":[{"kind":"...","content":"...","tags":["..."],"importance":0.0~1.0}],"reinforce_ids":[],"supersede_ids":[],"suggestions":[{"kind":"...","title":"...","body":"...","action":null}],"mood":"content","diary":"..."}"#;
/// Build the learn user prompt from existing memories, pending (status='new')
/// suggestions and new events. Feeding the pending suggestions back lets the
/// model honor rule 10 (no semantically duplicate suggestions).
pub fn build_learn_prompt(
memories: &[CompanionMemory],
pending_suggestions: &[CompanionSuggestion],
events_json: &[String],
truncated: bool,
) -> String {
let mut prompt = String::from("## 已有记忆(id | kind | 内容)\n");
if memories.is_empty() {
prompt.push_str("(暂无)\n");
}
for m in memories {
prompt.push_str(&format!("- {} | {} | {}\n", m.id, m.kind, m.content));
}
prompt.push_str("\n## 已有建议(kind | 标题 — 不要重复产出语义相同的建议)\n");
if pending_suggestions.is_empty() {
prompt.push_str("(暂无)\n");
}
for s in pending_suggestions {
prompt.push_str(&format!("- {} | {}\n", s.kind, s.title));
}
prompt.push_str("\n## 新事件记录(JSONL\n");
for line in events_json {
prompt.push_str(line);
prompt.push('\n');
}
if truncated {
prompt.push_str("\n(注意:本批事件因数量限制被截断,还有更多事件等待下次学习。)\n");
}
prompt.push_str("\n请按系统指令输出 JSON。");
prompt
}
/// Parse the model output into `LearnOutput`, tolerating ```json fences and
/// surrounding prose (extracts the outermost {...} block).
pub fn parse_learn_output(raw: &str) -> Result<LearnOutput, String> {
let cleaned = extract_json_object(raw).ok_or_else(|| "no JSON object found in model output".to_owned())?;
let mut output: LearnOutput = serde_json::from_str(cleaned).map_err(|e| format!("invalid learn JSON: {e}"))?;
output.memories.retain(|m| MEMORY_KINDS.contains(&m.kind.as_str()) && !m.content.trim().is_empty());
if let Some(mood) = &output.mood {
if !MOODS.contains(&mood.as_str()) {
output.mood = None;
}
}
Ok(output)
}
/// Extract the outermost `{...}` from text that may contain fences or prose.
fn extract_json_object(raw: &str) -> Option<&str> {
let start = raw.find('{')?;
let end = raw.rfind('}')?;
if end <= start {
return None;
}
Some(&raw[start..=end])
}
pub(crate) fn persona_flavor(preset: &str) -> &'static str {
match preset {
"calm" => "你的性格沉稳温柔,像一位安静可靠的伙伴,说话简洁、不用太多语气词。",
"sassy" => "你的性格机灵带点小毒舌,喜欢俏皮地调侃主人,但内心始终关心主人。",
_ => "你的性格活泼粘人,喜欢用可爱的语气和颜文字,对主人的事情充满好奇。",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_plain_and_fenced_json() {
let plain = r#"{"memories":[{"kind":"preference","content":"主人喜欢中文回复"}],"mood":"happy","diary":"今天学到了!"}"#;
let out = parse_learn_output(plain).unwrap();
assert_eq!(out.memories.len(), 1);
assert_eq!(out.mood.as_deref(), Some("happy"));
let fenced = format!("好的,这是结果:\n```json\n{plain}\n```\n以上。");
let out = parse_learn_output(&fenced).unwrap();
assert_eq!(out.memories.len(), 1);
}
#[test]
fn parse_rejects_garbage_and_filters_bad_kinds() {
assert!(parse_learn_output("我不知道").is_err());
let bad_kind = r#"{"memories":[{"kind":"nonsense","content":"x"},{"kind":"task","content":"修 bug"}],"mood":"angry"}"#;
let out = parse_learn_output(bad_kind).unwrap();
assert_eq!(out.memories.len(), 1);
assert_eq!(out.memories[0].kind, "task");
assert!(out.mood.is_none());
}
#[test]
fn learn_prompt_lists_pending_suggestions_and_system_has_loop_guards() {
let suggestion = CompanionSuggestion {
id: "sug_1".into(),
kind: "create_cron".into(),
title: "建议加个每日备份任务".into(),
body: "".into(),
action: None,
status: "new".into(),
created_at: 0,
decided_at: None,
};
let prompt = build_learn_prompt(&[], &[suggestion], &["{\"x\":1}".into()], false);
assert!(prompt.contains("已有建议"));
assert!(prompt.contains("create_cron | 建议加个每日备份任务"));
assert!(prompt.contains("不要重复产出语义相同的建议"));
// Empty lists render the placeholder.
let empty = build_learn_prompt(&[], &[], &[], false);
assert!(empty.contains("(暂无)"));
// The system prompt carries the anti-loop rules.
assert!(LEARN_SYSTEM.contains("companion/cron/autowork/idmm"));
assert!(LEARN_SYSTEM.contains("companion.user_message"));
assert!(LEARN_SYSTEM.contains("companion.reply"));
assert!(LEARN_SYSTEM.contains("supersede_ids"));
}
}
@@ -0,0 +1,661 @@
//! `CompanionRegistry` — the in-memory roster of companion profiles, mirrored to disk as
//! one `companion/companions/{id}/config.json` per companion. Boot does a synchronous [`scan`]
//! of the companions dir; afterwards every mutation (create/patch/remove) saves the
//! profile first and only then updates the map under the write lock, so the
//! map never claims a companion whose file failed to persist.
//!
//! The registry also owns companion short numbers ([`CompanionProfileConfig::seq`]) and
//! their high-watermark, persisted in a registry-private state file
//! ([`SEQ_STATE_FILE`] under the shared dir) that no API config write path
//! can reach: [`create`] allocates the next number from the watermark and
//! [`backfill_missing_seqs`] numbers pre-rollout profiles at boot — both
//! inside the same critical section that mutates the roster, so concurrent
//! creates can never mint the same number.
//!
//! [`scan`]: CompanionRegistry::scan
//! [`create`]: CompanionRegistry::create
//! [`backfill_missing_seqs`]: CompanionRegistry::backfill_missing_seqs
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use nomifun_common::AppError;
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use crate::profile::CompanionProfileConfig;
/// Maximum companion display-name length, counted in chars (not bytes) so CJK
/// names get the same budget as ASCII ones.
const MAX_NAME_CHARS: usize = 40;
/// File under the shared dir holding the registry-private seq watermark.
pub(crate) const SEQ_STATE_FILE: &str = "companion_seq.json";
/// Registry-private high-watermark for companion short numbers: the largest seq
/// ever allocated on this machine, persisted as `{shared_dir}/companion_seq.json`
/// (`{"last_companion_seq": N}`). It deliberately does NOT live on
/// [`crate::profile::SharedCompanionConfig`]: that object is user-writable
/// wholesale (full-object `PUT /api/companion/config`, future import paths, …), so
/// keeping the watermark there would make "never reuse a deleted companion's
/// number" depend on every present and future config write path remembering
/// to clamp it. A missing/corrupt file self-heals as 0 — the allocation
/// formula additionally takes the largest live seq into account.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub(crate) struct CompanionSeqState {
pub(crate) last_companion_seq: u64,
}
impl CompanionSeqState {
/// Load from `{shared_dir}/companion_seq.json`, falling back to 0 when the
/// file is missing or unreadable.
pub(crate) fn load(shared_dir: &Path) -> Self {
crate::fsio::load_json_or_default(&shared_dir.join(SEQ_STATE_FILE))
}
/// Atomically persist to `{shared_dir}/companion_seq.json`.
pub(crate) fn save(&self, shared_dir: &Path) -> std::io::Result<()> {
crate::fsio::save_json_atomic(shared_dir, SEQ_STATE_FILE, self)
}
}
/// RFC 7396 JSON merge patch: objects merge recursively, `null` deletes,
/// everything else replaces.
pub(crate) fn json_merge_patch(target: &mut serde_json::Value, patch: &serde_json::Value) {
if let (Some(target_map), Some(patch_map)) = (target.as_object_mut(), patch.as_object()) {
for (key, value) in patch_map {
if value.is_null() {
target_map.remove(key);
} else if value.is_object() && target_map.get(key).is_some_and(|t| t.is_object()) {
json_merge_patch(target_map.get_mut(key).unwrap(), value);
} else {
target_map.insert(key.clone(), value.clone());
}
}
} else {
*target = patch.clone();
}
}
/// Trimmed, non-empty, at most [`MAX_NAME_CHARS`] chars — or `BadRequest`.
fn validate_name(name: &str) -> Result<String, AppError> {
let name = name.trim();
if name.is_empty() {
return Err(AppError::BadRequest("companion name must not be empty".into()));
}
if name.chars().count() > MAX_NAME_CHARS {
return Err(AppError::BadRequest(format!(
"companion name must be at most {MAX_NAME_CHARS} characters"
)));
}
Ok(name.to_owned())
}
/// The largest seq carried by any companion in the map (0 when none carries one).
/// Lets allocation self-heal a stale/clobbered watermark while the
/// highest-numbered companion is still alive.
fn max_live_seq(companions: &HashMap<String, CompanionProfileConfig>) -> u64 {
companions.values().filter_map(|p| p.seq).max().unwrap_or(0)
}
pub struct CompanionRegistry {
companions_dir: PathBuf,
/// Shared multi-companion home (`{data_dir}/companion/shared`) — where the seq
/// watermark state file ([`SEQ_STATE_FILE`]) is persisted.
shared_dir: PathBuf,
/// In-memory seq watermark, mirrored to disk via [`CompanionSeqState`].
/// Registry-owned and only ever advanced.
///
/// Lock order: this lock is always acquired BEFORE the roster map below.
watermark: RwLock<u64>,
inner: RwLock<HashMap<String, CompanionProfileConfig>>,
}
impl CompanionRegistry {
/// Synchronous boot-time scan: every subdirectory of `companions_dir` is loaded
/// as a profile. Corrupt/missing configs (empty id sentinel) and dirs
/// whose name does not match the embedded id are warned about and
/// skipped — a broken profile must never brick boot or shadow a good one.
///
/// Callers should follow up with [`backfill_missing_seqs`] once inside an
/// async context to number any pre-seq profiles.
///
/// [`backfill_missing_seqs`]: CompanionRegistry::backfill_missing_seqs
pub fn scan(companions_dir: PathBuf, shared_dir: PathBuf) -> Self {
let mut companions = HashMap::new();
if let Ok(entries) = std::fs::read_dir(&companions_dir) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let dir_name = entry.file_name().to_string_lossy().into_owned();
let profile = CompanionProfileConfig::load(&path);
if profile.id.is_empty() || profile.id != dir_name {
tracing::warn!(
dir = %path.display(),
id = %profile.id,
"companion profile corrupt or id does not match its directory; skipping"
);
continue;
}
companions.insert(profile.id.clone(), profile);
}
}
let watermark = CompanionSeqState::load(&shared_dir).last_companion_seq;
Self {
companions_dir,
shared_dir,
watermark: RwLock::new(watermark),
inner: RwLock::new(companions),
}
}
/// All companions, oldest first (`created_at` ascending, id as tie-break so the
/// order is stable even for same-millisecond creations).
pub async fn list(&self) -> Vec<CompanionProfileConfig> {
let mut companions: Vec<CompanionProfileConfig> = self.inner.read().await.values().cloned().collect();
companions.sort_by(|a, b| a.created_at.cmp(&b.created_at).then_with(|| a.id.cmp(&b.id)));
companions
}
/// Root of the per-companion directories (`{data_dir}/companion/companions`) — where
/// non-config per-companion artifacts (e.g. the DIY figure image) live too.
pub(crate) fn companions_dir(&self) -> &Path {
&self.companions_dir
}
/// 伙伴工作区树根:companions_dir 的兄弟目录 `{data_dir}/companion/workspaces`。
/// companions_dir == `{data_dir}/companion/companions`,取 parent 再 join。)
/// 见名知意的每伙伴工作目录落在此树下,与 home 目录解耦。
pub(crate) fn workspaces_dir(&self) -> std::path::PathBuf {
self.companions_dir
.parent()
.map(|p| p.join("workspaces"))
.unwrap_or_else(|| self.companions_dir.join("workspaces"))
}
pub async fn get(&self, id: &str) -> Option<CompanionProfileConfig> {
self.inner.read().await.get(id).cloned()
}
/// Companion ids in the same order as [`list`](Self::list).
pub async fn ids(&self) -> Vec<String> {
self.list().await.into_iter().map(|p| p.id).collect()
}
/// 解析"代表全家发声"的伙伴 id(单一事实源,learner 与 evolution 引擎共用)。
/// 存活的显式默认体优先;否则首个注册伙伴;空 roster 返回空串。
/// liveness 检查同时修掉"默认体已删除却仍被当 owner"的潜伏问题。
pub async fn resolve_default(&self, default_companion_id: &str) -> String {
let ids = self.ids().await;
if !default_companion_id.is_empty() && ids.iter().any(|id| id == default_companion_id) {
return default_companion_id.to_owned();
}
ids.into_iter().next().unwrap_or_default()
}
/// Create a companion: validate the name, allocate its short number from the
/// registry watermark, persist `{companions_dir}/{id}/config.json`, then insert
/// into the map under the write lock. Allocation and both saves happen
/// inside one critical section so two concurrent creates can never mint
/// the same number. The watermark is only advanced after the profile
/// saved successfully — a failed create has zero persistent side effects,
/// so retrying it never burns numbers.
pub async fn create(&self, name: &str, character: &str) -> Result<CompanionProfileConfig, AppError> {
let name = validate_name(name)?;
let mut profile = CompanionProfileConfig::new(&name, character);
let dir = self.companions_dir.join(&profile.id);
// Lock order: watermark before the roster map (see struct docs).
let mut watermark = self.watermark.write().await;
let mut companions = self.inner.write().await;
// Never reuse: one past the watermark or the largest live seq,
// whichever is bigger.
let seq = (*watermark).max(max_live_seq(&companions)) + 1;
profile.seq = Some(seq);
profile
.save(&dir)
.map_err(|e| AppError::Internal(format!("save companion profile: {e}")))?;
self.advance_watermark(&mut watermark, seq);
companions.insert(profile.id.clone(), profile.clone());
Ok(profile)
}
/// Backfill short numbers for profiles written before the seq rollout
/// (or minted by the legacy migration without one), oldest first
/// (`created_at`, id as tie-break — the [`list`](Self::list) order).
/// Idempotent: a companion that already carries a seq is never renumbered.
/// Also heals a lagging watermark (e.g. a deleted/corrupt state file) by
/// advancing it to the largest live seq. Meant to run once per boot,
/// right after [`scan`](Self::scan).
pub async fn backfill_missing_seqs(&self) {
// Lock order: watermark before the roster map (see struct docs).
let mut watermark = self.watermark.write().await;
let mut companions = self.inner.write().await;
let mut missing: Vec<(i64, String)> = companions
.values()
.filter(|p| p.seq.is_none())
.map(|p| (p.created_at, p.id.clone()))
.collect();
missing.sort();
let mut next = (*watermark).max(max_live_seq(&companions)) + 1;
for (_, id) in missing {
let Some(profile) = companions.get_mut(&id) else { continue };
profile.seq = Some(next);
if let Err(e) = profile.save(&self.companions_dir.join(&id)) {
// The map must never claim state the disk doesn't have:
// leave the profile unnumbered. And never hand its number to
// a younger companion — seq is immutable once persisted, so that
// would permanently invert the numbering against created_at
// order. Stop numbering here instead: this companion and every
// younger one retry, in order, on the next boot.
tracing::warn!(
companion_id = %id, error = %e,
"backfill companion seq: save failed; deferring this and all younger companions to the next boot"
);
profile.seq = None;
break;
}
next += 1;
}
let live_max = max_live_seq(&companions);
self.advance_watermark(&mut watermark, live_max);
}
/// Advance the in-memory watermark to `seq` (never backwards) and
/// persist the state file. A failed save only warns: the number also
/// lives on the companion profile itself, so monotonicity survives through the
/// live-max term until the next successful save.
fn advance_watermark(&self, watermark: &mut u64, seq: u64) {
if seq <= *watermark {
return;
}
*watermark = seq;
if let Err(e) = (CompanionSeqState { last_companion_seq: seq }).save(&self.shared_dir) {
tracing::warn!(error = %e, "save companion seq watermark failed");
}
}
/// RFC 7396 partial update of one profile. `id`, `seq` and `created_at`
/// are immutable — whatever the patch says, they are restored from the
/// current profile before saving.
pub async fn patch(&self, id: &str, patch: serde_json::Value) -> Result<CompanionProfileConfig, AppError> {
if !patch.is_object() {
return Err(AppError::BadRequest("companion patch must be a JSON object".into()));
}
let mut companions = self.inner.write().await;
let current = companions
.get(id)
.ok_or_else(|| AppError::NotFound(format!("companion '{id}' not found")))?;
let mut value = serde_json::to_value(current)
.map_err(|e| AppError::Internal(format!("serialize companion profile: {e}")))?;
json_merge_patch(&mut value, &patch);
let mut merged: CompanionProfileConfig = serde_json::from_value(value)
.map_err(|e| AppError::BadRequest(format!("invalid companion patch: {e}")))?;
merged.id = current.id.clone();
merged.seq = current.seq;
merged.created_at = current.created_at;
merged.name = validate_name(&merged.name)?;
merged
.save(&self.companions_dir.join(&merged.id))
.map_err(|e| AppError::Internal(format!("save companion profile: {e}")))?;
companions.insert(merged.id.clone(), merged.clone());
Ok(merged)
}
/// Remove a companion from the map and delete its directory (an already-missing
/// directory is tolerated). Returns the removed profile.
pub async fn remove(&self, id: &str) -> Result<CompanionProfileConfig, AppError> {
let mut companions = self.inner.write().await;
let profile = companions
.remove(id)
.ok_or_else(|| AppError::NotFound(format!("companion '{id}' not found")))?;
match std::fs::remove_dir_all(self.companions_dir.join(id)) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(AppError::Internal(format!("remove companion dir: {e}"))),
}
Ok(profile)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Registry over `{dir}/companions` with its watermark state at
/// `{dir}/shared/companion_seq.json` (the production sibling layout).
fn scan_at(dir: &std::path::Path) -> CompanionRegistry {
scan_companions_at(dir, "companions")
}
/// Same as [`scan_at`] but over `{dir}/{companions}`.
fn scan_companions_at(dir: &std::path::Path, companions: &str) -> CompanionRegistry {
CompanionRegistry::scan(dir.join(companions), dir.join("shared"))
}
fn registry(dir: &std::path::Path) -> CompanionRegistry {
scan_at(dir)
}
#[test]
fn merge_patch_merges_nested_and_replaces_scalars() {
let mut base = serde_json::json!({
"appearance": {"companion_enabled": false, "companion_x": 10, "quiet_start": ""},
"learn": {"enabled": true, "interval_minutes": 60}
});
json_merge_patch(
&mut base,
&serde_json::json!({"appearance": {"companion_x": 99, "companion_y": 42}}),
);
assert_eq!(base["appearance"]["companion_x"], 99);
assert_eq!(base["appearance"]["companion_y"], 42);
assert_eq!(base["appearance"]["companion_enabled"], false);
assert_eq!(base["learn"]["interval_minutes"], 60);
}
#[tokio::test]
async fn resolve_default_prefers_alive_explicit_then_first() {
let dir = tempfile::tempdir().unwrap();
let reg = CompanionRegistry::scan(dir.path().join("companions"), dir.path().join("shared"));
// 空 roster → 空串
assert_eq!(reg.resolve_default("").await, "");
let _a = reg.create("", "ink").await.unwrap();
let b = reg.create("", "ink").await.unwrap();
let first = reg.ids().await.into_iter().next().unwrap();
// 显式默认体且存活 → 用之
assert_eq!(reg.resolve_default(&b.id).await, b.id);
// 显式默认体已删(不在 roster)→ 回退首个注册
assert_eq!(reg.resolve_default("companion_ghost").await, first);
// 空默认体 → 首个注册
assert_eq!(reg.resolve_default("").await, first);
}
#[tokio::test]
async fn create_persists_and_lists() {
let dir = tempfile::tempdir().unwrap();
let reg = registry(dir.path());
assert!(reg.list().await.is_empty());
let companion = reg.create(" 毛球 ", "ink").await.unwrap();
assert!(companion.id.starts_with("companion_"));
assert_eq!(companion.name, "毛球"); // trimmed
assert_eq!(companion.character, "ink");
assert_eq!(companion.seq, Some(1));
// Persisted on disk under {companions_dir}/{id}/config.json.
let on_disk = CompanionProfileConfig::load(&dir.path().join("companions").join(&companion.id));
assert_eq!(on_disk, companion);
assert_eq!(reg.get(&companion.id).await.unwrap(), companion);
assert_eq!(reg.ids().await, vec![companion.id.clone()]);
}
#[tokio::test]
async fn list_sorts_by_created_at_ascending() {
let dir = tempfile::tempdir().unwrap();
let companions_dir = dir.path().join("companions");
// Hand-build two profiles with crafted created_at, newer one first
// alphabetically so the sort genuinely exercises created_at.
let mut newer = CompanionProfileConfig::new("新宠", "boo");
newer.created_at = 2_000;
newer.save(&companions_dir.join(&newer.id)).unwrap();
let mut older = CompanionProfileConfig::new("老宠", "mochi");
older.created_at = 1_000;
older.save(&companions_dir.join(&older.id)).unwrap();
let reg = scan_at(dir.path());
let listed = reg.list().await;
assert_eq!(listed.len(), 2);
assert_eq!(listed[0].id, older.id);
assert_eq!(listed[1].id, newer.id);
assert_eq!(reg.ids().await, vec![older.id, newer.id]);
}
#[tokio::test]
async fn name_validation_rejects_empty_and_over_40_chars() {
let dir = tempfile::tempdir().unwrap();
let reg = registry(dir.path());
assert!(matches!(reg.create("", "ink").await, Err(AppError::BadRequest(_))));
assert!(matches!(reg.create(" ", "ink").await, Err(AppError::BadRequest(_))));
// 40 chars (counted in chars, not bytes) is fine, 41 is not.
let ok = "".repeat(40);
let too_long = "".repeat(41);
let companion = reg.create(&ok, "ink").await.unwrap();
assert_eq!(companion.name.chars().count(), 40);
assert!(matches!(reg.create(&too_long, "ink").await, Err(AppError::BadRequest(_))));
// patch enforces the same rules.
let err = reg.patch(&companion.id, serde_json::json!({"name": too_long})).await;
assert!(matches!(err, Err(AppError::BadRequest(_))));
let err = reg.patch(&companion.id, serde_json::json!({"name": " "})).await;
assert!(matches!(err, Err(AppError::BadRequest(_))));
}
#[tokio::test]
async fn patch_renames_but_never_changes_id_or_created_at() {
let dir = tempfile::tempdir().unwrap();
let reg = registry(dir.path());
let companion = reg.create("旧名", "ink").await.unwrap();
let patched = reg
.patch(
&companion.id,
serde_json::json!({
"name": "新名",
"id": "companion_evil",
"seq": 99,
"created_at": 1,
"appearance": {"companion_enabled": true, "companion_x": 7}
}),
)
.await
.unwrap();
assert_eq!(patched.id, companion.id);
assert_eq!(patched.seq, companion.seq, "seq is immutable through patches");
assert_eq!(patched.created_at, companion.created_at);
assert_eq!(patched.name, "新名");
assert!(patched.appearance.companion_enabled);
assert_eq!(patched.appearance.companion_x, Some(7));
// Untouched fields survive the merge.
assert_eq!(patched.character, "ink");
// Persisted and visible through the map.
let on_disk = CompanionProfileConfig::load(&dir.path().join("companions").join(&companion.id));
assert_eq!(on_disk, patched);
assert_eq!(reg.get(&companion.id).await.unwrap(), patched);
assert!(matches!(
reg.patch("companion_missing", serde_json::json!({"name": "x"})).await,
Err(AppError::NotFound(_))
));
assert!(matches!(
reg.patch(&companion.id, serde_json::json!(42)).await,
Err(AppError::BadRequest(_))
));
}
#[tokio::test]
async fn remove_deletes_dir_and_returns_profile() {
let dir = tempfile::tempdir().unwrap();
let reg = registry(dir.path());
let companion = reg.create("一郎", "ink").await.unwrap();
let keep = reg.create("二郎", "boo").await.unwrap();
let companion_dir = dir.path().join("companions").join(&companion.id);
assert!(companion_dir.exists());
let removed = reg.remove(&companion.id).await.unwrap();
assert_eq!(removed.id, companion.id);
assert!(!companion_dir.exists());
assert!(reg.get(&companion.id).await.is_none());
assert!(reg.get(&keep.id).await.is_some());
assert!(matches!(reg.remove(&companion.id).await, Err(AppError::NotFound(_))));
// An already-missing directory is tolerated.
std::fs::remove_dir_all(dir.path().join("companions").join(&keep.id)).unwrap();
let removed = reg.remove(&keep.id).await.unwrap();
assert_eq!(removed.id, keep.id);
}
#[tokio::test]
async fn scan_skips_corrupt_and_mismatched_dirs() {
let dir = tempfile::tempdir().unwrap();
let companions_dir = dir.path().join("companions");
// Good profile in a dir matching its id.
let good = CompanionProfileConfig::new("好宠", "ink");
good.save(&companions_dir.join(&good.id)).unwrap();
// Corrupt config.json -> empty-id sentinel -> skipped.
let corrupt_dir = companions_dir.join("companion_corrupt");
std::fs::create_dir_all(&corrupt_dir).unwrap();
std::fs::write(corrupt_dir.join("config.json"), "{not json").unwrap();
// Valid profile but stored in a dir that doesn't match its id.
let homeless = CompanionProfileConfig::new("流浪", "boo");
homeless.save(&companions_dir.join("companion_wrong_home")).unwrap();
// Empty dir (no config.json at all).
std::fs::create_dir_all(companions_dir.join("companion_empty")).unwrap();
// Stray file at the top level.
std::fs::write(companions_dir.join("stray.txt"), "?").unwrap();
let reg = scan_at(dir.path());
assert_eq!(reg.ids().await, vec![good.id.clone()]);
assert_eq!(reg.get(&good.id).await.unwrap(), good);
// A missing companions dir scans to an empty registry.
let empty = scan_companions_at(dir.path(), "nonexistent");
assert!(empty.list().await.is_empty());
}
#[tokio::test]
async fn create_allocates_monotonic_seq_never_reusing_deleted_numbers() {
let dir = tempfile::tempdir().unwrap();
let reg = registry(dir.path());
let first = reg.create("一号", "ink").await.unwrap();
let second = reg.create("二号", "boo").await.unwrap();
assert_eq!(first.seq, Some(1));
assert_eq!(second.seq, Some(2));
// Deleting the highest-numbered companion must not free its number.
reg.remove(&second.id).await.unwrap();
let third = reg.create("三号", "mochi").await.unwrap();
assert_eq!(third.seq, Some(3));
// The watermark is persisted in the registry's own state file (never
// in the user-writable shared config, which the registry must not
// touch at all)…
assert_eq!(CompanionSeqState::load(&dir.path().join("shared")).last_companion_seq, 3);
assert!(!crate::profile::SharedCompanionConfig::config_path(&dir.path().join("shared")).exists());
// …and the number on the profile itself.
let on_disk = CompanionProfileConfig::load(&dir.path().join("companions").join(&third.id));
assert_eq!(on_disk.seq, Some(3));
// A rescan (fresh process) keeps counting past the watermark even
// when the highest-numbered companion is gone.
reg.remove(&third.id).await.unwrap();
let reg2 = registry(dir.path());
let fourth = reg2.create("四号", "boo").await.unwrap();
assert_eq!(fourth.seq, Some(4));
}
#[tokio::test]
async fn backfill_numbers_unnumbered_companions_by_created_at_and_keeps_existing() {
let dir = tempfile::tempdir().unwrap();
let companions_dir = dir.path().join("companions");
// Two pre-rollout profiles (no seq), saved newest-first so the
// backfill order genuinely follows created_at, plus one companion that
// already carries a number.
let mut newer = CompanionProfileConfig::new("新宠", "boo");
newer.created_at = 2_000;
newer.save(&companions_dir.join(&newer.id)).unwrap();
let mut older = CompanionProfileConfig::new("老宠", "mochi");
older.created_at = 1_000;
older.save(&companions_dir.join(&older.id)).unwrap();
let mut numbered = CompanionProfileConfig::new("有号", "ink");
numbered.created_at = 1_500;
numbered.seq = Some(5);
numbered.save(&companions_dir.join(&numbered.id)).unwrap();
let reg = scan_at(dir.path());
reg.backfill_missing_seqs().await;
// Missing numbers continue past the largest live one, oldest first;
// an already-numbered companion is never renumbered.
assert_eq!(reg.get(&older.id).await.unwrap().seq, Some(6));
assert_eq!(reg.get(&newer.id).await.unwrap().seq, Some(7));
assert_eq!(reg.get(&numbered.id).await.unwrap().seq, Some(5));
// Persisted to each profile's config.json and to the watermark.
assert_eq!(CompanionProfileConfig::load(&companions_dir.join(&older.id)).seq, Some(6));
assert_eq!(CompanionProfileConfig::load(&companions_dir.join(&newer.id)).seq, Some(7));
assert_eq!(CompanionSeqState::load(&dir.path().join("shared")).last_companion_seq, 7);
// Idempotent: a second run changes no number.
reg.backfill_missing_seqs().await;
assert_eq!(reg.get(&older.id).await.unwrap().seq, Some(6));
assert_eq!(reg.get(&newer.id).await.unwrap().seq, Some(7));
assert_eq!(reg.get(&numbered.id).await.unwrap().seq, Some(5));
}
#[tokio::test]
async fn failed_create_does_not_advance_watermark() {
let dir = tempfile::tempdir().unwrap();
// A regular file where the companions dir should be makes every profile
// save fail (create_dir_all over a file errors on all platforms).
std::fs::write(dir.path().join("companions"), "blocker").unwrap();
let reg = scan_at(dir.path());
assert!(matches!(reg.create("一号", "ink").await, Err(AppError::Internal(_))));
// Zero persistent side effects: no watermark advanced, empty roster.
assert_eq!(CompanionSeqState::load(&dir.path().join("shared")).last_companion_seq, 0);
assert!(reg.list().await.is_empty());
// The retry (after the cause is fixed) still gets #1 — a failed
// create burns no number.
std::fs::remove_file(dir.path().join("companions")).unwrap();
let companion = reg.create("一号", "ink").await.unwrap();
assert_eq!(companion.seq, Some(1));
assert_eq!(CompanionSeqState::load(&dir.path().join("shared")).last_companion_seq, 1);
}
#[tokio::test]
async fn backfill_save_failure_stops_instead_of_renumbering_younger_companions() {
let dir = tempfile::tempdir().unwrap();
let companions_dir = dir.path().join("companions");
let mut a = CompanionProfileConfig::new("老大", "ink");
a.created_at = 1_000;
a.save(&companions_dir.join(&a.id)).unwrap();
let mut b = CompanionProfileConfig::new("老二", "boo");
b.created_at = 2_000;
b.save(&companions_dir.join(&b.id)).unwrap();
let mut c = CompanionProfileConfig::new("老三", "mochi");
c.created_at = 3_000;
c.save(&companions_dir.join(&c.id)).unwrap();
let reg = scan_at(dir.path());
// Break the middle companion's home: a regular file at its dir path makes
// (only) its save fail.
std::fs::remove_dir_all(companions_dir.join(&b.id)).unwrap();
std::fs::write(companions_dir.join(&b.id), "blocker").unwrap();
reg.backfill_missing_seqs().await;
// A got #1; B's save failed; C must NOT take #2 — numbering stops at
// the failure so the created_at order survives to the next retry
// (seq is immutable, a swap would be permanent).
assert_eq!(reg.get(&a.id).await.unwrap().seq, Some(1));
assert_eq!(reg.get(&b.id).await.unwrap().seq, None);
assert_eq!(reg.get(&c.id).await.unwrap().seq, None);
assert_eq!(CompanionSeqState::load(&dir.path().join("shared")).last_companion_seq, 1);
// Next boot (cause fixed): B and C get #2/#3, still in age order.
std::fs::remove_file(companions_dir.join(&b.id)).unwrap();
reg.backfill_missing_seqs().await;
assert_eq!(reg.get(&b.id).await.unwrap().seq, Some(2));
assert_eq!(reg.get(&c.id).await.unwrap().seq, Some(3));
assert_eq!(CompanionSeqState::load(&dir.path().join("shared")).last_companion_seq, 3);
}
}
@@ -0,0 +1,817 @@
//! `/api/companion/*` route handlers.
use axum::Router;
use axum::body::Body;
use axum::extract::rejection::JsonRejection;
use axum::extract::{Extension, Json, Path, Query, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, post};
use nomifun_api_types::ApiResponse;
use nomifun_auth::CurrentUser;
use nomifun_common::AppError;
use serde::Deserialize;
use crate::profile::{HeadBox, CompanionProfileConfig, SharedCompanionConfig};
use crate::service::{CompanionSkillContent, CompanionSkillView, CompanionStatus, CompanionWeeklyDigest, SourceStats};
use crate::state::CompanionRouterState;
use crate::store::{MemoryFilter, MemoryScope, CompanionLearnRun, CompanionMemory, CompanionSkill, CompanionSuggestion};
pub fn companion_routes(state: CompanionRouterState) -> Router {
Router::new()
.route("/api/companion/config", get(get_config).put(update_config).patch(patch_config))
.route("/api/companion/status", get(status))
.route("/api/companion/companions", get(list_companions).post(create_companion))
.route(
"/api/companion/companions/{companion_id}",
get(get_companion).patch(patch_companion).delete(delete_companion),
)
.route("/api/companion/companions/{companion_id}/status", get(companion_status))
.route("/api/companion/companions/{companion_id}/figure", post(upload_figure).get(get_figure))
.route("/api/companion/matting-model", get(get_matting_model))
.route("/api/companion/figures", get(list_figures).post(create_figure))
.route(
"/api/companion/figures/{figure_id}",
axum::routing::patch(update_figure).delete(delete_figure),
)
.route(
"/api/companion/companions/{companion_id}/companion/threads",
post(create_thread),
)
.route("/api/companion/companions/{companion_id}/companion/active", get(get_active_thread))
.route("/api/companion/memories", get(list_memories).post(add_memory))
.route("/api/companion/memories/{id}", axum::routing::put(update_memory).delete(delete_memory))
.route("/api/companion/suggestions", get(list_suggestions))
.route("/api/companion/suggestions/{id}/decide", post(decide_suggestion))
.route("/api/companion/companions/{companion_id}/skills", get(list_companion_skills))
.route("/api/companion/companions/{companion_id}/weekly-digest", get(weekly_digest))
.route(
"/api/companion/companions/{companion_id}/skills/{name}",
get(get_companion_skill).put(update_companion_skill),
)
.route(
"/api/companion/companions/{companion_id}/skills/{name}/decide",
post(decide_companion_skill),
)
.route(
"/api/companion/companions/{companion_id}/skills/from-session",
post(draft_skill_from_session),
)
.route(
"/api/companion/companions/{companion_id}/skills/{name}/gift",
post(gift_companion_skill),
)
.route("/api/companion/learn/run", post(run_learn))
.route("/api/companion/learn/runs", get(list_learn_runs))
.route("/api/companion/events/stats", get(event_stats))
.route("/api/companion/events/recent", get(recent_events))
.route("/api/companion/events", delete(clear_events))
.route("/api/companion/consent", post(apply_consent))
.route("/api/companion/disable-all", post(disable_all))
.route("/api/companion/export/memory", post(export_memory))
.route("/api/companion/export/companions/{companion_id}", post(export_companion))
.route("/api/companion/import", post(import_package))
.with_state(state)
}
/// Public (auth-exempt) figure-image serving.
///
/// `<img>` / `new Image()` are browser-native subresource loads with no
/// custom-header API, so under the desktop's `TrustLocalToken` policy they
/// cannot present the `x-nomi-local-trust` header — the authenticated router
/// would 403 every figure thumbnail (broken library image + blank desktop
/// companion mesh). This GET-only route therefore lives outside auth, exactly
/// like `asset_routes` (logos) and the office proxy. Figure ids are unguessable
/// (`figure_<uuidv7>`) and listing/creation/rename/delete stay authenticated,
/// so this only serves opaque-id image bytes — a capability URL, not an
/// enumeration surface.
pub fn companion_public_routes(state: CompanionRouterState) -> Router {
Router::new()
.route("/api/companion/figures/{figure_id}/image", get(get_figure_image))
.with_state(state)
}
async fn get_config(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
) -> Result<Json<ApiResponse<SharedCompanionConfig>>, AppError> {
Ok(Json(ApiResponse::ok(state.service.get_config().await)))
}
async fn update_config(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
body: Result<Json<SharedCompanionConfig>, JsonRejection>,
) -> Result<Json<ApiResponse<SharedCompanionConfig>>, AppError> {
let Json(config) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
Ok(Json(ApiResponse::ok(state.service.update_config(config).await?)))
}
async fn patch_config(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
body: Result<Json<serde_json::Value>, JsonRejection>,
) -> Result<Json<ApiResponse<SharedCompanionConfig>>, AppError> {
let Json(patch) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
Ok(Json(ApiResponse::ok(state.service.patch_config(patch).await?)))
}
async fn status(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
) -> Result<Json<ApiResponse<CompanionStatus>>, AppError> {
Ok(Json(ApiResponse::ok(state.service.status().await?)))
}
/// Build an optional [`MemoryScope`] from wire parts.
/// - `scope_kind = Some("companion")` with a non-empty id → private to it.
/// - `scope_kind = Some(_other)` → Shared.
/// - `scope_kind = None` → `None` (leave unchanged on update / default on add).
fn scope_from_parts(scope_kind: Option<&str>, scope_companion_id: Option<&str>) -> Option<MemoryScope> {
let kind = scope_kind?;
let cid = scope_companion_id.unwrap_or("").trim();
if kind == "companion" && !cid.is_empty() {
Some(MemoryScope::Companion(cid.to_owned()))
} else {
Some(MemoryScope::Shared)
}
}
#[derive(Deserialize)]
struct ListMemoriesQuery {
kind: Option<String>,
q: Option<String>,
status: Option<String>,
/// When set, scope the list to memories visible to this companion (shared +
/// its own private). Empty/absent = cross-companion "all" view.
scope_companion_id: Option<String>,
limit: Option<i64>,
offset: Option<i64>,
}
async fn list_memories(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
Query(query): Query<ListMemoriesQuery>,
) -> Result<Json<ApiResponse<Vec<CompanionMemory>>>, AppError> {
let filter = MemoryFilter {
kind: query.kind.filter(|k| !k.is_empty()),
q: query.q.filter(|q| !q.is_empty()),
status: Some(query.status.filter(|s| !s.is_empty()).unwrap_or_else(|| "active".into())),
scope_companion_id: query.scope_companion_id.filter(|s| !s.is_empty()),
limit: query.limit.unwrap_or(100),
offset: query.offset.unwrap_or(0),
};
Ok(Json(ApiResponse::ok(state.service.list_memories(&filter).await?)))
}
#[derive(Deserialize)]
struct AddMemoryRequest {
kind: String,
content: String,
#[serde(default)]
tags: Vec<String>,
/// Owning companion for a private memory; empty/absent = shared.
#[serde(default)]
scope_companion_id: Option<String>,
}
async fn add_memory(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
body: Result<Json<AddMemoryRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<CompanionMemory>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let scope = scope_from_parts(Some("companion"), req.scope_companion_id.as_deref()).unwrap_or(MemoryScope::Shared);
Ok(Json(ApiResponse::ok(
state.service.add_memory(&req.kind, &req.content, &req.tags, scope).await?,
)))
}
#[derive(Deserialize)]
struct UpdateMemoryRequest {
content: Option<String>,
pinned: Option<bool>,
status: Option<String>,
/// `'user'` (shared) or `'companion'` (private). Present together with
/// `scope_companion_id` to re-home a memory; both absent = scope unchanged.
scope_kind: Option<String>,
scope_companion_id: Option<String>,
}
async fn update_memory(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(id): Path<String>,
body: Result<Json<UpdateMemoryRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let scope = scope_from_parts(req.scope_kind.as_deref(), req.scope_companion_id.as_deref());
state
.service
.update_memory(&id, req.content.as_deref(), req.pinned, req.status.as_deref(), scope)
.await?;
Ok(Json(ApiResponse::ok(())))
}
async fn delete_memory(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(id): Path<String>,
) -> Result<Json<ApiResponse<()>>, AppError> {
state.service.delete_memory(&id).await?;
Ok(Json(ApiResponse::ok(())))
}
#[derive(Deserialize)]
struct ListSuggestionsQuery {
status: Option<String>,
limit: Option<i64>,
}
async fn list_suggestions(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
Query(query): Query<ListSuggestionsQuery>,
) -> Result<Json<ApiResponse<Vec<CompanionSuggestion>>>, AppError> {
let status = query.status.filter(|s| !s.is_empty());
Ok(Json(ApiResponse::ok(
state
.service
.list_suggestions(status.as_deref(), query.limit.unwrap_or(100))
.await?,
)))
}
#[derive(Deserialize)]
struct DecideSuggestionRequest {
accept: bool,
}
async fn decide_suggestion(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(id): Path<String>,
body: Result<Json<DecideSuggestionRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<CompanionSuggestion>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
Ok(Json(ApiResponse::ok(state.service.decide_suggestion(&id, req.accept).await?)))
}
#[derive(Deserialize)]
struct ListSkillsQuery {
include_shared: Option<bool>,
}
async fn list_companion_skills(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(companion_id): Path<String>,
Query(q): Query<ListSkillsQuery>,
) -> Result<Json<ApiResponse<Vec<CompanionSkillView>>>, AppError> {
let views = state
.service
.list_companion_skills(&companion_id, q.include_shared.unwrap_or(true))
.await?;
Ok(Json(ApiResponse::ok(views)))
}
#[derive(Deserialize)]
struct DigestQuery {
days: Option<i64>,
}
async fn weekly_digest(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(companion_id): Path<String>,
Query(q): Query<DigestQuery>,
) -> Result<Json<ApiResponse<CompanionWeeklyDigest>>, AppError> {
let days = q.days.unwrap_or(7).clamp(1, 90);
let since_ms = nomifun_common::now_ms() - days * 86_400_000;
Ok(Json(ApiResponse::ok(state.service.weekly_digest(&companion_id, since_ms).await?)))
}
async fn get_companion_skill(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
Path((companion_id, name)): Path<(String, String)>,
) -> Result<Json<ApiResponse<CompanionSkillContent>>, AppError> {
Ok(Json(ApiResponse::ok(state.service.get_companion_skill_content(&companion_id, &name).await?)))
}
#[derive(Deserialize)]
struct UpdateSkillRequest {
content: String,
}
async fn update_companion_skill(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
Path((companion_id, name)): Path<(String, String)>,
body: Result<Json<UpdateSkillRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
state.service.write_companion_skill_content(&companion_id, &name, &req.content).await?;
Ok(Json(ApiResponse::ok(())))
}
#[derive(Deserialize)]
struct DecideSkillRequest {
accept: bool,
reason: Option<String>,
}
async fn decide_companion_skill(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
Path((companion_id, name)): Path<(String, String)>,
body: Result<Json<DecideSkillRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<CompanionSkill>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
Ok(Json(ApiResponse::ok(
state
.service
.decide_companion_skill(&companion_id, &name, req.accept, req.reason.as_deref())
.await?,
)))
}
#[derive(Deserialize)]
struct FromSessionRequest {
conversation_id: String,
}
async fn draft_skill_from_session(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(companion_id): Path<String>,
body: Result<Json<FromSessionRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<Option<String>>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
Ok(Json(ApiResponse::ok(
state.service.draft_skill_from_session(&companion_id, &req.conversation_id).await?,
)))
}
#[derive(Deserialize)]
struct GiftSkillRequest {
to_companion_id: String,
}
async fn gift_companion_skill(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
Path((companion_id, name)): Path<(String, String)>,
body: Result<Json<GiftSkillRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<CompanionSkill>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
Ok(Json(ApiResponse::ok(
state.service.gift_companion_skill(&companion_id, &name, &req.to_companion_id).await?,
)))
}
async fn run_learn(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
) -> Result<Json<ApiResponse<CompanionLearnRun>>, AppError> {
Ok(Json(ApiResponse::ok(state.service.run_learn_now().await?)))
}
#[derive(Deserialize)]
struct LimitQuery {
limit: Option<i64>,
}
async fn list_learn_runs(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
Query(query): Query<LimitQuery>,
) -> Result<Json<ApiResponse<Vec<CompanionLearnRun>>>, AppError> {
Ok(Json(ApiResponse::ok(
state.service.list_learn_runs(query.limit.unwrap_or(30)).await?,
)))
}
async fn event_stats(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
) -> Result<Json<ApiResponse<Vec<SourceStats>>>, AppError> {
Ok(Json(ApiResponse::ok(state.service.event_stats())))
}
async fn recent_events(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
Query(query): Query<LimitQuery>,
) -> Result<Json<ApiResponse<Vec<crate::collector::CollectedEvent>>>, AppError> {
let limit = query.limit.unwrap_or(100).clamp(1, 500) as usize;
Ok(Json(ApiResponse::ok(state.service.recent_events(limit))))
}
async fn apply_consent(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
) -> Result<Json<ApiResponse<SharedCompanionConfig>>, AppError> {
Ok(Json(ApiResponse::ok(state.service.apply_default_on_consent().await?)))
}
async fn disable_all(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
) -> Result<Json<ApiResponse<SharedCompanionConfig>>, AppError> {
Ok(Json(ApiResponse::ok(state.service.disable_all().await?)))
}
// ----- companions -----
/// One companion card: profile fields flattened at the top level plus that companion's
/// live status — list/detail fetch everything for a card in one round trip.
#[derive(serde::Serialize)]
struct CompanionWithStatus {
#[serde(flatten)]
profile: CompanionProfileConfig,
status: CompanionStatus,
}
async fn list_companions(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
) -> Result<Json<ApiResponse<Vec<CompanionWithStatus>>>, AppError> {
let mut companions = Vec::new();
for profile in state.service.list_companions().await {
match state.service.companion_status(&profile.id).await {
Ok(status) => companions.push(CompanionWithStatus { profile, status }),
// The companion vanished between list and status (concurrent delete):
// drop the card rather than failing the whole list.
Err(AppError::NotFound(_)) => {}
Err(e) => return Err(e),
}
}
Ok(Json(ApiResponse::ok(companions)))
}
#[derive(Deserialize)]
struct CreateCompanionRequest {
name: String,
/// Empty/missing falls back to the default roster character.
#[serde(default)]
character: String,
}
async fn create_companion(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
body: Result<Json<CreateCompanionRequest>, JsonRejection>,
) -> Result<impl IntoResponse, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let profile = state.service.create_companion(&req.name, &req.character).await?;
Ok((StatusCode::CREATED, Json(ApiResponse::ok(profile))))
}
async fn get_companion(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(companion_id): Path<String>,
) -> Result<Json<ApiResponse<CompanionWithStatus>>, AppError> {
let profile = state.service.get_companion(&companion_id).await?;
let status = state.service.companion_status(&companion_id).await?;
Ok(Json(ApiResponse::ok(CompanionWithStatus { profile, status })))
}
/// RFC 7396 merge patch over one companion's profile.
async fn patch_companion(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(companion_id): Path<String>,
body: Result<Json<serde_json::Value>, JsonRejection>,
) -> Result<Json<ApiResponse<CompanionProfileConfig>>, AppError> {
let Json(patch) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
Ok(Json(ApiResponse::ok(state.service.patch_companion(&companion_id, patch).await?)))
}
async fn delete_companion(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(companion_id): Path<String>,
) -> Result<StatusCode, AppError> {
state.service.delete_companion(&companion_id).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn companion_status(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(companion_id): Path<String>,
) -> Result<Json<ApiResponse<CompanionStatus>>, AppError> {
Ok(Json(ApiResponse::ok(state.service.companion_status(&companion_id).await?)))
}
// ----- DIY custom figure (spec §3 存储与回显) -----
#[derive(Deserialize)]
struct UploadFigureRequest {
/// Temp path returned by `POST /api/fs/upload` (two-phase upload).
source_path: String,
}
async fn upload_figure(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(companion_id): Path<String>,
body: Result<Json<UploadFigureRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
state.service.ingest_figure(&companion_id, &req.source_path).await?;
Ok(Json(ApiResponse::ok(())))
}
/// Binary serve of one companion's figure (the nomifun-assets Response template,
/// disk-backed). `Cache-Control: no-cache` + a `"{mtime}-{len}"` ETag: the
/// browser revalidates every time and gets a cheap 304 until re-upload.
async fn get_figure(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(companion_id): Path<String>,
headers: HeaderMap,
) -> Result<Response, AppError> {
let (bytes, mtime) = state.service.read_figure(&companion_id).await?;
let etag = format!("\"{}-{}\"", mtime, bytes.len());
let if_none_match_hits = headers
.get(header::IF_NONE_MATCH)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.split(',').map(str::trim).any(|c| c == etag || c == "*"));
if if_none_match_hits {
return Response::builder()
.status(StatusCode::NOT_MODIFIED)
.header(header::CACHE_CONTROL, "no-cache")
.header(header::ETAG, etag)
.body(Body::empty())
.map_err(|e| AppError::Internal(e.to_string()));
}
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, crate::figure::content_type_of(&bytes))
.header(header::CACHE_CONTROL, "no-cache")
.header(header::ETAG, etag)
.body(Body::from(bytes))
.map_err(|e| AppError::Internal(e.to_string()))
}
/// Binary serve of the cached MODNet matting model, downloading it from a
/// mirror on first use (see [`crate::matting_model`]). The renderer fetches
/// this from `127.0.0.1` and mirrors it into Cache Storage, so the matting
/// Web Worker reads a local copy instead of hitting huggingface behind a 30 s
/// timeout. Immutable + long-lived: the filename is versioned, so the browser
/// may cache it forever.
async fn get_matting_model(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
) -> Result<Response, AppError> {
let bytes = state.service.matting_model_bytes().await?;
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/octet-stream")
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.body(Body::from(bytes))
.map_err(|e| AppError::Internal(e.to_string()))
}
// ----- custom-figure library (decoupled from companions) -----
async fn list_figures(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
) -> Result<Json<ApiResponse<Vec<crate::figures::FigureMeta>>>, AppError> {
Ok(Json(ApiResponse::ok(state.service.list_figures().await)))
}
#[derive(Deserialize)]
struct CreateFigureRequest {
/// Temp path returned by `POST /api/fs/upload` (two-phase upload).
source_path: String,
#[serde(default)]
name: String,
aspect: f32,
head_box: HeadBox,
#[serde(default)]
size_tier: String,
}
async fn create_figure(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
body: Result<Json<CreateFigureRequest>, JsonRejection>,
) -> Result<impl IntoResponse, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let figure = state
.service
.create_figure(&req.source_path, &req.name, req.aspect, req.head_box, &req.size_tier)
.await?;
Ok((StatusCode::CREATED, Json(ApiResponse::ok(figure))))
}
#[derive(Deserialize)]
struct UpdateFigureRequest {
name: Option<String>,
head_box: Option<HeadBox>,
size_tier: Option<String>,
}
async fn update_figure(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(figure_id): Path<String>,
body: Result<Json<UpdateFigureRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<crate::figures::FigureMeta>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
Ok(Json(ApiResponse::ok(state.service.update_figure(
&figure_id,
crate::figures::FigureUpdate { name: req.name, head_box: req.head_box, size_tier: req.size_tier },
).await?)))
}
async fn delete_figure(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(figure_id): Path<String>,
) -> Result<StatusCode, AppError> {
state.service.delete_figure(&figure_id).await?;
Ok(StatusCode::NO_CONTENT)
}
/// Binary serve of one library figure's image (same ETag/no-cache template as
/// the per-companion `get_figure`).
///
/// AUTH-EXEMPT route (see `companion_public_routes`): native `<img>` loads carry
/// no trust header, so `trust_resolve_middleware` injects NO `CurrentUser` for
/// them. This handler therefore MUST NOT extract `Extension<CurrentUser>` — that
/// extractor would 500 on the very (untrusted-header) requests this route exists
/// to serve. The figure id is the opaque capability; no user identity is needed.
async fn get_figure_image(
State(state): State<CompanionRouterState>,
Path(figure_id): Path<String>,
headers: HeaderMap,
) -> Result<Response, AppError> {
let (bytes, mtime) = state.service.read_figure_image(&figure_id).await?;
let etag = format!("\"{}-{}\"", mtime, bytes.len());
let if_none_match_hits = headers
.get(header::IF_NONE_MATCH)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.split(',').map(str::trim).any(|c| c == etag || c == "*"));
if if_none_match_hits {
return Response::builder()
.status(StatusCode::NOT_MODIFIED)
.header(header::CACHE_CONTROL, "no-cache")
.header(header::ETAG, etag)
.body(Body::empty())
.map_err(|e| AppError::Internal(e.to_string()));
}
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, crate::figure::content_type_of(&bytes))
.header(header::CACHE_CONTROL, "no-cache")
.header(header::ETAG, etag)
.body(Body::from(bytes))
.map_err(|e| AppError::Internal(e.to_string()))
}
// ----- companion thread (per companion, single session) -----
#[derive(Deserialize)]
struct CreateThreadRequest {
#[serde(default)]
title: Option<String>,
}
/// Idempotent ensure of the companion's single companion session: returns the
/// existing one, or creates it (requires the companion's model to be configured).
async fn create_thread(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(companion_id): Path<String>,
body: Result<Json<CreateThreadRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<crate::store::CompanionThread>>, AppError> {
let title = body.map(|Json(b)| b.title).unwrap_or_default();
Ok(Json(ApiResponse::ok(
state.service.create_companion_thread(&companion_id, title).await?,
)))
}
#[derive(serde::Serialize)]
struct ActiveThreadResponse {
conversation_id: Option<String>,
}
/// The companion's single companion session id (or null when none exists yet).
async fn get_active_thread(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(companion_id): Path<String>,
) -> Result<Json<ApiResponse<ActiveThreadResponse>>, AppError> {
// Existence gate: an unknown companion must 404, not read as "no active thread".
state.service.get_companion(&companion_id).await?;
Ok(Json(ApiResponse::ok(ActiveThreadResponse {
conversation_id: state.service.companion_active_thread(&companion_id).await?,
})))
}
async fn clear_events(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
) -> Result<Json<ApiResponse<()>>, AppError> {
state.service.clear_events()?;
Ok(Json(ApiResponse::ok(())))
}
// ----- export / import (§4.8 migration) -----
/// The live shared store + shared dir for export/import. `CompanionService` keeps
/// its store private, so the boot-time registration in `crate::store` is the
/// only crate-visible handle. `None` means boot fell back to the in-memory
/// store (corrupt/locked memory.db) — exporting that throwaway snapshot would
/// silently lose the on-disk data, so the endpoints refuse instead.
fn live_store() -> Result<(&'static std::path::Path, &'static crate::store::CompanionStore), AppError> {
crate::store::live_store()
.ok_or_else(|| AppError::Internal("伙伴存储当前处于内存降级模式,无法导入导出".into()))
}
#[derive(Deserialize)]
struct ExportMemoryRequest {
dest_path: String,
#[serde(default)]
include_events: bool,
}
async fn export_memory(
State(_state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
body: Result<Json<ExportMemoryRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<crate::export::ExportSummary>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let (shared_dir, store) = live_store()?;
let summary = crate::export::export_memory_bundle(
store,
shared_dir,
std::path::Path::new(&req.dest_path),
req.include_events,
)
.await?;
Ok(Json(ApiResponse::ok(summary)))
}
#[derive(Deserialize)]
struct ExportCompanionRequest {
dest_path: String,
/// Names of the knowledge bases bound to this companion, collected by the
/// frontend (the companion crate never reaches into the knowledge domain).
#[serde(default)]
knowledge_names: Vec<String>,
}
async fn export_companion(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(companion_id): Path<String>,
body: Result<Json<ExportCompanionRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<crate::export::ExportSummary>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
// Existence gate: an unknown companion must 404 before any file is written.
let profile = state.service.get_companion(&companion_id).await?;
let (_, store) = live_store()?;
let summary = crate::export::export_companion_bundle(
store,
&profile,
std::path::Path::new(&req.dest_path),
&req.knowledge_names,
)
.await?;
Ok(Json(ApiResponse::ok(summary)))
}
#[derive(Deserialize)]
struct ImportPackageRequest {
src_path: String,
}
async fn import_package(
State(state): State<CompanionRouterState>,
Extension(_user): Extension<CurrentUser>,
body: Result<Json<ImportPackageRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<crate::export::ImportOutcome>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let (shared_dir, store) = live_store()?;
let outcome = crate::export::import_bundle(
store,
state.service.as_ref(),
shared_dir,
std::path::Path::new(&req.src_path),
)
.await?;
Ok(Json(ApiResponse::ok(outcome)))
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,80 @@
//! `CompanionSkillStoreSink` — bridges the companion's skill registry + on-disk
//! SKILL.md bodies to the `nomifun_ai_agent::CompanionSkillSink` trait the agent
//! engine consumes for skill auto-use (design §7).
//!
//! `active_skills` feeds the per-turn `when_to_use` index (the `CompanionSkillContributor`);
//! `load_skill_body` resolves a named skill's SKILL.md on demand (the `companion_skill` tool).
//! Both scope to the default companion (the owner of mined skills) plus shared skills.
use std::sync::Arc;
use async_trait::async_trait;
use nomifun_ai_agent::{CompanionSkillSink, SkillListing};
use nomifun_extension::constants::SKILL_MANIFEST_FILE;
use nomifun_extension::skill_service::{self, SkillPaths, SkillScope};
use crate::collector::SharedConfig;
use crate::store::CompanionStore;
pub struct CompanionSkillStoreSink {
pub store: CompanionStore,
pub config: SharedConfig,
pub skill_paths: Arc<SkillPaths>,
}
impl CompanionSkillStoreSink {
/// The companion that owns mined skills (default companion).
async fn owner(&self) -> String {
self.config.read().await.default_companion_id.clone()
}
fn scope_of(companion_id: &str) -> SkillScope {
if companion_id.is_empty() {
SkillScope::Shared
} else {
SkillScope::Companion(companion_id.to_owned())
}
}
}
#[async_trait]
impl CompanionSkillSink for CompanionSkillStoreSink {
async fn active_skills(&self) -> Vec<SkillListing> {
let owner = self.owner().await;
if owner.is_empty() {
return Vec::new();
}
let skills = self.store.list_skills(&owner, true).await.unwrap_or_default();
let mut out = Vec::new();
for s in skills.into_iter().filter(|s| s.status == "active") {
let scope = Self::scope_of(&s.scope_companion_id);
// when_to_use index uses the SKILL.md description (what the skill does).
if let Ok(dir) = skill_service::skill_dir_for(&self.skill_paths, &scope, &s.skill_name, false) {
let desc = skill_service::read_skill_info(&dir).await.map(|(_, d)| d).unwrap_or_default();
out.push(SkillListing { name: s.skill_name, when_to_use: desc });
}
}
out
}
async fn load_skill_body(&self, name: &str) -> Option<String> {
let owner = self.owner().await;
// Prefer the owner's companion-scoped skill (record usage against the owner),
// then fall back to shared (recorded against the shared "" scope).
if !owner.is_empty() {
if let Ok(dir) = skill_service::skill_dir_for(&self.skill_paths, &SkillScope::Companion(owner.clone()), name, false) {
if let Ok(body) = tokio::fs::read_to_string(dir.join(SKILL_MANIFEST_FILE)).await {
let _ = self.store.record_skill_usage(&owner, name, nomifun_common::now_ms()).await;
return Some(body);
}
}
}
if let Ok(dir) = skill_service::skill_dir_for(&self.skill_paths, &SkillScope::Shared, name, false) {
if let Ok(body) = tokio::fs::read_to_string(dir.join(SKILL_MANIFEST_FILE)).await {
let _ = self.store.record_skill_usage("", name, nomifun_common::now_ms()).await;
return Some(body);
}
}
None
}
}
@@ -0,0 +1,16 @@
//! Router state for the companion domain. Holds the `Arc`-wrapped service.
use std::sync::Arc;
use crate::service::CompanionService;
#[derive(Clone)]
pub struct CompanionRouterState {
pub service: Arc<CompanionService>,
}
impl CompanionRouterState {
pub fn new(service: Arc<CompanionService>) -> Self {
Self { service }
}
}
File diff suppressed because it is too large Load Diff