1use crate::app;
2use crate::config::{AppConfig, ProjectConfig};
3use crate::domain::{
4 ContextBundle, MemoryRecord, OutputFormat, RouteInput, TargetTool, WakeupPacket, WakeupProfile,
5};
6use crate::enhancement_trace::{PromptOptimizeTrace, write_latest_prompt_optimize_trace};
7use crate::lifecycle_store::{LifecycleStore, lifecycle_root_from_config, wakeup_ready_entries};
8use crate::output;
9use crate::vault::WakeupSnapshot;
10use std::path::{Path, PathBuf};
11
12#[derive(Debug, Clone)]
13pub enum MemoryGatewayIntent {
14 Context,
15 Wakeup { profile: WakeupProfile },
16}
17
18#[derive(Debug, Clone)]
19pub struct MemoryGatewayRequest {
20 pub input: RouteInput,
21 pub intent: MemoryGatewayIntent,
22}
23
24#[derive(Debug, Clone)]
25pub struct MemoryGatewayResponse {
26 pub bundle: ContextBundle,
27 pub wakeup_packet: Option<WakeupPacket>,
28 pub used_vault_root: PathBuf,
29}
30
31impl MemoryGatewayResponse {
32 pub fn wakeup_packet(&self) -> Option<&WakeupPacket> {
33 self.wakeup_packet.as_ref()
34 }
35}
36
37#[derive(Debug, Clone)]
38pub struct PromptOptimizeRequest {
39 pub input: RouteInput,
40 pub profile: WakeupProfile,
41 pub provider: Option<String>,
42 pub session_id: Option<String>,
43 pub persist_runtime_trace: bool,
44}
45
46#[derive(Debug, Clone, serde::Serialize, ts_rs::TS)]
47#[ts(export, export_to = "../frontend/src/lib/types/generated/")]
48pub struct PromptOptimizeResponse {
49 pub combined_prompt: String,
50 pub context_prompt: String,
51 pub wakeup_prompt: String,
52 pub packet: WakeupPacket,
53 pub context_bundle: ContextBundle,
54 #[ts(type = "string")]
55 pub used_vault_root: PathBuf,
56 pub target: TargetTool,
57 pub profile: WakeupProfile,
58 pub provider: Option<String>,
59 pub session_id: Option<String>,
60 pub runtime_trace: Option<PromptOptimizeTrace>,
61}
62
63pub fn load_config(config_path: &Path) -> anyhow::Result<AppConfig> {
64 app::load(config_path)
65}
66
67pub fn context_request(input: RouteInput) -> MemoryGatewayRequest {
68 MemoryGatewayRequest {
69 input,
70 intent: MemoryGatewayIntent::Context,
71 }
72}
73
74pub fn wakeup_request(input: RouteInput, profile: WakeupProfile) -> MemoryGatewayRequest {
75 MemoryGatewayRequest {
76 input,
77 intent: MemoryGatewayIntent::Wakeup { profile },
78 }
79}
80
81pub fn prompt_optimize_request(
82 input: RouteInput,
83 profile: WakeupProfile,
84 provider: Option<String>,
85 session_id: Option<String>,
86 persist_runtime_trace: bool,
87) -> PromptOptimizeRequest {
88 PromptOptimizeRequest {
89 input,
90 profile,
91 provider,
92 session_id,
93 persist_runtime_trace,
94 }
95}
96
97pub fn execute(
98 config_path: &Path,
99 request: MemoryGatewayRequest,
100 vault_root_override: Option<&Path>,
101) -> anyhow::Result<MemoryGatewayResponse> {
102 let mut config = app::load(config_path)?;
103 if let Some(vault_root_override) = vault_root_override {
104 config.vault.root = app::resolve_override_path(vault_root_override, config_path)?;
105 }
106
107 let used_vault_root = config.vault.root.clone();
108 let lifecycle_root = lifecycle_root_for_config(config_path);
109 let lifecycle_records = load_lifecycle_records_from_root(&lifecycle_root);
110 let reference_map = crate::reference_tracker::read(&lifecycle_root);
111 match request.intent {
112 MemoryGatewayIntent::Context => {
113 let bundle = app::build_bundle_with_lifecycle_and_refs(
114 &config,
115 request.input,
116 &lifecycle_records,
117 Some(&reference_map),
118 )?;
119 touch_lifecycle_candidates(&lifecycle_root, &bundle);
120 Ok(MemoryGatewayResponse {
121 bundle,
122 wakeup_packet: None,
123 used_vault_root,
124 })
125 }
126 MemoryGatewayIntent::Wakeup { profile } => {
127 let (bundle, packet) = build_wakeup_from_config(
128 &config,
129 request.input,
130 profile,
131 &lifecycle_records,
132 &lifecycle_root,
133 )?;
134 touch_lifecycle_candidates(&lifecycle_root, &bundle);
135 Ok(MemoryGatewayResponse {
136 bundle,
137 wakeup_packet: Some(packet),
138 used_vault_root,
139 })
140 }
141 }
142}
143
144pub fn execute_prompt_optimize(
145 config_path: &Path,
146 request: PromptOptimizeRequest,
147 vault_root_override: Option<&Path>,
148) -> anyhow::Result<PromptOptimizeResponse> {
149 let mut config = app::load(config_path)?;
150 if let Some(vault_root_override) = vault_root_override {
151 config.vault.root = app::resolve_override_path(vault_root_override, config_path)?;
152 }
153
154 let used_vault_root = config.vault.root.clone();
155 let lifecycle_root = lifecycle_root_for_config(config_path);
156 let lifecycle_records = load_lifecycle_records_from_root(&lifecycle_root);
157 let reference_map = crate::reference_tracker::read(&lifecycle_root);
158 let target = request.input.target;
159 let context_input = RouteInput {
160 format: OutputFormat::Prompt,
161 ..request.input.clone()
162 };
163 let context_bundle = app::build_bundle_with_lifecycle_and_refs(
164 &config,
165 context_input,
166 &lifecycle_records,
167 Some(&reference_map),
168 )?;
169 let context_prompt = output::render(
170 &context_bundle,
171 config.output.max_chars,
172 OutputFormat::Prompt,
173 );
174
175 let (_wakeup_bundle, packet) = build_wakeup_from_config(
176 &config,
177 request.input,
178 request.profile,
179 &lifecycle_records,
180 &lifecycle_root,
181 )?;
182 let wakeup_prompt = output::wakeup::render(&packet, OutputFormat::Prompt);
183 let combined_prompt = format!("{}\n\n{}", wakeup_prompt.trim(), context_prompt.trim());
184
185 touch_lifecycle_candidates(&lifecycle_root, &context_bundle);
187
188 let runtime_trace = if request.persist_runtime_trace {
189 let trace = PromptOptimizeTrace::new(
190 context_bundle.input.cwd.display().to_string(),
191 context_bundle.input.task.clone(),
192 target_label(target),
193 profile_label(request.profile),
194 request.provider.clone(),
195 request.session_id.clone(),
196 context_bundle.route.debug.matched_project_id.clone(),
197 context_bundle.route.debug.note_count,
198 used_vault_root.display().to_string(),
199 );
200 write_latest_prompt_optimize_trace(config_path, &trace)?;
201 Some(trace)
202 } else {
203 None
204 };
205
206 Ok(PromptOptimizeResponse {
207 combined_prompt,
208 context_prompt,
209 wakeup_prompt,
210 packet,
211 context_bundle,
212 used_vault_root,
213 target,
214 profile: request.profile,
215 provider: request.provider,
216 session_id: request.session_id,
217 runtime_trace,
218 })
219}
220
221fn build_wakeup_from_config(
222 config: &AppConfig,
223 input: RouteInput,
224 profile: WakeupProfile,
225 lifecycle_records: &[(String, MemoryRecord)],
226 lifecycle_root: &Path,
227) -> anyhow::Result<(ContextBundle, WakeupPacket)> {
228 let input = RouteInput {
229 format: crate::domain::OutputFormat::Json,
230 ..input
231 };
232 let (wakeup_snapshot, bundle) =
233 wakeup_bundle_and_snapshot(config, input.clone(), profile, lifecycle_records)?;
234 let packet = packet_for_profile(
235 config,
236 &input,
237 &wakeup_snapshot,
238 &bundle,
239 profile,
240 lifecycle_root,
241 );
242 Ok((bundle, packet))
243}
244
245fn wakeup_bundle_and_snapshot(
246 config: &AppConfig,
247 input: RouteInput,
248 profile: WakeupProfile,
249 lifecycle_records: &[(String, MemoryRecord)],
250) -> anyhow::Result<(WakeupSnapshot, ContextBundle)> {
251 let wakeup_snapshot = resolve_wakeup_snapshot(config, &input.cwd, profile)?;
252 ensure_wakeup_contract(config, &wakeup_snapshot, profile)?;
253 let bundle = build_wakeup_bundle(config, &wakeup_snapshot, input, lifecycle_records);
254 Ok((wakeup_snapshot, bundle))
255}
256
257fn build_wakeup_bundle(
258 config: &AppConfig,
259 wakeup_snapshot: &WakeupSnapshot,
260 input: RouteInput,
261 lifecycle_records: &[(String, MemoryRecord)],
262) -> ContextBundle {
263 let debug = crate::domain::DebugTrace {
264 matched_project_id: wakeup_snapshot.project_id.clone(),
265 note_roots: wakeup_snapshot.note_roots.clone(),
266 scan_roots: wakeup_snapshot.snapshot.scan_roots.clone(),
267 limits: config.vault.limits.clone(),
268 note_count: wakeup_snapshot.snapshot.notes.len(),
269 };
270 crate::engine::build_context_with_lifecycle(
271 config,
272 &wakeup_snapshot.snapshot.notes,
273 lifecycle_records,
274 input,
275 debug,
276 )
277}
278
279fn lifecycle_root_for_config(config_path: &Path) -> PathBuf {
280 let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
281 lifecycle_root_from_config(config_dir)
282}
283
284fn load_lifecycle_records_from_root(root: &Path) -> Vec<(String, MemoryRecord)> {
285 if !root.exists() {
286 return Vec::new();
287 }
288 let store = LifecycleStore::new(root);
289 wakeup_ready_entries(&store)
290 .unwrap_or_default()
291 .into_iter()
292 .map(|entry| (entry.record_id, entry.record))
293 .collect()
294}
295
296fn touch_lifecycle_candidates(lifecycle_root: &Path, bundle: &ContextBundle) {
299 if bundle.route.lifecycle_candidates.is_empty() {
300 return;
301 }
302 let ids: Vec<&str> = bundle
303 .route
304 .lifecycle_candidates
305 .iter()
306 .map(|c| c.record_id.as_str())
307 .collect();
308 crate::reference_tracker::touch(lifecycle_root, &ids);
309}
310
311fn resolve_wakeup_snapshot(
312 config: &AppConfig,
313 cwd: &Path,
314 profile: WakeupProfile,
315) -> anyhow::Result<WakeupSnapshot> {
316 match profile {
317 WakeupProfile::Project => build_project_wakeup_snapshot(config, cwd),
318 WakeupProfile::Developer => build_developer_wakeup_snapshot(config, cwd),
319 }
320}
321
322fn build_project_wakeup_snapshot(config: &AppConfig, cwd: &Path) -> anyhow::Result<WakeupSnapshot> {
323 let project_config = require_project_config(config, cwd)?;
324 let note_roots = project_config.note_roots.clone();
325 let snapshot = crate::vault::cached_scan_notes_with_debug(
326 &config.vault.root,
327 note_roots.as_slice(),
328 &config.vault.limits,
329 )?;
330
331 Ok(WakeupSnapshot {
332 project_id: Some(project_config.id.clone()),
333 note_roots,
334 snapshot,
335 })
336}
337
338fn build_developer_wakeup_snapshot(
339 config: &AppConfig,
340 cwd: &Path,
341) -> anyhow::Result<WakeupSnapshot> {
342 let matched_project = crate::engine::project_config_for_input(config, cwd);
343 let note_roots = config.developer.effective_note_roots(
344 matched_project
345 .map(|project| project.note_roots.as_slice())
346 .unwrap_or(&[]),
347 );
348 if note_roots.is_empty() {
349 anyhow::bail!("developer wakeup has no note_roots configured");
350 }
351 let snapshot = crate::vault::cached_scan_notes_with_debug(
352 &config.vault.root,
353 note_roots.as_slice(),
354 &config.vault.limits,
355 )?;
356
357 Ok(WakeupSnapshot {
358 project_id: matched_project.map(|project| project.id.clone()),
359 note_roots,
360 snapshot,
361 })
362}
363
364fn ensure_wakeup_contract(
365 config: &AppConfig,
366 wakeup_snapshot: &WakeupSnapshot,
367 profile: WakeupProfile,
368) -> anyhow::Result<()> {
369 if wakeup_snapshot.note_roots.is_empty() {
370 anyhow::bail!("wakeup profile has no note_roots configured");
371 }
372 if matches!(profile, WakeupProfile::Project) && wakeup_snapshot.project_id.is_none() {
373 anyhow::bail!("project wakeup requires a matched project");
374 }
375 if matches!(profile, WakeupProfile::Developer)
376 && config.developer.effective_note_roots(&[]).is_empty()
377 {
378 anyhow::bail!("developer wakeup requires developer note_roots");
379 }
380 Ok(())
381}
382
383fn packet_for_profile(
384 config: &AppConfig,
385 input: &RouteInput,
386 wakeup_snapshot: &WakeupSnapshot,
387 bundle: &ContextBundle,
388 profile: WakeupProfile,
389 lifecycle_root: &Path,
390) -> WakeupPacket {
391 match profile {
392 WakeupProfile::Developer => {
393 build_developer_packet(config, wakeup_snapshot, bundle, input, lifecycle_root)
394 }
395 WakeupProfile::Project => {
396 build_project_packet(config, wakeup_snapshot, bundle, input, lifecycle_root)
397 }
398 }
399}
400
401fn target_label(target: TargetTool) -> &'static str {
402 match target {
403 TargetTool::Claude => "claude",
404 TargetTool::Codex => "codex",
405 TargetTool::Opencode => "opencode",
406 }
407}
408
409fn profile_label(profile: WakeupProfile) -> &'static str {
410 match profile {
411 WakeupProfile::Developer => "developer",
412 WakeupProfile::Project => "project",
413 }
414}
415
416fn build_developer_packet(
417 config: &AppConfig,
418 wakeup_snapshot: &WakeupSnapshot,
419 bundle: &ContextBundle,
420 input: &RouteInput,
421 lifecycle_root: &Path,
422) -> WakeupPacket {
423 let matched_project = matched_project_for_wakeup(config, wakeup_snapshot);
424 let scored_notes = build_wakeup_scored_notes(
425 config,
426 bundle,
427 matched_project,
428 wakeup_snapshot,
429 input,
430 WakeupProfile::Developer,
431 );
432 build_wakeup_packet(
433 bundle,
434 &scored_notes,
435 matched_project,
436 config,
437 WakeupProfile::Developer,
438 lifecycle_root,
439 )
440}
441
442fn build_project_packet(
443 config: &AppConfig,
444 wakeup_snapshot: &WakeupSnapshot,
445 bundle: &ContextBundle,
446 input: &RouteInput,
447 lifecycle_root: &Path,
448) -> WakeupPacket {
449 let matched_project = matched_project_for_wakeup(config, wakeup_snapshot);
450 let scored_notes = build_wakeup_scored_notes(
451 config,
452 bundle,
453 matched_project,
454 wakeup_snapshot,
455 input,
456 WakeupProfile::Project,
457 );
458 build_wakeup_packet(
459 bundle,
460 &scored_notes,
461 matched_project,
462 config,
463 WakeupProfile::Project,
464 lifecycle_root,
465 )
466}
467
468fn matched_project_for_wakeup<'a>(
469 config: &'a AppConfig,
470 wakeup_snapshot: &'a WakeupSnapshot,
471) -> Option<&'a ProjectConfig> {
472 wakeup_snapshot
473 .project_id
474 .as_deref()
475 .and_then(|project_id| {
476 config
477 .projects
478 .iter()
479 .find(|project| project.id == project_id)
480 })
481}
482
483fn build_wakeup_scored_notes(
484 config: &AppConfig,
485 bundle: &ContextBundle,
486 matched_project: Option<&ProjectConfig>,
487 wakeup_snapshot: &WakeupSnapshot,
488 input: &RouteInput,
489 profile: WakeupProfile,
490) -> Vec<crate::domain::ScoredNote> {
491 crate::engine::selector::select_scored_notes(
492 matched_project,
493 bundle.route.project.as_ref(),
494 &bundle.route.modules,
495 &bundle.route.scenes,
496 &wakeup_snapshot.snapshot.notes,
497 input,
498 match profile {
499 WakeupProfile::Developer => config.output.max_notes.max(12),
500 WakeupProfile::Project => config.output.max_notes.max(8),
501 },
502 )
503}
504
505fn build_wakeup_packet(
506 bundle: &ContextBundle,
507 scored_notes: &[crate::domain::ScoredNote],
508 matched_project: Option<&ProjectConfig>,
509 config: &AppConfig,
510 profile: WakeupProfile,
511 lifecycle_root: &Path,
512) -> WakeupPacket {
513 let vault_root = config.vault.root.as_path();
514 let current_project_id = bundle
515 .route
516 .project
517 .as_ref()
518 .map(|project| project.id.as_str());
519 let knowledge_index = crate::wiki_index::load_index_section(vault_root, current_project_id);
520 crate::wakeup::build_packet_with_index(
521 bundle,
522 scored_notes,
523 matched_project,
524 &config.developer.note_roots,
525 profile,
526 knowledge_index,
527 Some(lifecycle_root),
528 )
529}
530
531fn require_project_config<'a>(
532 config: &'a AppConfig,
533 cwd: &Path,
534) -> anyhow::Result<&'a ProjectConfig> {
535 let project = crate::engine::project_config_for_input(config, cwd)
536 .ok_or_else(|| anyhow::anyhow!("no project matched cwd: {}", cwd.display()))?;
537 if project.note_roots.is_empty() {
538 anyhow::bail!(
539 "matched project has no note_roots configured: {}",
540 project.id
541 );
542 }
543 Ok(project)
544}
545
546#[cfg(test)]
547mod tests {
548 use super::{
549 context_request, execute, execute_prompt_optimize, load_config, prompt_optimize_request,
550 wakeup_request,
551 };
552 use crate::domain::{OutputFormat, RouteInput, TargetTool, WakeupProfile};
553 use crate::enhancement_trace::read_latest_prompt_optimize_trace;
554 use std::fs;
555 use tempfile::tempdir;
556
557 #[test]
558 fn gateway_should_build_context_and_optional_wakeup() {
559 let temp = tempdir().unwrap();
560 let vault_dir = temp.path().join("vault");
561 let repo_dir = temp.path().join("repo");
562 fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
563 fs::create_dir_all(&repo_dir).unwrap();
564
565 fs::write(
566 vault_dir.join("10-Projects/project.md"),
567 "# spool\n\nproject context\n",
568 )
569 .unwrap();
570
571 let config = format!(
572 "[vault]\nroot = \"{}\"\n\n[[projects]]\nid = \"spool\"\nname = \"spool\"\nrepo_paths = [\"{}\"]\nnote_roots = [\"10-Projects\"]\n",
573 vault_dir.display(),
574 repo_dir.display()
575 );
576 let config_path = temp.path().join("spool.toml");
577 fs::write(&config_path, config).unwrap();
578
579 let context = execute(
580 &config_path,
581 context_request(RouteInput {
582 task: "gateway route".to_string(),
583 cwd: repo_dir.clone(),
584 files: vec![],
585 target: TargetTool::Claude,
586 format: OutputFormat::Markdown,
587 }),
588 None,
589 )
590 .unwrap();
591
592 let wakeup = execute(
593 &config_path,
594 wakeup_request(
595 RouteInput {
596 task: "gateway wakeup".to_string(),
597 cwd: repo_dir.clone(),
598 files: vec![],
599 target: TargetTool::Claude,
600 format: OutputFormat::Markdown,
601 },
602 WakeupProfile::Project,
603 ),
604 None,
605 )
606 .unwrap();
607
608 let loaded = load_config(&config_path).unwrap();
609 assert!(
610 crate::output::render(
611 &context.bundle,
612 loaded.output.max_chars,
613 OutputFormat::Markdown
614 )
615 .contains("spool")
616 || crate::output::render(
617 &context.bundle,
618 loaded.output.max_chars,
619 OutputFormat::Markdown
620 )
621 .contains("project")
622 );
623 assert!(crate::output::explain(&context.bundle).contains("# route explain"));
624 assert_eq!(
625 wakeup.wakeup_packet().unwrap().profile,
626 WakeupProfile::Project
627 );
628 }
629
630 #[test]
631 fn gateway_should_build_combined_prompt_and_optional_runtime_trace() {
632 let temp = tempdir().unwrap();
633 let vault_dir = temp.path().join("vault");
634 let repo_dir = temp.path().join("repo");
635 fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
636 fs::create_dir_all(&repo_dir).unwrap();
637
638 fs::write(
639 vault_dir.join("10-Projects/project.md"),
640 "# spool\n\nproject context\n",
641 )
642 .unwrap();
643
644 let config = format!(
645 "[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",
646 vault_dir.display(),
647 repo_dir.display()
648 );
649 let config_path = temp.path().join("spool.toml");
650 fs::write(&config_path, config).unwrap();
651
652 let response = execute_prompt_optimize(
653 &config_path,
654 prompt_optimize_request(
655 RouteInput {
656 task: "optimize prompt".to_string(),
657 cwd: repo_dir.clone(),
658 files: vec!["src/mcp.rs".to_string()],
659 target: TargetTool::Codex,
660 format: OutputFormat::Prompt,
661 },
662 WakeupProfile::Project,
663 Some("codex".to_string()),
664 Some("codex:session-99".to_string()),
665 true,
666 ),
667 None,
668 )
669 .unwrap();
670
671 assert!(response.combined_prompt.contains("Codex"));
672 assert_eq!(response.target, TargetTool::Codex);
673 assert_eq!(response.profile, WakeupProfile::Project);
674 assert_eq!(response.provider.as_deref(), Some("codex"));
675 assert_eq!(response.session_id.as_deref(), Some("codex:session-99"));
676 assert!(response.runtime_trace.is_some());
677
678 let trace = read_latest_prompt_optimize_trace(&config_path)
679 .unwrap()
680 .unwrap();
681 assert_eq!(trace.session_id.as_deref(), Some("codex:session-99"));
682 }
683}