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,376 @@
|
||||
//! Per-agent first-user-message rendering.
|
||||
//!
|
||||
//! Mirrors `prompt::context::PromptContext` but for the first user message
|
||||
//! (the prefix that contains `<user_info>`, `<git_status>`, optional
|
||||
//! workspace overview, optional rules / skills / MCP listings).
|
||||
//!
|
||||
//! `UserMessageTemplate` selects the rendering strategy:
|
||||
//! - `Default` -- the legacy Grok Build prefix (built by the shell layer).
|
||||
//! - `Custom` -- caller-supplied template string (MiniJinja, same delimiters
|
||||
//! as the system prompt templates).
|
||||
//!
|
||||
//! The shell layer gathers session-scoped inputs (cwd, vcs status, rule
|
||||
//! files, skill registry, MCP servers) and hands them to
|
||||
//! `UserMessageContext::render`, which dispatches on `template`.
|
||||
use crate::prompt::agents_md::AgentConfigFile;
|
||||
use chrono::NaiveDate;
|
||||
use kigi_tools::bridge::ToolBridge;
|
||||
use kigi_tools::implementations::skills::types::SkillInfo;
|
||||
use kigi_tools::types::skill_discovery_tracker::{XmlRenderMode, format_announcement_xml};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
/// Date format for the `Today's date` field of the user-message preamble
|
||||
/// (e.g. "Friday Apr 24, 2026"). Any format change is observable to the model.
|
||||
pub const USER_MESSAGE_DATE_FORMAT: &str = "%A %b %-d, %Y";
|
||||
/// Per-repo character cap applied to `vcs_status` at render time. The
|
||||
/// `<git_status>` block has no token budget -- this character cap is the only
|
||||
/// size control, and it is applied per repo at render, never at gather, so
|
||||
/// other consumers of the raw status are unaffected.
|
||||
pub const GIT_STATUS_CHARACTER_LIMIT: usize = 10_000;
|
||||
/// Trim, drop-if-empty, and cap a VCS status string for the
|
||||
/// `<git_status>` block.
|
||||
///
|
||||
/// Returns `None` when the trimmed status is empty (so the section is dropped
|
||||
/// and no empty code fence is emitted), otherwise the status capped at
|
||||
/// [`GIT_STATUS_CHARACTER_LIMIT`] -- snapped back to the last newline -- with
|
||||
/// the `... (git status truncated)` marker appended.
|
||||
fn normalize_git_status(status: &str) -> Option<String> {
|
||||
let status = status.trim();
|
||||
if status.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if status.len() <= GIT_STATUS_CHARACTER_LIMIT {
|
||||
return Some(status.to_string());
|
||||
}
|
||||
let mut end = GIT_STATUS_CHARACTER_LIMIT;
|
||||
while !status.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
let mut truncated = &status[..end];
|
||||
if let Some(nl) = truncated.rfind('\n')
|
||||
&& nl > 0
|
||||
{
|
||||
truncated = &truncated[..nl];
|
||||
}
|
||||
Some(format!("{truncated}\n\n... (git status truncated)"))
|
||||
}
|
||||
/// Selects the first-user-message rendering strategy for an agent.
|
||||
///
|
||||
/// Built-in variants decrypt the underlying XOR-obfuscated template on demand
|
||||
/// (obfuscation, not security). Decrypted bytes are zeroed on drop via
|
||||
/// `Zeroizing`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum UserMessageTemplate {
|
||||
/// Legacy Grok Build prefix: `<user_info>` + optional `<git_status>`.
|
||||
/// Built directly by the shell layer; this
|
||||
/// renderer returns `None` for `Default` and the caller falls back to
|
||||
/// its own legacy path.
|
||||
#[default]
|
||||
Default,
|
||||
/// Caller-supplied MiniJinja template string.
|
||||
Custom(String),
|
||||
}
|
||||
impl UserMessageTemplate {
|
||||
pub fn is_cursor(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
/// Backward-compatible deserialization: accepts both the new tagged format
|
||||
/// (`"default"`, `{"custom": "..."}`) and a bare string (treated
|
||||
/// as `Custom`).
|
||||
impl<'de> Deserialize<'de> for UserMessageTemplate {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
struct Visitor;
|
||||
impl<'de> serde::de::Visitor<'de> for Visitor {
|
||||
type Value = UserMessageTemplate;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str(r#""default", "cursor", {"custom": "..."}, or a template string"#)
|
||||
}
|
||||
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
|
||||
match v {
|
||||
"default" => Ok(UserMessageTemplate::Default),
|
||||
other => Ok(UserMessageTemplate::Custom(other.to_owned())),
|
||||
}
|
||||
}
|
||||
fn visit_map<M: serde::de::MapAccess<'de>>(
|
||||
self,
|
||||
mut map: M,
|
||||
) -> Result<Self::Value, M::Error> {
|
||||
match map.next_key::<String>()? {
|
||||
Some(ref k) if k == "custom" => {
|
||||
let val: String = map.next_value()?;
|
||||
Ok(UserMessageTemplate::Custom(val))
|
||||
}
|
||||
Some(other) => Err(serde::de::Error::unknown_field(&other, &["custom"])),
|
||||
None => Err(serde::de::Error::custom(r#"expected {"custom": "..."}"#)),
|
||||
}
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_any(Visitor)
|
||||
}
|
||||
}
|
||||
/// One discovered rule file (AGENTS.md / Claude.md / .kigi/rules/*.md).
|
||||
///
|
||||
/// Wire-compatible with `AgentConfigFile` -- this type exists so the
|
||||
/// `UserMessageContext` does not depend on the AGENTS-discovery internals
|
||||
/// beyond the path/content pair.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RuleEntry {
|
||||
/// Absolute path of the file (used as the rule `name` attribute).
|
||||
pub path: String,
|
||||
/// Raw file body.
|
||||
pub content: String,
|
||||
}
|
||||
impl From<AgentConfigFile> for RuleEntry {
|
||||
fn from(f: AgentConfigFile) -> Self {
|
||||
Self {
|
||||
path: f.file_path,
|
||||
content: f.content,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Connected MCP server metadata.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpServerEntry {
|
||||
pub name: String,
|
||||
/// Free-form usage instructions a user provided when configuring the
|
||||
/// server. Surfaced in the `serverUseInstructions` attribute.
|
||||
pub server_use_instructions: Option<String>,
|
||||
/// Absolute path to the per-server descriptor folder. Surfaced in
|
||||
/// the `folderPath` attribute. Compatible models read tool
|
||||
/// schemas from `<folder_path>/tools/<tool>.json` and resource
|
||||
/// descriptors from `<folder_path>/resources/<resource>.json` before
|
||||
/// calling `CallMcpTool`/`FetchMcpResource`. The session is
|
||||
/// responsible for materializing the descriptor files at this path.
|
||||
pub folder_path: Option<String>,
|
||||
}
|
||||
/// All inputs the templated first user message needs. The shell gathers
|
||||
/// these once at session start (and again on compaction) and hands the
|
||||
/// struct to `render`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserMessageContext {
|
||||
pub template: UserMessageTemplate,
|
||||
/// Display path -- the path the model sees as the workspace.
|
||||
pub workspace_path: PathBuf,
|
||||
/// OS identifier surfaced as the `<user_info>` `OS Version:` value.
|
||||
///
|
||||
/// This is `"<kernel> <release>"` (e.g. `"darwin 24.6.0"`,
|
||||
/// `"linux 6.5.0-..."`) -- not the OS family (`std::env::consts::OS`, e.g.
|
||||
/// `"macos"`). Producers that don't have a uname-style string available may
|
||||
/// pass `std::env::consts::OS` as a fallback; callers that need the full
|
||||
/// string should use `kigi_shell::util::uname::os_kernel_and_release`
|
||||
/// (or equivalent).
|
||||
pub os_family: String,
|
||||
/// `$SHELL` env, basename only -- e.g. "zsh", "bash".
|
||||
pub shell: String,
|
||||
/// Git/jj working-tree root, if any.
|
||||
pub vcs_root: Option<PathBuf>,
|
||||
/// Pre-fetched VCS status output (caller handles timeouts).
|
||||
pub vcs_status: Option<String>,
|
||||
/// Local date captured at session start (or compaction). Formatted
|
||||
/// inside the renderer using [`USER_MESSAGE_DATE_FORMAT`] so the producer
|
||||
/// cannot accidentally drift the model-facing date shape.
|
||||
pub today_local: Option<NaiveDate>,
|
||||
/// Per-workspace terminals folder, surfaced as
|
||||
/// `Terminals folder: <path>` in the `<user_info>` block. The
|
||||
/// shell tool persists each background command's output to a file
|
||||
/// here (`<terminals_folder>/<numeric-shell-id>.txt`); the model uses
|
||||
/// this path to read terminal state via the read tool. Optional --
|
||||
/// when `None`, the line is omitted from the rendered preamble.
|
||||
pub terminals_folder: Option<PathBuf>,
|
||||
/// Workspace-scoped rule files (cwd / repo root / optional workspace user dir).
|
||||
pub workspace_rules: Vec<RuleEntry>,
|
||||
/// User-scoped rule files (~/.kigi/, ~/.claude/).
|
||||
pub user_rules: Vec<RuleEntry>,
|
||||
/// Skill registry snapshot (already deduped). Rendered through the
|
||||
/// shared budget-tier renderer.
|
||||
pub skills: Vec<SkillInfo>,
|
||||
/// Optional listing budget in characters; defaults to the standard
|
||||
/// 1%-of-context heuristic when None.
|
||||
pub skill_listing_budget_chars: Option<usize>,
|
||||
/// Connected MCP servers (alphabetical).
|
||||
pub mcp_servers: Vec<McpServerEntry>,
|
||||
/// Absolute path to the per-workspace MCP descriptor root
|
||||
/// (`~/.kigi/projects/<encoded-cwd>/mcps`). Surfaced in
|
||||
/// the `<mcp_file_system>` instructions so the model knows where
|
||||
/// to discover tool/resource schemas. Required when `mcp_servers` is
|
||||
/// non-empty; ignored otherwise.
|
||||
pub mcps_root: Option<String>,
|
||||
/// Client-facing name of the read tool (resolved from `TemplateRenderer`).
|
||||
/// Used in the skill section's instructional text. Defaults to `"Read"`.
|
||||
pub read_tool_name: String,
|
||||
}
|
||||
/// Typed placeholder bag handed to MiniJinja.
|
||||
///
|
||||
/// Field names here must match `${{ … }}` references in any caller-supplied
|
||||
/// `Custom` template. Keeping this as a typed
|
||||
/// struct -- rather than a free-form `serde_json::Value` -- means the set
|
||||
/// of supported placeholders is greppable from one place, every nested
|
||||
/// shape is enforced by `Serialize`, and rename refactors flow through
|
||||
/// the compiler instead of silently producing empty strings at render
|
||||
/// time.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct UserMessagePlaceholders<'a> {
|
||||
workspace_path: String,
|
||||
os_family: &'a str,
|
||||
shell: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
vcs_root: Option<String>,
|
||||
/// Owned because the renderer caps/normalizes the raw status via
|
||||
/// [`normalize_git_status`] before handing it to MiniJinja.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
vcs_status: Option<String>,
|
||||
/// Pre-formatted using [`USER_MESSAGE_DATE_FORMAT`]; `None` is rendered as
|
||||
/// `null` so the `${% if today_local %}` guard in the template drops
|
||||
/// the line entirely.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
today_local: Option<String>,
|
||||
/// Pre-rendered as a string so the template can `${% if terminals_folder %}`-guard.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
terminals_folder: Option<String>,
|
||||
has_rules: bool,
|
||||
workspace_rules: &'a [RuleEntry],
|
||||
user_rules: &'a [RuleEntry],
|
||||
/// Pre-rendered budgeted `<agent_skill>` XML rows; the template
|
||||
/// just substitutes this verbatim. See `render_skill_listing_xml` for
|
||||
/// why the skill listing is special-cased.
|
||||
skill_listing: String,
|
||||
/// Client-facing name of the read tool, used in the skill section's
|
||||
/// instructional text. Defaults to `"Read"`.
|
||||
read_tool_name: String,
|
||||
mcp_servers: &'a [McpServerEntry],
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
mcps_root: Option<&'a str>,
|
||||
}
|
||||
impl UserMessageContext {
|
||||
/// Build placeholders for MiniJinja rendering.
|
||||
fn placeholders(&self) -> UserMessagePlaceholders<'_> {
|
||||
UserMessagePlaceholders {
|
||||
workspace_path: self.workspace_path.to_string_lossy().into_owned(),
|
||||
os_family: &self.os_family,
|
||||
shell: &self.shell,
|
||||
vcs_root: self
|
||||
.vcs_root
|
||||
.as_ref()
|
||||
.map(|p| p.to_string_lossy().into_owned()),
|
||||
vcs_status: self.vcs_status.as_deref().and_then(normalize_git_status),
|
||||
today_local: self
|
||||
.today_local
|
||||
.map(|d| d.format(USER_MESSAGE_DATE_FORMAT).to_string()),
|
||||
terminals_folder: self
|
||||
.terminals_folder
|
||||
.as_ref()
|
||||
.map(|p| p.to_string_lossy().into_owned()),
|
||||
has_rules: !self.workspace_rules.is_empty() || !self.user_rules.is_empty(),
|
||||
workspace_rules: &self.workspace_rules,
|
||||
user_rules: &self.user_rules,
|
||||
skill_listing: self.render_skill_listing_xml().unwrap_or_default(),
|
||||
read_tool_name: self.read_tool_name.clone(),
|
||||
mcp_servers: &self.mcp_servers,
|
||||
mcps_root: self.mcps_root.as_deref(),
|
||||
}
|
||||
}
|
||||
/// Render the skill list as `<agent_skill>` XML rows.
|
||||
pub fn render_skill_listing_xml(&self) -> Option<String> {
|
||||
if self.skills.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mode = if self.template.is_cursor() {
|
||||
XmlRenderMode::Verbatim
|
||||
} else {
|
||||
XmlRenderMode::Budgeted {
|
||||
budget_chars: self.skill_listing_budget_chars,
|
||||
overflow_indicator: true,
|
||||
}
|
||||
};
|
||||
let mut announced = HashSet::new();
|
||||
format_announcement_xml(&self.skills, &mut announced, None, None, mode)
|
||||
}
|
||||
/// Render the first user message.
|
||||
///
|
||||
/// Returns `None` for `UserMessageTemplate::Default` -- the caller is
|
||||
/// responsible for the legacy prefix path. `Custom` dispatches through
|
||||
/// `ToolBridge::render_prompt` so MiniJinja
|
||||
/// `${{ tools.by_kind.* }}` references resolve correctly.
|
||||
pub async fn render(&self, bridge: &ToolBridge) -> Option<String> {
|
||||
let placeholders = serde_json::to_value(self.placeholders())
|
||||
.expect("UserMessagePlaceholders serializes infallibly");
|
||||
let rendered = match &self.template {
|
||||
UserMessageTemplate::Default => return None,
|
||||
UserMessageTemplate::Custom(s) => bridge.render_prompt(s, &placeholders).await,
|
||||
};
|
||||
rendered.map(|s| s.trim_end().to_string())
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn template_override_deserialize_strings() {
|
||||
let v: UserMessageTemplate = serde_json::from_str(r#""default""#).unwrap();
|
||||
assert_eq!(v, UserMessageTemplate::Default);
|
||||
let v: UserMessageTemplate = serde_json::from_str(r#""my custom""#).unwrap();
|
||||
assert_eq!(v, UserMessageTemplate::Custom("my custom".into()));
|
||||
}
|
||||
#[test]
|
||||
fn template_override_deserialize_custom_map() {
|
||||
let v: UserMessageTemplate =
|
||||
serde_json::from_str(r#"{"custom": "my template body"}"#).unwrap();
|
||||
assert_eq!(v, UserMessageTemplate::Custom("my template body".into()));
|
||||
}
|
||||
#[test]
|
||||
fn template_override_round_trip() {
|
||||
for original in [
|
||||
UserMessageTemplate::Default,
|
||||
UserMessageTemplate::Custom("body".into()),
|
||||
] {
|
||||
let json = serde_json::to_string(&original).unwrap();
|
||||
let loaded: UserMessageTemplate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(original, loaded);
|
||||
}
|
||||
}
|
||||
/// A status under the cap passes through unchanged (trim is a no-op for
|
||||
/// real `git status --short --branch` output, which starts with `##`).
|
||||
#[test]
|
||||
fn normalize_git_status_passthrough_under_limit() {
|
||||
let status = "## main...origin/main\n M src/app.rs";
|
||||
assert_eq!(normalize_git_status(status).as_deref(), Some(status));
|
||||
}
|
||||
/// Empty / whitespace-only status -> `None` so the section is dropped and
|
||||
/// no empty fence is emitted.
|
||||
#[test]
|
||||
fn normalize_git_status_drops_whitespace_only() {
|
||||
assert_eq!(normalize_git_status(""), None);
|
||||
assert_eq!(normalize_git_status(" \n\t "), None);
|
||||
}
|
||||
/// A status over the cap is truncated at the last newline before the limit
|
||||
/// and carries the spec's truncation marker.
|
||||
#[test]
|
||||
fn normalize_git_status_truncates_over_limit() {
|
||||
let mut status = String::from("## main...origin/main\n");
|
||||
while status.len() <= GIT_STATUS_CHARACTER_LIMIT {
|
||||
status.push_str(" M src/some/long/path/to/file.rs\n");
|
||||
}
|
||||
assert!(status.len() > GIT_STATUS_CHARACTER_LIMIT);
|
||||
let out = normalize_git_status(&status).expect("non-empty status");
|
||||
assert!(
|
||||
out.ends_with("\n\n... (git status truncated)"),
|
||||
"missing truncation marker: {out}"
|
||||
);
|
||||
let body = out
|
||||
.strip_suffix("\n\n... (git status truncated)")
|
||||
.expect("marker suffix");
|
||||
assert!(
|
||||
body.len() <= GIT_STATUS_CHARACTER_LIMIT,
|
||||
"body {} exceeds cap {GIT_STATUS_CHARACTER_LIMIT}",
|
||||
body.len()
|
||||
);
|
||||
assert!(status.starts_with(body), "body is not a clean prefix");
|
||||
assert!(!body.ends_with('\n'), "body should be snapped to last line");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user