Skip to main content

spool/
memory_gateway.rs

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 from the context bundle
186    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
296/// Fire-and-forget touch for staleness tracking: update the reference
297/// tracker for all lifecycle candidates that made it into the bundle.
298fn 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}