use crate::config::{Config, KumihoConfig, McpServerConfig, McpTransport};
use directories::UserDirs;
use std::collections::HashMap;
pub const KUMIHO_SERVER_NAME: &str = "kumiho-memory";
pub const DEFAULT_MCP_PATH_SUFFIX: &str = ".construct/kumiho/run_kumiho_mcp.py";
pub const KUMIHO_BOOTSTRAP_PROMPT: &str = "\
SESSION-START INSTRUCTION (kumiho-memory — Construct daemon)
=== EVERY TURN AFTER THE FIRST ===
The bootstrap is DONE. On turn 2 and beyond, follow ONLY these rules:
- Do NOT invoke the kumiho-memory skill.
- Do NOT call kumiho_get_revision_by_tag. Identity is already loaded.
- Do NOT greet the user unless they greeted you first. If their \
message is a question or task, answer directly.
- ENGAGE: Call kumiho_memory_engage ONCE when prior context \
is needed to answer correctly — for example, when the user \
references prior work, a past decision, a person, a project, \
asks 'do you remember', or mentions something not in the current \
conversation. Skip engage for greetings, acknowledgements, yes/no \
answers, simple status checks, tool-availability confirmations, \
brief meta chat, and short direct answers. Your query MUST derive \
from the user's current message. Hold the returned source_krefs \
for reflect. IMPORTANT: User memories and conversation history \
live in the CognitiveMemory project — use \
space_paths=['CognitiveMemory'] for memory recall. Do NOT search \
Construct/ for user memories — that space holds agent operational \
data (AgentPool, Teams, Plans).
- NEVER SAY 'I DON'T KNOW' WITHOUT CHECKING MEMORY — If the \
answer is not in the current conversation and you would otherwise \
say you don't know, don't have context, or can't find something, \
you MUST call kumiho_memory_engage first. Only after engage \
returns empty may you tell the user you don't have the information.
- ONE TOOL FOR RECALL — Always use kumiho_memory_engage for \
recall. It returns aggregated results in a single call. Do NOT \
chain low-level tools (fulltext_search, search_items, get_item, \
etc.) to piece together recall — that wastes tokens and time.
- REFLECT: Call kumiho_memory_reflect only for explicit \
'remember this' requests, durable preferences/facts/decisions/\
corrections, meaningful multi-step outcomes worth preserving, or \
compacted session summaries/handoffs. Skip reflect for greetings, \
acknowledgements, yes/no answers, simple status checks, \
tool-availability confirmations, brief meta chat, and short direct \
answers.
- EXPLICIT REMEMBER REQUESTS — When the user says 'remember \
this', 'keep this in mind', 'note that', or similar, you MUST \
capture it via kumiho_memory_reflect. Do NOT rely on built-in \
memory tools — Kumiho MCP tools are the canonical memory store.
- Do NOT narrate memory operations.
- Do NOT repeat content you already showed the user. Refer to \
it briefly (e.g. 'the draft above') instead of reproducing it.
- Do NOT re-ask questions already answered in this conversation.
- Do NOT re-execute tasks already completed.
- If you need user input, ask and STOP. Never simulate the \
user's answer.
=== FIRST MESSAGE ONLY ===
Skip this block on all subsequent messages.
1. Invoke the kumiho-memory:kumiho-memory skill.
2. If the user's first message is a greeting or casual talk (hi, \
hey, good morning, etc.), just greet back — do NOT call \
kumiho_memory_engage. Only engage if their first message is a \
question or task that would benefit from prior context.
3. Never narrate the bootstrap (no 'Memory connected!' or similar).
=== ALWAYS ===
TEMPORAL AWARENESS — When using engage results, compare each \
result's created_at against today's date. Express memory age \
naturally ('earlier today', 'yesterday', 'last Tuesday', 'about \
two weeks ago'). Recent memories take precedence over stale ones \
when they conflict. When capturing memories via reflect, always \
use absolute dates in titles ('on Mar 29', not 'today') — relative \
time becomes meaningless when recalled in a future session.
COMPACTION — On /compact or auto-compression, capture summary via \
kumiho_memory_reflect with type='summary', tags=['compact','session-context'].
SKILL DISCOVERY — Search CognitiveMemory/Skills via engage when \
you need specialised guidance. Cache discovered skills for the session.
=== CONSTRUCT NAMESPACES ===
Construct/ is the operational root. CognitiveMemory/ is the user's \
personal memory — never write agent data there.
- Construct/AgentPool/ — agent templates (keyed by name)
- Construct/Plans/ — task plans with DEPENDS_ON edges
- Construct/Sessions/ — session summaries and handoffs
- Construct/Teams/ — agent team DAGs (REPORTS_TO, SUPPORTS)
- CognitiveMemory/Skills/ — shared skill library (only shared space)
Use space_hint in reflect to route captures to the correct subspace.";
pub const KUMIHO_CHANNEL_BOOTSTRAP_PROMPT: &str = "\
SESSION-START INSTRUCTION (kumiho-memory — Construct channel)
You have access to kumiho-memory MCP for persistent memory.
ENGAGE: Call kumiho_memory_engage ONCE when prior context is needed \
(user references past work, decisions, people, or asks 'do you \
remember' something not in current conversation). Use \
space_paths=['CognitiveMemory']. Skip for greetings, simple \
answers, and casual chat. NEVER say 'I don't know' or 'I don't \
have that context' without calling engage first. Use \
kumiho_memory_engage for all recall — do NOT chain low-level tools.
REFLECT: Call kumiho_memory_reflect only for explicit 'remember \
this' requests, durable preferences, corrections, or significant \
decisions. Use absolute dates in titles ('on Apr 1', not 'today').
Rules:
- Do not call memory tools on every turn — skip for trivial exchanges.
- Do not narrate memory operations.
- Do not repeat content already shown.
- Recent memories take precedence over stale ones.";
pub fn resolve_mcp_path(kumiho_cfg: &KumihoConfig) -> String {
let configured = kumiho_cfg.mcp_path.trim();
if !configured.is_empty() {
return expand_tilde(configured);
}
let home = UserDirs::new()
.map(|u| u.home_dir().to_string_lossy().into_owned())
.unwrap_or_else(|| "~".to_string());
format!("{home}/{DEFAULT_MCP_PATH_SUFFIX}")
}
pub fn kumiho_mcp_server_config(kumiho_cfg: &KumihoConfig) -> McpServerConfig {
let script_path = resolve_mcp_path(kumiho_cfg);
let mut env: HashMap<String, String> = HashMap::new();
env.insert(
"CONSTRUCT_AGENT_ROOT".to_string(),
expand_tilde("~/.construct"),
);
if !kumiho_cfg.space_prefix.trim().is_empty() {
env.insert(
"KUMIHO_SPACE_PREFIX".to_string(),
kumiho_cfg.space_prefix.clone(),
);
}
env.insert(
"KUMIHO_MEMORY_PROJECT".to_string(),
kumiho_cfg.memory_project.clone(),
);
env.insert(
"KUMIHO_HARNESS_PROJECT".to_string(),
kumiho_cfg.harness_project.clone(),
);
let auth_token = std::env::var("KUMIHO_SERVICE_TOKEN")
.ok()
.filter(|t| !t.trim().is_empty())
.or_else(|| {
std::env::var("KUMIHO_AUTH_TOKEN")
.ok()
.filter(|t| !t.trim().is_empty())
})
.or_else(|| {
let auth_path = expand_tilde("~/.kumiho/kumiho_authentication.json");
std::fs::read_to_string(&auth_path)
.ok()
.and_then(|content| serde_json::from_str::<serde_json::Value>(&content).ok())
.and_then(|v| {
v.get("control_plane_token")
.and_then(|t| t.as_str().map(String::from))
})
.filter(|t| !t.trim().is_empty())
});
if let Some(token) = auth_token {
env.insert("KUMIHO_AUTH_TOKEN".to_string(), token);
}
if let Ok(url) = std::env::var("KUMIHO_CONTROL_PLANE_URL") {
if !url.trim().is_empty() {
env.insert("KUMIHO_CONTROL_PLANE_URL".to_string(), url);
}
}
env.insert("KUMIHO_AUTO_CONFIGURE".to_string(), "1".to_string());
for var in &[
"KUMIHO_LLM_API_KEY",
"KUMIHO_LLM_PROVIDER",
"KUMIHO_LLM_MODEL",
"KUMIHO_LLM_LIGHT_MODEL",
"KUMIHO_LLM_BASE_URL",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
] {
if let Ok(val) = std::env::var(var) {
if !val.trim().is_empty() {
env.insert(var.to_string(), val);
}
}
}
McpServerConfig {
name: KUMIHO_SERVER_NAME.to_string(),
transport: McpTransport::Stdio,
command: "python3".to_string(),
args: vec![script_path],
env,
url: None,
headers: HashMap::new(),
tool_timeout_secs: None,
}
}
pub fn inject_kumiho(mut config: Config, is_internal: bool) -> Config {
if is_internal {
return config;
}
if !config.kumiho.enabled {
return config;
}
config.mcp.enabled = true;
let already_registered = config
.mcp
.servers
.iter()
.any(|s| s.name == KUMIHO_SERVER_NAME);
if !already_registered {
let kumiho_cfg = config.kumiho.clone();
let mut server = kumiho_mcp_server_config(&kumiho_cfg);
if !server.env.contains_key("KUMIHO_LLM_API_KEY")
&& !server.env.contains_key("OPENAI_API_KEY")
&& !server.env.contains_key("ANTHROPIC_API_KEY")
{
let provider_name = config.default_provider.as_deref().unwrap_or("");
let candidates: Vec<&str> = if provider_name.is_empty() {
vec!["openai", "anthropic"]
} else {
vec![provider_name, "openai", "anthropic"]
};
for candidate in candidates {
if let Some(key) = crate::providers::resolve_provider_credential(candidate, None) {
let kumiho_provider = match candidate {
c if c.contains("anthropic") => "anthropic",
_ => "openai",
};
server.env.insert("KUMIHO_LLM_API_KEY".to_string(), key);
server.env.insert(
"KUMIHO_LLM_PROVIDER".to_string(),
kumiho_provider.to_string(),
);
break;
}
}
}
config.mcp.servers.insert(0, server);
}
config
}
pub fn substitute_project_names(template: &str, config: &Config) -> String {
let mem = &config.kumiho.memory_project;
let har = &config.kumiho.harness_project;
let mut out = template.to_string();
if mem != "CognitiveMemory" {
out = out.replace("CognitiveMemory", mem);
}
if har != "Construct" {
out = out.replace("Construct/", &format!("{har}/"));
}
out
}
pub fn append_kumiho_bootstrap(system_prompt: &mut String, config: &Config, is_internal: bool) {
if is_internal || !config.kumiho.enabled {
return;
}
if system_prompt.contains("SESSION-START INSTRUCTION (kumiho-memory") {
return; }
let prompt = substitute_project_names(KUMIHO_BOOTSTRAP_PROMPT, config);
system_prompt.push_str("\n\n---\n\n");
system_prompt.push_str(&prompt);
}
pub fn append_kumiho_channel_bootstrap(
system_prompt: &mut String,
config: &Config,
is_internal: bool,
) {
if is_internal || !config.kumiho.enabled {
return;
}
if system_prompt.contains("SESSION-START INSTRUCTION (kumiho-memory") {
return;
}
let prompt = substitute_project_names(KUMIHO_CHANNEL_BOOTSTRAP_PROMPT, config);
system_prompt.push_str("\n\n---\n\n");
system_prompt.push_str(&prompt);
}
fn expand_tilde(path: &str) -> String {
let expanded = shellexpand::tilde(path);
let expanded_str = expanded.as_ref();
if expanded_str.starts_with('~') {
if let Some(user_dirs) = UserDirs::new() {
let home = user_dirs.home_dir();
if let Some(rest) = expanded_str.strip_prefix('~') {
return format!(
"{}{}{}",
home.display(),
if rest.starts_with('/') { "" } else { "/" },
rest.trim_start_matches('/')
);
}
}
}
expanded_str.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::KumihoConfig;
#[test]
fn inject_kumiho_adds_server() {
let cfg = Config::default();
assert!(!cfg.mcp.servers.iter().any(|s| s.name == KUMIHO_SERVER_NAME));
let injected = inject_kumiho(cfg, false);
assert!(injected.mcp.enabled);
assert!(
injected
.mcp
.servers
.iter()
.any(|s| s.name == KUMIHO_SERVER_NAME)
);
}
#[test]
fn append_kumiho_bootstrap_adds_prompt() {
let cfg = Config::default();
let mut prompt = "## Identity\n\nYou are Construct.".to_string();
append_kumiho_bootstrap(&mut prompt, &cfg, false);
assert!(prompt.contains("SESSION-START INSTRUCTION (kumiho-memory"));
}
#[test]
fn append_kumiho_bootstrap_is_idempotent() {
let cfg = Config::default();
let mut prompt = String::new();
append_kumiho_bootstrap(&mut prompt, &cfg, false);
let after_first = prompt.len();
append_kumiho_bootstrap(&mut prompt, &cfg, false);
assert_eq!(prompt.len(), after_first);
}
#[test]
fn inject_kumiho_skips_internal_agents() {
let cfg = Config::default();
let original_servers = cfg.mcp.servers.len();
let unchanged = inject_kumiho(cfg, true);
assert_eq!(unchanged.mcp.servers.len(), original_servers);
}
#[test]
fn inject_kumiho_is_idempotent() {
let cfg = Config::default();
let once = inject_kumiho(cfg, false);
let count_after_once = once
.mcp
.servers
.iter()
.filter(|s| s.name == KUMIHO_SERVER_NAME)
.count();
let twice = inject_kumiho(once, false);
let count_after_twice = twice
.mcp
.servers
.iter()
.filter(|s| s.name == KUMIHO_SERVER_NAME)
.count();
assert_eq!(count_after_once, count_after_twice);
}
#[test]
fn inject_kumiho_respects_disabled_flag() {
let mut cfg = Config::default();
cfg.kumiho.enabled = false;
let unchanged = inject_kumiho(cfg, false);
assert!(
!unchanged
.mcp
.servers
.iter()
.any(|s| s.name == KUMIHO_SERVER_NAME)
);
}
#[test]
fn kumiho_mcp_server_config_uses_custom_path() {
let kc = KumihoConfig {
enabled: true,
mcp_path: "/opt/kumiho/run_kumiho_mcp.py".to_string(),
space_prefix: "MyProject".to_string(),
api_url: "http://localhost:8000".to_string(),
memory_project: "CognitiveMemory".to_string(),
harness_project: "Construct".to_string(),
};
let server = kumiho_mcp_server_config(&kc);
assert_eq!(server.command, "python3");
assert_eq!(server.args, vec!["/opt/kumiho/run_kumiho_mcp.py"]);
assert_eq!(
server.env.get("KUMIHO_SPACE_PREFIX").map(|s| s.as_str()),
Some("MyProject")
);
}
#[test]
fn substitute_project_names_with_defaults_is_noop() {
let cfg = Config::default();
let result = substitute_project_names(KUMIHO_BOOTSTRAP_PROMPT, &cfg);
assert_eq!(result, KUMIHO_BOOTSTRAP_PROMPT);
}
#[test]
fn substitute_project_names_replaces_memory_project() {
let mut cfg = Config::default();
cfg.kumiho.memory_project = "MyMemory".to_string();
let result = substitute_project_names(KUMIHO_BOOTSTRAP_PROMPT, &cfg);
assert!(result.contains("MyMemory"));
assert!(!result.contains("CognitiveMemory"));
assert!(result.contains("Construct/"));
}
#[test]
fn substitute_project_names_replaces_harness_project() {
let mut cfg = Config::default();
cfg.kumiho.harness_project = "MyHarness".to_string();
let result = substitute_project_names(KUMIHO_BOOTSTRAP_PROMPT, &cfg);
assert!(result.contains("MyHarness/"));
assert!(!result.contains("Construct/"));
assert!(result.contains("CognitiveMemory"));
}
#[test]
fn substitute_project_names_replaces_both() {
let mut cfg = Config::default();
cfg.kumiho.memory_project = "ProdMemory".to_string();
cfg.kumiho.harness_project = "ProdHarness".to_string();
let result = substitute_project_names(KUMIHO_BOOTSTRAP_PROMPT, &cfg);
assert!(result.contains("ProdMemory"));
assert!(result.contains("ProdHarness/"));
assert!(!result.contains("CognitiveMemory"));
assert!(!result.contains("Construct/"));
}
#[test]
fn substitute_project_names_works_on_channel_prompt() {
let mut cfg = Config::default();
cfg.kumiho.memory_project = "ChannelMem".to_string();
let result = substitute_project_names(KUMIHO_CHANNEL_BOOTSTRAP_PROMPT, &cfg);
assert!(result.contains("ChannelMem"));
assert!(!result.contains("CognitiveMemory"));
}
#[test]
fn append_kumiho_bootstrap_substitutes_custom_projects() {
let mut cfg = Config::default();
cfg.kumiho.memory_project = "CustomMem".to_string();
cfg.kumiho.harness_project = "CustomHarness".to_string();
let mut prompt = String::new();
append_kumiho_bootstrap(&mut prompt, &cfg, false);
assert!(prompt.contains("CustomMem"));
assert!(prompt.contains("CustomHarness/"));
assert!(!prompt.contains("CognitiveMemory"));
assert!(!prompt.contains("Construct/"));
}
}