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:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
@@ -0,0 +1,22 @@
[package]
license = "Apache-2.0"
name = "kigi-config-types"
version.workspace = true
edition.workspace = true
description = "Leaf configuration value types for the grok CLI, extracted from kigi-shell for dependency inversion."
[dependencies]
agent-client-protocol = { workspace = true }
indexmap = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
strum = { workspace = true }
tracing = { workspace = true }
kigi-config = { workspace = true }
kigi-mcp = { workspace = true }
[features]
default-bazel = []
[lints]
workspace = true
@@ -0,0 +1,164 @@
//! Config-value resolution leaf types and per-model laziness config,
//! extracted from kigi-shell for dependency inversion.
use kigi_config::env_bool;
/// Where a resolved config value came from.
#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display)]
#[strum(serialize_all = "snake_case")]
pub enum ConfigSource {
Requirement,
Cli,
Env,
SystemManagedConfig,
ManagedConfig,
UserConfig,
Config,
Remote,
Default,
}
/// A resolved config value with its source for diagnostics.
#[derive(Debug, Clone)]
pub struct Resolved<T> {
pub value: T,
pub source: ConfigSource,
}
impl<T> Resolved<T> {
pub fn new(value: T, source: ConfigSource) -> Self {
Self { value, source }
}
}
impl<T: std::fmt::Display> std::fmt::Display for Resolved<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} ({})", self.value, self.source)
}
}
/// Resolve a boolean feature flag: requirement > cli > env > config > managed > feature flag > default.
pub struct BoolFlag<'a> {
requirement: Option<bool>,
cli: Option<bool>,
env_var: &'a str,
config: Option<bool>,
managed: Option<bool>,
feature_flag: Option<bool>,
default: bool,
}
impl<'a> BoolFlag<'a> {
pub fn env(env_var: &'a str) -> Self {
Self {
requirement: None,
cli: None,
env_var,
config: None,
managed: None,
feature_flag: None,
default: false,
}
}
pub fn requirement(mut self, v: Option<bool>) -> Self {
self.requirement = v;
self
}
pub fn cli(mut self, v: Option<bool>) -> Self {
self.cli = v;
self
}
pub fn config(mut self, v: Option<bool>) -> Self {
self.config = v;
self
}
pub fn managed(mut self, v: Option<bool>) -> Self {
self.managed = v;
self
}
pub fn feature_flag(mut self, v: Option<bool>) -> Self {
self.feature_flag = v;
self
}
pub fn default(mut self, v: bool) -> Self {
self.default = v;
self
}
pub fn resolve(self) -> Resolved<bool> {
resolve_bool_flag(
self.requirement,
self.cli,
self.env_var,
self.config,
self.managed,
self.feature_flag,
self.default,
)
}
}
fn resolve_bool_flag(
requirement: Option<bool>,
cli_arg: Option<bool>,
env_var: &str,
config_val: Option<bool>,
managed_val: Option<bool>,
feature_flag_val: Option<bool>,
default: bool,
) -> Resolved<bool> {
if let Some(val) = requirement {
return Resolved::new(val, ConfigSource::Requirement);
}
if let Some(val) = cli_arg {
return Resolved::new(val, ConfigSource::Cli);
}
if let Some(val) = env_bool(env_var) {
return Resolved::new(val, ConfigSource::Env);
}
if let Some(val) = config_val {
return Resolved::new(val, ConfigSource::Config);
}
if let Some(val) = managed_val {
return Resolved::new(val, ConfigSource::ManagedConfig);
}
if let Some(val) = feature_flag_val {
return Resolved::new(val, ConfigSource::Remote);
}
Resolved::new(default, ConfigSource::Default)
}
/// Per-model configuration for the Layer-3 LazinessDetector.
///
/// All fields default to the disabled state. Activation is a deliberate
/// two-step opt-in: setting `enabled = true` lets the classifier fire
/// (and emit `LazinessClassifierFired` telemetry), but a nudge is only
/// injected when `max_nudges_per_session > 0` as well. This makes
/// observation-only rollout (classify-but-don't-act) the natural
/// intermediate state.
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct LazinessDetectorPerModelConfig {
/// Master switch. When `false` (the default), the classifier never
/// fires for this model and no per-classification cost is incurred.
#[serde(default)]
pub enabled: bool,
/// Hard cap on `<system-reminder>` nudges injected per session for
/// this model. Default `0` makes `enabled = true` alone an
/// observation-only mode (classifier fires, no nudges).
#[serde(default)]
pub max_nudges_per_session: u32,
/// How long the session must be idle before the classifier runs.
/// `None` defers to the harness default (10 seconds).
#[serde(default)]
pub idle_threshold_ms: Option<u64>,
/// Minimum classifier confidence required to inject a nudge. `None`
/// defers to the harness default (0.7).
#[serde(default)]
pub min_confidence: Option<f32>,
/// When `Some(true)` (or `None` — the default), the classifier sees
/// the assistant's plain-text reasoning as `[assistant reasoning]`
/// lines. `Some(false)` drops them (the pre-2026-05 behavior).
/// `None` defers to the harness default (`LAZINESS_INCLUDE_REASONING`,
/// currently `true`).
#[serde(default)]
pub include_reasoning: Option<bool>,
}
File diff suppressed because it is too large Load Diff
+276
View File
@@ -0,0 +1,276 @@
//! MCP server configuration value types, extracted from kigi-shell
//! (config dependency inversion).
use agent_client_protocol as acp;
use indexmap::IndexMap;
use kigi_mcp::oauth_config::McpOAuthConfig;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
/// serde default helper. Kept module-local rather than shared — the `pool`
/// module keeps its own copy for `PoolConfig`.
fn default_true() -> bool {
true
}
/// Read an MCP OAuth client secret from the named env var. Moved here with
/// `McpServerConfig` (its only caller).
fn resolve_oauth_client_secret(env_var: Option<&String>) -> Option<String> {
let env_var = env_var?;
match std::env::var(env_var) {
Ok(secret) => Some(secret),
Err(_) => {
tracing::warn!(
env_var = env_var.as_str(),
"MCP OAuth client_secret env var is configured but not set in the environment; \
proceeding without a client secret"
);
None
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum McpServerTransportConfig {
Stdio {
command: String,
#[serde(default)]
args: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
env: Option<HashMap<String, String>>,
/// Standard MCP JSON supports `cwd`, but ACP stdio server config does not yet expose it.
#[serde(default, skip_serializing_if = "Option::is_none")]
cwd: Option<String>,
},
StreamableHttp {
url: String,
#[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
transport_type: Option<String>,
/// Name of the environment variable to read and set for `Authorization: Bearer <token>`.
#[serde(default, skip_serializing_if = "Option::is_none")]
bearer_token_env_var: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
headers: Option<HashMap<String, String>>,
/// OAuth client ID for providers that don't support Dynamic Client Registration.
#[serde(default, skip_serializing_if = "Option::is_none")]
oauth_client_id: Option<String>,
/// Name of the env var holding the OAuth client secret (for BYO credentials).
#[serde(default, skip_serializing_if = "Option::is_none")]
oauth_client_secret_env_var: Option<String>,
/// OAuth scopes to request during authorization.
#[serde(default, skip_serializing_if = "Option::is_none")]
oauth_scopes: Option<Vec<String>>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct McpJsonOAuthBlock {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_secret_env_var: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scopes: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub callback_port: Option<u16>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServerConfig {
#[serde(flatten)]
pub transport: McpServerTransportConfig,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oauth: Option<McpJsonOAuthBlock>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub startup_timeout_sec: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_timeout_sec: Option<u64>,
/// Per-tool timeout overrides in seconds: `{ "create_issue" = 120, "search" = 30 }`.
/// Falls back to `tool_timeout_sec` for tools not listed here.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_timeouts: Option<HashMap<String, u64>>,
/// Also keep the raw base64 in tool-result text so agents can forward
/// bytes via path-based tools (`base64 -d > /tmp/x.png && send_file ...`).
/// ~2× tokens per image. Overridden by `_meta.mcpConfig.<server>.exposeImageBase64`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expose_image_base64: Option<bool>,
}
impl McpServerConfig {
pub fn expand_strings(&mut self, sub: &dyn Fn(&str) -> String) {
match &mut self.transport {
McpServerTransportConfig::Stdio {
command,
args,
env,
cwd,
} => {
*command = sub(command);
for arg in args.iter_mut() {
*arg = sub(arg);
}
if let Some(env) = env.as_mut() {
for value in env.values_mut() {
*value = sub(value);
}
}
if let Some(cwd) = cwd.as_mut() {
*cwd = sub(cwd);
}
}
McpServerTransportConfig::StreamableHttp { url, headers, .. } => {
*url = sub(url);
if let Some(headers) = headers.as_mut() {
for value in headers.values_mut() {
*value = sub(value);
}
}
}
}
}
pub fn to_acp_mcp_server(&self, name: impl Into<String>) -> Option<acp::McpServer> {
if !self.enabled {
return None;
}
let name = name.into();
match &self.transport {
McpServerTransportConfig::Stdio {
command,
args,
env,
cwd: _,
} => {
let env_variables: Vec<acp::EnvVariable> = env
.as_ref()
.map(|e| {
e.iter()
.map(|(k, v)| acp::EnvVariable::new(k.clone(), v.clone()))
.collect()
})
.unwrap_or_default();
Some(acp::McpServer::Stdio(
acp::McpServerStdio::new(name, PathBuf::from(command))
.args(args.clone())
.env(env_variables),
))
}
McpServerTransportConfig::StreamableHttp {
url,
transport_type,
bearer_token_env_var,
headers,
..
} => {
let mut http_headers: Vec<acp::HttpHeader> = headers
.as_ref()
.map(|h| {
h.iter()
.map(|(k, v)| acp::HttpHeader::new(k.clone(), v.clone()))
.collect()
})
.unwrap_or_default();
// Add bearer token from environment variable if specified
if let Some(env_var) = bearer_token_env_var {
match std::env::var(env_var) {
Ok(token) => {
http_headers.push(acp::HttpHeader::new(
"Authorization",
format!("Bearer {}", token),
));
}
Err(_) => {
tracing::warn!(
"MCP server '{}': bearer_token_env_var '{}' not set in environment",
name,
env_var
);
}
}
}
let is_sse = transport_type
.as_deref()
.is_some_and(|transport| transport.eq_ignore_ascii_case("sse"))
|| url.ends_with("/sse");
Some(if is_sse {
acp::McpServer::Sse(
acp::McpServerSse::new(name, url.clone()).headers(http_headers),
)
} else {
acp::McpServer::Http(
acp::McpServerHttp::new(name, url.clone()).headers(http_headers),
)
})
}
}
}
/// Extract OAuth configuration for this server, if any OAuth fields are set.
pub fn oauth_config(&self) -> Option<McpOAuthConfig> {
if let McpServerTransportConfig::StreamableHttp {
oauth_client_id,
oauth_client_secret_env_var,
oauth_scopes,
..
} = &self.transport
&& oauth_client_id.is_some()
{
return Some(McpOAuthConfig {
client_id: oauth_client_id.clone(),
client_secret: resolve_oauth_client_secret(oauth_client_secret_env_var.as_ref()),
scopes: oauth_scopes.clone(),
callback_port: None,
});
}
if let Some(block) = &self.oauth
&& block.client_id.is_some()
{
return Some(McpOAuthConfig {
client_id: block.client_id.clone(),
client_secret: resolve_oauth_client_secret(block.client_secret_env_var.as_ref()),
scopes: block.scopes.clone(),
callback_port: block.callback_port,
});
}
None
}
}
/// Configuration for relay session sharing.
/// Set in config.toml under [relay] section.
///
/// Example:
/// ```toml
/// [relay]
/// enabled = true
/// ```
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RelaySyncConfig {
pub enabled: Option<bool>,
}
impl RelaySyncConfig {
/// Check if relay sync is enabled. Env var takes precedence over config.
pub fn is_enabled(&self) -> bool {
if let Ok(env_val) = std::env::var("KIGI_RELAY_SYNC_ENABLED") {
return env_val.eq_ignore_ascii_case("true") || env_val == "1";
}
self.enabled.unwrap_or(false)
}
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct McpConfig {
#[serde(default, rename = "mcpServers")]
pub mcp_servers: IndexMap<String, McpServerConfig>,
}
@@ -0,0 +1,468 @@
//! Memory-system configuration value types, extracted from kigi-shell
//! (config dependency inversion).
//!
//! These are the leaf `[memory.*]` and `[compaction.*]` sub-config structs.
//! The `MemoryConfig` aggregate and its `resolve()` loader stay in
//! `kigi-shell` — `resolve()` depends on `toml` and on shell-internal
//! flag resolution, and is part of shell's public API (cross-crate caller).
use serde::{Deserialize, Serialize};
/// Index and chunking configuration (`[memory.index]`).
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(default)]
pub struct MemoryIndexConfig {
/// Maximum chunk size in characters (approx tokens × 4).
pub max_chunk_chars: usize,
/// Character overlap between consecutive chunks.
pub chunk_overlap_chars: usize,
}
impl Default for MemoryIndexConfig {
fn default() -> Self {
Self {
max_chunk_chars: 1600,
chunk_overlap_chars: 320,
}
}
}
/// Embedding provider configuration (`[memory.embedding]`).
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(default)]
pub struct MemoryEmbeddingConfig {
/// Provider type: `"api"`, `"local"`, or `"auto"`.
pub provider: String,
/// Model name for the embedding API. `None` disables vector embeddings.
pub model: Option<String>,
/// Embedding vector dimensions.
pub dimensions: usize,
}
impl Default for MemoryEmbeddingConfig {
fn default() -> Self {
Self {
provider: "api".to_string(),
model: None,
dimensions: 1024,
}
}
}
/// Hybrid search scoring configuration (`[memory.search]`).
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(default)]
pub struct MemorySearchConfig {
/// Maximum number of search results to return.
pub max_results: usize,
/// Minimum score threshold for inclusion.
pub min_score: f32,
/// Weight for vector similarity in hybrid scoring.
pub vector_weight: f32,
/// Weight for BM25 text similarity in hybrid scoring.
pub text_weight: f32,
/// **Deprecated** — use `temporal_decay` instead.
///
/// Per-day decay factor for recency boosting (0.01.0).
/// When `temporal_decay.enabled` is true, this field is ignored.
/// When `temporal_decay.enabled` is false and this is set, it is
/// converted to an approximate half-life for backward compatibility:
/// `half_life ≈ -1 / log₂(recency_decay)`.
pub recency_decay: f32,
/// Temporal decay configuration for time-aware scoring.
pub temporal_decay: TemporalDecayConfig,
/// MMR diversity re-ranking configuration (opt-in).
pub mmr: MmrConfig,
/// Source-type weight multipliers: all default to 1.0.
pub source_weights: std::collections::HashMap<String, f32>,
}
impl Default for MemorySearchConfig {
fn default() -> Self {
let mut source_weights = std::collections::HashMap::new();
source_weights.insert("workspace".to_string(), 1.0);
source_weights.insert("session".to_string(), 1.0);
source_weights.insert("global".to_string(), 1.0);
Self {
max_results: 6,
min_score: 0.35,
vector_weight: 0.7,
text_weight: 0.3,
recency_decay: DEFAULT_RECENCY_DECAY,
temporal_decay: TemporalDecayConfig::default(),
mmr: MmrConfig::default(),
source_weights,
}
}
}
/// Temporal decay configuration for time-aware search scoring.
///
/// Controls how memory chunk scores decay over time. Chunks from
/// "evergreen" sources (`global`, `workspace`) are exempt from decay
/// since they contain curated long-term knowledge. Only `session`
/// chunks decay, using an exponential half-life formula:
///
/// ```text
/// decayed_score = base_score × e^(-λ × age_days)
/// where λ = ln(2) / half_life_days
/// ```
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(default)]
pub struct TemporalDecayConfig {
/// Whether temporal decay is enabled.
pub enabled: bool,
/// Number of days after which a session chunk's score is halved.
pub half_life_days: f64,
}
impl Default for TemporalDecayConfig {
fn default() -> Self {
Self {
enabled: true,
half_life_days: 7.0,
}
}
}
/// MMR (Maximal Marginal Relevance) diversity re-ranking configuration.
///
/// When enabled, re-ranks search results to penalize redundancy. Uses
/// Jaccard similarity on tokenized snippets to measure inter-result
/// similarity, then greedily selects results that balance relevance
/// with diversity:
///
/// ```text
/// MMR(d) = λ × relevance(d) - (1-λ) × max_similarity(d, selected)
/// ```
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(default)]
pub struct MmrConfig {
/// Whether MMR re-ranking is enabled. Default: false (opt-in).
pub enabled: bool,
/// Trade-off between relevance and diversity.
/// 0.0 = maximum diversity, 1.0 = pure relevance (no re-ranking).
/// Clamped to [0.0, 1.0] at parse time. Default: 0.7.
#[serde(deserialize_with = "deserialize_clamped_unit")]
pub lambda: f64,
}
impl Default for MmrConfig {
fn default() -> Self {
Self {
enabled: false,
lambda: 0.7,
}
}
}
/// Deserialize an `f64` clamped to [0.0, 1.0].
///
/// Used for fields where values outside the unit interval are meaningless
/// (e.g. cosine similarity thresholds, trade-off lambdas).
fn deserialize_clamped_unit<'de, D>(deserializer: D) -> Result<f64, D::Error>
where
D: serde::Deserializer<'de>,
{
let v = f64::deserialize(deserializer)?;
Ok(v.clamp(0.0, 1.0))
}
/// Like [`deserialize_clamped_unit`] but for `Option<f64>` fields.
fn deserialize_clamped_unit_option<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
where
D: serde::Deserializer<'de>,
{
let v: Option<f64> = Option::deserialize(deserializer)?;
Ok(v.map(|x| x.clamp(0.0, 1.0)))
}
/// Default value for the legacy `recency_decay` field.
pub const DEFAULT_RECENCY_DECAY: f32 = 0.95;
impl MemorySearchConfig {
/// Resolve the effective half-life for temporal decay.
///
/// Priority order:
/// 1. `temporal_decay.enabled = true` → use `temporal_decay.half_life_days`
/// 2. `temporal_decay.enabled = false` AND `recency_decay` differs from
/// the default (0.95) → convert the legacy per-day factor to an
/// approximate half-life: `half_life ≈ -1.0 / log₂(recency_decay)`.
/// This preserves behavior for users who only set `recency_decay`.
/// 3. Otherwise → `None` (decay fully disabled).
pub fn effective_half_life_days(&self) -> Option<f64> {
if self.temporal_decay.enabled {
if self.temporal_decay.half_life_days <= 0.0 {
tracing::warn!(
half_life_days = self.temporal_decay.half_life_days,
"temporal_decay.half_life_days must be positive, disabling decay"
);
return None;
}
return Some(self.temporal_decay.half_life_days);
}
// Legacy backward compat: if the user explicitly set recency_decay
// to a non-default value, convert it to an approximate half-life.
if (self.recency_decay - DEFAULT_RECENCY_DECAY).abs() > f32::EPSILON
&& self.recency_decay > 0.0
&& self.recency_decay < 1.0
{
let half_life = -1.0 / (self.recency_decay as f64).log2();
tracing::info!(
recency_decay = self.recency_decay,
converted_half_life_days = half_life,
"converting legacy recency_decay to temporal decay half-life; \
consider migrating to [memory.search.temporal_decay]"
);
return Some(half_life);
}
None
}
}
/// First-turn memory injection configuration (`[memory.initial_injection]`).
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(default)]
pub struct MemoryInitialInjectionConfig {
/// Whether to search memory and inject a reminder on the first turn.
pub enabled: bool,
/// Optional score threshold override for first-turn injection.
/// When `None`, the first-turn search uses the historical default of `0.0`
/// (no threshold filtering).
pub min_score: Option<f32>,
}
impl Default for MemoryInitialInjectionConfig {
fn default() -> Self {
Self {
enabled: true,
min_score: None,
}
}
}
/// Session lifecycle configuration (`[memory.session]`).
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(default)]
pub struct MemorySessionConfig {
/// Whether to auto-save a session summary to memory on session end.
pub save_on_end: bool,
}
impl Default for MemorySessionConfig {
fn default() -> Self {
Self { save_on_end: true }
}
}
/// autoDream consolidation configuration (`[memory.dream]`).
#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
#[serde(default)]
pub struct MemoryDreamConfig {
/// Whether autoDream background consolidation is enabled.
pub enabled: bool,
/// Minimum hours between consolidations.
pub min_hours: u64,
/// Minimum sessions since last consolidation to trigger.
pub min_sessions: u64,
/// Seconds before a stale dream lock is reclaimed.
pub stale_lock_secs: u64,
/// Periodic dream check interval in seconds.
/// `None` = disabled (dream only at session end or via /dream).
/// When set, the session actor checks dream gates on this interval.
pub check_interval_secs: Option<u64>,
}
impl Default for MemoryDreamConfig {
fn default() -> Self {
Self {
enabled: true,
min_hours: 4,
min_sessions: 3,
stale_lock_secs: 3600,
check_interval_secs: None,
}
}
}
/// File watcher configuration for detecting external memory edits (`[memory.watcher]`).
///
/// When enabled, watches `~/.kigi/memory/` for `.md` file changes (create,
/// modify, delete) and syncs the index on the next `memory_search` call:
/// - Created/modified files are reindexed.
/// - Deleted files have their stale chunks removed from the index.
///
/// Events are coalesced in a lock-free `ArcSwap` set; sync runs at most once
/// per search call when dirty files are present and the claim is acquired.
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(default)]
pub struct MemoryWatcherConfig {
/// Whether the file watcher is enabled. Default: true (when memory is enabled).
pub enabled: bool,
/// Seconds after which a reindex claim is considered stale (crashed agent).
/// Default: 60.
pub stale_claim_secs: i64,
}
impl Default for MemoryWatcherConfig {
fn default() -> Self {
Self {
enabled: true,
stale_claim_secs: 60,
}
}
}
/// Garbage collection for orphaned workspace memory directories (`[memory.gc]`).
///
/// On session init, directories under `~/.kigi/memory/` are scanned:
/// - `tmp*` dirs: empty ones removed unconditionally, non-empty ones removed
/// after 7 days.
/// - Other workspaces with no session files: removed after `max_age_days`.
/// - Non-empty non-tmp workspaces: never touched.
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(default)]
pub struct MemoryGcConfig {
pub max_age_days: u64,
}
impl Default for MemoryGcConfig {
fn default() -> Self {
Self { max_age_days: 30 }
}
}
/// Pre-compaction memory flush configuration (`[compaction.memory_flush]`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct MemoryFlushConfig {
/// Whether the flush step is enabled before compaction.
pub enabled: bool,
/// Token headroom before the compact threshold to trigger flush.
pub soft_threshold_tokens: u64,
/// Model to use for the flush turn. `None` = session's primary model.
pub flush_model: Option<String>,
/// Max characters the flush response may write to memory.
pub max_flush_write_chars: usize,
/// Idle timeout in seconds: when no user message is received for this
/// duration, a background flush is triggered automatically.
/// `None` = disabled (flush only before compaction).
#[serde(default)]
pub idle_timeout_secs: Option<u64>,
/// Cosine similarity threshold for semantic dedup of flush content.
/// When `None`, falls back to the compiled-in default (0.92).
/// Clamped to [0.0, 1.0] at parse time.
#[serde(default, deserialize_with = "deserialize_clamped_unit_option")]
pub semantic_dedup_threshold: Option<f64>,
}
impl Default for MemoryFlushConfig {
fn default() -> Self {
Self {
enabled: true,
soft_threshold_tokens: 4000,
flush_model: None,
max_flush_write_chars: 8000,
idle_timeout_secs: None,
semantic_dedup_threshold: None,
}
}
}
/// Tool-result pruning configuration (`[compaction.pruning]`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct PruningConfig {
/// Whether pruning is enabled.
pub enabled: bool,
/// Number of recent turns whose tool results are never pruned.
pub keep_last_n_turns: usize,
/// Character threshold above which old tool results are soft-trimmed.
pub soft_trim_threshold: usize,
/// Characters to keep from the start of a soft-trimmed result.
pub soft_trim_head: usize,
/// Characters to keep from the end of a soft-trimmed result.
pub soft_trim_tail: usize,
/// Turn age after which tool results are hard-cleared (replaced with placeholder).
pub hard_clear_age_turns: usize,
}
impl Default for PruningConfig {
fn default() -> Self {
Self {
enabled: true,
keep_last_n_turns: 3,
soft_trim_threshold: 4000,
soft_trim_head: 1500,
soft_trim_tail: 1500,
hard_clear_age_turns: 10,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sub_config_defaults_match() {
assert_eq!(MemoryIndexConfig::default().max_chunk_chars, 1600);
assert_eq!(MemoryEmbeddingConfig::default().dimensions, 1024);
let s = MemorySearchConfig::default();
assert_eq!(s.max_results, 6);
assert_eq!(s.recency_decay, DEFAULT_RECENCY_DECAY);
assert!(s.temporal_decay.enabled);
assert!(!s.mmr.enabled);
assert!(MemorySessionConfig::default().save_on_end);
assert_eq!(MemoryGcConfig::default().max_age_days, 30);
assert_eq!(PruningConfig::default().keep_last_n_turns, 3);
}
#[test]
fn mmr_lambda_is_clamped_on_deserialize() {
let m: MmrConfig = serde_json::from_str(r#"{"enabled": true, "lambda": 5.0}"#).unwrap();
assert_eq!(m.lambda, 1.0);
let m: MmrConfig = serde_json::from_str(r#"{"enabled": true, "lambda": -3.0}"#).unwrap();
assert_eq!(m.lambda, 0.0);
}
#[test]
fn flush_semantic_dedup_threshold_clamped_option() {
let f: MemoryFlushConfig =
serde_json::from_str(r#"{"semantic_dedup_threshold": 2.0}"#).unwrap();
assert_eq!(f.semantic_dedup_threshold, Some(1.0));
let f: MemoryFlushConfig = serde_json::from_str("{}").unwrap();
assert_eq!(f.semantic_dedup_threshold, None);
}
#[test]
fn effective_half_life_prefers_temporal_decay() {
let mut s = MemorySearchConfig::default();
s.temporal_decay.enabled = true;
s.temporal_decay.half_life_days = 14.0;
assert_eq!(s.effective_half_life_days(), Some(14.0));
}
#[test]
fn effective_half_life_converts_legacy_recency_decay() {
let mut s = MemorySearchConfig::default();
s.temporal_decay.enabled = false;
s.recency_decay = 0.5; // non-default → converted
let hl = s.effective_half_life_days().unwrap();
assert!(
(hl - 1.0).abs() < 1e-9,
"0.5 per-day decay ⇒ ~1 day half-life, got {hl}"
);
}
#[test]
fn effective_half_life_none_when_disabled_and_default_recency() {
let mut s = MemorySearchConfig::default();
s.temporal_decay.enabled = false;
// recency_decay left at default ⇒ no decay
assert_eq!(s.effective_half_life_days(), None);
}
}
@@ -0,0 +1,86 @@
//! Permission-policy config value types, extracted from kigi-shell
//! (config dependency inversion).
use serde::{Deserialize, Serialize};
/// Permission policy configuration loaded from `[permission]` section in config.toml.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct PermissionConfig {
pub rules: Vec<PermissionRule>,
}
/// A single permission rule.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionRule {
pub action: RuleAction,
#[serde(default)]
pub tool: ToolFilter,
pub pattern: Option<String>,
#[serde(default)]
pub pattern_mode: PatternMode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum PatternMode {
#[default]
Glob,
/// Match against URL host rather than full string (from `WebFetch(domain:...)`).
Domain,
}
/// Action to take when rule matches.
///
/// CWE-1188: Default changed from Allow to Deny so that omitting the
/// `action` field in a TOML permission rule does not silently create a
/// catch-all allow rule.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum RuleAction {
Allow,
#[default]
Deny,
Ask,
}
/// Tool filter for permission rules.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ToolFilter {
#[default]
Any,
Bash,
Edit,
Read,
Grep,
Mcp,
WebFetch,
}
/// How the agent handles tool execution permissions.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum PermissionMode {
/// Prompt the user for each tool call (default).
#[default]
Ask,
/// Approve everything without prompting.
AlwaysApprove,
/// LLM transcript classifier reviews non-fast-path tool calls.
Auto,
}
impl PermissionMode {
pub fn is_always_approve(self) -> bool {
matches!(self, Self::AlwaysApprove)
}
pub fn is_auto(self) -> bool {
matches!(self, Self::Auto)
}
pub fn from_yolo(yolo: bool) -> Self {
if yolo { Self::AlwaysApprove } else { Self::Ask }
}
}
@@ -0,0 +1,103 @@
//! Worktree-pool configuration value type, extracted from kigi-shell
//! (config dependency inversion).
use serde::{Deserialize, Serialize};
/// Configuration for the pre-created worktree pool.
///
/// The pool pre-creates linked worktrees in the background so that fork
/// flows can acquire a ready-made worktree instead of creating one from
/// scratch. Set in `config.toml` under `[worktree_pool]`.
///
/// Example:
/// ```toml
/// [worktree_pool]
/// enabled = true
/// pool_size = 2
/// file_count_threshold = 50000
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PoolConfig {
/// Whether the pool is enabled at all.
/// Can be set to false to disable pooling regardless of repo size.
/// Default: true (auto-detect based on file_count_threshold)
#[serde(default = "default_true")]
pub enabled: bool,
/// Number of worktrees to keep ready in the pool.
/// 2 is the minimum useful value when forks need parallel worktrees.
/// Default: 2
#[serde(default = "default_pool_size")]
pub pool_size: usize,
/// Minimum number of tracked files for the pool to activate.
/// Below this threshold, on-demand creation is fast enough.
/// Default: 50_000
#[serde(default = "default_file_count_threshold")]
pub file_count_threshold: usize,
/// Number of threads to use for worktree creation when populating the pool.
/// This can speed up pool population on large repos, but also increases resource usage.
/// Default: 3.
#[serde(default = "default_pool_parallelism")]
pub parallelism: usize,
}
fn default_true() -> bool {
true
}
fn default_pool_parallelism() -> usize {
3
}
fn default_pool_size() -> usize {
2
}
fn default_file_count_threshold() -> usize {
50_000
}
impl Default for PoolConfig {
fn default() -> Self {
Self {
enabled: true,
pool_size: default_pool_size(),
file_count_threshold: default_file_count_threshold(),
parallelism: default_pool_parallelism(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_matches_serde_helpers() {
let c = PoolConfig::default();
assert!(c.enabled);
assert_eq!(c.pool_size, 2);
assert_eq!(c.file_count_threshold, 50_000);
assert_eq!(c.parallelism, 3);
}
#[test]
fn empty_table_applies_all_field_defaults() {
let c: PoolConfig = serde_json::from_str("{}").unwrap();
assert!(c.enabled);
assert_eq!(c.pool_size, 2);
assert_eq!(c.file_count_threshold, 50_000);
assert_eq!(c.parallelism, 3);
}
#[test]
fn partial_override_keeps_other_field_defaults() {
let c: PoolConfig = serde_json::from_str(r#"{"enabled": false, "pool_size": 5}"#).unwrap();
assert!(!c.enabled);
assert_eq!(c.pool_size, 5);
assert_eq!(c.file_count_threshold, 50_000);
assert_eq!(c.parallelism, 3);
}
}