use super::super::*;
pub(crate) const SLEEP_FENCE: &str = "```a3s-sleep";
#[derive(Clone, serde::Deserialize)]
pub(crate) struct SleepMemory {
#[serde(default)]
pub(crate) kind: String,
#[serde(default)]
pub(crate) content: String,
}
#[derive(serde::Deserialize)]
struct SleepReport {
memories: Vec<SleepMemory>,
}
pub(crate) fn sleep_today() -> String {
chrono::Local::now().format("%Y-%m-%d").to_string()
}
pub(crate) fn sleep_directive(focus: &str, ctx_ready: bool, today: &str) -> String {
let ctx_clause = if ctx_ready {
" Use the `ctx` history CLI (per your context-recall guide) to search today's \
sessions across ALL projects — query several broad topics from today's work, \
not just this directory's. If ctx returns nothing, move on —"
} else {
" Your only sources are listed next —"
};
let focus_clause = if focus.is_empty() {
String::new()
} else {
format!("\n\nFocus especially on: {focus}")
};
format!(
"You are running SLEEP consolidation for {today} — the end-of-day pass that \
turns today's work into durable long-term memory.\n\
1. Reconstruct what was worked on and accomplished today.{ctx_clause} \
review this session's own conversation. Do NOT crawl the filesystem for \
other sessions' data — ctx and this conversation are the only sources.\n\
2. Distill ONLY durable, cross-session takeaways, of three kinds:\n\
- \"experience\": an approach that WORKED today (or a mistake to avoid) with \
the why — written so a future session can apply it directly.\n\
- \"preference\": how the user likes things done — style, tools, language, \
workflow — observed from their requests and corrections today.\n\
- \"knowledge\": a stable fact about a project or environment worth keeping \
(locations, invariants, gotchas).\n\
Rules: each item self-contained and specific; skip transient state and TODO \
minutiae; NEVER include secrets, tokens, or credentials; do not repeat what \
your context shows is already remembered.\n\
3. End your FINAL message with exactly this fenced block (the host parses it \
and writes each item into long-term memory):\n\
{SLEEP_FENCE}\n\
{{\"memories\": [{{\"kind\": <experience|preference|knowledge>, \"content\": \
<one self-contained takeaway>}}]}}\n\
```\n\
Valid JSON, at most 20 items, empty array if nothing durable surfaced \
today.{focus_clause}"
)
}
pub(crate) fn parse_sleep_report(text: &str) -> Option<Vec<SleepMemory>> {
let mut hay = text;
while let Some(start) = line_anchored_rfind(hay, SLEEP_FENCE) {
let body = &hay[start + SLEEP_FENCE.len()..];
if let Some(end) = body.find("\n```") {
if let Ok(report) = serde_json::from_str::<SleepReport>(body[..end].trim()) {
return Some(report.memories);
}
}
hay = &hay[..start];
}
None
}
fn line_anchored_rfind(hay: &str, needle: &str) -> Option<usize> {
let mut upto = hay.len();
loop {
let pos = hay[..upto].rfind(needle)?;
if pos == 0 || hay.as_bytes()[pos - 1] == b'\n' {
return Some(pos);
}
upto = pos;
}
}
pub(crate) fn sleep_memory_item(m: &SleepMemory, today: &str) -> a3s_memory::MemoryItem {
let kind = m.kind.trim().to_lowercase();
let memory_type = match kind.as_str() {
"experience" => a3s_memory::MemoryType::Procedural,
"preference" | "knowledge" => a3s_memory::MemoryType::Semantic,
_ => a3s_memory::MemoryType::Episodic,
};
let tag = if kind.is_empty() {
"note".to_string()
} else {
kind
};
a3s_memory::MemoryItem::new(m.content.trim().to_string())
.with_type(memory_type)
.with_importance(0.75) .with_tags(vec!["sleep".to_string(), tag])
.with_metadata("source", "sleep")
.with_metadata("sleep_date", today)
}
impl App {
pub(crate) fn capture_sleep(&mut self, text: &str) -> Option<Cmd<Msg>> {
if !self.sleep_pending || !text.contains(SLEEP_FENCE) {
return None;
}
let Some(memories) = parse_sleep_report(text) else {
self.push_line(&Style::new().fg(TN_YELLOW).render(
" ⚑ sleep report was malformed — ask the agent to re-emit the a3s-sleep block",
));
return None;
};
self.sleep_pending = false;
self.loop_remaining = 0;
let today = sleep_today();
let items: Vec<a3s_memory::MemoryItem> = memories
.iter()
.filter(|m| !m.content.trim().is_empty())
.take(20)
.map(|m| sleep_memory_item(m, &today))
.collect();
if items.is_empty() {
self.push_line(
&Style::new()
.fg(TN_GRAY)
.render(" ☾ nothing durable to keep from today"),
);
return None;
}
let mem = self.session.memory().cloned();
let dir = memory_dir();
Some(cmd::cmd(move || async move {
let n = items.len();
let res = async {
if let Some(mem) = mem {
for item in items {
mem.remember(item).await.map_err(|e| e.to_string())?;
}
} else {
let store = a3s_memory::FileMemoryStore::new(&dir)
.await
.map_err(|e| e.to_string())?;
for item in items {
a3s_memory::MemoryStore::store(&store, item)
.await
.map_err(|e| e.to_string())?;
}
}
Ok(n)
}
.await;
Msg::SleepSaved(res)
}))
}
pub(crate) fn disarm_sleep_if_over(&mut self, captured: bool) {
if self.sleep_pending && !captured && self.loop_remaining == 0 {
self.sleep_pending = false;
self.push_line(
&Style::new()
.fg(TN_YELLOW)
.render(" ☾ sleep ended without a memory report — /sleep to retry"),
);
}
}
pub(crate) fn on_sleep_saved(&mut self, res: Result<usize, String>) {
match res {
Ok(n) => {
self.push_line(&Style::new().fg(TN_GREEN).render(&format!(
" ☾ sleep consolidation: {n} memor{} saved · /memory to browse",
if n == 1 { "y" } else { "ies" }
)));
if let Some(m) = self.memory.as_mut() {
m.sel = 0;
m.apply_data(memutil::load_panel_data(&m.dir));
}
}
Err(e) => {
self.push_line(
&Style::new()
.fg(TN_RED)
.render(&format!(" sleep consolidation failed to save: {e}")),
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn block(json: &str) -> String {
format!("prose before\n{SLEEP_FENCE}\n{json}\n```\nDONE")
}
#[test]
fn parse_sleep_report_extracts_items() {
let text = block(
r#"{"memories": [{"kind": "experience", "content": "A worked"},
{"kind": "preference", "content": "B prefers"}]}"#,
);
let items = parse_sleep_report(&text).expect("report parses");
assert_eq!(items.len(), 2);
assert_eq!(items[0].kind, "experience");
assert_eq!(items[1].content, "B prefers");
}
#[test]
fn parse_sleep_report_prefers_last_valid_block() {
let text = format!(
"the {SLEEP_FENCE} tag is documented\n```\nmid\n{}\ntrailing prose",
block(r#"{"memories": [{"kind": "knowledge", "content": "real"}]}"#)
);
let items = parse_sleep_report(&text).expect("real block parses");
assert_eq!(items.len(), 1);
assert_eq!(items[0].content, "real");
}
#[test]
fn parse_sleep_report_rejects_malformed() {
assert!(parse_sleep_report(&block("not json")).is_none());
assert!(parse_sleep_report("no fence at all").is_none());
}
#[test]
fn echoed_directive_template_never_parses() {
let directive = sleep_directive("", true, "2026-07-02");
assert!(parse_sleep_report(&directive).is_none());
}
#[test]
fn in_string_fence_mention_cannot_shadow_the_report() {
let text = block(
r#"{"memories": [{"kind": "knowledge", "content": "the TUI parses the ```a3s-sleep block"}]}"#,
);
let items = parse_sleep_report(&text).expect("in-string mention must not break parsing");
assert_eq!(items.len(), 1);
assert!(items[0].content.contains("a3s-sleep"));
}
#[test]
fn sleep_memory_item_maps_kinds_and_provenance() {
let today = "2026-07-02";
let exp = sleep_memory_item(
&SleepMemory {
kind: "Experience".into(),
content: " use flock ".into(),
},
today,
);
assert_eq!(exp.memory_type, a3s_memory::MemoryType::Procedural);
assert_eq!(exp.content, "use flock");
assert!(exp.tags.contains(&"sleep".to_string()));
assert!(exp.tags.contains(&"experience".to_string()));
let pref = sleep_memory_item(
&SleepMemory {
kind: "preference".into(),
content: "HCL over TOML".into(),
},
today,
);
assert_eq!(pref.memory_type, a3s_memory::MemoryType::Semantic);
let odd = sleep_memory_item(
&SleepMemory {
kind: "".into(),
content: "misc".into(),
},
today,
);
assert_eq!(odd.memory_type, a3s_memory::MemoryType::Episodic);
assert!(odd.tags.contains(&"note".to_string()));
}
#[test]
fn sleep_directive_carries_contract_and_focus() {
let d = sleep_directive("the box crate", true, "2026-07-02");
assert!(d.contains(SLEEP_FENCE));
assert!(d.contains("ctx")); assert!(d.contains("Focus especially on: the box crate"));
assert!(d.contains("2026-07-02"));
let no_ctx = sleep_directive("", false, "2026-07-02");
assert!(!no_ctx.contains("context-recall guide"));
assert!(!no_ctx.contains("Focus especially"));
}
}