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:
parent
6c7569e411
commit
07eb0b83ea
28 changed files with 2483 additions and 0 deletions
3
_primitives/_rust/Cargo.lock
generated
3
_primitives/_rust/Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
47
_primitives/_rust/kei-pet/src/bridge.rs
Normal file
47
_primitives/_rust/kei-pet/src/bridge.rs
Normal 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
|
||||
}
|
||||
171
_primitives/_rust/kei-pet/src/evolution.rs
Normal file
171
_primitives/_rust/kei-pet/src/evolution.rs
Normal 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",
|
||||
}
|
||||
}
|
||||
124
_primitives/_rust/kei-pet/src/fleet.rs
Normal file
124
_primitives/_rust/kei-pet/src/fleet.rs
Normal 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}")
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
152
_primitives/_rust/kei-pet/src/memory.rs
Normal file
152
_primitives/_rust/kei-pet/src/memory.rs
Normal 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
|
||||
}
|
||||
94
_primitives/_rust/kei-pet/src/recall.rs
Normal file
94
_primitives/_rust/kei-pet/src/recall.rs
Normal 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)
|
||||
}
|
||||
159
_primitives/_rust/kei-pet/src/reflect.rs
Normal file
159
_primitives/_rust/kei-pet/src/reflect.rs
Normal 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()));
|
||||
}
|
||||
55
_primitives/_rust/kei-pet/src/templates.rs
Normal file
55
_primitives/_rust/kei-pet/src/templates.rs
Normal 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",
|
||||
),
|
||||
]
|
||||
}
|
||||
36
_primitives/_rust/kei-pet/templates/coach.toml
Normal file
36
_primitives/_rust/kei-pet/templates/coach.toml
Normal 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
|
||||
35
_primitives/_rust/kei-pet/templates/friend.toml
Normal file
35
_primitives/_rust/kei-pet/templates/friend.toml
Normal 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
|
||||
|
|
@ -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
|
||||
37
_primitives/_rust/kei-pet/templates/therapist-companion.toml
Normal file
37
_primitives/_rust/kei-pet/templates/therapist-companion.toml
Normal 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
|
||||
36
_primitives/_rust/kei-pet/templates/tutor.toml
Normal file
36
_primitives/_rust/kei-pet/templates/tutor.toml
Normal 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
|
||||
83
_primitives/_rust/kei-pet/tests/bridge_tests.rs
Normal file
83
_primitives/_rust/kei-pet/tests/bridge_tests.rs
Normal 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}"
|
||||
);
|
||||
}
|
||||
61
_primitives/_rust/kei-pet/tests/evolution_tests.rs
Normal file
61
_primitives/_rust/kei-pet/tests/evolution_tests.rs
Normal 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");
|
||||
}
|
||||
83
_primitives/_rust/kei-pet/tests/fleet_tests.rs
Normal file
83
_primitives/_rust/kei-pet/tests/fleet_tests.rs
Normal 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(_)));
|
||||
}
|
||||
133
_primitives/_rust/kei-pet/tests/memory_tests.rs
Normal file
133
_primitives/_rust/kei-pet/tests/memory_tests.rs
Normal 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);
|
||||
}
|
||||
116
_primitives/_rust/kei-pet/tests/recall_tests.rs
Normal file
116
_primitives/_rust/kei-pet/tests/recall_tests.rs
Normal 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");
|
||||
}
|
||||
163
_primitives/_rust/kei-pet/tests/reflect_tests.rs
Normal file
163
_primitives/_rust/kei-pet/tests/reflect_tests.rs
Normal 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:?}"
|
||||
);
|
||||
}
|
||||
44
_primitives/_rust/kei-pet/tests/templates_tests.rs
Normal file
44
_primitives/_rust/kei-pet/tests/templates_tests.rs
Normal 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
95
skills/pet-init/SKILL.md
Normal 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
|
||||
106
skills/pet-init/phase-1-identity.md
Normal file
106
skills/pet-init/phase-1-identity.md
Normal 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.
|
||||
124
skills/pet-init/phase-2-voice.md
Normal file
124
skills/pet-init/phase-2-voice.md
Normal 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
|
||||
131
skills/pet-init/phase-3-edge.md
Normal file
131
skills/pet-init/phase-3-edge.md
Normal 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 `[]`
|
||||
167
skills/pet-init/phase-4-emit.md
Normal file
167
skills/pet-init/phase-4-emit.md
Normal 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.
|
||||
181
skills/spawn-agent/phase-3-pet-overlay.md
Normal file
181
skills/spawn-agent/phase-3-pet-overlay.md
Normal 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.
|
||||
Loading…
Reference in a new issue