1use std::path::PathBuf;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum StartupStatus {
14 Ready,
16 Degraded {
19 reason: String,
20 last_usable_wakeup: Option<SystemPromptBlock>,
21 },
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct SystemPromptResponse {
27 pub status: StartupStatus,
28 pub prompt_block: Option<SystemPromptBlock>,
29}
30
31impl SystemPromptResponse {
32 #[must_use]
33 pub fn ready(prompt_block: SystemPromptBlock) -> Self {
34 Self {
35 status: StartupStatus::Ready,
36 prompt_block: Some(prompt_block),
37 }
38 }
39
40 #[must_use]
41 pub fn degraded(reason: impl Into<String>) -> Self {
42 let reason = reason.into();
43 Self {
44 prompt_block: Some(SystemPromptBlock {
45 shape: StartupInjectionShape::CompactRenderedMarkdown,
46 markdown: render_degraded_startup_markdown(&reason),
47 }),
48 status: StartupStatus::Degraded {
49 reason,
50 last_usable_wakeup: None,
51 },
52 }
53 }
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum StartupInjectionShape {
62 CompactRenderedMarkdown,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum PrefetchStatus {
69 SkippedNoIntent,
71 Ready,
73 Failed { reason: String },
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum SyncTurnStatus {
80 Persisted,
82 Noop,
84 Failed { reason: String },
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum SessionEndStatus {
91 Triggered,
93 Noop,
95 AlreadyHandled,
97 Failed { reason: String },
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct WakeupPackSummary {
107 pub identity: String,
108 pub recent_state: String,
109 pub latest_capsule: Option<String>,
110 pub key_relations: Vec<String>,
111 pub unresolved_threads: Vec<String>,
112 pub generated_at: Option<String>,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct RhythmTrigger {
118 pub name: String,
119 pub reason: Option<String>,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Default)]
125pub struct StartupContextSnapshot {
126 pub laputa_state_root: Option<PathBuf>,
128 pub soul_markdown: Option<String>,
130 pub wakeup_markdown: Option<String>,
132 pub wakeup_pack: Option<WakeupPackSummary>,
135 pub rhythm_triggers: Vec<RhythmTrigger>,
137 pub memory_markdown: Option<String>,
139}
140
141impl StartupContextSnapshot {
142 pub fn into_system_prompt_block(self) -> Option<SystemPromptBlock> {
144 let markdown = self.render_compact_markdown();
145 if markdown.is_empty() {
146 None
147 } else {
148 Some(SystemPromptBlock {
149 shape: StartupInjectionShape::CompactRenderedMarkdown,
150 markdown,
151 })
152 }
153 }
154
155 fn render_compact_markdown(&self) -> String {
156 let mut sections = Vec::new();
157
158 if let Some(memory_markdown) = trimmed_markdown(self.memory_markdown.as_deref()) {
159 sections.push(memory_markdown.to_string());
160 }
161
162 if let Some(soul_markdown) = trimmed_markdown(self.soul_markdown.as_deref()) {
163 sections.push(format!("## Soul Projection\n{}", soul_markdown));
164 }
165
166 if let Some(wakeup_markdown) = trimmed_markdown(self.wakeup_markdown.as_deref()) {
167 sections.push(format!("## Wakeup Projection\n{}", wakeup_markdown));
168 } else if let Some(wakeup_pack) = &self.wakeup_pack {
169 sections.push(render_wakeup_pack_summary(wakeup_pack));
170 }
171
172 if !self.rhythm_triggers.is_empty() {
173 let triggers = self
174 .rhythm_triggers
175 .iter()
176 .map(|trigger| match trigger.reason.as_deref() {
177 Some(reason) if !reason.trim().is_empty() => {
178 format!("- {} — {}", trigger.name.trim(), reason.trim())
179 }
180 _ => format!("- {}", trigger.name.trim()),
181 })
182 .collect::<Vec<_>>()
183 .join("\n");
184 sections.push(format!("## Rhythm Signals\n{}", triggers));
185 }
186
187 sections.join("\n\n")
188 }
189}
190
191fn render_degraded_startup_markdown(reason: &str) -> String {
192 format!(
193 "## Memory Startup Status\n- status: degraded\n- reason: {}\n- last_usable_wakeup: omitted (no cache reuse)\n",
194 reason.trim()
195 )
196}
197
198fn trimmed_markdown(markdown: Option<&str>) -> Option<&str> {
199 let markdown = markdown?.trim();
200 if markdown.is_empty() {
201 None
202 } else {
203 Some(markdown)
204 }
205}
206
207fn render_wakeup_pack_summary(pack: &WakeupPackSummary) -> String {
208 let latest_capsule = pack.latest_capsule.as_deref().unwrap_or("None");
209 let key_relations = if pack.key_relations.is_empty() {
210 "- None".to_string()
211 } else {
212 pack.key_relations
213 .iter()
214 .map(|item| format!("- {}", item.trim()))
215 .collect::<Vec<_>>()
216 .join("\n")
217 };
218 let unresolved_threads = if pack.unresolved_threads.is_empty() {
219 "- None".to_string()
220 } else {
221 pack.unresolved_threads
222 .iter()
223 .map(|item| format!("- {}", item.trim()))
224 .collect::<Vec<_>>()
225 .join("\n")
226 };
227
228 let mut rendered = String::from("## Wakeup Summary");
229 if let Some(generated_at) = trimmed_markdown(pack.generated_at.as_deref()) {
230 rendered.push_str("\nGenerated: ");
231 rendered.push_str(generated_at);
232 }
233 rendered.push_str("\n\n### Identity\n");
234 rendered.push_str(pack.identity.trim());
235 rendered.push_str("\n\n### Recent State\n");
236 rendered.push_str(pack.recent_state.trim());
237 rendered.push_str("\n\n### Latest Capsule\n");
238 rendered.push_str(latest_capsule.trim());
239 rendered.push_str("\n\n### Key Relations\n");
240 rendered.push_str(&key_relations);
241 rendered.push_str("\n\n### Unresolved Threads\n");
242 rendered.push_str(&unresolved_threads);
243 rendered
244}
245
246#[derive(Debug, Clone, PartialEq, Eq)]
253pub struct SystemPromptRequest {
254 pub workspace_root: PathBuf,
256}
257
258#[derive(Debug, Clone, PartialEq, Eq)]
260pub struct SystemPromptBlock {
261 pub shape: StartupInjectionShape,
263 pub markdown: String,
265}
266
267#[derive(Debug, Clone, PartialEq, Eq)]
273pub struct PrefetchRequest {
274 pub workspace_root: PathBuf,
276 pub intent: String,
278 pub current_room: Option<String>,
280 pub user_message: Option<String>,
282}
283
284#[derive(Debug, Clone, PartialEq, Eq)]
286pub struct PrefetchResponse {
287 pub status: PrefetchStatus,
289 pub prompt_block: Option<String>,
291}
292
293impl Default for PrefetchResponse {
294 fn default() -> Self {
295 Self {
296 status: PrefetchStatus::SkippedNoIntent,
297 prompt_block: None,
298 }
299 }
300}
301
302#[derive(Debug, Clone, PartialEq, Eq)]
309pub struct SyncTurnRequest {
310 pub workspace_root: PathBuf,
312 pub memory_update_markdown: Option<String>,
314 pub history_entry: Option<String>,
316}
317
318#[derive(Debug, Clone, PartialEq, Eq)]
320pub struct SyncTurnResponse {
321 pub status: SyncTurnStatus,
323}
324
325impl Default for SyncTurnResponse {
326 fn default() -> Self {
327 Self {
328 status: SyncTurnStatus::Noop,
329 }
330 }
331}
332
333#[derive(Debug, Clone, PartialEq, Eq)]
338pub struct SessionEndRequest {
339 pub workspace_root: PathBuf,
341 pub session_id: Option<String>,
343}
344
345#[derive(Debug, Clone, PartialEq, Eq)]
347pub struct SessionEndResponse {
348 pub status: SessionEndStatus,
350}
351
352impl Default for SessionEndResponse {
353 fn default() -> Self {
354 Self {
355 status: SessionEndStatus::Noop,
356 }
357 }
358}
359
360#[async_trait::async_trait]
371pub trait MemoryProvider: Send + Sync {
372 fn system_prompt_block(
374 &self,
375 request: &SystemPromptRequest,
376 ) -> crate::Result<SystemPromptResponse>;
377
378 async fn prefetch(&self, request: PrefetchRequest) -> crate::Result<PrefetchResponse>;
380
381 async fn sync_turn(&self, request: SyncTurnRequest) -> crate::Result<SyncTurnResponse>;
383
384 async fn on_session_end(
386 &self,
387 request: SessionEndRequest,
388 ) -> crate::Result<SessionEndResponse>;
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 struct DummyProvider;
396
397 #[async_trait::async_trait]
398 impl MemoryProvider for DummyProvider {
399 fn system_prompt_block(
400 &self,
401 request: &SystemPromptRequest,
402 ) -> crate::Result<SystemPromptResponse> {
403 Ok(SystemPromptResponse::ready(SystemPromptBlock {
404 shape: StartupInjectionShape::CompactRenderedMarkdown,
405 markdown: format!("workspace={}", request.workspace_root.display()),
406 }))
407 }
408
409 async fn prefetch(&self, request: PrefetchRequest) -> crate::Result<PrefetchResponse> {
410 Ok(PrefetchResponse {
411 status: PrefetchStatus::Ready,
412 prompt_block: Some(format!(
413 "intent={} room={}",
414 request.intent,
415 request.current_room.unwrap_or_else(|| "none".to_string())
416 )),
417 })
418 }
419
420 async fn sync_turn(&self, request: SyncTurnRequest) -> crate::Result<SyncTurnResponse> {
421 Ok(SyncTurnResponse {
422 status: if request.memory_update_markdown.is_some() || request.history_entry.is_some()
423 {
424 SyncTurnStatus::Persisted
425 } else {
426 SyncTurnStatus::Noop
427 },
428 })
429 }
430
431 async fn on_session_end(
432 &self,
433 request: SessionEndRequest,
434 ) -> crate::Result<SessionEndResponse> {
435 Ok(SessionEndResponse {
436 status: if request.session_id.is_some() {
437 SessionEndStatus::Triggered
438 } else {
439 SessionEndStatus::Noop
440 },
441 })
442 }
443 }
444
445 #[tokio::test]
446 async fn test_memory_provider_contract_is_domain_only() {
447 let provider = DummyProvider;
448 let prompt = provider
449 .system_prompt_block(&SystemPromptRequest {
450 workspace_root: PathBuf::from("/tmp/diva"),
451 })
452 .unwrap()
453 .prompt_block
454 .expect("dummy provider should return a prompt block");
455 assert!(prompt.markdown.contains("workspace=/tmp/diva"));
456 assert_eq!(prompt.shape, StartupInjectionShape::CompactRenderedMarkdown);
457
458 let prefetch = provider
459 .prefetch(PrefetchRequest {
460 workspace_root: PathBuf::from("/tmp/diva"),
461 intent: "recall-project-status".to_string(),
462 current_room: Some("roadmap".to_string()),
463 user_message: Some("what changed?".to_string()),
464 })
465 .await
466 .unwrap();
467 assert_eq!(prefetch.status, PrefetchStatus::Ready);
468 assert_eq!(prefetch.prompt_block.as_deref(), Some("intent=recall-project-status room=roadmap"));
469
470 let sync = provider
471 .sync_turn(SyncTurnRequest {
472 workspace_root: PathBuf::from("/tmp/diva"),
473 memory_update_markdown: Some("updated".to_string()),
474 history_entry: None,
475 })
476 .await
477 .unwrap();
478 assert_eq!(sync.status, SyncTurnStatus::Persisted);
479
480 let shutdown = provider
481 .on_session_end(SessionEndRequest {
482 workspace_root: PathBuf::from("/tmp/diva"),
483 session_id: Some("session-42".to_string()),
484 })
485 .await
486 .unwrap();
487 assert_eq!(shutdown.status, SessionEndStatus::Triggered);
488 }
489
490 #[test]
491 fn test_startup_context_snapshot_renders_compact_markdown() {
492 let block = StartupContextSnapshot {
493 laputa_state_root: Some(PathBuf::from("/tmp/diva/.laputa")),
494 soul_markdown: Some("# Identity\n\nGenerated soul".to_string()),
495 wakeup_markdown: None,
496 wakeup_pack: Some(WakeupPackSummary {
497 identity: "You are Diva.".to_string(),
498 recent_state: "- roadmap: Hot (heat: 5)".to_string(),
499 latest_capsule: Some("Weekly review complete.".to_string()),
500 key_relations: vec!["maintainer <-> roadmap".to_string()],
501 unresolved_threads: vec!["ship provider boundary".to_string()],
502 generated_at: Some("2026-05-08 10:00 UTC".to_string()),
503 }),
504 rhythm_triggers: vec![RhythmTrigger {
505 name: "weekly".to_string(),
506 reason: Some("capsule due".to_string()),
507 }],
508 memory_markdown: Some("## Long-term Memory\nExisting durable memory".to_string()),
509 }
510 .into_system_prompt_block()
511 .expect("startup context should render a prompt block");
512
513 assert_eq!(block.shape, StartupInjectionShape::CompactRenderedMarkdown);
514 assert!(block.markdown.contains("## Long-term Memory"));
515 assert!(block.markdown.contains("## Soul Projection"));
516 assert!(block.markdown.contains("## Wakeup Summary"));
517 assert!(block.markdown.contains("## Rhythm Signals"));
518 assert!(block.markdown.contains("weekly — capsule due"));
519 }
520
521 #[test]
522 fn test_degraded_startup_explicitly_omits_cached_wakeup() {
523 let response = SystemPromptResponse::degraded("wakeup generation failed");
524
525 match response.status {
526 StartupStatus::Degraded {
527 reason,
528 last_usable_wakeup,
529 } => {
530 assert_eq!(reason, "wakeup generation failed");
531 assert!(last_usable_wakeup.is_none());
532 }
533 other => panic!("expected degraded startup, got {other:?}"),
534 }
535
536 let block = response
537 .prompt_block
538 .expect("degraded startup should still emit an explicit prompt block");
539 assert!(block.markdown.contains("status: degraded"));
540 assert!(block.markdown.contains("last_usable_wakeup: omitted"));
541 }
542}