feat(kei-buddy): scaffold runtime crate — 11-state onboarding FSM enum

First atom of the kei-buddy phase-1 plan. Pure scaffold — no business
logic; that comes in follow-up commits.

Crate location: _primitives/_rust/kei-buddy/
LOC: 262 across 7 files (largest src/state.rs 85 LOC; all <200).

Contents:
  * src/state.rs — OnboardState enum with 11 variants matching the
    TS state-machine in keisei-marketplace/src/lib/keibuddy/chat-onboard.ts:
    Intro, AskName, AskTone, AskInterests, AskHobbies, TopicSpecifics,
    TopicNowLater, TopicResearch, TopicSources, AskSchedule, Ready.
    serde(rename_all = "snake_case") matches TS naming.
    `next()` is a stub (returns self.clone(); real transitions TBD).
  * src/transition.rs — TransitionInput struct (user_text +
    extracted_fields json::Value). Struct only, no extraction yet.
  * src/error.rs — BuddyError enum via thiserror (StateMachine /
    Memory / Transport). No From impls yet.
  * src/lib.rs — module declarations + re-exports.
  * src/bin/kei-buddy.rs — minimal `kei-buddy serve` clap subcommand,
    currently prints "not yet implemented".
  * Cargo.toml — workspace member, maturity = "concept".
  * README.md — crate-level README, roadmap of 4 follow-up bullets.

Workspace registration: _primitives/_rust/Cargo.toml members list
gains "kei-buddy". Lockfile updated accordingly.

Verify-before-commit (RULE 0.13 §):
  * cargo check --offline -p kei-buddy: PASS
  * cargo test --offline -p kei-buddy --lib: 1 passed / 0 failed
    (state::tests::all_variants_serde_roundtrip)
  * cargo check --workspace --offline: PASS
  * STATUS-TRUTH MARKER from agent: shipped=scaffolding, stubs=1
    (state.rs:50 next() returns self.clone(), expected for scaffold)

Follow-up tasks (tracked in TaskList):
  * Port handleStep transition logic from chat-onboard.ts
  * LLM extract via kei-cortex
  * Memory binding via kei-memory-sqlite
  * Telegram webhook driver (new crate kei-telegram-webhook)
  * kei-tts trait + 4 backends (ElevenLabs / OpenAI / Google / Piper)
  * kei-stt trait + 3 backends (Whisper local / Deepgram / OpenAI API)
This commit is contained in:
Parfii-bot 2026-05-12 13:14:00 +08:00
parent 94e975c92b
commit a2d4bc9206
9 changed files with 304 additions and 29 deletions

View file

@ -713,7 +713,7 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
"tokio", "tokio",
"tokio-rustls 0.26.4", "tokio-rustls 0.26.4",
"tower", "tower 0.5.3",
"tracing", "tracing",
] ]
@ -889,7 +889,7 @@ dependencies = [
"sync_wrapper 1.0.2", "sync_wrapper 1.0.2",
"tokio", "tokio",
"tokio-tungstenite 0.24.0", "tokio-tungstenite 0.24.0",
"tower", "tower 0.5.3",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing", "tracing",
@ -1574,12 +1574,11 @@ dependencies = [
[[package]] [[package]]
name = "dashmap" name = "dashmap"
version = "6.1.0" version = "5.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"crossbeam-utils",
"hashbrown 0.14.5", "hashbrown 0.14.5",
"lock_api", "lock_api",
"once_cell", "once_cell",
@ -3186,6 +3185,18 @@ dependencies = [
"thiserror 1.0.69", "thiserror 1.0.69",
] ]
[[package]]
name = "kei-buddy"
version = "0.1.0"
dependencies = [
"clap",
"serde",
"serde_json",
"thiserror 1.0.69",
"tokio",
"tracing",
]
[[package]] [[package]]
name = "kei-cache" name = "kei-cache"
version = "0.1.0" version = "0.1.0"
@ -3366,7 +3377,7 @@ dependencies = [
"tokio-stream", "tokio-stream",
"tokio-util", "tokio-util",
"toml", "toml",
"tower", "tower 0.4.13",
"tower-http 0.5.2", "tower-http 0.5.2",
"url", "url",
"uuid", "uuid",
@ -3530,7 +3541,7 @@ dependencies = [
"serde_json", "serde_json",
"tempfile", "tempfile",
"tokio", "tokio",
"tower", "tower 0.4.13",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
] ]
@ -3683,22 +3694,6 @@ dependencies = [
"toml", "toml",
] ]
[[package]]
name = "kei-graph-stream"
version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"clap",
"futures",
"reqwest 0.12.28",
"serde",
"serde_json",
"tempfile",
"tokio",
"tokio-tungstenite 0.29.0",
]
[[package]] [[package]]
name = "kei-hibernate" name = "kei-hibernate"
version = "0.1.0" version = "0.1.0"
@ -4500,11 +4495,11 @@ dependencies = [
[[package]] [[package]]
name = "kqueue-sys" name = "kqueue-sys"
version = "1.0.4" version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" checksum = "a7b65860415f949f23fa882e669f2dbd4a0f0eeb1acdd56790b30494afd7da2f"
dependencies = [ dependencies = [
"bitflags 1.3.2", "bitflags 2.11.1",
"libc", "libc",
] ]
@ -5894,7 +5889,7 @@ dependencies = [
"tokio", "tokio",
"tokio-rustls 0.26.4", "tokio-rustls 0.26.4",
"tokio-util", "tokio-util",
"tower", "tower 0.5.3",
"tower-http 0.6.8", "tower-http 0.6.8",
"tower-service", "tower-service",
"url", "url",
@ -7546,6 +7541,23 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "tower"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
dependencies = [
"futures-core",
"futures-util",
"pin-project",
"pin-project-lite",
"tokio",
"tokio-util",
"tower-layer",
"tower-service",
"tracing",
]
[[package]] [[package]]
name = "tower" name = "tower"
version = "0.5.3" version = "0.5.3"
@ -7557,7 +7569,6 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
"sync_wrapper 1.0.2", "sync_wrapper 1.0.2",
"tokio", "tokio",
"tokio-util",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing", "tracing",
@ -7593,7 +7604,7 @@ dependencies = [
"http-body 1.0.1", "http-body 1.0.1",
"iri-string", "iri-string",
"pin-project-lite", "pin-project-lite",
"tower", "tower 0.5.3",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
] ]

View file

@ -179,6 +179,8 @@ members = [
"kei-db-contract", "kei-db-contract",
# Live runtime-graph exporter (registry + ledger → D3 space fragment) # Live runtime-graph exporter (registry + ledger → D3 space fragment)
"kei-graph-export", "kei-graph-export",
# KeiBuddy personal-assistant Telegram bot — onboarding FSM scaffold
"kei-buddy",
] ]
[workspace.package] [workspace.package]

View file

@ -0,0 +1,33 @@
[package]
name = "kei-buddy"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
description = "KeiBuddy personal-assistant Telegram bot — onboarding state-machine + skeleton driver. Concept-level scaffold."
authors.workspace = true
license.workspace = true
[[bin]]
name = "kei-buddy"
path = "src/bin/kei-buddy.rs"
[lib]
name = "kei_buddy"
path = "src/lib.rs"
[dependencies]
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
tracing = "0.1"
clap = { workspace = true, features = ["derive"] }
[features]
# future: pulls in kei-notify-telegram for real Telegram transport
telegram = []
[package.metadata.keisei]
maturity = "concept"
description = "KeiBuddy personal-assistant: onboarding FSM + bot driver scaffold"
authors = ["Denis Parfionovich <parfionovich@keilab.io>"]

View file

@ -0,0 +1,37 @@
# kei-buddy
**Maturity:** concept / scaffold — no business logic yet.
## Purpose
`kei-buddy` is the runtime crate that composes existing KeiSeiKit
primitives (`kei-pet`, `kei-memory-sqlite`, `kei-cortex`,
`kei-notify-telegram`) into a personal-assistant Telegram bot called
KeiBuddy.
On first contact the bot walks the user through an 11-state onboarding
flow: name, tone, interests, hobbies, per-topic decomposition (specifics
→ now-or-later → research preference → source selection), and digest
schedule. After onboarding the bot enters ongoing conversation mode,
drawing on the stored persona and memory.
This crate provides the state-machine enum and skeleton driver. The
onboarding FSM is ported from
`keisei-marketplace/src/lib/keibuddy/chat-onboard.ts`.
## Status
Scaffold only. The `OnboardState` enum and `TransitionInput` struct are
defined. All transition logic is stubbed (`next()` returns `self.clone()`).
The binary entry point prints a placeholder message and exits 0.
## Roadmap
- **State-machine transition logic** — port `handleStep` from
`chat-onboard.ts`; wire per-state LLM-extract calls through kei-cortex.
- **Memory binding** — persist scratchpad and finalised persona via
kei-memory-sqlite; implement `getChatState` / `setChatStep` equivalents.
- **Persona binding** — read persona manifest via `kei-pet`; apply tone
overlay to outgoing replies.
- **Transport binding** — wire kei-notify-telegram for outbound messages;
add a real Telegram webhook server (or kei-gateway adapter) for inbound.

View file

@ -0,0 +1,34 @@
// SPDX-License-Identifier: Apache-2.0
//! kei-buddy binary entry point.
//!
//! Scaffold — the `serve` subcommand is a no-op stub until the
//! Telegram webhook driver and memory layer are wired in.
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(
name = "kei-buddy",
about = "KeiBuddy personal-assistant bot (scaffold)",
version
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Start the Telegram webhook listener (not yet implemented).
Serve,
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
match cli.command {
Command::Serve => {
println!("kei-buddy serve: not yet implemented, scaffold only");
}
}
}

View file

@ -0,0 +1,26 @@
// SPDX-License-Identifier: Apache-2.0
//! Error type for kei-buddy operations.
//!
//! Three categories cover the three integration layers that will be
//! wired in follow-up tasks: state-machine, memory store, transport.
use thiserror::Error;
/// Top-level error type for the KeiBuddy crate.
///
/// Variants will gain `#[from]` impls once the concrete dependencies
/// (kei-memory-sqlite, kei-notify-telegram, kei-cortex) are wired.
#[derive(Debug, Error)]
pub enum BuddyError {
/// Error originating in the onboarding state machine.
#[error("state machine error: {0}")]
StateMachine(String),
/// Error originating in the memory persistence layer.
#[error("memory error: {0}")]
Memory(String),
/// Error originating in the Telegram (or other) transport layer.
#[error("transport error: {0}")]
Transport(String),
}

View file

@ -0,0 +1,23 @@
// SPDX-License-Identifier: Apache-2.0
//! kei-buddy — KeiBuddy personal-assistant Telegram bot scaffold.
//!
//! Concept-level crate. This file declares the public module surface.
//! No business logic lives here; see individual modules.
//!
//! Module layout (Constructor Pattern — one file, one responsibility):
//! * `state` — `OnboardState` enum + `next()` stub
//! * `transition` — `TransitionInput` input struct
//! * `error` — `BuddyError` error type
//!
//! Follow-up tasks will add:
//! * LLM extraction via kei-cortex
//! * Memory persistence via kei-memory-sqlite
//! * Telegram webhook driver (kei-notify-telegram)
pub mod error;
pub mod state;
pub mod transition;
pub use error::BuddyError;
pub use state::OnboardState;
pub use transition::TransitionInput;

View file

@ -0,0 +1,85 @@
// SPDX-License-Identifier: Apache-2.0
//! Onboarding state-machine enum.
//!
//! Ported from `keisei-marketplace/src/lib/keibuddy/chat-onboard.ts`.
//! Each variant corresponds to one `Step` in the TypeScript source.
//! Transitions are stubs; real logic arrives in a follow-up task.
use serde::{Deserialize, Serialize};
use crate::transition::TransitionInput;
/// 11-state onboarding finite-state machine.
///
/// Mirrors the TypeScript `Step` union type exactly:
/// `intro | ask_name | ask_tone | ask_interests | ask_hobbies |
/// topic_specifics | topic_now_later | topic_research |
/// topic_sources | ask_schedule | ready`
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OnboardState {
/// Initial greeting — bot explains itself.
Intro,
/// Collecting user's display name.
AskName,
/// Collecting preferred communication tone.
AskTone,
/// Collecting list of interests.
AskInterests,
/// Collecting list of hobbies.
AskHobbies,
/// Per-topic: "what specifically interests you here?"
TopicSpecifics,
/// Per-topic: "discuss now or save for later?"
TopicNowLater,
/// Per-topic: "want ongoing source monitoring?"
TopicResearch,
/// Per-topic: "here are proposed sources, which to add?"
TopicSources,
/// Collecting digest schedule (morning/evening hours + timezone).
AskSchedule,
/// Onboarding complete; regular conversation mode.
Ready,
}
impl OnboardState {
/// Advance to the next state given user input.
///
/// **Stub** — returns `self.clone()` until transition logic is ported.
/// Real implementation will extract fields via kei-cortex and follow
/// the per-topic queue logic from `chat-onboard.ts::handleStep`.
pub fn next(&self, _input: &TransitionInput) -> Self {
// TODO: port transition logic from chat-onboard.ts::handleStep
self.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Smoke test: every variant round-trips through JSON serialisation.
#[test]
fn all_variants_serde_roundtrip() {
let variants = [
OnboardState::Intro,
OnboardState::AskName,
OnboardState::AskTone,
OnboardState::AskInterests,
OnboardState::AskHobbies,
OnboardState::TopicSpecifics,
OnboardState::TopicNowLater,
OnboardState::TopicResearch,
OnboardState::TopicSources,
OnboardState::AskSchedule,
OnboardState::Ready,
];
for variant in &variants {
let json = serde_json::to_string(variant)
.unwrap_or_else(|e| panic!("serialize {:?}: {e}", variant));
let back: OnboardState = serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("deserialize {:?} from {json:?}: {e}", variant));
assert_eq!(variant, &back, "round-trip failed for {:?}", variant);
}
}
}

View file

@ -0,0 +1,24 @@
// SPDX-License-Identifier: Apache-2.0
//! Input struct for state-machine transitions.
//!
//! `TransitionInput` carries the raw user message and any structured
//! fields that an LLM extractor has already parsed from it.
//! In the scaffold phase the `extracted_fields` value is always `null`.
use serde::{Deserialize, Serialize};
/// Input to `OnboardState::next()`.
///
/// `user_text` is the verbatim Telegram message body.
/// `extracted_fields` will hold the result of an LLM-extract call
/// (e.g. `extractName`, `extractTone`, `extractList` from
/// `chat-onboard-extract.ts`) once kei-cortex integration is wired.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransitionInput {
/// Raw message text from the user, UTF-8, already trimmed.
pub user_text: String,
/// Structured fields extracted by an LLM call.
/// `serde_json::Value::Null` until kei-cortex integration is added.
pub extracted_fields: serde_json::Value,
}