use std::io::Write;
use std::path::PathBuf;
use anyhow::Context;
use crate::domain::{OutputFormat, RouteInput, TargetTool, WakeupProfile};
use crate::memory_gateway::{self, wakeup_request};
#[derive(Debug, Clone)]
pub struct SessionStartArgs {
pub config_path: PathBuf,
pub cwd: Option<PathBuf>,
pub task: Option<String>,
pub profile: WakeupProfile,
}
pub fn run(args: SessionStartArgs) -> anyhow::Result<()> {
let cwd = match args.cwd {
Some(p) => p,
None => std::env::current_dir().context("resolving cwd for session-start hook")?,
};
let task = args.task.unwrap_or_else(|| String::from("session start"));
let mut stdout = std::io::stdout().lock();
let response = match memory_gateway::execute(
&args.config_path,
wakeup_request(
RouteInput {
task,
cwd: cwd.clone(),
files: Vec::new(),
target: TargetTool::Claude,
format: OutputFormat::Prompt,
},
args.profile,
),
None,
) {
Ok(r) => r,
Err(err) => {
write_error_block(&mut stdout, &err)?;
return Ok(());
}
};
let packet = match response.wakeup_packet() {
Some(p) => p,
None => {
write_empty_block(&mut stdout)?;
return Ok(());
}
};
let body = render_memory_injection(packet);
if !body.is_empty() {
write_block(&mut stdout, &body)?;
} else {
write_empty_block(&mut stdout)?;
}
Ok(())
}
const REGION_OPEN: &str = "<spool-memory>";
const REGION_CLOSE: &str = "</spool-memory>";
fn write_block<W: Write>(w: &mut W, body: &str) -> anyhow::Result<()> {
writeln!(w, "{}", REGION_OPEN)?;
writeln!(w, "{}", body)?;
writeln!(w, "{}", REGION_CLOSE)?;
Ok(())
}
fn write_empty_block<W: Write>(w: &mut W) -> anyhow::Result<()> {
writeln!(w, "<spool-memory>")?;
writeln!(
w,
"spool: no active memories yet. Say \"记一下\" to capture."
)?;
writeln!(w, "</spool-memory>")?;
Ok(())
}
fn write_error_block<W: Write>(w: &mut W, err: &anyhow::Error) -> anyhow::Result<()> {
write_block(
w,
&format!(
"Memory unavailable: {}. Run `spool mcp doctor` for diagnosis.",
short_error(err)
),
)
}
const MAX_INJECTION_CHARS: usize = 2000;
const MAX_SUMMARY_CHARS: usize = 120;
fn render_memory_injection(packet: &crate::domain::WakeupPacket) -> String {
use crate::domain::WakeupMemoryItem;
fn has_content(items: &[WakeupMemoryItem]) -> bool {
!items.is_empty()
}
let has_any = has_content(&packet.constraints)
|| has_content(&packet.decisions)
|| has_content(&packet.working_style.items)
|| has_content(&packet.incidents);
if !has_any {
return String::new();
}
let sections: Vec<(&str, &[WakeupMemoryItem])> = vec![
("Constraints", &packet.constraints),
("Decisions", &packet.decisions),
("Preferences", &packet.working_style.items),
("Incidents", &packet.incidents),
];
let mut lines: Vec<String> = Vec::new();
let mut total_chars: usize = 0;
let mut dropped: usize = 0;
for (heading, items) in §ions {
if items.is_empty() {
continue;
}
let header_line = format!("## {heading}");
let header_cost = header_line.len() + 1;
if total_chars + header_cost > MAX_INJECTION_CHARS {
dropped += items.len();
continue;
}
if !lines.is_empty() {
lines.push(String::new());
total_chars += 1;
}
lines.push(header_line);
total_chars += header_cost;
for item in *items {
let summary = truncate_summary(&item.summary);
let line = format!("- {summary}");
let line_cost = line.len() + 1;
if total_chars + line_cost > MAX_INJECTION_CHARS {
dropped += 1;
continue;
}
lines.push(line);
total_chars += line_cost;
}
}
if dropped > 0 {
lines.push(String::new());
lines.push(format!(
"({dropped} more — use /spool-wakeup for full list)"
));
}
lines.join("\n")
}
fn truncate_summary(s: &str) -> String {
let trimmed = s.trim();
if trimmed.chars().count() <= MAX_SUMMARY_CHARS {
return trimmed.to_string();
}
let mut out: String = trimmed.chars().take(MAX_SUMMARY_CHARS).collect();
out.push('…');
out
}
fn short_error(err: &anyhow::Error) -> String {
let raw = format!("{}", err);
let trimmed: String = raw.chars().take(160).collect();
if trimmed.len() < raw.len() {
format!("{}…", trimmed)
} else {
trimmed
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn fixture(temp: &tempfile::TempDir) -> PathBuf {
let vault = temp.path().join("vault");
fs::create_dir_all(vault.join("10-Projects")).unwrap();
fs::write(
vault.join("10-Projects/proj.md"),
r#"---
memory_type: decision
project_id: spool
retrieval_priority: high
---
# 决策: 用 cargo install
"#,
)
.unwrap();
let cwd = temp.path().join("repo");
fs::create_dir_all(&cwd).unwrap();
let config = format!(
r#"[vault]
root = "{}"
[output]
default_format = "prompt"
max_chars = 8000
max_notes = 4
[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
vault.display(),
cwd.display()
);
let cfg = temp.path().join("spool.toml");
fs::write(&cfg, config).unwrap();
cfg
}
#[test]
fn write_block_wraps_body_in_markers() {
let mut buf = Vec::new();
write_block(&mut buf, "hello world").unwrap();
let text = String::from_utf8(buf).unwrap();
assert!(text.starts_with("<spool-memory>\n"));
assert!(text.contains("hello world"));
assert!(text.trim_end().ends_with("</spool-memory>"));
}
#[test]
fn write_empty_block_provides_actionable_hint() {
let mut buf = Vec::new();
write_empty_block(&mut buf).unwrap();
let text = String::from_utf8(buf).unwrap();
assert!(text.contains("记一下"));
}
#[test]
fn short_error_truncates_long_errors() {
let long = "x".repeat(500);
let err = anyhow::anyhow!("{}", long);
let s = short_error(&err);
assert!(s.ends_with('…'));
assert!(s.len() < long.len());
}
#[test]
fn run_emits_wakeup_block_for_real_project() {
let temp = tempdir().unwrap();
let cfg = fixture(&temp);
let cwd = temp.path().join("repo");
let response = memory_gateway::execute(
&cfg,
wakeup_request(
RouteInput {
task: "session start".into(),
cwd: cwd.clone(),
files: Vec::new(),
target: TargetTool::Claude,
format: OutputFormat::Prompt,
},
WakeupProfile::Project,
),
None,
)
.unwrap();
assert!(response.wakeup_packet().is_some());
}
#[test]
fn run_works_even_with_trellis_present() {
let temp = tempdir().unwrap();
let cfg = fixture(&temp);
let cwd = temp.path().join("repo");
let trellis = cwd.join(".trellis");
fs::create_dir_all(&trellis).unwrap();
fs::write(trellis.join(".developer"), "name=long").unwrap();
let result = run(SessionStartArgs {
config_path: cfg,
cwd: Some(cwd),
task: None,
profile: WakeupProfile::Project,
});
assert!(result.is_ok(), "should work regardless of trellis presence");
}
}