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,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
@@ -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.0–1.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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user