M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -0,0 +1,369 @@
|
||||
//! `x.ai/feedback`, `x.ai/feedback/dismiss`, `x.ai/btw`, and `x.ai/review/*`
|
||||
//! extension handlers.
|
||||
//!
|
||||
//! - `feedback`/`feedback/dismiss`: persist user ratings/text locally and
|
||||
//! forward to cli-chat-proxy.
|
||||
//! - `btw`: dispatch a side question to the active session via
|
||||
//! `SessionCommand::SideQuestion` and return the answer.
|
||||
//! - `review/comment` and `review/comment/delete`: record inline code review
|
||||
//! events to cloud storage.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use agent_client_protocol as acp;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use super::{ExtResult, parse_params};
|
||||
use crate::agent::MvpAgent;
|
||||
use crate::session::persistence::{LocalFeedbackEntry, UserFeedbackEntry};
|
||||
use crate::session::{
|
||||
ClientFeedbackInput, CommentDeleteRequest, CommentDeleteResponse, CommentRequest,
|
||||
CommentResponse, FeedbackRequestDismiss, FeedbackResponse, SessionCommand,
|
||||
};
|
||||
|
||||
#[tracing::instrument(skip_all, fields(method = %args.method))]
|
||||
pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
||||
match args.method.as_ref() {
|
||||
"x.ai/btw" => {
|
||||
tracing::info!("handling /btw side question");
|
||||
handle_btw(agent, args).await
|
||||
}
|
||||
"x.ai/feedback" | "x.ai/feedback/dismiss" => {
|
||||
tracing::info!("handling user feedback");
|
||||
handle_feedback(agent, args).await
|
||||
}
|
||||
m if m.starts_with("x.ai/review") => {
|
||||
tracing::info!("handling review comment");
|
||||
handle_review(agent, args).await
|
||||
}
|
||||
_ => Err(acp::Error::method_not_found()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle `x.ai/btw` -- a side question that doesn't interrupt the current turn.
|
||||
async fn handle_btw(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct BtwRequest {
|
||||
session_id: String,
|
||||
question: String,
|
||||
}
|
||||
|
||||
let req: BtwRequest = parse_params(args)?;
|
||||
let sid: acp::SessionId = req.session_id.clone().into();
|
||||
let session_handle = {
|
||||
let sessions = agent.sessions.borrow();
|
||||
sessions.get(&sid).cloned()
|
||||
};
|
||||
let Some(session) = session_handle else {
|
||||
return Err(
|
||||
acp::Error::invalid_params().data(format!("session not found: {}", req.session_id))
|
||||
);
|
||||
};
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let _ = session.cmd_tx.send(SessionCommand::SideQuestion {
|
||||
question: req.question,
|
||||
respond_to: tx,
|
||||
});
|
||||
let result = rx
|
||||
.await
|
||||
.map_err(|_| acp::Error::internal_error().data("session failed to respond"))?;
|
||||
match result {
|
||||
Ok(answer) => super::to_ext_response(Ok(serde_json::json!({
|
||||
"answer": answer,
|
||||
}))),
|
||||
Err(e) => Err(acp::Error::internal_error().data(e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_feedback(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
||||
if !agent.cfg.borrow().is_feedback_enabled() {
|
||||
return Err(acp::Error::internal_error().data(
|
||||
"Feedback is disabled. To enable, set KIGI_FEEDBACK_ENABLED=true or \
|
||||
[features] feedback = true in config.toml.",
|
||||
));
|
||||
}
|
||||
|
||||
match args.method.as_ref() {
|
||||
"x.ai/feedback" => {
|
||||
// Parse the input -- try the full ClientFeedbackInput first,
|
||||
// then fall back to the simple FeedbackRequest (from /feedback slash command)
|
||||
// which only has {session_id, feedback_text} and no client_type.
|
||||
let feedback_input: ClientFeedbackInput =
|
||||
match serde_json::from_str::<ClientFeedbackInput>(args.params.get()) {
|
||||
Ok(input) => input,
|
||||
Err(_) => {
|
||||
// Fallback: parse simple FeedbackRequest from /feedback command
|
||||
let simple: crate::session::FeedbackRequest = parse_params(args)?;
|
||||
ClientFeedbackInput {
|
||||
session_id: simple.session_id,
|
||||
client_type:
|
||||
prod_mc_cli_chat_proxy_types::feedback_types::ClientType::Tui,
|
||||
rating_type: None,
|
||||
rating_value: None,
|
||||
feedback_text: Some(simple.feedback_text),
|
||||
feedback_categories: vec![],
|
||||
context_type: None,
|
||||
turn_number: None,
|
||||
request_id: None,
|
||||
client_version: None,
|
||||
metadata: None,
|
||||
terminal_info: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let session_id = acp::SessionId::new(feedback_input.session_id.clone());
|
||||
let session_handle = agent.sessions.borrow().get(&session_id).cloned();
|
||||
|
||||
let (model_id, model_metadata) = if let Some(ref session) = session_handle {
|
||||
let (tx1, rx1) = tokio::sync::oneshot::channel();
|
||||
let _ = session
|
||||
.cmd_tx
|
||||
.send(SessionCommand::GetCurrentModel { responds_to: tx1 });
|
||||
let model_id = rx1.await.ok();
|
||||
|
||||
let model_metadata = session.get_model_metadata().await;
|
||||
|
||||
(model_id, model_metadata)
|
||||
} else {
|
||||
let sampling_config = agent.sampling_config.borrow().clone();
|
||||
(Some(sampling_config.model.clone()), Default::default())
|
||||
};
|
||||
|
||||
let turn_number = feedback_input.turn_number.or_else(|| {
|
||||
agent
|
||||
.session_turn_number(&session_id)
|
||||
.map(|t| t.saturating_sub(1) as i64)
|
||||
});
|
||||
|
||||
let mut submission = feedback_input.to_submission(
|
||||
model_id.clone(),
|
||||
model_metadata.resolved_model_id,
|
||||
model_metadata.model_fingerprint,
|
||||
turn_number,
|
||||
);
|
||||
let turn_number = submission.turn_number;
|
||||
|
||||
if let Some(user_meta) =
|
||||
crate::agent::mvp_agent::parse_json_object_env("KIGI_USER_METADATA")
|
||||
{
|
||||
submission.merge_metadata(user_meta);
|
||||
}
|
||||
|
||||
// Enrich with session context for Slack notifications (best-effort).
|
||||
if let Some(ref session_handle) = session_handle {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let _ = session_handle
|
||||
.cmd_tx
|
||||
.send(SessionCommand::GetFeedbackContext {
|
||||
turn_number,
|
||||
responds_to: tx,
|
||||
});
|
||||
if let Ok(ctx) = rx.await {
|
||||
submission.tool_outcomes = ctx.tool_outcomes;
|
||||
submission.session_cwd = Some(ctx.session_cwd);
|
||||
submission.compaction_count = Some(ctx.compaction_count);
|
||||
submission.context_window_usage = Some(ctx.context_window_usage);
|
||||
submission.context_tokens_used = Some(ctx.context_tokens_used);
|
||||
submission.context_window_tokens = Some(ctx.context_window_tokens);
|
||||
}
|
||||
}
|
||||
|
||||
// Track rating in session signals
|
||||
if let (Some(session_handle), Some(rating_value)) =
|
||||
(&session_handle, feedback_input.rating_value)
|
||||
{
|
||||
use prod_mc_cli_chat_proxy_types::feedback_types::RatingType;
|
||||
let (is_positive, is_negative) = match feedback_input.rating_type {
|
||||
// Thumbs: -1 = down, 0 = neutral, 1 = up
|
||||
Some(RatingType::Thumbs) | None => (rating_value > 0, rating_value < 0),
|
||||
// Stars (1-5): >= 4 positive, <= 2 negative, 3 neutral
|
||||
Some(RatingType::Stars) => (rating_value >= 4, rating_value <= 2),
|
||||
// NPS (0-10): 9-10 promoter, 0-6 detractor, 7-8 passive
|
||||
Some(RatingType::Nps) => (rating_value >= 9, rating_value <= 6),
|
||||
};
|
||||
if is_positive {
|
||||
session_handle.signals_handle.record_positive_rating();
|
||||
} else if is_negative {
|
||||
session_handle.signals_handle.record_negative_rating();
|
||||
}
|
||||
}
|
||||
|
||||
// Log feedback type for debugging
|
||||
if feedback_input.is_solicited() {
|
||||
tracing::info!(
|
||||
session_id = %feedback_input.session_id,
|
||||
request_id = ?feedback_input.request_id(),
|
||||
turn_number = ?turn_number,
|
||||
"Solicited feedback received (response to feedback request)"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
session_id = %feedback_input.session_id,
|
||||
turn_number = ?turn_number,
|
||||
"Spontaneous user feedback received"
|
||||
);
|
||||
}
|
||||
|
||||
let client = agent.feedback_client();
|
||||
if client.is_none() {
|
||||
tracing::warn!(
|
||||
"no feedback client available (missing proxy credentials); feedback saved locally only"
|
||||
);
|
||||
}
|
||||
let outcome = crate::session::feedback_manager::submit_feedback_workflow(
|
||||
&mut submission,
|
||||
client.as_ref(),
|
||||
session_handle.as_ref().map(|h| &h.persistence_tx),
|
||||
feedback_input.is_solicited(),
|
||||
)
|
||||
.await;
|
||||
|
||||
match &outcome {
|
||||
crate::session::feedback_manager::SubmitOutcome::Submitted => {
|
||||
tracing::info!("feedback submitted to proxy successfully");
|
||||
}
|
||||
crate::session::feedback_manager::SubmitOutcome::LocalOnly => {
|
||||
tracing::warn!("feedback saved locally only (no proxy client)");
|
||||
}
|
||||
crate::session::feedback_manager::SubmitOutcome::Failed(e) => {
|
||||
tracing::error!(error = %e, "feedback submission to proxy failed");
|
||||
return Err(acp::Error::internal_error()
|
||||
.data(format!("Feedback submission failed: {e}")));
|
||||
}
|
||||
}
|
||||
|
||||
let value = serde_json::to_value(FeedbackResponse { success: true })
|
||||
.map(|value| serde_json::value::to_raw_value(&value).map(Arc::from))
|
||||
.expect("to work")
|
||||
.expect("to work");
|
||||
Ok(acp::ExtResponse::new(value))
|
||||
}
|
||||
"x.ai/feedback/dismiss" => {
|
||||
let dismiss_input: FeedbackRequestDismiss = parse_params(args)?;
|
||||
|
||||
tracing::info!(
|
||||
session_id = %dismiss_input.session_id,
|
||||
request_id = %dismiss_input.request_id,
|
||||
"Feedback request dismissed by user"
|
||||
);
|
||||
|
||||
// Count dismissals too (else event_type is always "responded" and
|
||||
// response-rate is unknowable).
|
||||
{
|
||||
tracing::info_span!(
|
||||
"feedback.survey",
|
||||
survey_type = "session",
|
||||
event_type = "dismissed",
|
||||
appearance_id = %dismiss_input.request_id,
|
||||
has_feedback_text = false,
|
||||
is_solicited = true,
|
||||
)
|
||||
.in_scope(|| {});
|
||||
}
|
||||
|
||||
// Persist dismiss locally; flushed before storage CopyFile by the persistence actor.
|
||||
{
|
||||
let session_id = acp::SessionId::new(dismiss_input.session_id.clone());
|
||||
if let Some(session_handle) = agent.sessions.borrow().get(&session_id) {
|
||||
session_handle.persist_feedback(LocalFeedbackEntry::UserFeedback(
|
||||
UserFeedbackEntry {
|
||||
submitted_at: chrono::Utc::now(),
|
||||
session_id: dismiss_input.session_id.clone(),
|
||||
turn_number: None,
|
||||
solicited: true,
|
||||
request_id: Some(dismiss_input.request_id.clone()),
|
||||
dismissed: true,
|
||||
submission: None,
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let request_id = dismiss_input.request_id.clone();
|
||||
let client = agent
|
||||
.feedback_client()
|
||||
.ok_or_else(|| acp::Error::internal_error().data("No credentials for feedback"))?;
|
||||
let feedback_base_url = agent.cfg.borrow().endpoints.resolve_feedback_base_url();
|
||||
match client.dismiss_request(&request_id).await {
|
||||
Ok(response) => {
|
||||
tracing::info!(
|
||||
request_id = %response.request_id,
|
||||
status = %response.status,
|
||||
feedback_url = %feedback_base_url,
|
||||
"Feedback request dismissed"
|
||||
);
|
||||
let value = serde_json::to_value(&response)
|
||||
.map(|value| serde_json::value::to_raw_value(&value).map(Arc::from))
|
||||
.expect("to work")
|
||||
.expect("to work");
|
||||
Ok(acp::ExtResponse::new(value))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
request_id = %request_id,
|
||||
feedback_url = %feedback_base_url,
|
||||
"Failed to dismiss feedback request"
|
||||
);
|
||||
Err(acp::Error::internal_error()
|
||||
.data(format!("Failed to dismiss feedback request: {e}")))
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => Err(acp::Error::method_not_found()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record inline code review events.
|
||||
///
|
||||
/// Methods:
|
||||
/// - `x.ai/review/comment`: record a new inline code comment to cloud storage
|
||||
/// - `x.ai/review/comment/delete`: record a tombstone event for a deleted comment
|
||||
async fn handle_review(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
||||
match args.method.as_ref() {
|
||||
"x.ai/review/comment" => {
|
||||
let request: CommentRequest = parse_params(args)?;
|
||||
|
||||
let comment_id = uuid::Uuid::now_v7().to_string();
|
||||
|
||||
tracing::info!(
|
||||
comment_id = %comment_id,
|
||||
session_id = %request.session_id,
|
||||
prompt_index = request.prompt_index,
|
||||
path = %request.citation.path,
|
||||
lines = %format!("{}-{}", request.citation.start_line, request.citation.end_line),
|
||||
"Comment received"
|
||||
);
|
||||
|
||||
let value = serde_json::to_value(CommentResponse {
|
||||
comment_id,
|
||||
recorded: true,
|
||||
})
|
||||
.map(|value| serde_json::value::to_raw_value(&value).map(Arc::from))
|
||||
.expect("to work")
|
||||
.expect("to work");
|
||||
Ok(acp::ExtResponse::new(value))
|
||||
}
|
||||
"x.ai/review/comment/delete" => {
|
||||
let request: CommentDeleteRequest = parse_params(args)?;
|
||||
|
||||
tracing::info!(
|
||||
comment_id = %request.comment_id,
|
||||
session_id = %request.session_id,
|
||||
"Comment delete received"
|
||||
);
|
||||
|
||||
let value = serde_json::to_value(CommentDeleteResponse {
|
||||
comment_id: request.comment_id,
|
||||
deleted: true,
|
||||
})
|
||||
.map(|value| serde_json::value::to_raw_value(&value).map(Arc::from))
|
||||
.expect("to work")
|
||||
.expect("to work");
|
||||
Ok(acp::ExtResponse::new(value))
|
||||
}
|
||||
_ => Err(acp::Error::method_not_found()),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user