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,391 @@
|
||||
//! Path-not-found enrichment hints for tool error messages.
|
||||
//!
|
||||
//! Enriches "does not exist" errors from `list_dir`, `read_file`,
|
||||
//! `search_replace`, and `grep` with actionable hints.
|
||||
|
||||
use std::fmt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Ceiling for the single blocking-thread filesystem probe.
|
||||
const HINT_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
/// Max similar-name suggestions
|
||||
const MAX_SIMILAR: usize = 3;
|
||||
/// Reduces noise from single-character names that would match on too many entries
|
||||
const MIN_LEAF_LEN: usize = 2;
|
||||
/// Minimum stem length for reverse substring matching (query contains entry).
|
||||
/// Prevents short stems from over-matching.
|
||||
const MIN_REVERSE_STEM_LEN: usize = 4;
|
||||
|
||||
/// Enrichment hints for a path that was not found.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PathNotFoundHint {
|
||||
/// A corrected path from "dropped repo folder" detection.
|
||||
pub suggestion: Option<PathBuf>,
|
||||
/// Up to [`MAX_SIMILAR`] entries from the parent directory whose names
|
||||
/// are case-insensitive substring matches of the missing leaf.
|
||||
pub similar: Vec<PathBuf>,
|
||||
/// Always-present CWD note for model re-orientation.
|
||||
pub cwd_note: String,
|
||||
}
|
||||
|
||||
impl fmt::Display for PathNotFoundHint {
|
||||
/// Formats as a suffix to append after `"Error: {path} does not exist."`.
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
if let Some(ref s) = self.suggestion {
|
||||
write!(f, " Did you mean {}?", s.display())?;
|
||||
} else if !self.similar.is_empty() {
|
||||
let names: Vec<&str> = self
|
||||
.similar
|
||||
.iter()
|
||||
.filter_map(|p| p.file_name().and_then(|n| n.to_str()))
|
||||
.collect();
|
||||
write!(
|
||||
f,
|
||||
"\nSimilar entries in parent directory: {}",
|
||||
names.join(", ")
|
||||
)?;
|
||||
}
|
||||
|
||||
write!(f, "\n{}", self.cwd_note)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build hints for a path-not-found error.
|
||||
///
|
||||
/// Returns [`PathNotFoundHint`].
|
||||
///
|
||||
/// `path` is the resolved (real) filesystem path that failed.
|
||||
/// `display_cwd` is the model-facing working directory (for the CWD note).
|
||||
#[tracing::instrument(name = "fs.path_not_found_hint", skip_all)]
|
||||
pub async fn path_not_found_hint(path: &Path, cwd: &Path, display_cwd: &Path) -> PathNotFoundHint {
|
||||
let cwd_note = format!(
|
||||
"Note: your current working directory is {}",
|
||||
display_cwd.display()
|
||||
);
|
||||
|
||||
// All filesystem probing runs in a single spawn_blocking.
|
||||
let path_owned = path.to_path_buf();
|
||||
let cwd_owned = cwd.to_path_buf();
|
||||
|
||||
let result = tokio::time::timeout(
|
||||
HINT_TIMEOUT,
|
||||
tokio::task::spawn_blocking(move || collect_hints(&path_owned, &cwd_owned)),
|
||||
)
|
||||
.await;
|
||||
|
||||
let (suggestion, similar) = match result {
|
||||
Ok(Ok(val)) => val,
|
||||
_ => (None, Vec::new()),
|
||||
};
|
||||
|
||||
// Remap resolved worktree path to display space so the model never
|
||||
// sees internal paths (e.g. /worktree/abc-123/...).
|
||||
let suggestion = suggestion.map(|corrected| {
|
||||
corrected
|
||||
.strip_prefix(cwd)
|
||||
.map(|rel| display_cwd.join(rel))
|
||||
.unwrap_or_else(|_| {
|
||||
tracing::warn!(
|
||||
corrected = %corrected.display(),
|
||||
cwd = %cwd.display(),
|
||||
"corrected path not under cwd; falling back to corrected path"
|
||||
);
|
||||
corrected
|
||||
})
|
||||
});
|
||||
|
||||
PathNotFoundHint {
|
||||
suggestion,
|
||||
similar,
|
||||
cwd_note,
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a path-not-found error message.
|
||||
///
|
||||
/// When `hints_enabled` is `false`, returns a bare error string.
|
||||
/// When `true`, appends CWD note, "did you mean?" correction, or similar-name
|
||||
/// suggestions via [`path_not_found_hint`].
|
||||
///
|
||||
/// `display_path` is the model-facing path (for the error message).
|
||||
/// `resolved_path` is the real filesystem path (for hint lookups).
|
||||
pub async fn format_not_found_error(
|
||||
display_path: &Path,
|
||||
resolved_path: &Path,
|
||||
cwd: &Path,
|
||||
display_cwd: &Path,
|
||||
hints_enabled: bool,
|
||||
) -> String {
|
||||
let base = format!("Error: {} does not exist.", display_path.display());
|
||||
if !hints_enabled {
|
||||
return base;
|
||||
}
|
||||
let hint = path_not_found_hint(resolved_path, cwd, display_cwd).await;
|
||||
format!("{base}{hint}")
|
||||
}
|
||||
|
||||
/// Returns `(suggestion, similar)` where `suggestion` is a corrected path from
|
||||
/// "dropped repo folder" detection (raw, not yet remapped to display space) and
|
||||
/// `similar` is a list of substring-matched sibling entries.
|
||||
fn collect_hints(path: &Path, cwd: &Path) -> (Option<PathBuf>, Vec<PathBuf>) {
|
||||
if let Some(corrected) = try_suggest_under_cwd(path, cwd) {
|
||||
return (Some(corrected), Vec::new());
|
||||
}
|
||||
(None, find_similar_entries(path))
|
||||
}
|
||||
|
||||
/// Detect the "dropped repo folder" pattern.
|
||||
///
|
||||
/// If the model asks for `/parent/foo` but cwd is `/parent/repo`, check
|
||||
/// whether `/parent/repo/foo` exists. Only fires when the requested path
|
||||
/// is under cwd's parent but not already under cwd.
|
||||
fn try_suggest_under_cwd(path: &Path, cwd: &Path) -> Option<PathBuf> {
|
||||
if !path.is_absolute() || path.starts_with(cwd) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let cwd_parent = cwd.parent()?;
|
||||
let rel_from_parent = path.strip_prefix(cwd_parent).ok()?;
|
||||
|
||||
// Guard against existing paths outside of repo.
|
||||
if let Some(std::path::Component::Normal(first)) = rel_from_parent.components().next() {
|
||||
let sibling = cwd_parent.join(first);
|
||||
if sibling != cwd && sibling.exists() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let candidate = cwd.join(rel_from_parent);
|
||||
candidate.exists().then_some(candidate)
|
||||
}
|
||||
|
||||
/// Scan the parent directory for entries whose names are case-insensitive
|
||||
/// substring matches of the missing leaf name.
|
||||
fn find_similar_entries(path: &Path) -> Vec<PathBuf> {
|
||||
let parent = match path.parent() {
|
||||
Some(p) => p,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
let base = match path.file_name().and_then(|n| n.to_str()) {
|
||||
Some(b) if b.len() >= MIN_LEAF_LEN => b.to_lowercase(),
|
||||
_ => return Vec::new(),
|
||||
};
|
||||
|
||||
// Strip extension from the query leaf for stem-level comparison.
|
||||
let base_stem = Path::new(&base)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or(&base)
|
||||
.to_lowercase();
|
||||
|
||||
let read_dir = match std::fs::read_dir(parent) {
|
||||
Ok(rd) => rd,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
let mut matches = Vec::new();
|
||||
for entry in read_dir.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_lowercase();
|
||||
if name == base {
|
||||
continue;
|
||||
}
|
||||
|
||||
let name_stem = Path::new(&name)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or(&name)
|
||||
.to_lowercase();
|
||||
|
||||
// Find file matches that are substrings or reverse substrings up to MIN_REVERSE_STEM_LEN
|
||||
let forward = name_stem.contains(&base_stem);
|
||||
let reverse =
|
||||
!forward && name_stem.len() >= MIN_REVERSE_STEM_LEN && base_stem.contains(&name_stem);
|
||||
if forward || reverse {
|
||||
matches.push(entry.path());
|
||||
if matches.len() >= MAX_SIMILAR {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
matches
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
// Unit tests here cover internal invariants (guards, caps, priority,
|
||||
// Display formatting). Broader integration fixtures live in
|
||||
// tests/path_suggestions_production.rs.
|
||||
|
||||
// ── CWD note ──────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn cwd_note_always_present() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cwd = tmp.path();
|
||||
let missing = cwd.join("nonexistent");
|
||||
|
||||
let hint = path_not_found_hint(&missing, cwd, cwd).await;
|
||||
|
||||
assert!(hint.cwd_note.contains(&cwd.display().to_string()));
|
||||
assert!(hint.suggestion.is_none());
|
||||
assert!(hint.similar.is_empty());
|
||||
}
|
||||
|
||||
// ── "dropped repo folder" detection ───────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn dropped_repo_folder_detected() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let repo = tmp.path().join("repo");
|
||||
let target = repo.join("src");
|
||||
std::fs::create_dir_all(&target).unwrap();
|
||||
|
||||
let bad_path = tmp.path().join("src");
|
||||
let hint = path_not_found_hint(&bad_path, &repo, &repo).await;
|
||||
|
||||
assert_eq!(hint.suggestion.as_deref(), Some(target.as_path()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dropped_repo_folder_not_triggered_for_path_under_cwd() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cwd = tmp.path().to_path_buf();
|
||||
let path = cwd.join("some_missing_file.rs");
|
||||
|
||||
let hint = path_not_found_hint(&path, &cwd, &cwd).await;
|
||||
|
||||
assert!(hint.suggestion.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dropped_repo_folder_not_triggered_for_existing_sibling() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let repo = tmp.path().join("repo");
|
||||
let repo_backup = tmp.path().join("repo_backup");
|
||||
std::fs::create_dir_all(&repo).unwrap();
|
||||
std::fs::create_dir_all(&repo_backup).unwrap();
|
||||
std::fs::create_dir_all(repo.join("repo_backup")).unwrap();
|
||||
std::fs::write(repo.join("repo_backup/config"), b"").unwrap();
|
||||
|
||||
let bad_path = repo_backup.join("config");
|
||||
let hint = path_not_found_hint(&bad_path, &repo, &repo).await;
|
||||
|
||||
assert!(
|
||||
hint.suggestion.is_none(),
|
||||
"should not suggest path under cwd when model targets an existing sibling"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn suggestion_takes_priority_over_similar_scan() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let repo = tmp.path().join("repo");
|
||||
std::fs::create_dir_all(repo.join("src")).unwrap();
|
||||
std::fs::create_dir(tmp.path().join("src_old")).unwrap();
|
||||
|
||||
let bad_path = tmp.path().join("src");
|
||||
let hint = path_not_found_hint(&bad_path, &repo, &repo).await;
|
||||
|
||||
assert!(hint.suggestion.is_some());
|
||||
assert!(hint.similar.is_empty());
|
||||
}
|
||||
|
||||
// ── similar-name scan (internal invariants) ───────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn similar_name_multi_match() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("helpers.rs"), b"").unwrap();
|
||||
std::fs::write(tmp.path().join("helper_test.rs"), b"").unwrap();
|
||||
|
||||
let missing = tmp.path().join("helper");
|
||||
let hint = path_not_found_hint(&missing, tmp.path(), tmp.path()).await;
|
||||
|
||||
let names: Vec<String> = hint
|
||||
.similar
|
||||
.iter()
|
||||
.filter_map(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
|
||||
.collect();
|
||||
assert!(names.contains(&"helpers.rs".to_string()), "got: {names:?}");
|
||||
assert!(
|
||||
names.contains(&"helper_test.rs".to_string()),
|
||||
"got: {names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn similar_name_cap_at_max() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
for i in 0..10 {
|
||||
std::fs::write(tmp.path().join(format!("test_{i}.rs")), b"").unwrap();
|
||||
}
|
||||
|
||||
let missing = tmp.path().join("test");
|
||||
let hint = path_not_found_hint(&missing, tmp.path(), tmp.path()).await;
|
||||
|
||||
assert_eq!(hint.similar.len(), MAX_SIMILAR);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn similar_name_short_entry_not_matched() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("he"), b"").unwrap();
|
||||
std::fs::write(tmp.path().join("rs"), b"").unwrap();
|
||||
|
||||
let missing = tmp.path().join("helpers_test");
|
||||
let hint = path_not_found_hint(&missing, tmp.path(), tmp.path()).await;
|
||||
|
||||
assert!(
|
||||
hint.similar.is_empty(),
|
||||
"short entries should not match: got {:?}",
|
||||
hint.similar
|
||||
);
|
||||
}
|
||||
|
||||
// ── Display formatting ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn display_with_suggestion() {
|
||||
let hint = PathNotFoundHint {
|
||||
suggestion: Some(PathBuf::from("/project/repo/src")),
|
||||
similar: Vec::new(),
|
||||
cwd_note: "Note: your current working directory is /project/repo".into(),
|
||||
};
|
||||
let output = hint.to_string();
|
||||
assert!(output.contains("Did you mean /project/repo/src?"));
|
||||
assert!(output.contains("Note: your current working directory is"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_with_similar() {
|
||||
let hint = PathNotFoundHint {
|
||||
suggestion: None,
|
||||
similar: vec![
|
||||
PathBuf::from("/project/helpers.rs"),
|
||||
PathBuf::from("/project/helper_test.rs"),
|
||||
],
|
||||
cwd_note: "Note: your current working directory is /project".into(),
|
||||
};
|
||||
let output = hint.to_string();
|
||||
assert!(output.contains("Similar entries in parent directory:"));
|
||||
assert!(output.contains("helpers.rs"));
|
||||
assert!(output.contains("helper_test.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_empty() {
|
||||
let hint = PathNotFoundHint {
|
||||
suggestion: None,
|
||||
similar: Vec::new(),
|
||||
cwd_note: "Note: your current working directory is /project".into(),
|
||||
};
|
||||
let output = hint.to_string();
|
||||
assert!(!output.contains("Did you mean"));
|
||||
assert!(!output.contains("Similar entries"));
|
||||
assert!(output.contains("Note: your current working directory is /project"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user