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,442 @@
|
||||
//! Shared utility functions.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub use kigi_config::kigi_home;
|
||||
|
||||
/// Path to `$KIGI_SHARE_DIR/pager.toml`.
|
||||
pub fn pager_toml_path() -> PathBuf {
|
||||
kigi_home().join("pager.toml")
|
||||
}
|
||||
|
||||
/// User-facing label for the user grok directory (``~/.kigi`` or ``$KIGI_SHARE_DIR``).
|
||||
///
|
||||
/// Derived from resolved [`kigi_home()`] vs `kigi_config::default_kigi_home()`,
|
||||
/// not from whether `KIGI_SHARE_DIR` is set in the environment.
|
||||
pub fn display_kigi_home_prefix() -> String {
|
||||
if kigi_home() == kigi_config::default_kigi_home() {
|
||||
"~/.kigi".to_string()
|
||||
} else {
|
||||
"$KIGI_SHARE_DIR".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// User-facing path under [`kigi_home()`], e.g. ``~/.kigi/config.toml``.
|
||||
pub fn display_user_grok_path(relative: impl AsRef<Path>) -> String {
|
||||
let rel = relative.as_ref();
|
||||
let prefix = display_kigi_home_prefix();
|
||||
if rel.as_os_str().is_empty() {
|
||||
return prefix;
|
||||
}
|
||||
format!("{prefix}/{}", rel.display())
|
||||
}
|
||||
|
||||
/// Abbreviate an absolute path for display: prefer [`kigi_home()`], then `$HOME`.
|
||||
pub fn abbreviate_path(path: &str) -> Cow<'_, str> {
|
||||
let path_buf = Path::new(path);
|
||||
let grok = kigi_home();
|
||||
if let Ok(rest) = path_buf.strip_prefix(&grok) {
|
||||
let prefix = display_kigi_home_prefix();
|
||||
if rest.as_os_str().is_empty() {
|
||||
return Cow::Owned(prefix);
|
||||
}
|
||||
return Cow::Owned(format!("{prefix}/{}", rest.display()));
|
||||
}
|
||||
if let Ok(home) = std::env::var("HOME")
|
||||
&& !home.is_empty()
|
||||
&& let Some(rest) = path.strip_prefix(&home)
|
||||
{
|
||||
if rest.is_empty() {
|
||||
return Cow::Borrowed("~");
|
||||
}
|
||||
if rest.starts_with('/') {
|
||||
return Cow::Owned(format!("~{rest}"));
|
||||
}
|
||||
}
|
||||
Cow::Borrowed(path)
|
||||
}
|
||||
|
||||
/// True when `path` is under user [`kigi_home()`] (not project `{cwd}/.kigi`).
|
||||
pub fn is_under_user_kigi_home(path: &Path) -> bool {
|
||||
path.starts_with(kigi_home())
|
||||
}
|
||||
|
||||
/// Format a duration as a compact human-friendly string.
|
||||
///
|
||||
/// Uses consistent rounding for visual stability:
|
||||
/// - Under 10s: `"5.2s"` (one decimal for granularity)
|
||||
/// - 10-59s: `"32s"` (no decimal)
|
||||
/// - 1m-59m: `"2m5s"`
|
||||
/// - 1h+: `"1h2m"`
|
||||
pub fn format_duration(d: Duration) -> String {
|
||||
let total_secs = d.as_secs();
|
||||
if total_secs < 10 {
|
||||
return format!("{:.1}s", d.as_secs_f64());
|
||||
}
|
||||
if total_secs < 60 {
|
||||
return format!("{total_secs}s");
|
||||
}
|
||||
let mins = total_secs / 60;
|
||||
let secs = total_secs % 60;
|
||||
if mins < 60 {
|
||||
return format!("{mins}m{secs}s");
|
||||
}
|
||||
let hours = mins / 60;
|
||||
let remaining_mins = mins % 60;
|
||||
format!("{hours}h{remaining_mins}m")
|
||||
}
|
||||
|
||||
/// Format a duration as a coarse recency string for "time ago" / age
|
||||
/// displays (e.g. dashboard row age column and peek panel prefix).
|
||||
///
|
||||
/// Buckets chosen for the agent dashboard so the column stays compact
|
||||
/// and doesn't distract with second-level churn:
|
||||
/// - < 1 minute: `"just now"`
|
||||
/// - minutes: `"1m"` … `"59m"`
|
||||
/// - hours: `"1h"` … `"23h"`
|
||||
/// - days: `"1d"` … `"29d"`
|
||||
/// - months (≈30d+): `"1mo"` … `"11mo"`
|
||||
/// - years (≈365d+): `"1y"` …
|
||||
pub fn format_time_ago(d: Duration) -> String {
|
||||
let secs = d.as_secs();
|
||||
if secs < 60 {
|
||||
return "just now".to_string();
|
||||
}
|
||||
if secs < 3600 {
|
||||
let mins = secs / 60;
|
||||
return format!("{mins}m");
|
||||
}
|
||||
if secs < 86400 {
|
||||
let hours = secs / 3600;
|
||||
return format!("{hours}h");
|
||||
}
|
||||
let days = secs / 86400;
|
||||
if days < 30 {
|
||||
return format!("{days}d");
|
||||
}
|
||||
if days < 365 {
|
||||
let months = days / 30;
|
||||
return format!("{months}mo");
|
||||
}
|
||||
let years = days / 365;
|
||||
format!("{years}y")
|
||||
}
|
||||
|
||||
/// Convert unix-epoch millis into a wall-clock [`SystemTime`].
|
||||
///
|
||||
/// Used for dashboard recency that originates as a wall-clock timestamp (the
|
||||
/// leader roster's `last_change_unix_ms`). A non-positive value — the
|
||||
/// `#[serde(default)]` `0` sentinel for a missing roster timestamp — falls
|
||||
/// back to "now".
|
||||
pub fn system_time_from_unix_ms(unix_ms: i64) -> SystemTime {
|
||||
if unix_ms <= 0 {
|
||||
return SystemTime::now();
|
||||
}
|
||||
UNIX_EPOCH
|
||||
.checked_add(Duration::from_millis(unix_ms as u64))
|
||||
.unwrap_or_else(SystemTime::now)
|
||||
}
|
||||
|
||||
/// Project a monotonic [`Instant`] onto the wall clock as the [`SystemTime`]
|
||||
/// it corresponds to (`SystemTime::now() - instant.elapsed()`).
|
||||
///
|
||||
/// The dashboard stores row recency as a wall-clock `SystemTime` so on-disk
|
||||
/// roster timestamps (which can predate this process — even the machine's
|
||||
/// boot — and so are unrepresentable as a monotonic `Instant`) sit in the same
|
||||
/// comparable space as local rows. Local rows hold live `Instant` anchors;
|
||||
/// this maps them across. A fixed anchor ages correctly because only `now`
|
||||
/// advances, and the sub-millisecond skew between the two `now()` samples is
|
||||
/// invisible to the minute-granularity [`format_time_ago`] buckets.
|
||||
pub fn system_time_from_instant(instant: Instant) -> SystemTime {
|
||||
SystemTime::now()
|
||||
.checked_sub(instant.elapsed())
|
||||
.unwrap_or_else(SystemTime::now)
|
||||
}
|
||||
|
||||
/// Decode common HTML entities (`&`, `<`, `>`, `"`, `'`)
|
||||
/// that may appear in LLM-generated session summaries.
|
||||
pub fn decode_html_entities(s: &str) -> std::borrow::Cow<'_, str> {
|
||||
if !s.contains('&') {
|
||||
return std::borrow::Cow::Borrowed(s);
|
||||
}
|
||||
let mut out = s.to_string();
|
||||
out = out.replace("&", "&");
|
||||
out = out.replace("<", "<");
|
||||
out = out.replace(">", ">");
|
||||
out = out.replace(""", "\"");
|
||||
out = out.replace("'", "'");
|
||||
out = out.replace("'", "'");
|
||||
out = out.replace("'", "'");
|
||||
std::borrow::Cow::Owned(out)
|
||||
}
|
||||
|
||||
pub fn parse_schedule_interval_secs(human: &str) -> Option<u64> {
|
||||
let s = human.trim_start();
|
||||
if !s.starts_with("every ") {
|
||||
return None;
|
||||
}
|
||||
let rest = s[6..].trim_start();
|
||||
let (num_str, unit) = if let Some(sp) = rest.find(char::is_whitespace) {
|
||||
(&rest[..sp], &rest[sp + 1..])
|
||||
} else if rest.len() >= 2 {
|
||||
let (d, u) = rest.split_at(rest.len() - 1);
|
||||
(d, u)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let n: u64 = num_str.parse().ok()?;
|
||||
let unit = unit.trim();
|
||||
let secs_per = match unit {
|
||||
"s" | "second" | "seconds" => 1,
|
||||
"m" | "minute" | "minutes" => 60,
|
||||
"h" | "hour" | "hours" => 3600,
|
||||
"d" | "day" | "days" => 86400,
|
||||
_ => return None,
|
||||
};
|
||||
Some(n * secs_per)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn subsecond() {
|
||||
assert_eq!(format_duration(Duration::from_millis(500)), "0.5s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn under_ten_seconds() {
|
||||
assert_eq!(format_duration(Duration::from_secs_f64(5.23)), "5.2s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ten_seconds_no_decimal() {
|
||||
assert_eq!(format_duration(Duration::from_secs(10)), "10s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seconds_no_decimal() {
|
||||
assert_eq!(format_duration(Duration::from_secs_f64(12.3)), "12s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thirty_seconds() {
|
||||
assert_eq!(format_duration(Duration::from_secs(30)), "30s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minutes() {
|
||||
assert_eq!(format_duration(Duration::from_secs(125)), "2m5s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hours() {
|
||||
assert_eq!(format_duration(Duration::from_secs(3725)), "1h2m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_ago_just_now() {
|
||||
assert_eq!(format_time_ago(Duration::from_secs(0)), "just now");
|
||||
assert_eq!(format_time_ago(Duration::from_secs(30)), "just now");
|
||||
assert_eq!(format_time_ago(Duration::from_secs(59)), "just now");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_ago_minutes() {
|
||||
assert_eq!(format_time_ago(Duration::from_secs(60)), "1m");
|
||||
assert_eq!(format_time_ago(Duration::from_secs(125)), "2m");
|
||||
assert_eq!(format_time_ago(Duration::from_secs(3599)), "59m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_ago_hours() {
|
||||
assert_eq!(format_time_ago(Duration::from_secs(3600)), "1h");
|
||||
assert_eq!(format_time_ago(Duration::from_secs(7200)), "2h");
|
||||
assert_eq!(format_time_ago(Duration::from_secs(86399)), "23h");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_ago_days() {
|
||||
assert_eq!(format_time_ago(Duration::from_secs(86400)), "1d");
|
||||
assert_eq!(format_time_ago(Duration::from_secs(172800)), "2d");
|
||||
assert_eq!(format_time_ago(Duration::from_secs(2_592_000 - 1)), "29d"); // just under 30d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_ago_months() {
|
||||
assert_eq!(format_time_ago(Duration::from_secs(2_592_000)), "1mo"); // 30d
|
||||
assert_eq!(format_time_ago(Duration::from_secs(5_184_000)), "2mo");
|
||||
// 359d is still 11mo (359/30=11); 360d would be 12mo.
|
||||
assert_eq!(format_time_ago(Duration::from_secs(359 * 86400)), "11mo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_ago_years() {
|
||||
assert_eq!(format_time_ago(Duration::from_secs(31_536_000)), "1y"); // 365d
|
||||
assert_eq!(format_time_ago(Duration::from_secs(63_072_000)), "2y");
|
||||
}
|
||||
|
||||
fn now_unix_ms() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as i64
|
||||
}
|
||||
|
||||
/// A real past timestamp survives the round-trip and renders its true age —
|
||||
/// including ages beyond the machine's uptime, which a monotonic `Instant`
|
||||
/// could not represent (its floor is system boot).
|
||||
#[test]
|
||||
fn system_time_from_unix_ms_renders_real_age() {
|
||||
let two_hours_ago = now_unix_ms() - 2 * 3_600_000;
|
||||
let elapsed = system_time_from_unix_ms(two_hours_ago)
|
||||
.elapsed()
|
||||
.unwrap_or_default();
|
||||
assert_eq!(format_time_ago(elapsed), "2h");
|
||||
|
||||
let forty_five_days_ago = now_unix_ms() - 45 * 86_400_000;
|
||||
let elapsed = system_time_from_unix_ms(forty_five_days_ago)
|
||||
.elapsed()
|
||||
.unwrap_or_default();
|
||||
assert_eq!(format_time_ago(elapsed), "1mo");
|
||||
}
|
||||
|
||||
/// A zero / missing timestamp (the `#[serde(default)]` sentinel) falls back
|
||||
/// to "now" rather than the unix epoch (1970).
|
||||
#[test]
|
||||
fn system_time_from_unix_ms_zero_falls_back_to_now() {
|
||||
let elapsed = system_time_from_unix_ms(0).elapsed().unwrap_or_default();
|
||||
assert!(elapsed.as_secs() < 5, "zero sentinel must fall back to now");
|
||||
}
|
||||
|
||||
/// A future timestamp (clock skew) renders as "just now": `elapsed()` errors
|
||||
/// on a future `SystemTime`, and callers default that to a zero duration.
|
||||
#[test]
|
||||
fn system_time_from_unix_ms_future_renders_just_now() {
|
||||
let future = now_unix_ms() + 10_000_000;
|
||||
let elapsed = system_time_from_unix_ms(future)
|
||||
.elapsed()
|
||||
.unwrap_or_default();
|
||||
assert_eq!(format_time_ago(elapsed), "just now");
|
||||
}
|
||||
|
||||
/// A fixed `Instant` projects to a stable wall-clock moment, so its age
|
||||
/// reflects time-since-anchor (here ~10m) rather than re-anchoring to now.
|
||||
#[test]
|
||||
fn system_time_from_instant_reflects_elapsed() {
|
||||
let ten_min_ago = Instant::now() - Duration::from_secs(600);
|
||||
let elapsed = system_time_from_instant(ten_min_ago)
|
||||
.elapsed()
|
||||
.unwrap_or_default();
|
||||
assert_eq!(format_time_ago(elapsed), "10m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_every_5_minutes() {
|
||||
assert_eq!(parse_schedule_interval_secs("every 5 minutes"), Some(300));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_every_5m_short() {
|
||||
assert_eq!(parse_schedule_interval_secs("every 5m"), Some(300));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_every_10s() {
|
||||
assert_eq!(parse_schedule_interval_secs("every 10s"), Some(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_every_1_hour() {
|
||||
assert_eq!(parse_schedule_interval_secs("every 1 hour"), Some(3600));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_every_1_day() {
|
||||
assert_eq!(parse_schedule_interval_secs("every 1 day"), Some(86400));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_html_entities_no_entities() {
|
||||
let s = "hello world";
|
||||
let out = decode_html_entities(s);
|
||||
assert!(matches!(out, std::borrow::Cow::Borrowed(_)));
|
||||
assert_eq!(out.as_ref(), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_html_entities_amp() {
|
||||
assert_eq!(decode_html_entities("foo & bar").as_ref(), "foo & bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_html_entities_multiple() {
|
||||
assert_eq!(
|
||||
decode_html_entities("1 < 2 && 3 > 2").as_ref(),
|
||||
"1 < 2 && 3 > 2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_html_entities_quotes() {
|
||||
assert_eq!(
|
||||
decode_html_entities(""hello" & 'world'").as_ref(),
|
||||
"\"hello\" & 'world'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_schedule_returns_none() {
|
||||
assert_eq!(parse_schedule_interval_secs("foo bar"), None);
|
||||
assert_eq!(parse_schedule_interval_secs("every foo"), None);
|
||||
assert_eq!(parse_schedule_interval_secs("every 5x"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_kigi_home_prefix_default_install() {
|
||||
if std::env::var("KIGI_SHARE_DIR").is_ok() {
|
||||
return;
|
||||
}
|
||||
assert_eq!(display_kigi_home_prefix(), "~/.kigi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_user_grok_path_joins_relative() {
|
||||
let path = display_user_grok_path("config.toml");
|
||||
assert!(path.ends_with("/config.toml") || path.ends_with("\\config.toml"));
|
||||
assert!(path.contains(".kigi") || path.contains("$KIGI_SHARE_DIR"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abbreviate_path_uses_home_when_under_default_grok() {
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
if home.is_empty() {
|
||||
return;
|
||||
}
|
||||
let full = format!("{home}/.kigi/memory/MEMORY.md");
|
||||
let abbreviated = abbreviate_path(&full);
|
||||
assert!(
|
||||
abbreviated.contains("memory/MEMORY.md"),
|
||||
"got {abbreviated}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn abbreviate_path_empty_home_does_not_fake_tilde() {
|
||||
let prev = std::env::var("HOME").ok();
|
||||
unsafe {
|
||||
std::env::set_var("HOME", "");
|
||||
}
|
||||
assert_eq!(abbreviate_path("/foo").as_ref(), "/foo");
|
||||
|
||||
match prev {
|
||||
Some(home) => unsafe { std::env::set_var("HOME", home) },
|
||||
None => unsafe { std::env::remove_var("HOME") },
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user