1#![forbid(unsafe_code)]
4
5use async_trait::async_trait;
6
7use serde_json::{Value, json};
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10use wm_core::{
11 Context, EffectRow, EpisodicKind, Galaxy, Gana, ProvenanceSource, Resource, Tool, ToolStats,
12};
13use wm_memory::{Memory, MemoryStore};
14
15fn capture_git_state(root: &Path) -> Option<Value> {
20 let run = |git_args: &[&str]| -> Option<String> {
21 let out = std::process::Command::new("git")
22 .args(git_args)
23 .current_dir(root)
24 .env("GIT_OPTIONAL_LOCKS", "0")
29 .output()
30 .ok()?;
31 if out.status.success() {
32 Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
33 } else {
34 None
35 }
36 };
37 run(&["rev-parse", "--is-inside-work-tree"])?;
40 let commit = run(&["rev-parse", "HEAD"]).unwrap_or_default();
41 let branch = run(&["rev-parse", "--abbrev-ref", "HEAD"]).unwrap_or_default();
42 let dirty_count = run(&["status", "--porcelain"]).map_or(0, |s| s.lines().count());
43 Some(json!({
44 "commit": commit,
45 "branch": branch,
46 "dirty_count": dirty_count,
47 }))
48}
49
50fn resolve_project_root(args: &Value) -> Option<PathBuf> {
53 args.get("root")
54 .and_then(|v| v.as_str())
55 .filter(|s| !s.is_empty())
56 .map(PathBuf::from)
57 .or_else(|| {
58 std::env::var("WM_PROJECT_ROOT")
59 .ok()
60 .filter(|s| !s.is_empty())
61 .map(PathBuf::from)
62 })
63}
64
65fn latest_session_start(store: &MemoryStore) -> Option<String> {
68 store
69 .scan_all(Galaxy::Sessions)
70 .ok()?
71 .iter()
72 .filter(|m| m.metadata.tags.contains(&"start".to_string()))
73 .max_by_key(|m| m.metadata.created_at)
74 .map(|m| m.metadata.id.to_string())
75}
76
77pub struct SessionStartTool {
79 store: Arc<MemoryStore>,
80 stats: ToolStats,
81 effects: EffectRow,
82}
83
84impl SessionStartTool {
85 pub fn new(store: Arc<MemoryStore>) -> Self {
86 Self {
87 store,
88 stats: ToolStats::default(),
89 effects: EffectRow {
90 writes: vec![Resource::Galaxy("sessions".into())],
91 ..Default::default()
92 },
93 }
94 }
95}
96
97#[async_trait]
98impl Tool for SessionStartTool {
99 fn name(&self) -> &str {
100 "session.start"
101 }
102 fn gana(&self) -> Gana {
103 Gana::StraddlingLegs
104 }
105 fn effects(&self) -> &EffectRow {
106 &self.effects
107 }
108 fn input_schema(&self) -> Value {
109 super::common::schema(
110 &json!({
111 "title": super::common::str_prop("Session title"),
112 "user": super::common::str_prop("User identifier (default 'default')"),
113 }),
114 &[],
115 )
116 }
117 fn description(&self) -> &str {
118 "Start a new session — creates a session memory in Sessions galaxy"
119 }
120 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
121 let title = args
122 .get("title")
123 .and_then(|v| v.as_str())
124 .unwrap_or("Untitled Session");
125 let user = args
126 .get("user")
127 .and_then(|v| v.as_str())
128 .unwrap_or("default");
129 let mut mem = Memory::new(
130 Galaxy::Sessions,
131 json!({
132 "type": "session_start",
133 "title": title,
134 "user": user,
135 })
136 .to_string(),
137 );
138 mem.metadata.tags = vec!["session".into(), "start".into()];
139 mem.metadata.importance = 0.7;
140 mem.metadata.source = "system".to_string();
142 mem.metadata.source_trust = 0.7;
143 self.store.put(Galaxy::Sessions, &mem)?;
144 crate::capture_explicit_memory(
145 &self.store,
146 &mem,
147 EpisodicKind::SystemEvent,
148 ProvenanceSource::System,
149 Some(mem.metadata.id),
150 0,
151 );
152 Ok(json!({
153 "status": "success",
154 "session_id": mem.metadata.id,
155 "title": title,
156 "user": user,
157 }))
158 }
159 fn stats(&self) -> &ToolStats {
160 &self.stats
161 }
162}
163
164pub struct SessionCheckpointTool {
166 store: Arc<MemoryStore>,
167 stats: ToolStats,
168 effects: EffectRow,
169}
170
171impl SessionCheckpointTool {
172 pub fn new(store: Arc<MemoryStore>) -> Self {
173 Self {
174 store,
175 stats: ToolStats::default(),
176 effects: EffectRow {
177 writes: vec![Resource::Galaxy("sessions".into())],
178 ..Default::default()
179 },
180 }
181 }
182}
183
184#[async_trait]
185impl Tool for SessionCheckpointTool {
186 fn name(&self) -> &str {
187 "session.checkpoint"
188 }
189 fn gana(&self) -> Gana {
190 Gana::StraddlingLegs
191 }
192 fn effects(&self) -> &EffectRow {
193 &self.effects
194 }
195 fn input_schema(&self) -> Value {
196 super::common::schema(
197 &json!({
198 "session_id": super::common::str_prop("Target session (default: most recent session)"),
199 "label": super::common::str_prop("Checkpoint label (default 'checkpoint')"),
200 "data": {
201 "type": "object",
202 "description": "Legacy free-form passthrough stored beside the handoff."
203 },
204 "commit": super::common::str_prop("Manual commit hash (auto-captured from git when root/WM_PROJECT_ROOT is set)"),
205 "branch": super::common::str_prop("Manual branch name (auto-captured when git is available)"),
206 "tests_green": {
207 "type": "boolean",
208 "description": "Whether the test suite was green at checkpoint time."
209 },
210 "next_queue": {
211 "type": "array",
212 "description": "Ordered next-step strings for the next session."
213 },
214 "open_flags": {
215 "type": "array",
216 "description": "Open concerns/flags worth surfacing on resume."
217 },
218 "lease_id": super::common::str_prop("Claimed scope (code.claim lease_id) that remains held at this handoff"),
219 "root": super::common::str_prop("Repository root for auto git-capture (default: WM_PROJECT_ROOT env)"),
220 }),
221 &[],
222 )
223 }
224 fn description(&self) -> &str {
225 "Save a session checkpoint with a verifiable structured handoff: commit, branch, dirty count (auto-captured from git via WM_PROJECT_ROOT), tests_green, next_queue, open_flags, lease_id (a code.claim scope that stays held)."
226 }
227 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
228 let session_id = match args.get("session_id").and_then(|v| v.as_str()) {
229 Some(sid) if !sid.is_empty() => sid.to_string(),
230 _ => latest_session_start(&self.store).ok_or_else(|| {
231 wm_core::CoreError::Tool("no session found — run session.start first".into())
232 })?,
233 };
234 let label = args
235 .get("label")
236 .and_then(|v| v.as_str())
237 .unwrap_or("checkpoint");
238 let data = args.get("data").cloned().unwrap_or_else(|| json!({}));
239
240 let git_state = resolve_project_root(&args).and_then(|root| capture_git_state(&root));
243 let mut handoff = json!({});
244 {
245 let h = handoff.as_object_mut().expect("just created");
246 for (key, value) in [
247 ("commit", args.get("commit")),
248 ("branch", args.get("branch")),
249 ("tests_green", args.get("tests_green")),
250 ("next_queue", args.get("next_queue")),
251 ("open_flags", args.get("open_flags")),
252 ("lease_id", args.get("lease_id")),
253 ] {
254 if value.is_some() {
255 h.insert(key.to_string(), value.cloned().expect("checked above"));
256 }
257 }
258 if let Some(git) = git_state {
259 h.insert("git".to_string(), git);
260 }
261 }
262
263 let mut mem = Memory::new(
264 Galaxy::Sessions,
265 json!({
266 "type": "checkpoint",
267 "session_id": session_id,
268 "label": label,
269 "data": data,
270 "handoff": handoff,
271 })
272 .to_string(),
273 );
274 mem.metadata.tags = vec!["session".into(), "checkpoint".into()];
275 mem.metadata.importance = 0.5;
276 mem.metadata.source = "system".to_string();
278 mem.metadata.source_trust = 0.7;
279 self.store.put(Galaxy::Sessions, &mem)?;
280 crate::capture_explicit_memory(
281 &self.store,
282 &mem,
283 EpisodicKind::SystemEvent,
284 ProvenanceSource::System,
285 uuid::Uuid::parse_str(&session_id).ok(),
286 0,
287 );
288 Ok(json!({
289 "status": "success",
290 "checkpoint_id": mem.metadata.id,
291 "session_id": session_id,
292 "label": label,
293 "handoff": handoff,
294 }))
295 }
296 fn stats(&self) -> &ToolStats {
297 &self.stats
298 }
299}
300
301pub struct SessionVerifyTool {
308 store: Arc<MemoryStore>,
309 stats: ToolStats,
310 effects: EffectRow,
311}
312
313impl SessionVerifyTool {
314 pub fn new(store: Arc<MemoryStore>) -> Self {
315 Self {
316 store,
317 stats: ToolStats::default(),
318 effects: EffectRow::read_only(vec![Resource::Galaxy("sessions".into())]),
319 }
320 }
321}
322
323#[async_trait]
324impl Tool for SessionVerifyTool {
325 fn name(&self) -> &str {
326 "session.verify"
327 }
328 fn gana(&self) -> Gana {
329 Gana::StraddlingLegs
330 }
331 fn effects(&self) -> &EffectRow {
332 &self.effects
333 }
334 fn input_schema(&self) -> Value {
335 super::common::schema(
336 &json!({
337 "session_id": super::common::str_prop("Session whose latest checkpoint to verify (default: most recent session)"),
338 "root": super::common::str_prop("Repository root to verify against (default: WM_PROJECT_ROOT env)"),
339 }),
340 &[],
341 )
342 }
343 fn description(&self) -> &str {
344 "Verify a session's stored checkpoint against live git state — reports commit drift and dirty-count delta ('your memory says HEAD was X; git says Y')."
345 }
346 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
347 let session_id = match args.get("session_id").and_then(|v| v.as_str()) {
348 Some(sid) if !sid.is_empty() => sid.to_string(),
349 _ => latest_session_start(&self.store).ok_or_else(|| {
350 wm_core::CoreError::Tool("no session found — run session.start first".into())
351 })?,
352 };
353
354 let memories = self.store.scan_all(Galaxy::Sessions)?;
357 let stored = memories
358 .iter()
359 .filter(|m| {
360 m.metadata.tags.contains(&"checkpoint".to_string())
361 && m.content.contains(&session_id)
362 })
363 .filter_map(|m| {
364 let parsed: Value = serde_json::from_str(&m.content).ok()?;
365 let git = parsed.get("handoff")?.get("git")?.clone();
366 if git.get("commit").and_then(Value::as_str).is_some() {
367 Some((m.metadata.id.to_string(), m.metadata.created_at, git))
368 } else {
369 None
370 }
371 })
372 .max_by_key(|(_, created_at, _)| *created_at);
373
374 let Some((checkpoint_id, _, stored_git)) = stored else {
375 return Ok(json!({
376 "status": "success",
377 "verifiable": false,
378 "message": "no checkpoint with captured git state found for this session — checkpoint with WM_PROJECT_ROOT set (or an explicit root) to enable verification"
379 }));
380 };
381
382 let Some(root) = resolve_project_root(&args) else {
383 return Ok(json!({
384 "status": "error",
385 "checkpoint_id": checkpoint_id,
386 "stored_git": stored_git,
387 "message": "no repository root available — pass 'root' or set WM_PROJECT_ROOT to verify against live git"
388 }));
389 };
390 let Some(current_git) = capture_git_state(&root) else {
391 return Ok(json!({
392 "status": "error",
393 "checkpoint_id": checkpoint_id,
394 "stored_git": stored_git,
395 "message": format!("'{}' is not a usable git work tree", root.display())
396 }));
397 };
398
399 let stored_commit = stored_git["commit"].as_str().unwrap_or_default();
400 let current_commit = current_git["commit"].as_str().unwrap_or_default();
401 let commits_ahead = if stored_commit == current_commit {
402 Some(0)
403 } else {
404 std::process::Command::new("git")
405 .args(["rev-list", "--count", &format!("{stored_commit}..HEAD")])
406 .current_dir(&root)
407 .output()
408 .ok()
409 .filter(|o| o.status.success())
410 .and_then(|o| {
411 String::from_utf8_lossy(&o.stdout)
412 .trim()
413 .parse::<u64>()
414 .ok()
415 })
416 };
417 let dirty_delta = current_git["dirty_count"].as_i64().unwrap_or(0)
418 - stored_git["dirty_count"].as_i64().unwrap_or(0);
419
420 let verdict = if stored_commit == current_commit && dirty_delta == 0 {
421 "clean"
422 } else if stored_commit == current_commit {
423 "dirty-drift"
424 } else {
425 "drifted"
426 };
427
428 Ok(json!({
429 "status": "success",
430 "verifiable": true,
431 "session_id": session_id,
432 "checkpoint_id": checkpoint_id,
433 "stored_git": stored_git,
434 "current_git": current_git,
435 "commits_ahead": commits_ahead,
436 "dirty_delta": dirty_delta,
437 "verdict": verdict,
438 }))
439 }
440 fn stats(&self) -> &ToolStats {
441 &self.stats
442 }
443}
444
445pub struct SessionRecallTool {
447 store: Arc<MemoryStore>,
448 stats: ToolStats,
449 effects: EffectRow,
450}
451
452impl SessionRecallTool {
453 pub fn new(store: Arc<MemoryStore>) -> Self {
454 Self {
455 store,
456 stats: ToolStats::default(),
457 effects: EffectRow::read_only(vec![Resource::Galaxy("sessions".into())]),
458 }
459 }
460}
461
462#[async_trait]
463impl Tool for SessionRecallTool {
464 fn input_schema(&self) -> Value {
465 super::common::schema(
466 &json!({
467 "session_id": super::common::str_prop("Session UUID to recall (defaults to the most recent session)"),
468 "limit": super::common::int_prop("Maximum turns to return (default 50)"),
469 }),
470 &[],
471 )
472 }
473 fn name(&self) -> &str {
474 "session.recall"
475 }
476 fn gana(&self) -> Gana {
477 Gana::StraddlingLegs
478 }
479 fn effects(&self) -> &EffectRow {
480 &self.effects
481 }
482 fn description(&self) -> &str {
483 "Recall session memories by session_id"
484 }
485 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
486 let session_id = args
487 .get("session_id")
488 .and_then(|v| v.as_str())
489 .unwrap_or("");
490 let limit = args
491 .get("limit")
492 .and_then(serde_json::Value::as_u64)
493 .unwrap_or(50) as usize;
494 let memories = self.store.scan_all(Galaxy::Sessions)?;
495 let filtered: Vec<Value> = memories
496 .iter()
497 .filter(|m| m.content.contains(session_id))
498 .take(limit)
499 .map(|m| {
500 json!({
501 "id": m.metadata.id,
502 "content": m.content,
503 "tags": m.metadata.tags,
504 "created_at": m.metadata.created_at.to_rfc3339(),
505 })
506 })
507 .collect();
508 Ok(json!({
509 "status": "success",
510 "session_id": session_id,
511 "count": filtered.len(),
512 "memories": filtered,
513 }))
514 }
515 fn stats(&self) -> &ToolStats {
516 &self.stats
517 }
518}
519
520pub struct SessionEndTool {
522 store: Arc<MemoryStore>,
523 stats: ToolStats,
524 effects: EffectRow,
525}
526
527impl SessionEndTool {
528 pub fn new(store: Arc<MemoryStore>) -> Self {
529 Self {
530 store,
531 stats: ToolStats::default(),
532 effects: EffectRow {
533 writes: vec![Resource::Galaxy("sessions".into())],
534 ..Default::default()
535 },
536 }
537 }
538}
539
540#[async_trait]
541impl Tool for SessionEndTool {
542 fn name(&self) -> &str {
543 "session.end"
544 }
545 fn gana(&self) -> Gana {
546 Gana::StraddlingLegs
547 }
548 fn effects(&self) -> &EffectRow {
549 &self.effects
550 }
551 fn description(&self) -> &str {
552 "End a session — writes a session_end marker"
553 }
554 fn input_schema(&self) -> Value {
555 super::common::schema(
556 &json!({
557 "session_id": super::common::str_prop("Session UUID to end (start-memory id)"),
558 "summary": super::common::str_prop("Optional closing summary"),
559 }),
560 &["session_id"],
561 )
562 }
563 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
564 let session_id = args
565 .get("session_id")
566 .and_then(|v| v.as_str())
567 .unwrap_or("");
568 let summary = args.get("summary").and_then(|v| v.as_str()).unwrap_or("");
569 let mut mem = Memory::new(
570 Galaxy::Sessions,
571 json!({
572 "type": "session_end",
573 "session_id": session_id,
574 "summary": summary,
575 })
576 .to_string(),
577 );
578 mem.metadata.tags = vec!["session".into(), "end".into()];
579 mem.metadata.importance = 0.6;
580 mem.metadata.source = "system".to_string();
582 mem.metadata.source_trust = 0.7;
583 self.store.put(Galaxy::Sessions, &mem)?;
584 crate::capture_explicit_memory(
585 &self.store,
586 &mem,
587 EpisodicKind::SystemEvent,
588 ProvenanceSource::System,
589 uuid::Uuid::parse_str(session_id).ok(),
590 0,
591 );
592 Ok(json!({
593 "status": "success",
594 "session_id": session_id,
595 "end_id": mem.metadata.id,
596 }))
597 }
598 fn stats(&self) -> &ToolStats {
599 &self.stats
600 }
601}
602
603#[cfg(test)]
604mod tests {
605 use super::*;
606
607 fn test_store() -> Arc<MemoryStore> {
608 let dir = tempfile::tempdir().unwrap();
609 let path = dir.path().join("lmdb");
610 std::fs::create_dir_all(&path).unwrap();
611 Arc::new(MemoryStore::open_default(path).unwrap())
612 }
613
614 fn start_session(store: &MemoryStore) -> String {
615 let mut mem = Memory::new(
616 Galaxy::Sessions,
617 json!({"type": "session_start"}).to_string(),
618 );
619 mem.metadata.tags = vec!["session".into(), "start".into()];
620 store.put(Galaxy::Sessions, &mem).unwrap();
621 mem.metadata.id.to_string()
622 }
623
624 fn git_repo() -> (tempfile::TempDir, PathBuf) {
627 let dir = tempfile::tempdir().unwrap();
628 let root = dir.path().to_path_buf();
629 let run = |args: &[&str]| {
630 std::process::Command::new("git")
631 .args(args)
632 .current_dir(&root)
633 .output()
634 .expect("git must be available")
635 };
636 assert!(run(&["init", "-q"]).status.success());
637 assert!(run(&["config", "user.email", "t@t"]).status.success());
638 assert!(run(&["config", "user.name", "t"]).status.success());
639 assert!(
640 run(&["commit", "--allow-empty", "-m", "c1"])
641 .status
642 .success()
643 );
644 (dir, root)
645 }
646
647 #[tokio::test]
650 async fn start_marker_stamps_system_provenance() {
651 let store = test_store();
652 let tool = SessionStartTool::new(store.clone());
653 let mut ctx = Context::default();
654 let out = tool
655 .call(&mut ctx, json!({"title": "prov test"}))
656 .await
657 .unwrap();
658 let sid = uuid::Uuid::parse_str(out["session_id"].as_str().unwrap()).unwrap();
659 let mem = store
660 .get(Galaxy::Sessions, sid)
661 .expect("start stored")
662 .expect("start present");
663 assert_eq!(mem.metadata.source, "system");
664 assert!((mem.metadata.source_trust - 0.7).abs() < 1e-5);
665 }
666
667 #[tokio::test]
668 async fn checkpoint_auto_captures_git_state() {
669 let store = test_store();
670 let sid = start_session(&store);
671 let (_guard, root) = git_repo();
672
673 let tool = SessionCheckpointTool::new(store.clone());
674 let mut ctx = Context::default();
675 let r = tool
676 .call(
677 &mut ctx,
678 json!({"session_id": sid, "root": root.display().to_string(), "tests_green": true}),
679 )
680 .await
681 .unwrap();
682
683 assert_eq!(r["status"], "success");
684 let git = &r["handoff"]["git"];
685 let expected = String::from_utf8(
686 std::process::Command::new("git")
687 .args(["rev-parse", "HEAD"])
688 .current_dir(&root)
689 .output()
690 .unwrap()
691 .stdout,
692 )
693 .unwrap();
694 assert_eq!(
695 git["commit"].as_str().unwrap().trim(),
696 expected.trim(),
697 "checkpoint must auto-capture the live HEAD"
698 );
699 assert_eq!(git["dirty_count"], 0);
700 assert_eq!(r["handoff"]["tests_green"], true);
701 }
702
703 #[tokio::test]
704 async fn checkpoint_resolves_latest_session_when_absent() {
705 let store = test_store();
706 let _old = start_session(&store);
707 let newest = start_session(&store);
708
709 let tool = SessionCheckpointTool::new(store);
710 let mut ctx = Context::default();
711 let r = tool.call(&mut ctx, json!({"label": "wrap"})).await.unwrap();
712
713 assert_eq!(r["status"], "success");
714 assert_eq!(r["session_id"], newest, "must target the newest session");
715 }
716
717 #[tokio::test]
718 async fn verify_reports_clean_then_drifted() {
719 let store = test_store();
720 let sid = start_session(&store);
721 let (dir_guard, root) = git_repo();
722 let root_str = root.display().to_string();
723
724 let cp = SessionCheckpointTool::new(store.clone());
725 let mut ctx = Context::default();
726 cp.call(&mut ctx, json!({"session_id": sid, "root": root_str}))
727 .await
728 .unwrap();
729
730 let verify = SessionVerifyTool::new(store.clone());
731 let clean = verify
732 .call(&mut ctx, json!({"session_id": sid, "root": root_str}))
733 .await
734 .unwrap();
735 assert_eq!(clean["verifiable"], true);
736 assert_eq!(clean["verdict"], "clean", "got: {clean}");
737 assert_eq!(clean["commits_ahead"], 0);
738
739 assert!(
741 std::process::Command::new("git")
742 .args(["commit", "--allow-empty", "-m", "c2"])
743 .current_dir(&root)
744 .output()
745 .unwrap()
746 .status
747 .success()
748 );
749
750 let drifted = verify
751 .call(&mut ctx, json!({"session_id": sid, "root": root_str}))
752 .await
753 .unwrap();
754 assert_eq!(drifted["verdict"], "drifted", "got: {drifted}");
755 assert_eq!(drifted["commits_ahead"], 1);
756 assert_ne!(
757 drifted["stored_git"]["commit"],
758 drifted["current_git"]["commit"]
759 );
760
761 drop(dir_guard);
762 }
763
764 #[tokio::test]
765 async fn checkpoint_carries_lease_id_in_handoff() {
766 let store = test_store();
767 let sid = start_session(&store);
768
769 let tool = SessionCheckpointTool::new(store);
770 let mut ctx = Context::default();
771 let r = tool
772 .call(
773 &mut ctx,
774 json!({"session_id": sid, "lease_id": "src/expansion/"}),
775 )
776 .await
777 .unwrap();
778
779 assert_eq!(r["status"], "success");
780 assert_eq!(r["handoff"]["lease_id"], "src/expansion/");
781 }
782
783 #[tokio::test]
784 async fn verify_reports_unverifiable_without_git_checkpoint() {
785 let store = test_store();
786 let sid = start_session(&store);
787
788 let cp = SessionCheckpointTool::new(store.clone());
790 let mut ctx = Context::default();
791 let r = cp.call(&mut ctx, json!({"session_id": sid})).await.unwrap();
793 assert!(r["handoff"]["git"].is_null());
794
795 let verify = SessionVerifyTool::new(store);
796 let v = verify
797 .call(&mut ctx, json!({"session_id": sid}))
798 .await
799 .unwrap();
800 assert_eq!(v["verifiable"], false, "got: {v}");
801 assert!(v["message"].as_str().unwrap().contains("no checkpoint"));
802 }
803}