use crate::app;
use crate::config::{AppConfig, ProjectConfig};
use crate::domain::{
ContextBundle, MemoryRecord, OutputFormat, RouteInput, TargetTool, WakeupPacket, WakeupProfile,
};
use crate::enhancement_trace::{PromptOptimizeTrace, write_latest_prompt_optimize_trace};
use crate::lifecycle_store::{LifecycleStore, lifecycle_root_from_config, wakeup_ready_entries};
use crate::output;
use crate::vault::WakeupSnapshot;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub enum MemoryGatewayIntent {
Context,
Wakeup { profile: WakeupProfile },
}
#[derive(Debug, Clone)]
pub struct MemoryGatewayRequest {
pub input: RouteInput,
pub intent: MemoryGatewayIntent,
}
#[derive(Debug, Clone)]
pub struct MemoryGatewayResponse {
pub bundle: ContextBundle,
pub wakeup_packet: Option<WakeupPacket>,
pub used_vault_root: PathBuf,
}
impl MemoryGatewayResponse {
pub fn wakeup_packet(&self) -> Option<&WakeupPacket> {
self.wakeup_packet.as_ref()
}
}
#[derive(Debug, Clone)]
pub struct PromptOptimizeRequest {
pub input: RouteInput,
pub profile: WakeupProfile,
pub provider: Option<String>,
pub session_id: Option<String>,
pub persist_runtime_trace: bool,
}
#[derive(Debug, Clone, serde::Serialize, ts_rs::TS)]
#[ts(export, export_to = "../frontend/src/lib/types/generated/")]
pub struct PromptOptimizeResponse {
pub combined_prompt: String,
pub context_prompt: String,
pub wakeup_prompt: String,
pub packet: WakeupPacket,
pub context_bundle: ContextBundle,
#[ts(type = "string")]
pub used_vault_root: PathBuf,
pub target: TargetTool,
pub profile: WakeupProfile,
pub provider: Option<String>,
pub session_id: Option<String>,
pub runtime_trace: Option<PromptOptimizeTrace>,
}
pub fn load_config(config_path: &Path) -> anyhow::Result<AppConfig> {
app::load(config_path)
}
pub fn context_request(input: RouteInput) -> MemoryGatewayRequest {
MemoryGatewayRequest {
input,
intent: MemoryGatewayIntent::Context,
}
}
pub fn wakeup_request(input: RouteInput, profile: WakeupProfile) -> MemoryGatewayRequest {
MemoryGatewayRequest {
input,
intent: MemoryGatewayIntent::Wakeup { profile },
}
}
pub fn prompt_optimize_request(
input: RouteInput,
profile: WakeupProfile,
provider: Option<String>,
session_id: Option<String>,
persist_runtime_trace: bool,
) -> PromptOptimizeRequest {
PromptOptimizeRequest {
input,
profile,
provider,
session_id,
persist_runtime_trace,
}
}
pub fn execute(
config_path: &Path,
request: MemoryGatewayRequest,
vault_root_override: Option<&Path>,
) -> anyhow::Result<MemoryGatewayResponse> {
let mut config = app::load(config_path)?;
if let Some(vault_root_override) = vault_root_override {
config.vault.root = app::resolve_override_path(vault_root_override, config_path)?;
}
let used_vault_root = config.vault.root.clone();
let lifecycle_root = lifecycle_root_for_config(config_path);
let lifecycle_records = load_lifecycle_records_from_root(&lifecycle_root);
let reference_map = crate::reference_tracker::read(&lifecycle_root);
match request.intent {
MemoryGatewayIntent::Context => {
let bundle = app::build_bundle_with_lifecycle_and_refs(
&config,
request.input,
&lifecycle_records,
Some(&reference_map),
)?;
touch_lifecycle_candidates(&lifecycle_root, &bundle);
Ok(MemoryGatewayResponse {
bundle,
wakeup_packet: None,
used_vault_root,
})
}
MemoryGatewayIntent::Wakeup { profile } => {
let (bundle, packet) = build_wakeup_from_config(
&config,
request.input,
profile,
&lifecycle_records,
&lifecycle_root,
)?;
touch_lifecycle_candidates(&lifecycle_root, &bundle);
Ok(MemoryGatewayResponse {
bundle,
wakeup_packet: Some(packet),
used_vault_root,
})
}
}
}
pub fn execute_prompt_optimize(
config_path: &Path,
request: PromptOptimizeRequest,
vault_root_override: Option<&Path>,
) -> anyhow::Result<PromptOptimizeResponse> {
let mut config = app::load(config_path)?;
if let Some(vault_root_override) = vault_root_override {
config.vault.root = app::resolve_override_path(vault_root_override, config_path)?;
}
let used_vault_root = config.vault.root.clone();
let lifecycle_root = lifecycle_root_for_config(config_path);
let lifecycle_records = load_lifecycle_records_from_root(&lifecycle_root);
let reference_map = crate::reference_tracker::read(&lifecycle_root);
let target = request.input.target;
let context_input = RouteInput {
format: OutputFormat::Prompt,
..request.input.clone()
};
let context_bundle = app::build_bundle_with_lifecycle_and_refs(
&config,
context_input,
&lifecycle_records,
Some(&reference_map),
)?;
let context_prompt = output::render(
&context_bundle,
config.output.max_chars,
OutputFormat::Prompt,
);
let (_wakeup_bundle, packet) = build_wakeup_from_config(
&config,
request.input,
request.profile,
&lifecycle_records,
&lifecycle_root,
)?;
let wakeup_prompt = output::wakeup::render(&packet, OutputFormat::Prompt);
let combined_prompt = format!("{}\n\n{}", wakeup_prompt.trim(), context_prompt.trim());
touch_lifecycle_candidates(&lifecycle_root, &context_bundle);
let runtime_trace = if request.persist_runtime_trace {
let trace = PromptOptimizeTrace::new(
context_bundle.input.cwd.display().to_string(),
context_bundle.input.task.clone(),
target_label(target),
profile_label(request.profile),
request.provider.clone(),
request.session_id.clone(),
context_bundle.route.debug.matched_project_id.clone(),
context_bundle.route.debug.note_count,
used_vault_root.display().to_string(),
);
write_latest_prompt_optimize_trace(config_path, &trace)?;
Some(trace)
} else {
None
};
Ok(PromptOptimizeResponse {
combined_prompt,
context_prompt,
wakeup_prompt,
packet,
context_bundle,
used_vault_root,
target,
profile: request.profile,
provider: request.provider,
session_id: request.session_id,
runtime_trace,
})
}
fn build_wakeup_from_config(
config: &AppConfig,
input: RouteInput,
profile: WakeupProfile,
lifecycle_records: &[(String, MemoryRecord)],
lifecycle_root: &Path,
) -> anyhow::Result<(ContextBundle, WakeupPacket)> {
let input = RouteInput {
format: crate::domain::OutputFormat::Json,
..input
};
let (wakeup_snapshot, bundle) =
wakeup_bundle_and_snapshot(config, input.clone(), profile, lifecycle_records)?;
let packet = packet_for_profile(
config,
&input,
&wakeup_snapshot,
&bundle,
profile,
lifecycle_root,
);
Ok((bundle, packet))
}
fn wakeup_bundle_and_snapshot(
config: &AppConfig,
input: RouteInput,
profile: WakeupProfile,
lifecycle_records: &[(String, MemoryRecord)],
) -> anyhow::Result<(WakeupSnapshot, ContextBundle)> {
let wakeup_snapshot = resolve_wakeup_snapshot(config, &input.cwd, profile)?;
ensure_wakeup_contract(config, &wakeup_snapshot, profile)?;
let bundle = build_wakeup_bundle(config, &wakeup_snapshot, input, lifecycle_records);
Ok((wakeup_snapshot, bundle))
}
fn build_wakeup_bundle(
config: &AppConfig,
wakeup_snapshot: &WakeupSnapshot,
input: RouteInput,
lifecycle_records: &[(String, MemoryRecord)],
) -> ContextBundle {
let debug = crate::domain::DebugTrace {
matched_project_id: wakeup_snapshot.project_id.clone(),
note_roots: wakeup_snapshot.note_roots.clone(),
scan_roots: wakeup_snapshot.snapshot.scan_roots.clone(),
limits: config.vault.limits.clone(),
note_count: wakeup_snapshot.snapshot.notes.len(),
};
crate::engine::build_context_with_lifecycle(
config,
&wakeup_snapshot.snapshot.notes,
lifecycle_records,
input,
debug,
)
}
fn lifecycle_root_for_config(config_path: &Path) -> PathBuf {
let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
lifecycle_root_from_config(config_dir)
}
fn load_lifecycle_records_from_root(root: &Path) -> Vec<(String, MemoryRecord)> {
if !root.exists() {
return Vec::new();
}
let store = LifecycleStore::new(root);
wakeup_ready_entries(&store)
.unwrap_or_default()
.into_iter()
.map(|entry| (entry.record_id, entry.record))
.collect()
}
fn touch_lifecycle_candidates(lifecycle_root: &Path, bundle: &ContextBundle) {
if bundle.route.lifecycle_candidates.is_empty() {
return;
}
let ids: Vec<&str> = bundle
.route
.lifecycle_candidates
.iter()
.map(|c| c.record_id.as_str())
.collect();
crate::reference_tracker::touch(lifecycle_root, &ids);
}
fn resolve_wakeup_snapshot(
config: &AppConfig,
cwd: &Path,
profile: WakeupProfile,
) -> anyhow::Result<WakeupSnapshot> {
match profile {
WakeupProfile::Project => build_project_wakeup_snapshot(config, cwd),
WakeupProfile::Developer => build_developer_wakeup_snapshot(config, cwd),
}
}
fn build_project_wakeup_snapshot(config: &AppConfig, cwd: &Path) -> anyhow::Result<WakeupSnapshot> {
let project_config = require_project_config(config, cwd)?;
let note_roots = project_config.note_roots.clone();
let snapshot = crate::vault::cached_scan_notes_with_debug(
&config.vault.root,
note_roots.as_slice(),
&config.vault.limits,
)?;
Ok(WakeupSnapshot {
project_id: Some(project_config.id.clone()),
note_roots,
snapshot,
})
}
fn build_developer_wakeup_snapshot(
config: &AppConfig,
cwd: &Path,
) -> anyhow::Result<WakeupSnapshot> {
let matched_project = crate::engine::project_config_for_input(config, cwd);
let note_roots = config.developer.effective_note_roots(
matched_project
.map(|project| project.note_roots.as_slice())
.unwrap_or(&[]),
);
if note_roots.is_empty() {
anyhow::bail!("developer wakeup has no note_roots configured");
}
let snapshot = crate::vault::cached_scan_notes_with_debug(
&config.vault.root,
note_roots.as_slice(),
&config.vault.limits,
)?;
Ok(WakeupSnapshot {
project_id: matched_project.map(|project| project.id.clone()),
note_roots,
snapshot,
})
}
fn ensure_wakeup_contract(
config: &AppConfig,
wakeup_snapshot: &WakeupSnapshot,
profile: WakeupProfile,
) -> anyhow::Result<()> {
if wakeup_snapshot.note_roots.is_empty() {
anyhow::bail!("wakeup profile has no note_roots configured");
}
if matches!(profile, WakeupProfile::Project) && wakeup_snapshot.project_id.is_none() {
anyhow::bail!("project wakeup requires a matched project");
}
if matches!(profile, WakeupProfile::Developer)
&& config.developer.effective_note_roots(&[]).is_empty()
{
anyhow::bail!("developer wakeup requires developer note_roots");
}
Ok(())
}
fn packet_for_profile(
config: &AppConfig,
input: &RouteInput,
wakeup_snapshot: &WakeupSnapshot,
bundle: &ContextBundle,
profile: WakeupProfile,
lifecycle_root: &Path,
) -> WakeupPacket {
match profile {
WakeupProfile::Developer => {
build_developer_packet(config, wakeup_snapshot, bundle, input, lifecycle_root)
}
WakeupProfile::Project => {
build_project_packet(config, wakeup_snapshot, bundle, input, lifecycle_root)
}
}
}
fn target_label(target: TargetTool) -> &'static str {
match target {
TargetTool::Claude => "claude",
TargetTool::Codex => "codex",
TargetTool::Opencode => "opencode",
}
}
fn profile_label(profile: WakeupProfile) -> &'static str {
match profile {
WakeupProfile::Developer => "developer",
WakeupProfile::Project => "project",
}
}
fn build_developer_packet(
config: &AppConfig,
wakeup_snapshot: &WakeupSnapshot,
bundle: &ContextBundle,
input: &RouteInput,
lifecycle_root: &Path,
) -> WakeupPacket {
let matched_project = matched_project_for_wakeup(config, wakeup_snapshot);
let scored_notes = build_wakeup_scored_notes(
config,
bundle,
matched_project,
wakeup_snapshot,
input,
WakeupProfile::Developer,
);
build_wakeup_packet(
bundle,
&scored_notes,
matched_project,
config,
WakeupProfile::Developer,
lifecycle_root,
)
}
fn build_project_packet(
config: &AppConfig,
wakeup_snapshot: &WakeupSnapshot,
bundle: &ContextBundle,
input: &RouteInput,
lifecycle_root: &Path,
) -> WakeupPacket {
let matched_project = matched_project_for_wakeup(config, wakeup_snapshot);
let scored_notes = build_wakeup_scored_notes(
config,
bundle,
matched_project,
wakeup_snapshot,
input,
WakeupProfile::Project,
);
build_wakeup_packet(
bundle,
&scored_notes,
matched_project,
config,
WakeupProfile::Project,
lifecycle_root,
)
}
fn matched_project_for_wakeup<'a>(
config: &'a AppConfig,
wakeup_snapshot: &'a WakeupSnapshot,
) -> Option<&'a ProjectConfig> {
wakeup_snapshot
.project_id
.as_deref()
.and_then(|project_id| {
config
.projects
.iter()
.find(|project| project.id == project_id)
})
}
fn build_wakeup_scored_notes(
config: &AppConfig,
bundle: &ContextBundle,
matched_project: Option<&ProjectConfig>,
wakeup_snapshot: &WakeupSnapshot,
input: &RouteInput,
profile: WakeupProfile,
) -> Vec<crate::domain::ScoredNote> {
crate::engine::selector::select_scored_notes(
matched_project,
bundle.route.project.as_ref(),
&bundle.route.modules,
&bundle.route.scenes,
&wakeup_snapshot.snapshot.notes,
input,
match profile {
WakeupProfile::Developer => config.output.max_notes.max(12),
WakeupProfile::Project => config.output.max_notes.max(8),
},
)
}
fn build_wakeup_packet(
bundle: &ContextBundle,
scored_notes: &[crate::domain::ScoredNote],
matched_project: Option<&ProjectConfig>,
config: &AppConfig,
profile: WakeupProfile,
lifecycle_root: &Path,
) -> WakeupPacket {
let vault_root = config.vault.root.as_path();
let current_project_id = bundle
.route
.project
.as_ref()
.map(|project| project.id.as_str());
let knowledge_index = crate::wiki_index::load_index_section(vault_root, current_project_id);
crate::wakeup::build_packet_with_index(
bundle,
scored_notes,
matched_project,
&config.developer.note_roots,
profile,
knowledge_index,
Some(lifecycle_root),
)
}
fn require_project_config<'a>(
config: &'a AppConfig,
cwd: &Path,
) -> anyhow::Result<&'a ProjectConfig> {
let project = crate::engine::project_config_for_input(config, cwd)
.ok_or_else(|| anyhow::anyhow!("no project matched cwd: {}", cwd.display()))?;
if project.note_roots.is_empty() {
anyhow::bail!(
"matched project has no note_roots configured: {}",
project.id
);
}
Ok(project)
}
#[cfg(test)]
mod tests {
use super::{
context_request, execute, execute_prompt_optimize, load_config, prompt_optimize_request,
wakeup_request,
};
use crate::domain::{OutputFormat, RouteInput, TargetTool, WakeupProfile};
use crate::enhancement_trace::read_latest_prompt_optimize_trace;
use std::fs;
use tempfile::tempdir;
#[test]
fn gateway_should_build_context_and_optional_wakeup() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
let repo_dir = temp.path().join("repo");
fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
fs::create_dir_all(&repo_dir).unwrap();
fs::write(
vault_dir.join("10-Projects/project.md"),
"# spool\n\nproject context\n",
)
.unwrap();
let config = format!(
"[vault]\nroot = \"{}\"\n\n[[projects]]\nid = \"spool\"\nname = \"spool\"\nrepo_paths = [\"{}\"]\nnote_roots = [\"10-Projects\"]\n",
vault_dir.display(),
repo_dir.display()
);
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, config).unwrap();
let context = execute(
&config_path,
context_request(RouteInput {
task: "gateway route".to_string(),
cwd: repo_dir.clone(),
files: vec![],
target: TargetTool::Claude,
format: OutputFormat::Markdown,
}),
None,
)
.unwrap();
let wakeup = execute(
&config_path,
wakeup_request(
RouteInput {
task: "gateway wakeup".to_string(),
cwd: repo_dir.clone(),
files: vec![],
target: TargetTool::Claude,
format: OutputFormat::Markdown,
},
WakeupProfile::Project,
),
None,
)
.unwrap();
let loaded = load_config(&config_path).unwrap();
assert!(
crate::output::render(
&context.bundle,
loaded.output.max_chars,
OutputFormat::Markdown
)
.contains("spool")
|| crate::output::render(
&context.bundle,
loaded.output.max_chars,
OutputFormat::Markdown
)
.contains("project")
);
assert!(crate::output::explain(&context.bundle).contains("# route explain"));
assert_eq!(
wakeup.wakeup_packet().unwrap().profile,
WakeupProfile::Project
);
}
#[test]
fn gateway_should_build_combined_prompt_and_optional_runtime_trace() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
let repo_dir = temp.path().join("repo");
fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
fs::create_dir_all(&repo_dir).unwrap();
fs::write(
vault_dir.join("10-Projects/project.md"),
"# spool\n\nproject context\n",
)
.unwrap();
let config = format!(
"[vault]\nroot = \"{}\"\n\n[output]\ndefault_format = \"markdown\"\nmax_chars = 12000\nmax_notes = 8\n\n[[projects]]\nid = \"spool\"\nname = \"spool\"\nrepo_paths = [\"{}\"]\nnote_roots = [\"10-Projects\"]\n",
vault_dir.display(),
repo_dir.display()
);
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, config).unwrap();
let response = execute_prompt_optimize(
&config_path,
prompt_optimize_request(
RouteInput {
task: "optimize prompt".to_string(),
cwd: repo_dir.clone(),
files: vec!["src/mcp.rs".to_string()],
target: TargetTool::Codex,
format: OutputFormat::Prompt,
},
WakeupProfile::Project,
Some("codex".to_string()),
Some("codex:session-99".to_string()),
true,
),
None,
)
.unwrap();
assert!(response.combined_prompt.contains("Codex"));
assert_eq!(response.target, TargetTool::Codex);
assert_eq!(response.profile, WakeupProfile::Project);
assert_eq!(response.provider.as_deref(), Some("codex"));
assert_eq!(response.session_id.as_deref(), Some("codex:session-99"));
assert!(response.runtime_trace.is_some());
let trace = read_latest_prompt_optimize_trace(&config_path)
.unwrap()
.unwrap();
assert_eq!(trace.session_id.as_deref(), Some("codex:session-99"));
}
}