feat(wave19): kei-pet Day 2 — 8 pet gaps closed via substrate dogfood

48 crates, 859 tests green (+58 kei-pet tests, was 801 at v0.35.0).

Full substrate pipeline test: all 8 agents launched via kei-agent-runtime
prepare → composed capability-fragment prompts → Agent tool invocations.
Zero file conflicts across disjoint scopes. Every agent self-verified
and landed files direct to main.

## A. memory (4 tests) — persistent conversations
- src/memory.rs — (user_id, pet_name)-scoped conversation log
- SQLite via rusqlite, index (user_id, pet_name, ts DESC)
- record_interaction / recent / search with LIKE-escape

## B. evolution (3 tests) — version diff + fork chain
- src/evolution.rs — PersonaVersion { version, parent_version, manifest }
- diff(old, new) → Vec<Change> (tone / directness / initiative / forbidden / humor)
- fork_version increments + links parent

## C. wizard (5 markdown phases) — /pet-init skill
- skills/pet-init/SKILL.md + 4 phases (identity / voice / edge / emit)
- AskUserQuestion-driven, no TOML editing for end users
- Writes ~/.claude/pet/<user_id>.toml + calls kei-pet keygen if needed

## D. templates (3 tests + 5 presets) — role-based personas
- templates/{friend,tutor,coach,therapist-companion,productivity-partner}.toml
- src/templates.rs — PetTemplate enum + load_template + list_templates
- Schema-enum mapping documented (dry→engineering-meta, etc) — schema.rs
  expansion is future work

## E. bridge (3 tests) — /spawn-agent pet overlay
- src/bridge.rs — compose_prompt_with_pet(base + persona overlay + task)
- skills/spawn-agent/phase-3-pet-overlay.md — interactive pet selector

## F. recall (4 tests) — "have we discussed this before?"
- src/recall.rs — wraps kei_dna_index::precedent with body_sha8()
- SHA-256 first 4 bytes → 8 hex lowercase (matches kei_shared width)
- Fetches started_ts per hit for honest sort-by-recency

## G. reflect (7 tests) — self-reflection threshold proposals
- src/reflect.rs — CorrectionSignal + ProposedChange
- Thresholds: 3× too_verbose → SetDirectness, 2× forbidden_topic → AddForbidden, etc
- Idempotent: no-op if manifest already in desired state

## H. fleet (6 tests) — multi-pet per user
- src/fleet.rs — PetFleet { user_id, pets, active_pet }
- add_pet / switch_active / load_fleet with toml persistence
- shared_memory_key vs per_pet_memory_key — one user scopes multiple pets

## Known follow-ups (not blockers)

- Phase-4-emit of /spawn-agent should read PET_MANIFEST_PATH from new
  phase-3-pet-overlay and pass to kei-spawn (wiring next wave)
- SKILL.md for spawn-agent should list new pet-overlay phase
- Schema enum expansion: humor_style "dry/witty", directness "direct/
  gentle/blunt", initiative "proactive/nudge" as first-class variants

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parfii-bot 2026-04-24 00:37:24 +08:00
parent 6c7569e411
commit 07eb0b83ea
28 changed files with 2483 additions and 0 deletions

View file

@ -2714,8 +2714,11 @@ dependencies = [
"clap",
"ed25519-dalek",
"hex",
"kei-dna-index",
"rand_core 0.6.4",
"rusqlite",
"serde",
"sha2 0.10.9",
"tempfile",
"thiserror 1.0.69",
"toml",

View file

@ -25,6 +25,9 @@ ed25519-dalek = { version = "2", features = ["rand_core", "std"] }
rand_core = { version = "0.6", features = ["std"] }
blake3 = "1"
hex = "0.4"
rusqlite = { version = "0.31", features = ["bundled"] }
sha2 = "0.10"
kei-dna-index = { path = "../kei-dna-index" }
[dev-dependencies]
tempfile = "3"

View file

@ -0,0 +1,47 @@
//! Bridge between a validated `PetManifest` and an agent-spawn prompt.
//!
//! Used by the `/spawn-agent` skill's pet-overlay phase: compose the final
//! system prompt as `base_prompt` ++ (optional persona overlay) ++ `task_body`.
//! No I/O here — pure string composition. Deterministic.
//!
//! Scope boundary (see crate root): this module renders prompts for any
//! agent runtime. It imports nothing from sibling research-grade projects.
use crate::overlay::system_prompt;
use crate::schema::PetManifest;
/// Everything the bridge needs to compose one spawn prompt.
///
/// `base_prompt` is the composed capabilities string from the agent runtime
/// (e.g. `kei-agent-runtime`). `pet_manifest` is `None` when the user opted
/// out of a persona overlay during the spawn wizard. `task_body` is the
/// verbatim task description the orchestrator wants the agent to execute.
#[derive(Debug, Clone)]
pub struct AgentSpawnRequest {
pub role: String,
pub pet_manifest: Option<PetManifest>,
pub task_body: String,
pub base_prompt: String,
}
/// Compose the full prompt: base + persona overlay (if any) + task body.
///
/// Layout:
/// <base_prompt>
/// \n\n---\n\n
/// [## Persona overlay\n\n<overlay>\n\n---\n\n] (only when manifest set)
/// <task_body>
pub fn compose_prompt_with_pet(req: &AgentSpawnRequest) -> String {
let mut out = String::with_capacity(
req.base_prompt.len() + req.task_body.len() + 1024,
);
out.push_str(&req.base_prompt);
out.push_str("\n\n---\n\n");
if let Some(m) = &req.pet_manifest {
out.push_str("## Persona overlay\n\n");
out.push_str(&system_prompt(m));
out.push_str("\n\n---\n\n");
}
out.push_str(&req.task_body);
out
}

View file

@ -0,0 +1,171 @@
//! Persona version history + manifest diff.
//!
//! `PersonaVersion` records a single snapshot of a `PetManifest` with a
//! monotonic version number and an optional parent pointer — forming a linked
//! history chain. `diff` produces a minimal set of human-readable `Change`
//! entries between two manifests (voice tone, edge directness/initiative,
//! humor style, forbidden topics, identity languages). Persistence (file
//! layout, serialization target) is the caller's concern; this module is
//! pure data.
use crate::schema::{
Directness, HumorStyle, Initiative, PetManifest, Tone,
};
use serde::{Deserialize, Serialize};
// ─────────────────────────── public types ────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersonaVersion {
pub version: u32,
pub parent_version: Option<u32>,
pub manifest: PetManifest,
/// Unix seconds (UTC). Caller fills via `chrono::Utc::now().timestamp()`
/// or equivalent; the struct is agnostic to the clock source.
pub created_at: i64,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Change {
VoiceTonePrimaryChanged { from: String, to: String },
EdgeDirectnessChanged { from: String, to: String },
EdgeInitiativeChanged { from: String, to: String },
ForbiddenTopicAdded(String),
ForbiddenTopicRemoved(String),
LanguageAdded(String),
LanguageRemoved(String),
HumorStyleChanged { from: String, to: String },
}
// ───────────────────────────── diff api ──────────────────────────────
/// Minimal ordered diff between two manifests.
///
/// Field order: voice → edge → humor → forbidden (topics) → identity
/// (languages). Added/Removed entries emitted in source-vector order.
pub fn diff(old: &PetManifest, new: &PetManifest) -> Vec<Change> {
let mut out = Vec::new();
diff_voice(old, new, &mut out);
diff_edge(old, new, &mut out);
diff_humor(old, new, &mut out);
diff_forbidden(old, new, &mut out);
diff_languages(old, new, &mut out);
out
}
/// Fork a new version off `parent`. `created_at` is left at 0 — caller
/// should overwrite with a real timestamp before persisting.
pub fn fork_version(
parent: &PersonaVersion,
reason: &str,
new_manifest: PetManifest,
) -> PersonaVersion {
PersonaVersion {
version: parent.version + 1,
parent_version: Some(parent.version),
manifest: new_manifest,
created_at: 0,
reason: reason.to_string(),
}
}
// ─────────────────────────── sub-diff helpers ────────────────────────
fn diff_voice(old: &PetManifest, new: &PetManifest, out: &mut Vec<Change>) {
if old.voice.tone_primary != new.voice.tone_primary {
out.push(Change::VoiceTonePrimaryChanged {
from: tone_str(old.voice.tone_primary).to_string(),
to: tone_str(new.voice.tone_primary).to_string(),
});
}
}
fn diff_edge(old: &PetManifest, new: &PetManifest, out: &mut Vec<Change>) {
if old.edge.directness != new.edge.directness {
out.push(Change::EdgeDirectnessChanged {
from: directness_str(old.edge.directness).to_string(),
to: directness_str(new.edge.directness).to_string(),
});
}
if old.edge.initiative != new.edge.initiative {
out.push(Change::EdgeInitiativeChanged {
from: initiative_str(old.edge.initiative).to_string(),
to: initiative_str(new.edge.initiative).to_string(),
});
}
}
fn diff_humor(old: &PetManifest, new: &PetManifest, out: &mut Vec<Change>) {
if old.voice.humor_style != new.voice.humor_style {
out.push(Change::HumorStyleChanged {
from: humor_style_str(old.voice.humor_style).to_string(),
to: humor_style_str(new.voice.humor_style).to_string(),
});
}
}
fn diff_forbidden(old: &PetManifest, new: &PetManifest, out: &mut Vec<Change>) {
for t in &new.forbidden.topics {
if !old.forbidden.topics.contains(t) {
out.push(Change::ForbiddenTopicAdded(t.clone()));
}
}
for t in &old.forbidden.topics {
if !new.forbidden.topics.contains(t) {
out.push(Change::ForbiddenTopicRemoved(t.clone()));
}
}
}
fn diff_languages(old: &PetManifest, new: &PetManifest, out: &mut Vec<Change>) {
for l in &new.identity.languages {
if !old.identity.languages.contains(l) {
out.push(Change::LanguageAdded(l.clone()));
}
}
for l in &old.identity.languages {
if !new.identity.languages.contains(l) {
out.push(Change::LanguageRemoved(l.clone()));
}
}
}
// ─────────────────────────── enum → kebab-case ───────────────────────
fn tone_str(t: Tone) -> &'static str {
match t {
Tone::Warm => "warm",
Tone::Dry => "dry",
Tone::Sarcastic => "sarcastic",
Tone::Neutral => "neutral",
Tone::Supportive => "supportive",
}
}
fn directness_str(d: Directness) -> &'static str {
match d {
Directness::Soft => "soft",
Directness::Balanced => "balanced",
Directness::Hard => "hard",
}
}
fn initiative_str(i: Initiative) -> &'static str {
match i {
Initiative::Wait => "wait",
Initiative::Suggest => "suggest",
Initiative::TapOnShoulder => "tap-on-shoulder",
}
}
fn humor_style_str(h: HumorStyle) -> &'static str {
match h {
HumorStyle::None => "none",
HumorStyle::Puns => "puns",
HumorStyle::Dark => "dark",
HumorStyle::Absurd => "absurd",
HumorStyle::EngineeringMeta => "engineering-meta",
HumorStyle::DarkMeta => "dark+meta",
}
}

View file

@ -0,0 +1,124 @@
//! Multi-pet fleet per user.
//!
//! One user_id owns N pet personas. All pets under that user share one
//! user-level memory scope (shared_memory_key), but each pet keeps its own
//! conversation stream (per_pet_memory_key). Fleet state is serialized to
//! `<fleet_root>/<user_id>/fleet.toml`; per-pet manifests are written by
//! the caller at paths recorded in `PetHandle::manifest_path`.
//!
//! Scope boundary: this module owns only the fleet index file. It never
//! reads or writes individual pet manifests — those are the caller's
//! responsibility, referenced here by `PathBuf` only.
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
/// Fleet = ordered list of pet handles plus the currently active pet.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PetFleet {
pub user_id: String,
pub pets: Vec<PetHandle>,
pub active_pet: Option<String>,
}
/// Pointer to one pet persona + its role + manifest location on disk.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PetHandle {
pub pet_name: String,
pub role: String,
pub manifest_path: PathBuf,
pub last_active: i64,
}
/// Errors surfaced by fleet operations.
#[derive(Debug, thiserror::Error)]
pub enum FleetError {
#[error("fleet not found for user {0}")]
NotFound(String),
#[error("pet {0} not in fleet")]
PetNotInFleet(String),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Toml(#[from] toml::de::Error),
#[error(transparent)]
TomlSer(#[from] toml::ser::Error),
}
/// Canonical on-disk path for a user's fleet index file.
pub fn fleet_path(user_id: &str, fleet_root: &Path) -> PathBuf {
fleet_root.join(user_id).join("fleet.toml")
}
/// Load fleet for `user_id`. If the index file does not yet exist, return
/// an empty fleet (no pets, no active). Parse errors propagate.
pub fn load_fleet(user_id: &str, fleet_root: &Path) -> Result<PetFleet, FleetError> {
let path = fleet_path(user_id, fleet_root);
if !path.exists() {
return Ok(PetFleet {
user_id: user_id.to_string(),
pets: Vec::new(),
active_pet: None,
});
}
let text = std::fs::read_to_string(&path)?;
let fleet: PetFleet = toml::from_str(&text)?;
Ok(fleet)
}
/// Serialize fleet to `<fleet_root>/<user_id>/fleet.toml`, creating the
/// parent directory if needed. Overwrites existing file atomically enough
/// for single-writer use; concurrent writers must layer their own locking.
pub fn save_fleet(fleet: &PetFleet, fleet_root: &Path) -> Result<(), FleetError> {
let path = fleet_path(&fleet.user_id, fleet_root);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let text = toml::to_string_pretty(fleet)?;
std::fs::write(&path, text)?;
Ok(())
}
/// Append `handle` to the user's fleet. If this is the first pet added,
/// it also becomes `active_pet`. Creates the fleet file if absent.
pub fn add_pet(
user_id: &str,
handle: PetHandle,
fleet_root: &Path,
) -> Result<(), FleetError> {
let mut fleet = load_fleet(user_id, fleet_root)?;
if fleet.active_pet.is_none() {
fleet.active_pet = Some(handle.pet_name.clone());
}
fleet.pets.push(handle);
save_fleet(&fleet, fleet_root)
}
/// Set `active_pet` to `pet_name`. Errors if the fleet is absent or the
/// pet name is not present in the fleet.
pub fn switch_active(
user_id: &str,
pet_name: &str,
fleet_root: &Path,
) -> Result<(), FleetError> {
let path = fleet_path(user_id, fleet_root);
if !path.exists() {
return Err(FleetError::NotFound(user_id.to_string()));
}
let mut fleet = load_fleet(user_id, fleet_root)?;
if !fleet.pets.iter().any(|p| p.pet_name == pet_name) {
return Err(FleetError::PetNotInFleet(pet_name.to_string()));
}
fleet.active_pet = Some(pet_name.to_string());
save_fleet(&fleet, fleet_root)
}
/// Shared memory key (all pets under this user share this scope).
pub fn shared_memory_key(user_id: &str) -> String {
format!("shared::{user_id}")
}
/// Per-pet memory key (one conversation stream per (user, pet) pair).
pub fn per_pet_memory_key(user_id: &str, pet_name: &str) -> String {
format!("pet::{user_id}::{pet_name}")
}

View file

@ -9,11 +9,20 @@ pub mod schema;
pub mod validate;
pub mod overlay;
pub mod identity;
pub mod memory;
pub mod evolution;
pub mod bridge;
pub mod fleet;
pub mod reflect;
pub mod recall;
pub mod templates;
pub use schema::PetManifest;
pub use validate::{ValidationError, validate};
pub use overlay::system_prompt;
pub use identity::{generate_keypair, user_id_from_pubkey, Keypair};
pub use bridge::{AgentSpawnRequest, compose_prompt_with_pet};
pub use templates::{load_template, list_templates, PetTemplate};
/// Current schema version written by this crate.
pub const SCHEMA_VERSION: u32 = 1;

View file

@ -0,0 +1,152 @@
//! Persistent conversation memory indexed by (user_id, pet_name).
//!
//! Each row is a single message exchange turn (role = "user" | "assistant" |
//! caller-defined). Storage is SQLite. No FTS: `search` is a simple LIKE scan
//! scoped by the (user_id, pet_name) tuple.
//!
//! Scope boundary: this module does not open connections — the caller
//! supplies a `rusqlite::Connection` (on-disk or in-memory). That keeps the
//! module hermetically testable and lets the host choose the DB path.
use rusqlite::{params, Connection};
/// Conversation stream identity: one stream per (user, pet) pair.
#[derive(Debug, Clone)]
pub struct MemoryTag {
pub user_id: String,
pub pet_name: String,
}
/// A single recorded interaction row.
#[derive(Debug, Clone)]
pub struct Interaction {
pub id: i64,
pub role: String,
pub text: String,
pub ts: i64,
}
/// Errors surfaced by this module.
#[derive(Debug, thiserror::Error)]
pub enum MemoryError {
#[error(transparent)]
Sql(#[from] rusqlite::Error),
}
/// Create the `pet_conversations` table and its (user_id, pet_name, ts DESC)
/// index if they don't exist yet. Idempotent.
pub fn ensure_schema(conn: &Connection) -> Result<(), MemoryError> {
conn.execute(
"CREATE TABLE IF NOT EXISTS pet_conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
pet_name TEXT NOT NULL,
role TEXT NOT NULL,
text TEXT NOT NULL,
ts INTEGER NOT NULL
)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_pet_conv_tag_ts
ON pet_conversations (user_id, pet_name, ts DESC)",
[],
)?;
Ok(())
}
/// Insert one interaction row, returning its rowid.
pub fn record_interaction(
conn: &Connection,
tag: &MemoryTag,
role: &str,
text: &str,
ts: i64,
) -> Result<i64, MemoryError> {
conn.execute(
"INSERT INTO pet_conversations (user_id, pet_name, role, text, ts)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![tag.user_id, tag.pet_name, role, text, ts],
)?;
Ok(conn.last_insert_rowid())
}
/// Return up to `limit` most recent interactions for `tag`, newest first.
pub fn recent(
conn: &Connection,
tag: &MemoryTag,
limit: usize,
) -> Result<Vec<Interaction>, MemoryError> {
let mut stmt = conn.prepare(
"SELECT id, role, text, ts
FROM pet_conversations
WHERE user_id = ?1 AND pet_name = ?2
ORDER BY ts DESC, id DESC
LIMIT ?3",
)?;
let rows = stmt.query_map(
params![tag.user_id, tag.pet_name, limit as i64],
row_to_interaction,
)?;
collect_rows(rows)
}
/// Return up to `limit` interactions whose `text` contains `query` as a
/// literal substring (case-insensitive via LIKE), scoped to `tag`,
/// newest first.
pub fn search(
conn: &Connection,
tag: &MemoryTag,
query: &str,
limit: usize,
) -> Result<Vec<Interaction>, MemoryError> {
let pattern = format!("%{}%", escape_like(query));
let mut stmt = conn.prepare(
"SELECT id, role, text, ts
FROM pet_conversations
WHERE user_id = ?1 AND pet_name = ?2
AND text LIKE ?3 ESCAPE '\\'
ORDER BY ts DESC, id DESC
LIMIT ?4",
)?;
let rows = stmt.query_map(
params![tag.user_id, tag.pet_name, pattern, limit as i64],
row_to_interaction,
)?;
collect_rows(rows)
}
fn row_to_interaction(row: &rusqlite::Row<'_>) -> rusqlite::Result<Interaction> {
Ok(Interaction {
id: row.get(0)?,
role: row.get(1)?,
text: row.get(2)?,
ts: row.get(3)?,
})
}
fn collect_rows<I>(rows: I) -> Result<Vec<Interaction>, MemoryError>
where
I: Iterator<Item = rusqlite::Result<Interaction>>,
{
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok(out)
}
/// Escape LIKE metacharacters (`%`, `_`, `\`) so callers can pass raw text.
fn escape_like(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' | '%' | '_' => {
out.push('\\');
out.push(c);
}
_ => out.push(c),
}
}
out
}

View file

@ -0,0 +1,94 @@
//! Conversational recall — "have we discussed this before?"
//!
//! Thin adapter over `kei_dna_index::precedent`. Hashes a task body with
//! SHA-256, truncates to the first 4 bytes (8 hex chars — matches the DNA
//! `body_sha` width SSoT in `kei_shared::dna`), then asks the ledger for
//! past agents whose DNA carries the same body_sha.
//!
//! Scope: reads the `agents` table on the supplied `Connection`. No writes,
//! no schema mutation. Caller decides whether the connection points at the
//! real ledger or a test fixture.
use kei_dna_index::precedent;
use rusqlite::{params, Connection};
use sha2::{Digest, Sha256};
/// One past agent whose DNA body_sha matches the current task body.
#[derive(Debug, Clone)]
pub struct RecallHit {
pub past_agent_id: String,
pub body_preview: String,
pub timestamp: i64,
pub status: String,
}
/// Errors surfaced by recall.
#[derive(Debug, thiserror::Error)]
pub enum RecallError {
#[error(transparent)]
Sql(#[from] rusqlite::Error),
#[error(transparent)]
DnaIndex(#[from] kei_dna_index::Error),
}
/// SHA-256 of `task_body`, truncated to the first 4 bytes rendered as
/// lowercase hex (8 chars). Matches the `body_sha` width in the DNA wire
/// format — see `kei_shared::dna`.
pub fn body_sha8(task_body: &str) -> String {
let mut h = Sha256::new();
h.update(task_body.as_bytes());
let digest = h.finalize();
hex_lower(&digest[..4])
}
fn hex_lower(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{:02x}", b));
}
s
}
/// First 80 characters of `task_body`, respecting UTF-8 char boundaries.
fn preview(task_body: &str) -> String {
task_body.chars().take(80).collect()
}
/// Fetch `started_ts` for a given agent_id. Returns 0 when the row is gone
/// (shouldn't happen inside a single transaction but we degrade gracefully).
fn fetch_started_ts(conn: &Connection, agent_id: &str) -> Result<i64, rusqlite::Error> {
let mut stmt = conn.prepare("SELECT started_ts FROM agents WHERE id = ?1")?;
let ts: Option<i64> = stmt
.query_row(params![agent_id], |r| r.get::<_, i64>(0))
.ok();
Ok(ts.unwrap_or(0))
}
/// Find up to `limit` past agents whose DNA body_sha matches the hash of
/// `task_body`. Results are sorted newest-first by `started_ts`.
pub fn recall_similar(
conn: &Connection,
task_body: &str,
limit: usize,
) -> Result<Vec<RecallHit>, RecallError> {
let sha = body_sha8(task_body);
let matches = precedent(conn, &sha, None)?;
let prev = preview(task_body);
let mut hits: Vec<RecallHit> = matches
.into_iter()
.map(|r| {
let ts = fetch_started_ts(conn, &r.agent_id).unwrap_or(0);
RecallHit {
past_agent_id: r.agent_id,
body_preview: prev.clone(),
timestamp: ts,
status: r.status,
}
})
.collect();
hits.sort_by_key(|h| std::cmp::Reverse(h.timestamp));
if limit > 0 && hits.len() > limit {
hits.truncate(limit);
}
Ok(hits)
}

View file

@ -0,0 +1,159 @@
//! Pet self-reflection — analyse user correction signals, propose persona
//! tune changes.
//!
//! Pipeline: caller accumulates `CorrectionSignal`s over some window (a
//! session, a day, since last tune). `propose_tune` groups them by topic
//! and emits a minimal, idempotent set of `ProposedChange`s against the
//! current `PetManifest`. Persistence and user-approval UX are the
//! caller's concern — this module is pure data + pure logic.
use crate::schema::{Directness, Initiative, PetManifest, Tone};
use std::collections::HashMap;
// ─────────────────────────── public types ────────────────────────────
#[derive(Debug, Clone)]
pub struct CorrectionSignal {
pub timestamp: i64,
/// Topic label. Two shapes:
/// * flat topic, e.g. `"too_verbose"`, `"too_formal"`,
/// `"not_proactive_enough"`.
/// * namespaced topic, e.g. `"forbidden_topic:diagnosis"` — the
/// prefix before `:` is the category, the suffix is the payload.
pub topic: String,
pub severity: u8,
pub note: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ProposedChange {
SetDirectness(String),
AddForbiddenTopic(String),
SetInitiative(String),
SetTonePrimary(String),
}
// ─────────────────────────── thresholds ──────────────────────────────
const TOO_VERBOSE_THRESHOLD: usize = 3;
const FORBIDDEN_TOPIC_THRESHOLD: usize = 2;
const NOT_PROACTIVE_THRESHOLD: usize = 3;
const TOO_FORMAL_THRESHOLD: usize = 3;
// ─────────────────────────── public api ──────────────────────────────
/// Produce an ordered, idempotent set of proposed changes.
///
/// Order: directness → forbidden topics (by first-seen order) →
/// initiative → tone. Idempotent: a change is only emitted when the
/// manifest is NOT already in the desired state.
pub fn propose_tune(
manifest: &PetManifest,
signals: &[CorrectionSignal],
) -> Vec<ProposedChange> {
let counts = tally(signals);
let forbidden_topics = tally_forbidden(signals);
let mut out = Vec::new();
maybe_directness(&counts, manifest, &mut out);
emit_forbidden(&forbidden_topics, manifest, &mut out);
maybe_initiative(&counts, manifest, &mut out);
maybe_tone(&counts, manifest, &mut out);
out
}
// ─────────────────────────── tallying ────────────────────────────────
fn tally(signals: &[CorrectionSignal]) -> HashMap<&str, usize> {
let mut out: HashMap<&str, usize> = HashMap::new();
for sig in signals {
if sig.topic.contains(':') {
continue;
}
*out.entry(sig.topic.as_str()).or_insert(0) += 1;
}
out
}
/// Collect `forbidden_topic:<payload>` signals preserving first-seen
/// order, with per-payload counts.
fn tally_forbidden(signals: &[CorrectionSignal]) -> Vec<(String, usize)> {
let mut order: Vec<String> = Vec::new();
let mut counts: HashMap<String, usize> = HashMap::new();
for sig in signals {
let Some(payload) = sig.topic.strip_prefix("forbidden_topic:") else {
continue;
};
let payload = payload.to_string();
if !counts.contains_key(&payload) {
order.push(payload.clone());
}
*counts.entry(payload).or_insert(0) += 1;
}
order.into_iter().map(|p| { let c = counts[&p]; (p, c) }).collect()
}
// ─────────────────────────── emitters ────────────────────────────────
fn maybe_directness(
counts: &HashMap<&str, usize>,
manifest: &PetManifest,
out: &mut Vec<ProposedChange>,
) {
let n = counts.get("too_verbose").copied().unwrap_or(0);
if n < TOO_VERBOSE_THRESHOLD {
return;
}
// "direct" maps to Directness::Hard (the terse end of the scale).
if manifest.edge.directness == Directness::Hard {
return;
}
out.push(ProposedChange::SetDirectness("direct".to_string()));
}
fn emit_forbidden(
forbidden: &[(String, usize)],
manifest: &PetManifest,
out: &mut Vec<ProposedChange>,
) {
for (topic, count) in forbidden {
if *count < FORBIDDEN_TOPIC_THRESHOLD {
continue;
}
if manifest.forbidden.topics.iter().any(|t| t == topic) {
continue;
}
out.push(ProposedChange::AddForbiddenTopic(topic.clone()));
}
}
fn maybe_initiative(
counts: &HashMap<&str, usize>,
manifest: &PetManifest,
out: &mut Vec<ProposedChange>,
) {
let n = counts.get("not_proactive_enough").copied().unwrap_or(0);
if n < NOT_PROACTIVE_THRESHOLD {
return;
}
// "proactive" maps to Initiative::TapOnShoulder (most proactive rung).
if manifest.edge.initiative == Initiative::TapOnShoulder {
return;
}
out.push(ProposedChange::SetInitiative("proactive".to_string()));
}
fn maybe_tone(
counts: &HashMap<&str, usize>,
manifest: &PetManifest,
out: &mut Vec<ProposedChange>,
) {
let n = counts.get("too_formal").copied().unwrap_or(0);
if n < TOO_FORMAL_THRESHOLD {
return;
}
if manifest.voice.tone_primary == Tone::Warm {
return;
}
out.push(ProposedChange::SetTonePrimary("warm".to_string()));
}

View file

@ -0,0 +1,55 @@
//! Preset pet persona templates.
//!
//! Each template is a bundled, schema-valid TOML seed parsed at runtime
//! via `crate::parse`. The set intentionally covers five distinct
//! personas so `/pet-setup` can offer one-click starting points.
use crate::schema::PetManifest;
/// The five preset persona archetypes shipped with kei-pet.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PetTemplate {
Friend,
Tutor,
Coach,
TherapistCompanion,
ProductivityPartner,
}
/// Load a preset template and return the fully-validated manifest.
///
/// All five bundled templates are verified to pass `validate()` by the
/// `all_five_templates_pass_validation` integration test.
pub fn load_template(t: PetTemplate) -> Result<PetManifest, anyhow::Error> {
let toml_str = match t {
PetTemplate::Friend => include_str!("../templates/friend.toml"),
PetTemplate::Tutor => include_str!("../templates/tutor.toml"),
PetTemplate::Coach => include_str!("../templates/coach.toml"),
PetTemplate::TherapistCompanion => {
include_str!("../templates/therapist-companion.toml")
}
PetTemplate::ProductivityPartner => {
include_str!("../templates/productivity-partner.toml")
}
};
crate::parse(toml_str)
}
/// Stable-ordered list of templates with short human descriptions.
///
/// Order is the same as enum declaration (Friend → ProductivityPartner).
pub fn list_templates() -> Vec<(PetTemplate, &'static str)> {
vec![
(PetTemplate::Friend, "Warm casual companion"),
(PetTemplate::Tutor, "Precise teaching assistant"),
(PetTemplate::Coach, "Direct improvement coach"),
(
PetTemplate::TherapistCompanion,
"Gentle listening companion (not a replacement for professional care)",
),
(
PetTemplate::ProductivityPartner,
"Focus + routine accountability",
),
]
}

View file

@ -0,0 +1,36 @@
# Preset: Coach — direct improvement coach.
#
# Intent: tone_primary=warm, humor_style=witty (mapped to
# "engineering-meta" — the closest clever/sharp schema enum),
# directness=direct (mapped to "hard"), initiative=nudge (mapped to
# "suggest" — the gentlest proactive schema enum).
schema = 1
[identity]
pet_name = "Kei"
user_name = "Athlete"
addressing = "by-name"
languages = ["en"]
[voice]
tone_primary = "warm"
tone_secondary = []
humor_style = "engineering-meta"
humor_frequency = "medium"
[edge]
profanity = "never"
profanity_languages = []
directness = "hard"
initiative = "suggest"
[forbidden]
topics = []
tone_patterns = []
[meta]
schema_version_written_by = "kei-pet 0.1.0"
created_at = "2026-04-23T00:00:00Z"
last_tuned = "2026-04-23T00:00:00Z"
tune_count = 0

View file

@ -0,0 +1,35 @@
# Preset: Friend — warm casual companion.
#
# Intent: tone_primary=warm, humor_style=dry (mapped to engineering-meta —
# the closest schema enum for dry/deadpan wit), directness=balanced,
# initiative=wait, forbidden.topics=[].
schema = 1
[identity]
pet_name = "Kei"
user_name = "Friend"
addressing = "by-name"
languages = ["en"]
[voice]
tone_primary = "warm"
tone_secondary = []
humor_style = "engineering-meta"
humor_frequency = "medium"
[edge]
profanity = "never"
profanity_languages = []
directness = "balanced"
initiative = "wait"
[forbidden]
topics = []
tone_patterns = []
[meta]
schema_version_written_by = "kei-pet 0.1.0"
created_at = "2026-04-23T00:00:00Z"
last_tuned = "2026-04-23T00:00:00Z"
tune_count = 0

View file

@ -0,0 +1,35 @@
# Preset: Productivity Partner — focus + routine accountability.
#
# Intent: tone_primary=neutral, humor_style=dry (mapped to
# "engineering-meta"), humor_frequency=rare, directness=direct
# (mapped to "hard"), initiative=nudge (mapped to "suggest").
schema = 1
[identity]
pet_name = "Kei"
user_name = "Operator"
addressing = "by-name"
languages = ["en"]
[voice]
tone_primary = "neutral"
tone_secondary = []
humor_style = "engineering-meta"
humor_frequency = "rare"
[edge]
profanity = "never"
profanity_languages = []
directness = "hard"
initiative = "suggest"
[forbidden]
topics = []
tone_patterns = []
[meta]
schema_version_written_by = "kei-pet 0.1.0"
created_at = "2026-04-23T00:00:00Z"
last_tuned = "2026-04-23T00:00:00Z"
tune_count = 0

View file

@ -0,0 +1,37 @@
# Preset: Therapist Companion — gentle listening companion.
#
# NOT a replacement for professional care.
#
# Intent: tone_primary=warm, humor_style=none, directness=gentle (mapped
# to "soft" — the schema's least-direct enum), initiative=wait,
# forbidden.topics preserve safety boundaries around medical scope.
schema = 1
[identity]
pet_name = "Kei"
user_name = "Companion"
addressing = "by-name"
languages = ["en"]
[voice]
tone_primary = "warm"
tone_secondary = ["supportive"]
humor_style = "none"
humor_frequency = "rare"
[edge]
profanity = "never"
profanity_languages = []
directness = "soft"
initiative = "wait"
[forbidden]
topics = ["diagnosis", "prescriptions", "substitute-for-professional-care"]
tone_patterns = []
[meta]
schema_version_written_by = "kei-pet 0.1.0"
created_at = "2026-04-23T00:00:00Z"
last_tuned = "2026-04-23T00:00:00Z"
tune_count = 0

View file

@ -0,0 +1,36 @@
# Preset: Tutor — precise teaching assistant.
#
# Intent: tone_primary=neutral, humor_style=none, directness=direct
# (mapped to "hard" — the strongest directness in the schema),
# initiative=proactive (mapped to "tap-on-shoulder" — the most proactive
# schema enum), forbidden.topics=["chit-chat-only"].
schema = 1
[identity]
pet_name = "Kei"
user_name = "Student"
addressing = "by-name"
languages = ["en"]
[voice]
tone_primary = "neutral"
tone_secondary = []
humor_style = "none"
humor_frequency = "rare"
[edge]
profanity = "never"
profanity_languages = []
directness = "hard"
initiative = "tap-on-shoulder"
[forbidden]
topics = ["chit-chat-only"]
tone_patterns = []
[meta]
schema_version_written_by = "kei-pet 0.1.0"
created_at = "2026-04-23T00:00:00Z"
last_tuned = "2026-04-23T00:00:00Z"
tune_count = 0

View file

@ -0,0 +1,83 @@
//! Integration tests for `bridge::compose_prompt_with_pet`.
//!
//! Fixtures load from `examples/full.toml` via `include_str!` — this is the
//! only reliable way to test against a known-good manifest until a shared
//! `templates` module exists.
use kei_pet::{parse, AgentSpawnRequest, compose_prompt_with_pet};
const FULL: &str = include_str!("../examples/full.toml");
fn base_req(with_pet: bool) -> AgentSpawnRequest {
let pet = if with_pet {
Some(parse(FULL).expect("examples/full.toml must validate"))
} else {
None
};
AgentSpawnRequest {
role: "code-implementer".to_string(),
pet_manifest: pet,
task_body: "Refactor the foo module into three cubes.".to_string(),
base_prompt: "You are a senior Rust engineer.".to_string(),
}
}
#[test]
fn compose_prompt_without_pet_returns_base_plus_body() {
let req = base_req(false);
let out = compose_prompt_with_pet(&req);
// Must contain both the base prompt and the task body verbatim.
assert!(
out.contains("You are a senior Rust engineer."),
"base prompt missing from composed output:\n{out}"
);
assert!(
out.contains("Refactor the foo module into three cubes."),
"task body missing from composed output:\n{out}"
);
// Must NOT contain the persona-overlay header.
assert!(
!out.contains("## Persona overlay"),
"persona overlay section leaked in without a manifest:\n{out}"
);
}
#[test]
fn compose_prompt_with_pet_includes_voice_tone_string() {
let req = base_req(true);
let out = compose_prompt_with_pet(&req);
// full.toml: tone_primary = "dry" → overlay emits "Primary tone: dry."
assert!(
out.contains("Primary tone: dry"),
"expected primary tone 'dry' in overlay output:\n{out}"
);
// Header must appear exactly once — overlay was injected.
assert!(
out.contains("## Persona overlay"),
"persona overlay header missing when manifest present:\n{out}"
);
}
#[test]
fn pet_forbidden_topics_appear_in_system_prompt() {
let req = base_req(true);
let out = compose_prompt_with_pet(&req);
// full.toml: forbidden.topics = ["politics", "crypto-hype"]
assert!(
out.contains("politics"),
"forbidden topic 'politics' not surfaced by overlay:\n{out}"
);
assert!(
out.contains("crypto-hype"),
"forbidden topic 'crypto-hype' not surfaced by overlay:\n{out}"
);
// And the "Never engage with" lead-in from overlay.rs must be present.
assert!(
out.contains("Never engage with:"),
"forbidden-topics lead-in phrase missing:\n{out}"
);
}

View file

@ -0,0 +1,61 @@
//! Integration tests for `kei_pet::evolution` — diff detection + fork
//! chain linking. Uses `examples/minimal.toml` as the baseline and mutates
//! clones to exercise each `Change` variant.
use kei_pet::evolution::{diff, fork_version, Change, PersonaVersion};
use kei_pet::parse;
use kei_pet::schema::Tone;
const MINIMAL: &str = include_str!("../examples/minimal.toml");
fn base() -> kei_pet::PetManifest {
parse(MINIMAL).expect("minimal.toml must validate")
}
#[test]
fn diff_detects_tone_change() {
let old = base();
let mut new = old.clone();
new.voice.tone_primary = Tone::Warm;
let changes = diff(&old, &new);
assert_eq!(changes.len(), 1, "expected exactly one change, got {changes:?}");
assert_eq!(
changes[0],
Change::VoiceTonePrimaryChanged {
from: "neutral".to_string(),
to: "warm".to_string(),
}
);
}
#[test]
fn diff_detects_forbidden_added() {
let old = base();
let mut new = old.clone();
new.forbidden.topics.push("diagnosis".to_string());
let changes = diff(&old, &new);
assert_eq!(changes.len(), 1, "expected exactly one change, got {changes:?}");
assert_eq!(
changes[0],
Change::ForbiddenTopicAdded("diagnosis".to_string())
);
}
#[test]
fn fork_version_increments_and_links() {
let manifest = base();
let v1 = PersonaVersion {
version: 1,
parent_version: None,
manifest: manifest.clone(),
created_at: 1_700_000_000,
reason: "initial".to_string(),
};
let v2 = fork_version(&v1, "tune tone", manifest);
assert_eq!(v2.version, 2);
assert_eq!(v2.parent_version, Some(1));
assert_eq!(v2.reason, "tune tone");
}

View file

@ -0,0 +1,83 @@
//! Hermetic tests for the multi-pet fleet module.
//!
//! Every test uses a fresh `tempfile::TempDir` as the fleet_root, so no
//! test touches real user state and no test depends on another's side
//! effects.
use std::path::PathBuf;
use kei_pet::fleet::{
add_pet, load_fleet, per_pet_memory_key, shared_memory_key, switch_active, PetHandle,
};
fn mk_handle(name: &str, role: &str) -> PetHandle {
PetHandle {
pet_name: name.to_string(),
role: role.to_string(),
manifest_path: PathBuf::from(format!("/tmp/{name}.toml")),
last_active: 0,
}
}
#[test]
fn load_fleet_empty_returns_zero_pets() {
let dir = tempfile::TempDir::new().expect("tempdir");
let fleet = load_fleet("user-alpha", dir.path()).expect("load empty");
assert_eq!(fleet.user_id, "user-alpha");
assert!(fleet.pets.is_empty());
assert!(fleet.active_pet.is_none());
}
#[test]
fn add_pet_persists_to_disk() {
let dir = tempfile::TempDir::new().expect("tempdir");
let handle = mk_handle("mira", "friend");
add_pet("user-alpha", handle, dir.path()).expect("add");
let fleet = load_fleet("user-alpha", dir.path()).expect("reload");
assert_eq!(fleet.pets.len(), 1);
assert_eq!(fleet.pets[0].pet_name, "mira");
assert_eq!(fleet.pets[0].role, "friend");
// First add should seed active_pet.
assert_eq!(fleet.active_pet.as_deref(), Some("mira"));
}
#[test]
fn switch_active_updates_file() {
let dir = tempfile::TempDir::new().expect("tempdir");
add_pet("user-alpha", mk_handle("mira", "friend"), dir.path()).expect("add 1");
add_pet("user-alpha", mk_handle("nova", "tutor"), dir.path()).expect("add 2");
switch_active("user-alpha", "nova", dir.path()).expect("switch");
let fleet = load_fleet("user-alpha", dir.path()).expect("reload");
assert_eq!(fleet.pets.len(), 2);
assert_eq!(fleet.active_pet.as_deref(), Some("nova"));
}
#[test]
fn memory_keys_differ_per_pet_same_user() {
let a = per_pet_memory_key("user-alpha", "mira");
let b = per_pet_memory_key("user-alpha", "nova");
assert_ne!(a, b);
assert!(a.contains("user-alpha"));
assert!(a.contains("mira"));
assert!(b.contains("nova"));
}
#[test]
fn shared_memory_key_stable() {
let k1 = shared_memory_key("user-alpha");
let k2 = shared_memory_key("user-alpha");
assert_eq!(k1, k2);
assert_ne!(k1, shared_memory_key("user-beta"));
}
#[test]
fn switch_active_errors_when_pet_absent() {
let dir = tempfile::TempDir::new().expect("tempdir");
add_pet("user-alpha", mk_handle("mira", "friend"), dir.path()).expect("add");
let err = switch_active("user-alpha", "ghost", dir.path()).unwrap_err();
assert!(matches!(err, kei_pet::fleet::FleetError::PetNotInFleet(_)));
}

View file

@ -0,0 +1,133 @@
//! Hermetic tests for `kei_pet::memory`. Every test uses an in-memory
//! SQLite connection so nothing touches disk.
use kei_pet::memory::{ensure_schema, record_interaction, recent, search, MemoryTag};
use rusqlite::Connection;
fn fresh_db() -> Connection {
let conn = Connection::open_in_memory().expect("open in-memory sqlite");
ensure_schema(&conn).expect("ensure_schema idempotent");
// Second call must be a no-op.
ensure_schema(&conn).expect("ensure_schema second call");
conn
}
fn tag(user: &str, pet: &str) -> MemoryTag {
MemoryTag { user_id: user.into(), pet_name: pet.into() }
}
#[test]
fn record_and_recall_round_trip() {
let conn = fresh_db();
let t = tag("alice", "scout");
let id1 = record_interaction(&conn, &t, "user", "hello scout", 100).unwrap();
let id2 = record_interaction(&conn, &t, "assistant", "woof back", 101).unwrap();
let id3 = record_interaction(&conn, &t, "user", "good boy", 102).unwrap();
assert!(id1 < id2 && id2 < id3, "rowids strictly increase");
let rows = recent(&conn, &t, 10).unwrap();
assert_eq!(rows.len(), 3);
// Newest first.
assert_eq!(rows[0].ts, 102);
assert_eq!(rows[0].text, "good boy");
assert_eq!(rows[0].role, "user");
assert_eq!(rows[1].ts, 101);
assert_eq!(rows[2].ts, 100);
// Limit is respected.
let top2 = recent(&conn, &t, 2).unwrap();
assert_eq!(top2.len(), 2);
assert_eq!(top2[0].ts, 102);
assert_eq!(top2[1].ts, 101);
}
#[test]
fn recall_scoped_by_user_id_and_pet_name() {
let conn = fresh_db();
// 2 users x 2 pets = 4 independent streams, 3 messages each.
let streams = [
tag("alice", "scout"),
tag("alice", "nova"),
tag("bob", "scout"),
tag("bob", "nova"),
];
for (i, s) in streams.iter().enumerate() {
for k in 0..3 {
let ts = (i as i64) * 1000 + k as i64;
let text = format!("{}/{}#{}", s.user_id, s.pet_name, k);
record_interaction(&conn, s, "user", &text, ts).unwrap();
}
}
// Each stream sees exactly its own 3 messages.
for s in &streams {
let rows = recent(&conn, s, 50).unwrap();
assert_eq!(rows.len(), 3, "stream {:?} should have 3 rows", s);
for r in &rows {
let prefix = format!("{}/{}#", s.user_id, s.pet_name);
assert!(
r.text.starts_with(&prefix),
"leak: {:?} leaked into stream {:?}",
r.text,
s
);
}
}
// Confirm total rows = 12 across all streams (sanity on writes).
let all: i64 = conn
.query_row("SELECT COUNT(*) FROM pet_conversations", [], |r| r.get(0))
.unwrap();
assert_eq!(all, 12);
}
#[test]
fn search_by_substring_matches_content() {
let conn = fresh_db();
let t = tag("alice", "scout");
let other = tag("bob", "scout");
record_interaction(&conn, &t, "user", "let's go to the park", 1).unwrap();
record_interaction(&conn, &t, "assistant", "park sounds great", 2).unwrap();
record_interaction(&conn, &t, "user", "what about dinner", 3).unwrap();
// Same keyword under a different tag — MUST NOT leak into alice/scout.
record_interaction(&conn, &other, "user", "park for bob", 4).unwrap();
let hits = search(&conn, &t, "park", 10).unwrap();
assert_eq!(hits.len(), 2, "two park matches for alice/scout");
// Newest first.
assert_eq!(hits[0].ts, 2);
assert_eq!(hits[1].ts, 1);
assert!(hits.iter().all(|h| h.text.contains("park")));
// No false matches.
let none = search(&conn, &t, "zebra", 10).unwrap();
assert!(none.is_empty());
// Limit respected.
let one = search(&conn, &t, "park", 1).unwrap();
assert_eq!(one.len(), 1);
assert_eq!(one[0].ts, 2);
}
#[test]
fn search_escapes_like_metacharacters() {
// Regression guard: `%` and `_` in the user query must be literal,
// not SQL LIKE wildcards.
let conn = fresh_db();
let t = tag("alice", "scout");
record_interaction(&conn, &t, "user", "literal 100% match", 1).unwrap();
record_interaction(&conn, &t, "user", "no percent here", 2).unwrap();
record_interaction(&conn, &t, "user", "under_score here", 3).unwrap();
let hits = search(&conn, &t, "100%", 10).unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].ts, 1);
let under = search(&conn, &t, "under_score", 10).unwrap();
assert_eq!(under.len(), 1);
assert_eq!(under[0].ts, 3);
}

View file

@ -0,0 +1,116 @@
//! Integration tests for `kei_pet::recall`.
//!
//! Hermetic: each test owns an in-memory SQLite Connection populated with
//! a minimal `agents` table that mirrors the subset of the real ledger
//! schema that `kei_dna_index::precedent` reads (id, dna, started_ts,
//! status).
use kei_pet::recall::{body_sha8, recall_similar};
use rusqlite::{params, Connection};
fn setup_agents_table(conn: &Connection) {
conn.execute(
"CREATE TABLE agents (
id TEXT PRIMARY KEY,
dna TEXT,
started_ts INTEGER NOT NULL,
status TEXT NOT NULL
)",
[],
)
.expect("create agents table");
}
fn insert_agent(
conn: &Connection,
id: &str,
dna: &str,
started_ts: i64,
status: &str,
) {
conn.execute(
"INSERT INTO agents (id, dna, started_ts, status) VALUES (?1, ?2, ?3, ?4)",
params![id, dna, started_ts, status],
)
.expect("insert agent");
}
fn dna_with_body_sha(role: &str, body_sha: &str, nonce: &str) -> String {
// Format matches kei_shared::dna SSoT: `<role>::<caps>::<sha8>::<sha8>-<sha8>`
format!("{role}::NG-FW-FD-CP::5435f821::{body_sha}-{nonce}")
}
#[test]
fn recall_returns_empty_on_fresh_db() {
let conn = Connection::open_in_memory().unwrap();
setup_agents_table(&conn);
let hits = recall_similar(&conn, "any task body", 10).expect("recall ok");
assert!(
hits.is_empty(),
"expected empty recall on fresh DB, got {} hits",
hits.len()
);
}
#[test]
fn recall_finds_same_body_sha() {
let conn = Connection::open_in_memory().unwrap();
setup_agents_table(&conn);
let task_body = "refactor: extract recall primitive";
let sha = body_sha8(task_body);
let dna = dna_with_body_sha("code-implementer", &sha, "deadbeef");
insert_agent(&conn, "agent-001", &dna, 1_700_000_000, "done");
// Second agent, unrelated body → should NOT match.
let other_sha = body_sha8("some completely different task");
let other_dna = dna_with_body_sha("code-implementer", &other_sha, "cafebabe");
insert_agent(&conn, "agent-002", &other_dna, 1_700_000_100, "done");
let hits = recall_similar(&conn, task_body, 10).expect("recall ok");
assert_eq!(hits.len(), 1, "expected exactly one recall hit");
let hit = &hits[0];
assert_eq!(hit.past_agent_id, "agent-001");
assert_eq!(hit.status, "done");
assert_eq!(hit.timestamp, 1_700_000_000);
assert_eq!(hit.body_preview, task_body);
}
#[test]
fn recall_different_body_returns_none() {
let conn = Connection::open_in_memory().unwrap();
setup_agents_table(&conn);
let other_sha = body_sha8("task A");
let dna = dna_with_body_sha("research", &other_sha, "00112233");
insert_agent(&conn, "agent-042", &dna, 1_700_000_050, "running");
let hits = recall_similar(&conn, "task B — nothing in common", 10)
.expect("recall ok");
assert!(
hits.is_empty(),
"expected no hits for unrelated body, got {}",
hits.len()
);
}
#[test]
fn recall_sorts_newest_first_and_respects_limit() {
let conn = Connection::open_in_memory().unwrap();
setup_agents_table(&conn);
let task_body = "shared task body";
let sha = body_sha8(task_body);
for (i, ts) in [1_000, 3_000, 2_000, 4_000].iter().enumerate() {
let id = format!("agent-{:03}", i);
let nonce = format!("{:08x}", i + 1);
let dna = dna_with_body_sha("code-implementer", &sha, &nonce);
insert_agent(&conn, &id, &dna, *ts, "done");
}
let hits = recall_similar(&conn, task_body, 2).expect("recall ok");
assert_eq!(hits.len(), 2, "limit=2 should truncate");
assert_eq!(hits[0].timestamp, 4_000, "newest first");
assert_eq!(hits[1].timestamp, 3_000, "second newest next");
}

View file

@ -0,0 +1,163 @@
//! Hermetic tests for `kei_pet::reflect::propose_tune`.
//!
//! Each test builds an in-memory `PetManifest` (no disk, no TOML parsing)
//! so the logic is tested in isolation from schema serialization.
use kei_pet::reflect::{propose_tune, CorrectionSignal, ProposedChange};
use kei_pet::schema::{
Addressing, Directness, Edge, Forbidden, HumorFrequency, HumorStyle,
Identity, Initiative, Meta, PetManifest, Profanity, Tone, Voice,
};
fn base_manifest() -> PetManifest {
PetManifest {
schema: 1,
identity: Identity {
pet_name: "Kei".into(),
user_name: "Alex".into(),
addressing: Addressing::ByName,
languages: vec!["en".into()],
},
voice: Voice {
tone_primary: Tone::Neutral,
tone_secondary: vec![],
humor_style: HumorStyle::None,
humor_frequency: HumorFrequency::Rare,
},
edge: Edge {
profanity: Profanity::Never,
profanity_languages: vec![],
directness: Directness::Balanced,
initiative: Initiative::Wait,
},
appearance: None,
room: None,
privacy: None,
interests: vec![],
routines: vec![],
forbidden: Forbidden {
topics: vec![],
tone_patterns: vec![],
},
meta: Meta {
schema_version_written_by: "kei-pet 0.1.0".into(),
created_at: "2026-04-23T12:00:00Z".into(),
last_tuned: "2026-04-23T12:00:00Z".into(),
tune_count: 0,
},
}
}
fn sig(topic: &str, ts: i64) -> CorrectionSignal {
CorrectionSignal {
timestamp: ts,
topic: topic.into(),
severity: 5,
note: None,
}
}
#[test]
fn propose_tune_empty_signals_returns_empty() {
let m = base_manifest();
let out = propose_tune(&m, &[]);
assert!(out.is_empty(), "empty signals → no proposals, got {out:?}");
}
#[test]
fn propose_tune_threshold_too_verbose_3() {
let m = base_manifest();
let signals = vec![
sig("too_verbose", 100),
sig("too_verbose", 101),
sig("too_verbose", 102),
];
let out = propose_tune(&m, &signals);
assert!(
out.contains(&ProposedChange::SetDirectness("direct".into())),
"3× too_verbose on balanced manifest must emit SetDirectness(direct); got {out:?}"
);
}
#[test]
fn propose_tune_below_threshold_too_verbose_2() {
let m = base_manifest();
let signals = vec![
sig("too_verbose", 100),
sig("too_verbose", 101),
];
let out = propose_tune(&m, &signals);
assert!(
!out.contains(&ProposedChange::SetDirectness("direct".into())),
"2× too_verbose is below threshold; got {out:?}"
);
}
#[test]
fn propose_tune_threshold_forbidden_2() {
let m = base_manifest();
let signals = vec![
sig("forbidden_topic:diagnosis", 100),
sig("forbidden_topic:diagnosis", 101),
];
let out = propose_tune(&m, &signals);
assert!(
out.contains(&ProposedChange::AddForbiddenTopic("diagnosis".into())),
"2× forbidden_topic:diagnosis on clean manifest must emit AddForbiddenTopic(diagnosis); got {out:?}"
);
}
#[test]
fn propose_tune_idempotent_directness_hard() {
let mut m = base_manifest();
m.edge.directness = Directness::Hard;
let signals = vec![
sig("too_verbose", 100),
sig("too_verbose", 101),
sig("too_verbose", 102),
sig("too_verbose", 103),
];
let out = propose_tune(&m, &signals);
assert!(
!out.iter().any(|c| matches!(c, ProposedChange::SetDirectness(_))),
"manifest already Hard → no SetDirectness proposal; got {out:?}"
);
}
#[test]
fn propose_tune_idempotent_forbidden_already_listed() {
let mut m = base_manifest();
m.forbidden.topics = vec!["diagnosis".into()];
let signals = vec![
sig("forbidden_topic:diagnosis", 100),
sig("forbidden_topic:diagnosis", 101),
sig("forbidden_topic:diagnosis", 102),
];
let out = propose_tune(&m, &signals);
assert!(
!out.iter().any(|c| matches!(c, ProposedChange::AddForbiddenTopic(_))),
"diagnosis already in forbidden list → no AddForbiddenTopic proposal; got {out:?}"
);
}
#[test]
fn propose_tune_initiative_and_tone_thresholds() {
let m = base_manifest();
let signals = vec![
sig("not_proactive_enough", 100),
sig("not_proactive_enough", 101),
sig("not_proactive_enough", 102),
sig("too_formal", 200),
sig("too_formal", 201),
sig("too_formal", 202),
];
let out = propose_tune(&m, &signals);
assert!(
out.contains(&ProposedChange::SetInitiative("proactive".into())),
"3× not_proactive_enough must emit SetInitiative(proactive); got {out:?}"
);
assert!(
out.contains(&ProposedChange::SetTonePrimary("warm".into())),
"3× too_formal must emit SetTonePrimary(warm); got {out:?}"
);
}

View file

@ -0,0 +1,44 @@
//! Integration tests for the preset persona templates.
//!
//! These tests guarantee that every bundled template:
//! - parses as valid TOML against the current schema,
//! - passes the full R1-R19 validator,
//! - stays exposed in a stable, published order for `/pet-setup`.
use kei_pet::{load_template, list_templates, PetTemplate};
#[test]
fn load_friend_template_parses_valid() {
let m = load_template(PetTemplate::Friend).expect("friend template must parse + validate");
assert_eq!(m.schema, 1);
assert_eq!(m.identity.pet_name, "Kei");
}
#[test]
fn all_five_templates_pass_validation() {
let all = [
PetTemplate::Friend,
PetTemplate::Tutor,
PetTemplate::Coach,
PetTemplate::TherapistCompanion,
PetTemplate::ProductivityPartner,
];
for t in all {
let r = load_template(t);
assert!(r.is_ok(), "template {:?} failed to parse/validate: {:?}", t, r.err());
}
}
#[test]
fn list_templates_returns_five_in_stable_order() {
let list = list_templates();
assert_eq!(list.len(), 5, "preset list must have exactly 5 entries");
assert_eq!(list[0].0, PetTemplate::Friend);
assert_eq!(list[1].0, PetTemplate::Tutor);
assert_eq!(list[2].0, PetTemplate::Coach);
assert_eq!(list[3].0, PetTemplate::TherapistCompanion);
assert_eq!(list[4].0, PetTemplate::ProductivityPartner);
for (_, desc) in &list {
assert!(!desc.is_empty(), "every template needs a non-empty description");
}
}

95
skills/pet-init/SKILL.md Normal file
View file

@ -0,0 +1,95 @@
---
name: pet-init
description: Create a personal AI pet persona via interactive wizard. No TOML editing required.
category: pet
---
# Pet Init — Interactive Persona Wizard (index)
You are helping a non-developer create their personal AI pet persona. The
output is a valid `pet.toml` manifest conforming to the `kei-pet` schema
(see `_primitives/_rust/kei-pet/examples/minimal.toml`). The user NEVER
edits TOML by hand — every field is gathered through `AskUserQuestion`
batches or short free-text prompts.
This `SKILL.md` is the INDEX. Each phase lives in its own file and runs in
strict order. Never skip or re-order phases.
---
## Pipeline overview (4 phases)
| Phase | File | Purpose | AskUserQuestion |
|---|---|---|---|
| 1 | [phase-1-identity.md](phase-1-identity.md) | Pet name, user name, addressing style, languages | 1 batch (2 questions) |
| 2 | [phase-2-voice.md](phase-2-voice.md) | Tone, humor style, humor frequency | 1 batch (4 questions) |
| 3 | [phase-3-edge.md](phase-3-edge.md) | Directness, initiative, profanity, forbidden topics | 1 batch (3 questions) + 1 free-text |
| 4 | [phase-4-emit.md](phase-4-emit.md) | Compose TOML, keygen if needed, write file, summary | 0 |
Exit: `~/.claude/pet/<user_id>.toml` written, summary displayed, next-step
suggestion shown.
---
## Variables the pipeline produces
| Name | Set in | Shape |
|---|---|---|
| `PET_NAME` | Phase 1 | string, 1-30 chars |
| `USER_NAME` | Phase 1 | string |
| `ADDRESSING` | Phase 1 | `by-name` / `formal` / `casual` |
| `LANGUAGES` | Phase 1 | array of ISO codes |
| `TONE_PRIMARY` | Phase 2 | `warm` / `neutral` / `formal` / `playful` |
| `TONE_SECONDARY` | Phase 2 | array, 0-2 entries |
| `HUMOR_STYLE` | Phase 2 | `none` / `dry` / `witty` / `silly` |
| `HUMOR_FREQUENCY` | Phase 2 | `rare` / `occasional` / `frequent` |
| `DIRECTNESS` | Phase 3 | `gentle` / `balanced` / `direct` / `blunt` |
| `INITIATIVE` | Phase 3 | `wait` / `nudge` / `proactive` |
| `PROFANITY` | Phase 3 | `never` / `rare` / `contextual` |
| `FORBIDDEN_TOPICS` | Phase 3 | array of strings, may be empty |
| `USER_ID` | Phase 4 | Ed25519 short id (from `kei-pet keygen`) |
---
## Rules (apply throughout)
- **No manual TOML.** The user never sees or edits raw TOML until after
Phase 4 emits the file. Any correction = re-run `/pet-init`.
- **RULE 0.4 (NO HALLUCINATION).** Never invent defaults silently. Every
field is either asked or explicitly defaulted in the phase file.
- **RULE 0.8 (SECRETS).** The Ed25519 secret key (created by
`kei-pet keygen`) is written by the primitive into its own keystore —
this skill never reads or displays secret-key material. Only the
public `user_id` short-hash is surfaced to the user.
- **NO DOWNGRADE.** If Phase 4 cannot write the file (permission error,
disk full, keygen failure), return 2-3 constructive paths — never
"can't be done".
- **Constructor Pattern.** Each phase file is a single cube ≤200 LOC.
This index stays ≤200 LOC.
- **Surgical Changes.** The only file written by this skill is
`~/.claude/pet/<user_id>.toml`. No other artefacts.
---
## Exit report (emit after Phase 4)
```
=== PET-INIT REPORT ===
Pet name: <PET_NAME>
User: <USER_NAME>
File: ~/.claude/pet/<USER_ID>.toml
Size: <bytes>
Keygen: <reused existing | newly created>
Next: /pet-chat or kei-pet render --pet ~/.claude/pet/<USER_ID>.toml
```
---
## References
- [phase-1-identity.md](phase-1-identity.md)
- [phase-2-voice.md](phase-2-voice.md)
- [phase-3-edge.md](phase-3-edge.md)
- [phase-4-emit.md](phase-4-emit.md)
- `_primitives/_rust/kei-pet/examples/minimal.toml` — schema reference
- `_primitives/_rust/kei-pet/examples/full.toml` — optional-section reference

View file

@ -0,0 +1,106 @@
# Phase 1 — Identity
Gather the four `[identity]` fields: `pet_name`, `user_name`, `addressing`,
`languages`. Free-text for names (no enum), click-based for the rest.
## 1a — Pet name (free text)
Emit a regular message (NOT AskUserQuestion):
> What should your pet be called?
> - 1 to 30 characters
> - letters, digits, hyphen, underscore, space
> - examples: `Kei`, `Momo`, `Pixel`, `小可`
>
> Reply with the name on one line.
Capture the reply as `PET_NAME`. Validate:
- length 1-30 chars after trimming whitespace
- at least one non-whitespace character
If validation fails → tell the user which rule was violated and ask again.
Never fall through with an invalid name. Never invent a default.
## 1b — User name (free text)
Emit a regular message:
> What should your pet call YOU?
> - examples: `Alex`, `Den`, `boss`, `capitan`
> - 1-30 characters, any script
>
> Reply on one line.
Capture as `USER_NAME`. Same validation as `PET_NAME`.
## 1c — Addressing + languages (AskUserQuestion, 1 batch)
Emit a single `AskUserQuestion` call with TWO questions:
```json
{
"questions": [
{
"question": "How should the pet address you?",
"header": "Addressing",
"multiSelect": false,
"options": [
{"label": "By name", "description": "Uses your name directly, e.g. \"Alex, look at this\""},
{"label": "Formal", "description": "Respectful, keeps distance, e.g. \"You may want to see this\""},
{"label": "Casual", "description": "Relaxed, nickname-friendly, e.g. \"Hey, check this out\""}
]
},
{
"question": "Which languages should the pet use?",
"header": "Languages",
"multiSelect": true,
"options": [
{"label": "English (en)", "description": "Default for most users"},
{"label": "Russian (ru)", "description": "русский"},
{"label": "Spanish (es)", "description": "español"},
{"label": "French (fr)", "description": "français"},
{"label": "German (de)", "description": "Deutsch"},
{"label": "Chinese (zh)", "description": "中文"},
{"label": "Japanese (ja)", "description": "日本語"},
{"label": "Other", "description": "I'll specify after this batch"}
]
}
]
}
```
Map the addressing click to `ADDRESSING`:
| Label | Value |
|-----------|------------|
| By name | `by-name` |
| Formal | `formal` |
| Casual | `casual` |
Map the language multi-select to `LANGUAGES` (ISO 639-1 codes). If the user
ticked "Other":
- emit a regular message: `Which other language? Reply with ISO 639-1 code (e.g. "it", "pt", "ko") or space-separated list.`
- parse reply into additional 2-letter codes
- append to `LANGUAGES`
If no language is selected (all options unchecked) → default to `["en"]`
and tell the user: `No language chosen — defaulting to English.`
## Verify-criterion
- `PET_NAME` set, trimmed, 1-30 chars
- `USER_NAME` set, trimmed, 1-30 chars
- `ADDRESSING` is exactly one of `by-name` / `formal` / `casual`
- `LANGUAGES` is a non-empty array of 2-letter ISO codes
- If user typed "Other", at least one extra code was captured
## Failure modes (constructive paths, NO DOWNGRADE)
If the user declines to give a name:
- (A) suggest `Kei` as a placeholder — explain it can be changed later via re-run
- (B) abort `/pet-init` and invite them to try when ready
- (C) pick a name from a small curated list (`Kei`, `Momo`, `Pixel`, `Echo`)
Offer all three; never silently fall through.

View file

@ -0,0 +1,124 @@
# Phase 2 — Voice
Gather the four `[voice]` fields: `tone_primary`, `tone_secondary`,
`humor_style`, `humor_frequency`. Entirely click-driven — no free text.
## 2a — Voice batch (AskUserQuestion, 1 batch with 4 questions)
Emit a single `AskUserQuestion` call:
```json
{
"questions": [
{
"question": "Primary tone of the pet?",
"header": "Primary tone",
"multiSelect": false,
"options": [
{"label": "Warm", "description": "Friendly, supportive, caring default"},
{"label": "Neutral", "description": "Even-keel, factual, no emotional color"},
{"label": "Formal", "description": "Polite, structured, keeps a professional distance"},
{"label": "Playful", "description": "Light, curious, uses wordplay and side-remarks"}
]
},
{
"question": "Secondary tones (pick up to 2, or none)?",
"header": "Secondary tones",
"multiSelect": true,
"options": [
{"label": "Warm", "description": "Add warmth on top of primary"},
{"label": "Neutral", "description": "Temper intensity of primary"},
{"label": "Formal", "description": "Add politeness on top of primary"},
{"label": "Playful", "description": "Add light tangents on top of primary"},
{"label": "Direct", "description": "Shorter, more to-the-point"},
{"label": "Gentle", "description": "Softer phrasing on hard topics"}
]
},
{
"question": "Humor style?",
"header": "Humor",
"multiSelect": false,
"options": [
{"label": "None", "description": "No jokes, no wordplay — task-focused"},
{"label": "Dry", "description": "Understated, deadpan, rare smirks"},
{"label": "Witty", "description": "Clever, observational, occasional puns"},
{"label": "Silly", "description": "Absurd, playful, freely silly"}
]
},
{
"question": "How often should humor appear?",
"header": "Humor frequency",
"multiSelect": false,
"options": [
{"label": "Rare", "description": "Only when the moment clearly invites it"},
{"label": "Occasional", "description": "A few light remarks per long conversation"},
{"label": "Frequent", "description": "Frequent jokes, side-remarks, playful asides"}
]
}
]
}
```
## 2b — Map clicks to variables
`TONE_PRIMARY` — lowercase the chosen label:
| Label | Value |
|----------|-------------|
| Warm | `warm` |
| Neutral | `neutral` |
| Formal | `formal` |
| Playful | `playful` |
`TONE_SECONDARY` — lowercase each ticked label. Rules:
- if the user ticked more than 2 → keep the first 2 in the order they
appeared in the response; tell the user: `Kept first 2 secondary tones; re-run /pet-init to adjust.`
- if the user ticked zero → `TONE_SECONDARY = []` (valid per schema)
- if the user ticked the SAME label as `TONE_PRIMARY` → drop the duplicate
silently; if that leaves 0, leave `TONE_SECONDARY = []`
`HUMOR_STYLE` — lowercase:
| Label | Value |
|---------|----------|
| None | `none` |
| Dry | `dry` |
| Witty | `witty` |
| Silly | `silly` |
`HUMOR_FREQUENCY` — lowercase:
| Label | Value |
|--------------|---------------|
| Rare | `rare` |
| Occasional | `occasional` |
| Frequent | `frequent` |
## 2c — Consistency check
If `HUMOR_STYLE == "none"` and `HUMOR_FREQUENCY != "rare"`, emit a regular
message:
> Humor style is "none" but frequency is "<freq>". "None" overrides
> frequency — the pet will simply not attempt humor. Continue? (yes / change)
- `yes` → set `HUMOR_FREQUENCY = "rare"` (schema-valid + semantically honest)
- `change` → re-emit the Phase-2 batch (no partial re-runs; the whole
voice set is asked again)
## Verify-criterion
- `TONE_PRIMARY` is one of `warm` / `neutral` / `formal` / `playful`
- `TONE_SECONDARY` is a list of 0-2 entries, no duplicates, none equal to
`TONE_PRIMARY`
- `HUMOR_STYLE` is one of `none` / `dry` / `witty` / `silly`
- `HUMOR_FREQUENCY` is one of `rare` / `occasional` / `frequent`
- Consistency rule (2c) has been applied
## Failure modes (constructive paths)
If the user bails mid-batch (closes without answering):
- (A) keep whatever is set; emit defaults for unset: `neutral` / `[]` / `none` / `rare`; show the user what was defaulted and ask confirm
- (B) abort `/pet-init` cleanly, no file written
- (C) re-emit the whole batch once more

View file

@ -0,0 +1,131 @@
# Phase 3 — Edge
Gather the three `[edge]` fields (`directness`, `initiative`, `profanity`)
plus the optional `[forbidden].topics` list. Click-driven for the enums,
one short free-text for forbidden topics.
## 3a — Edge batch (AskUserQuestion, 1 batch with 3 questions)
Emit a single `AskUserQuestion` call:
```json
{
"questions": [
{
"question": "How direct should the pet be?",
"header": "Directness",
"multiSelect": false,
"options": [
{"label": "Gentle", "description": "Soft-edge, wraps corrections in padding, never pushy"},
{"label": "Balanced", "description": "Honest but kind, states disagreement politely"},
{"label": "Direct", "description": "Minimal padding, tells you the thing"},
{"label": "Blunt", "description": "No padding, named-flaw feedback, warrior mode"}
]
},
{
"question": "How proactive should the pet be?",
"header": "Initiative",
"multiSelect": false,
"options": [
{"label": "Wait", "description": "Only speaks when you ask"},
{"label": "Nudge", "description": "Occasionally flags something that might matter"},
{"label": "Proactive", "description": "Will surface patterns, issues, or ideas unprompted"}
]
},
{
"question": "Profanity policy?",
"header": "Profanity",
"multiSelect": false,
"options": [
{"label": "Never", "description": "Pet never uses profanity, regardless of your style"},
{"label": "Rare", "description": "Occasional mild profanity when the moment fits"},
{"label": "Contextual", "description": "Mirrors your own register — matches if you swear"}
]
}
]
}
```
## 3b — Map clicks to variables
`DIRECTNESS` — lowercase the chosen label:
| Label | Value |
|------------|-------------|
| Gentle | `gentle` |
| Balanced | `balanced` |
| Direct | `direct` |
| Blunt | `blunt` |
`INITIATIVE` — lowercase:
| Label | Value |
|-------------|--------------|
| Wait | `wait` |
| Nudge | `nudge` |
| Proactive | `proactive` |
`PROFANITY` — lowercase:
| Label | Value |
|--------------|----------------|
| Never | `never` |
| Rare | `rare` |
| Contextual | `contextual` |
## 3c — Forbidden topics (free text, optional)
Emit a regular message (NOT AskUserQuestion):
> Any topics the pet should refuse to engage on?
> - comma-separated list
> - examples: `medical-advice, legal-advice, stock-picks`
> - leave blank and press enter to skip
>
> Reply on one line.
Parse the reply:
- trim whitespace
- split on comma
- trim each entry, drop empties
- lowercase + kebab-case each entry (`Medical Advice` → `medical-advice`)
- deduplicate while preserving order
- cap at 20 entries (if more, keep first 20 and tell the user)
Capture the result as `FORBIDDEN_TOPICS`. Empty reply → `[]` (schema-valid).
## 3d — Consistency check (soft)
If `DIRECTNESS == "blunt"` and `PROFANITY == "never"`, emit a regular
message (informational, no re-ask):
> Note: "blunt" directness with "never" profanity is valid — the pet will
> use strong language-free bluntness. Continuing.
No branch, no AskUserQuestion — this is just a heads-up so the user knows
the combination is deliberate, not a bug.
## Verify-criterion
- `DIRECTNESS` is one of `gentle` / `balanced` / `direct` / `blunt`
- `INITIATIVE` is one of `wait` / `nudge` / `proactive`
- `PROFANITY` is one of `never` / `rare` / `contextual`
- `FORBIDDEN_TOPICS` is a list (possibly empty) of kebab-case strings,
length ≤ 20, no duplicates
## Failure modes (constructive paths)
If the user seems confused by the Directness scale (asks "what does blunt
mean?"):
- (A) give a one-line example for each level, then re-emit the batch
- (B) default to `balanced` (the safest middle), confirm with user
- (C) move on with their best guess and remind them they can re-run
`/pet-init` any time
If the forbidden-topics free text contains something that looks like a
secret (matches the `secrets-guard` detector patterns — `sk-`, `ghp_`,
etc.), STOP:
- do NOT store the reply
- emit: `That looked like a credential token, not a topic. Re-enter topics only — no API keys or passwords.`
- re-ask once; if it repeats, skip forbidden-topics with `[]`

View file

@ -0,0 +1,167 @@
# Phase 4 — Emit
Compose the TOML from Phase 1-3 variables, ensure a keypair exists, write
`~/.claude/pet/<user_id>.toml`, display a summary, suggest next steps.
## 4a — User ID (keygen if needed)
Check whether a keypair already exists:
```bash
kei-pet keygen --status 2>/dev/null
```
Expected outputs:
- `keypair exists, user_id=<short-hash>` → reuse; set `USER_ID` to the hash,
set `KEYGEN_ACTION = "reused existing"`
- `no keypair` OR non-zero exit → create one:
```bash
kei-pet keygen --create
```
Capture the new `user_id` short-hash from stdout. Set `USER_ID` to that
hash, set `KEYGEN_ACTION = "newly created"`.
If `kei-pet keygen --create` fails (non-zero exit, no hash in stdout) →
STOP and emit 3 constructive paths:
- (A) check that the `kei-pet` binary is on `$PATH`; point user to
`install.sh --profile=dev`
- (B) invoke the raw primitive at `_primitives/_rust/kei-pet/target/release/kei-pet keygen --create`
- (C) manually set `USER_ID = "anonymous"` for a one-off local pet; warn
that this pet will not be portable across machines
Never silently fall through without a `USER_ID`.
## 4b — Compose TOML in memory
Build the TOML string exactly matching the schema in
`_primitives/_rust/kei-pet/examples/minimal.toml`. Use these variable
substitutions (all gathered in Phases 1-3 unless noted):
```toml
# Pet manifest for <PET_NAME> (owner <USER_NAME>, user_id <USER_ID>)
# Generated by /pet-init on <ISO8601-UTC-NOW>.
schema = 1
[identity]
pet_name = "<PET_NAME>"
user_name = "<USER_NAME>"
addressing = "<ADDRESSING>"
languages = [<comma-separated-quoted-LANGUAGES>]
[voice]
tone_primary = "<TONE_PRIMARY>"
tone_secondary = [<comma-separated-quoted-TONE_SECONDARY>]
humor_style = "<HUMOR_STYLE>"
humor_frequency = "<HUMOR_FREQUENCY>"
[edge]
profanity = "<PROFANITY>"
profanity_languages = []
directness = "<DIRECTNESS>"
initiative = "<INITIATIVE>"
[forbidden]
topics = [<comma-separated-quoted-FORBIDDEN_TOPICS>]
tone_patterns = []
[meta]
schema_version_written_by = "kei-pet 0.1.0"
created_at = "<ISO8601-UTC-NOW>"
last_tuned = "<ISO8601-UTC-NOW>"
tune_count = 0
```
String escaping rules inside double-quoted TOML values:
- escape `\``\\`
- escape `"``\"`
- reject control chars other than TAB (should never appear; guard)
Timestamps: emit current UTC time in RFC 3339 with `Z` suffix, e.g.
`2026-04-23T12:30:00Z`. `created_at` and `last_tuned` are equal on first
init.
## 4c — Write the file
Target path: `~/.claude/pet/<USER_ID>.toml` (absolute: expand `~` to `$HOME`).
```bash
mkdir -p "$HOME/.claude/pet"
```
Then write the composed TOML to `$HOME/.claude/pet/<USER_ID>.toml`. Use
the `Write` tool with the absolute path — do NOT echo TOML through shell
(quoting hazard).
If the file already exists at that path (re-run of `/pet-init` for the
same user_id), rename the existing file to
`~/.claude/pet/<USER_ID>.toml.bak-<ISO8601-UTC-NOW>` BEFORE writing the
new one. Tell the user: `Previous pet.toml backed up to <bak-path>`.
## 4d — Validate (best-effort)
Attempt a schema validation via the primitive:
```bash
kei-pet validate --pet "$HOME/.claude/pet/<USER_ID>.toml"
```
- exit 0 → good; proceed
- non-zero → emit the validator's stderr verbatim, keep the file on disk
(the user can re-run `/pet-init` to fix), set `VALIDATION = "FAILED"`
- command not found → set `VALIDATION = "SKIPPED (kei-pet not on PATH)"`
## 4e — Summary table + next steps
Emit a plain-text summary (NOT AskUserQuestion — this is the closing
message):
```
=== PET-INIT REPORT ===
Pet name: <PET_NAME>
Addressed by: <USER_NAME> via <ADDRESSING>
Languages: <LANGUAGES joined with comma-space>
Voice: <TONE_PRIMARY> (+ <TONE_SECONDARY or "no secondary">)
Humor: <HUMOR_STYLE> @ <HUMOR_FREQUENCY>
Edge: <DIRECTNESS> / <INITIATIVE> / profanity=<PROFANITY>
Forbidden: <FORBIDDEN_TOPICS joined with comma-space, or "(none)">
File: ~/.claude/pet/<USER_ID>.toml
Keygen: <KEYGEN_ACTION>
Validation: <PASSED | FAILED | SKIPPED>
Next:
/pet-chat
kei-pet render --pet ~/.claude/pet/<USER_ID>.toml
```
## Verify-criterion
- `USER_ID` is a non-empty short-hash OR the documented `"anonymous"`
fallback (only via constructive path C in 4a)
- `~/.claude/pet/<USER_ID>.toml` exists on disk and matches the composed
TOML byte-for-byte
- If a prior file existed, a `.bak-<ts>` backup exists alongside it
- Summary table is emitted with all 10 rows filled (no placeholders left)
- `kei-pet validate` was attempted; its verdict is surfaced
## Failure modes (constructive paths)
If `Write` fails (permission denied, read-only filesystem, disk full):
- (A) fall back to `~/Desktop/<USER_ID>.toml` and tell the user to move
the file manually
- (B) print the full TOML to stdout so the user can paste it anywhere
- (C) invite the user to re-run `/pet-init` after fixing the underlying
disk/permission issue
If `kei-pet validate` reports a schema violation that the wizard could
not anticipate (schema drift between this skill and the binary):
- (A) keep the file; tell the user the specific validator error
- (B) suggest `kei-pet migrate --pet <path>` if the primitive ships one
- (C) open an issue link to the KeiSeiKit repo for schema-drift reports
Never silently succeed while validation is failing.

View file

@ -0,0 +1,181 @@
# Phase 3 (pet-overlay) — Optional pet persona attached to this spawn
> Goal: decide whether this spawn receives a pet persona overlay, and if so
> which pet manifest to attach. The selected `pet.toml` path is stored for
> Phase 4, which passes it to `kei-spawn` as `--pet-manifest <path>` so the
> spawn ceremony bridges the overlay into the composed prompt via
> `kei_pet::compose_prompt_with_pet`.
>
> **Verify criterion:** `PET_MANIFEST_PATH` is either `None` (user declined)
> or an absolute path to a readable, `kei-pet validate`-clean `.toml` file.
This phase is additive to the existing scope/emit flow — run it AFTER
[phase-3-scope.md](phase-3-scope.md) and BEFORE [phase-4-emit.md](phase-4-emit.md).
---
## 3-pet.a — First AskUserQuestion: attach a pet?
Send ONE `AskUserQuestion`:
```json
{
"questions": [
{
"question": "Apply a pet persona to this spawn?",
"header": "Persona",
"multiSelect": false,
"options": [
{
"label": "Yes",
"description": "Attach one pet.toml manifest from ~/.claude/pet/. The overlay prepends the persona voice/edge/forbidden-topics block to the agent's system prompt."
},
{
"label": "No",
"description": "Skip the persona overlay. The spawn uses the base prompt only — identical to pre-pet spawn behaviour."
}
]
}
]
}
```
Store the clicked label as `PET_ATTACH`.
- **No** → set `PET_MANIFEST_PATH = None`, emit confirmation
`Persona: none (base prompt only)` and proceed to Phase 4.
- **Yes** → continue to 3-pet.b.
---
## 3-pet.b — Discover available pets
Run exactly one bash command (no chaining, so errors surface):
```bash
ls -1 ~/.claude/pet/*.toml 2>/dev/null | sort
```
Collect the stdout lines as `DISCOVERED`. Cases:
- **Zero files** — no manifests on disk. Offer three constructive paths:
- (A) Run `/new-pet` to author the first one (recommended path).
- (B) Loop back to 3-pet.a and click **No** to proceed without a pet.
- (C) Abort the spawn — no task.toml written, no ledger row.
Do NOT fabricate a default pet; do NOT fall through silently.
- **One file** — auto-select it, show the path, skip 3-pet.c, proceed to
3-pet.d for validation. Log `Persona: single pet auto-selected: <path>`.
- **Two or more files** — continue to 3-pet.c.
---
## 3-pet.c — Second AskUserQuestion: which pet?
Build one option per discovered `.toml`. The `label` is the bare filename
(no extension, no directory). The `description` is a short preview of the
manifest — `pet_name` + `user_name` + `tone_primary` read out with two
extra bash calls (kept cheap):
```bash
awk -F'"' '/^pet_name/ {print $2}' <path>
awk -F'"' '/^user_name/ {print $2}' <path>
awk -F'"' '/^tone_primary/{print $2}' <path>
```
If any awk fails or returns empty, use the filename alone as the
description — do NOT fabricate fields.
Skeleton:
```json
{
"questions": [
{
"question": "Which pet?",
"header": "Pet",
"multiSelect": false,
"options": [
{
"label": "<basename-1>",
"description": "<pet_name> — companion to <user_name>, tone <tone_primary>"
},
{
"label": "<basename-2>",
"description": "..."
}
]
}
]
}
```
Cap the option count at 10. If the user has >10 pets, include the first 9
alphabetically plus an "Enter path manually" tail option that triggers a
free-text prompt accepting an absolute path; re-validate via 3-pet.d.
Store the resolved absolute path as `PET_MANIFEST_PATH`.
---
## 3-pet.d — Validate the selected manifest
Run exactly one command:
```bash
kei-pet validate "<PET_MANIFEST_PATH>"
```
Fall back to `"$KEI_RUNTIME_BIN_DIR/kei-pet"` on `command not found`, mirroring
the SKILL.md runtime-resolution rule. If both fail, STOP and surface the
three install paths (A build / B export / C install.sh) — do NOT emit the
Agent-tool invocation.
On `kei-pet validate` non-zero exit: print stderr verbatim and loop back
to 3-pet.c (give the user a chance to pick a different pet). On a persistent
fail across two attempts, drop to the NO DOWNGRADE failure paths below.
---
## 3-pet.e — Verify criterion
- `PET_MANIFEST_PATH` is either `None` or an absolute filesystem path.
- When set, the file exists and `kei-pet validate` exits 0.
- No free-text was typed in 3-pet.a or 3-pet.c (only the manual-path tail
case permits one free-text entry).
Emit confirmation:
`Persona locked: <pet_name>@<basename>.toml` or `Persona: none`.
Proceed to Phase 4. The emit phase adds `--pet-manifest <path>` to the
`kei-spawn spawn` invocation when `PET_MANIFEST_PATH` is set. The runtime
uses `kei_pet::compose_prompt_with_pet` to bridge the overlay onto the base
prompt before handing the final string to the Agent tool.
---
## 3-pet.f — Failure paths (NO DOWNGRADE)
- (A) No pets on disk → offer `/new-pet`, NOT "skip silently". The user
clicked **Yes** in 3-pet.a for a reason.
- (B) Selected manifest fails validation twice → show the first two error
lines verbatim, then offer: fix the pet (exit skill), pick a different
pet (loop to 3-pet.c), or fall back to no persona (loop to 3-pet.a).
- (C) `kei-pet` binary missing → do NOT skip the validation step. Surface
the install paths. A spawn with an unvalidated persona is worse than
no spawn at all — the overlay is prepended to the agent prompt and a
malformed manifest propagates there.
---
## Rules (inherit from SKILL.md)
- **Pure-click contract.** At most one free-text entry in this phase, and
only in the manual-path tail of 3-pet.c (10+ pets edge case).
- **NO HALLUCINATION (RULE 0.4).** Never invent `pet_name`, `tone_primary`,
or any preview field — read them from the file or leave the description
as the bare filename.
- **Orchestrator branch first (RULE 0.13).** This phase does not invoke
git, does not write to the project tree. It only reads `~/.claude/pet/*.toml`
and shells out to `kei-pet validate`.
- **Constructor Pattern (RULE ZERO).** This file stays <200 LOC.