1#![forbid(unsafe_code)]
9
10use async_trait::async_trait;
11
12use chrono::{DateTime, NaiveDate, TimeZone, Utc};
13use serde_json::{Value, json};
14use std::fmt::Write as _;
15use std::sync::Arc;
16use wm_core::{Context, EffectRow, Galaxy, Gana, Resource, Tool, ToolStats};
17use wm_memory::{Memory, MemoryStore};
18
19fn parse_time_bound(v: &Value, end_of_day: bool) -> Option<DateTime<Utc>> {
23 if let Some(secs) = v
24 .as_i64()
25 .or_else(|| v.as_u64().and_then(|u| i64::try_from(u).ok()))
26 {
27 return Utc.timestamp_opt(secs, 0).single();
28 }
29 let s = v.as_str()?;
30 if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
31 return Some(dt.with_timezone(&Utc));
32 }
33 let day = NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()?;
34 let naive = if end_of_day {
35 day.and_hms_opt(23, 59, 59)?
36 } else {
37 day.and_hms_opt(0, 0, 0)?
38 };
39 Some(naive.and_utc())
40}
41
42fn filter_by_time(
45 turns: Vec<(Memory, Value)>,
46 args: &Value,
47) -> wm_core::Result<Vec<(Memory, Value)>> {
48 let since = match args.get("since") {
49 Some(v) if !v.is_null() => Some(parse_time_bound(v, false).ok_or_else(|| {
50 wm_core::CoreError::InvalidArgs(
51 "invalid 'since' — use epoch seconds, RFC 3339, or YYYY-MM-DD".into(),
52 )
53 })?),
54 _ => None,
55 };
56 let until = match args.get("until") {
57 Some(v) if !v.is_null() => Some(parse_time_bound(v, true).ok_or_else(|| {
58 wm_core::CoreError::InvalidArgs(
59 "invalid 'until' — use epoch seconds, RFC 3339, or YYYY-MM-DD".into(),
60 )
61 })?),
62 _ => None,
63 };
64 Ok(turns
65 .into_iter()
66 .filter(|(m, _)| {
67 since.is_none_or(|t| m.metadata.created_at >= t)
68 && until.is_none_or(|t| m.metadata.created_at <= t)
69 })
70 .collect())
71}
72
73fn turn_json(mem: &Memory) -> Option<Value> {
74 let v: Value = serde_json::from_str(&mem.content).ok()?;
75 if v.get("type").and_then(Value::as_str) == Some("session_turn") {
76 Some(v)
77 } else {
78 None
79 }
80}
81
82fn load_turns(
88 store: &MemoryStore,
89 session_id: Option<&str>,
90 limit: usize,
91 include_superseded: bool,
92) -> wm_core::Result<Vec<(Memory, Value)>> {
93 let memories = store.scan_all(Galaxy::Sessions)?;
94 let mut turns: Vec<(Memory, Value)> = memories
95 .iter()
96 .filter(|m| {
97 include_superseded
98 || !m
99 .metadata
100 .tags
101 .iter()
102 .any(|t| t.starts_with("superseded-by:"))
103 })
104 .filter_map(|m| turn_json(m).map(|v| (m.clone(), v)))
105 .filter(|(_, v)| {
106 session_id.is_none_or(|sid| v.get("session_id").and_then(Value::as_str) == Some(sid))
107 })
108 .collect();
109 turns.sort_by_key(|(_, v)| {
110 (
111 v.get("sequence").and_then(Value::as_u64).unwrap_or(0),
112 v.get("timestamp").and_then(Value::as_i64).unwrap_or(0),
113 )
114 });
115 turns.truncate(limit);
116 Ok(turns)
117}
118
119fn format_turn(v: &Value, full: bool) -> Value {
120 let role = v.get("role").and_then(Value::as_str).unwrap_or("?");
121 let content = v.get("content").and_then(Value::as_str).unwrap_or("");
122 if full {
123 json!({
124 "session_id": v.get("session_id"),
125 "sequence": v.get("sequence"),
126 "role": role,
127 "turn_type": v.get("turn_type"),
128 "importance": v.get("importance"),
129 "content": content,
130 })
131 } else {
132 json!({
133 "sequence": v.get("sequence"),
134 "role": role,
135 "turn_type": v.get("turn_type"),
136 "preview": content.chars().take(120).collect::<String>(),
137 })
138 }
139}
140
141pub struct SessionRecordTool {
143 store: Arc<MemoryStore>,
144 stats: ToolStats,
145 effects: EffectRow,
146}
147
148impl SessionRecordTool {
149 #[must_use]
150 pub fn new(store: Arc<MemoryStore>) -> Self {
151 Self {
152 store,
153 stats: ToolStats::default(),
154 effects: EffectRow {
155 writes: vec![Resource::Galaxy("sessions".into())],
156 ..Default::default()
157 },
158 }
159 }
160}
161
162#[async_trait]
163impl Tool for SessionRecordTool {
164 fn name(&self) -> &str {
165 "session.record"
166 }
167 fn gana(&self) -> Gana {
168 Gana::StraddlingLegs
169 }
170 fn effects(&self) -> &EffectRow {
171 &self.effects
172 }
173 fn input_schema(&self) -> Value {
174 super::common::schema(
175 &json!({
176 "content": super::common::str_prop("Turn content"),
177 "role": super::common::str_prop("user | ai (default user)"),
178 "turn_type": super::common::str_prop("message, decision, breakthrough, question, answer, code_change, error, summary, context"),
179 "importance": super::common::num_prop("0-1 importance (default 0.5)"),
180 "session_id": super::common::str_prop("Target session (default: most recent session)"),
181 "supersedes": super::common::str_prop("Memory id of an earlier turn this record corrects/replaces (amend-with-supersede)"),
182 }),
183 &["content"],
184 )
185 }
186 fn description(&self) -> &str {
187 "Record a conversation turn as persistent session memory. Args: content (required), role (user|ai, default user), turn_type (default message), importance (0-1, default 0.5), session_id (optional — defaults to the most recent session), supersedes (optional turn memory-id — marks the old turn superseded so replay/continuity/digest use the new record)."
188 }
189 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
190 let role = args.get("role").and_then(Value::as_str).unwrap_or("user");
191 if !matches!(role, "user" | "ai") {
192 return Err(wm_core::CoreError::InvalidArgs(
193 "role must be 'user' or 'ai'".into(),
194 ));
195 }
196 let content = args
197 .get("content")
198 .and_then(Value::as_str)
199 .ok_or_else(|| wm_core::CoreError::InvalidArgs("content is required".into()))?;
200 let turn_type = args
201 .get("turn_type")
202 .and_then(Value::as_str)
203 .unwrap_or("message");
204 let importance = args
205 .get("importance")
206 .and_then(Value::as_f64)
207 .unwrap_or(0.5)
208 .clamp(0.0, 1.0);
209 let session_id = args.get("session_id").and_then(Value::as_str);
210
211 let session_id: String = if let Some(sid) = session_id {
217 sid.to_string()
218 } else {
219 self.store
220 .scan_all(Galaxy::Sessions)?
221 .iter()
222 .filter(|m| m.metadata.tags.contains(&"start".to_string()))
223 .max_by_key(|m| m.metadata.created_at)
224 .map(|m| m.metadata.id.to_string())
225 .ok_or_else(|| {
226 wm_core::CoreError::Tool("no session found — run session.start first".into())
227 })?
228 };
229
230 let sequence = load_turns(&self.store, Some(&session_id), 10_000, true)?.len() as u64 + 1;
233
234 let mut mem = Memory::new(
235 Galaxy::Sessions,
236 json!({
237 "type": "session_turn",
238 "session_id": session_id,
239 "sequence": sequence,
240 "role": role,
241 "turn_type": turn_type,
242 "importance": importance,
243 "content": content,
244 "timestamp": wm_core::time::now_unix_millis(),
245 })
246 .to_string(),
247 );
248 mem.metadata.tags = vec![
249 "session".into(),
250 "turn".into(),
251 role.into(),
252 turn_type.into(),
253 format!("session:{session_id}"),
254 ];
255 let (source, trust): (&str, f32) = if role == "user" {
262 ("user", 1.0)
263 } else {
264 ("agent", 0.7)
265 };
266 mem.metadata.source = source.to_string();
267 mem.metadata.source_trust = trust;
268
269 if let Some(old_id_str) = args.get("supersedes").and_then(Value::as_str) {
273 let old_id = uuid::Uuid::parse_str(old_id_str).map_err(|e| {
274 wm_core::CoreError::InvalidArgs(format!("invalid 'supersedes' id: {e}"))
275 })?;
276 let mut old = self.store.get(Galaxy::Sessions, old_id)?.ok_or_else(|| {
277 wm_core::CoreError::NotFound(format!("superseded turn {old_id} not found"))
278 })?;
279 old.metadata
280 .tags
281 .push(format!("superseded-by:{}", mem.metadata.id));
282 self.store.put(Galaxy::Sessions, &old)?;
283 mem.metadata.tags.push(format!("supersedes:{old_id}"));
284 }
285
286 mem.metadata.importance = importance as f32;
287 self.store.put(Galaxy::Sessions, &mem)?;
288 Ok(json!({
289 "status": "success",
290 "session_id": session_id,
291 "sequence": sequence,
292 "memory_id": mem.metadata.id.to_string(),
293 }))
294 }
295 fn stats(&self) -> &ToolStats {
296 &self.stats
297 }
298}
299
300pub struct SessionReplayTool {
302 store: Arc<MemoryStore>,
303 stats: ToolStats,
304 effects: EffectRow,
305}
306
307impl SessionReplayTool {
308 #[must_use]
309 pub fn new(store: Arc<MemoryStore>) -> Self {
310 Self {
311 store,
312 stats: ToolStats::default(),
313 effects: EffectRow::read_only(vec![Resource::Galaxy("sessions".into())]),
314 }
315 }
316}
317
318#[async_trait]
319impl Tool for SessionReplayTool {
320 fn name(&self) -> &str {
321 "session.replay"
322 }
323 fn gana(&self) -> Gana {
324 Gana::StraddlingLegs
325 }
326 fn effects(&self) -> &EffectRow {
327 &self.effects
328 }
329 fn input_schema(&self) -> Value {
330 super::common::schema(
331 &json!({
332 "mode": super::common::str_prop("full | selective | progressive (default full)"),
333 "session_id": super::common::str_prop("Target session (default: most recent)"),
334 "n": super::common::int_prop("Maximum turns (default 50)"),
335 "since": super::common::str_prop("Time-range floor: epoch seconds, RFC 3339, or YYYY-MM-DD"),
336 "until": super::common::str_prop("Time-range ceiling: epoch seconds, RFC 3339, or YYYY-MM-DD"),
337 "include_superseded": {
338 "type": "boolean",
339 "description": "Also return turns replaced via supersedes (default false)."
340 },
341 "turn_types": super::common::str_array_prop("Selective mode: turn types to keep"),
342 "min_importance": super::common::num_prop("Selective mode floor (default 0.7)"),
343 "token_budget": super::common::int_prop("Progressive mode token budget (default 2000)"),
344 }),
345 &[],
346 )
347 }
348 fn description(&self) -> &str {
349 "Replay session turns. Args: mode (full|selective|progressive, default full), session_id (optional), n (default 50), since/until (epoch seconds | RFC 3339 | YYYY-MM-DD — applies to all modes), include_superseded (default false), turn_types (list, for selective), min_importance (0-1, default 0.7, for selective), token_budget (default 2000, for progressive)."
350 }
351 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
352 let mode = args.get("mode").and_then(Value::as_str).unwrap_or("full");
353 let session_id = args.get("session_id").and_then(Value::as_str);
354 let n = args.get("n").and_then(Value::as_u64).unwrap_or(50) as usize;
355 let include_superseded = args
356 .get("include_superseded")
357 .and_then(Value::as_bool)
358 .unwrap_or(false);
359 let turns = filter_by_time(
360 load_turns(&self.store, session_id, 10_000, include_superseded)?,
361 &args,
362 )?;
363
364 if session_id.is_some_and(|sid| !sid.is_empty()) && turns.is_empty() {
367 return Err(wm_core::CoreError::InvalidArgs(format!(
368 "no session found with id {session_id:?}"
369 )));
370 }
371
372 let selected: Vec<(Memory, Value)> = match mode {
373 "selective" => {
374 let min_importance = args
375 .get("min_importance")
376 .and_then(Value::as_f64)
377 .unwrap_or(0.7);
378 let turn_types: Vec<String> = args
379 .get("turn_types")
380 .and_then(Value::as_array)
381 .map_or_else(
382 || vec!["decision".into(), "breakthrough".into(), "answer".into()],
383 |a| {
384 a.iter()
385 .filter_map(Value::as_str)
386 .map(str::to_string)
387 .collect()
388 },
389 );
390 turns
391 .into_iter()
392 .filter(|(_, v)| {
393 v.get("importance").and_then(Value::as_f64).unwrap_or(0.0) >= min_importance
394 && v.get("turn_type")
395 .and_then(Value::as_str)
396 .is_some_and(|t| turn_types.contains(&t.to_string()))
397 })
398 .collect()
399 }
400 "progressive" => {
401 let budget = args
402 .get("token_budget")
403 .and_then(Value::as_u64)
404 .unwrap_or(2000) as usize;
405 let mut used = 0usize;
406 let mut out = Vec::new();
407 for (m, v) in turns.into_iter().rev() {
408 let approx = v
409 .get("content")
410 .and_then(Value::as_str)
411 .map_or(0, |c| c.len() / 4);
412 if used + approx > budget {
413 break;
414 }
415 used += approx;
416 out.push((m, v));
417 }
418 out.reverse();
419 out
420 }
421 _ => turns
422 .into_iter()
423 .rev()
424 .take(n)
425 .collect::<Vec<_>>()
426 .into_iter()
427 .rev()
428 .collect(),
429 };
430
431 let full = mode != "progressive";
432 let formatted: Vec<Value> = selected.iter().map(|(_, v)| format_turn(v, full)).collect();
433 Ok(json!({
434 "status": "success",
435 "mode": mode,
436 "count": formatted.len(),
437 "session_id": session_id,
438 "turns": formatted,
439 }))
440 }
441 fn stats(&self) -> &ToolStats {
442 &self.stats
443 }
444}
445
446pub struct SessionContinuityTool {
448 store: Arc<MemoryStore>,
449 stats: ToolStats,
450 effects: EffectRow,
451}
452
453impl SessionContinuityTool {
454 #[must_use]
455 pub fn new(store: Arc<MemoryStore>) -> Self {
456 Self {
457 store,
458 stats: ToolStats::default(),
459 effects: EffectRow::read_only(vec![Resource::Galaxy("sessions".into())]),
460 }
461 }
462}
463
464#[async_trait]
465impl Tool for SessionContinuityTool {
466 fn name(&self) -> &str {
467 "session.continuity"
468 }
469 fn gana(&self) -> Gana {
470 Gana::StraddlingLegs
471 }
472 fn effects(&self) -> &EffectRow {
473 &self.effects
474 }
475 fn input_schema(&self) -> Value {
476 super::common::schema(
477 &json!({
478 "current_session_id": super::common::str_prop("Session to exclude (optional)"),
479 "n": super::common::int_prop("Number of prior turns (default 10)"),
480 "since": super::common::str_prop("Time-range floor: epoch seconds, RFC 3339, or YYYY-MM-DD"),
481 "until": super::common::str_prop("Time-range ceiling: epoch seconds, RFC 3339, or YYYY-MM-DD"),
482 }),
483 &[],
484 )
485 }
486 fn description(&self) -> &str {
487 "Get cross-session continuity — the last N turns of the most recent prior session ('where we left off'). Args: current_session_id (optional, excluded), n (default 10), since/until (epoch seconds | RFC 3339 | YYYY-MM-DD)."
488 }
489 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
490 let current = args.get("current_session_id").and_then(Value::as_str);
491 let n = args.get("n").and_then(Value::as_u64).unwrap_or(10) as usize;
492
493 let memories = self.store.scan_all(Galaxy::Sessions)?;
497 let previous = memories
498 .iter()
499 .filter(|m| {
500 m.metadata.tags.contains(&"start".to_string())
501 && current.is_none_or(|c| m.metadata.id.to_string() != c)
502 })
503 .max_by_key(|m| m.metadata.created_at);
504
505 let Some(prev) = previous else {
506 let project = std::env::var("WM_PROJECT").ok().filter(|s| !s.is_empty());
513 let store = self.store.path().display().to_string();
514 let scope = project.map_or_else(
515 || format!("store {store}"),
516 |p| format!("store {store}, project '{p}'"),
517 );
518 return Ok(json!({
519 "status": "success",
520 "previous_session": null,
521 "turns": [],
522 "count": 0,
523 "message": "no previous session found",
524 "hint": format!(
525 "memory is project-scoped and this server's scope ({scope}) has no sessions. If you expected continuity for your project, your client may be wired to a different store: check the mcp block in your opencode config and compare with GET /status on the fleet (store_path, project). Per-project layout: docs/MULTI_PROJECT_MEMORY.md."
526 ),
527 }));
528 };
529
530 let prev_id = prev.metadata.id.to_string();
531 let mut turns = filter_by_time(
532 load_turns(&self.store, Some(&prev_id), 10_000, false)?,
533 &args,
534 )?;
535 let total = turns.len();
536 let tail: Vec<Value> = turns
537 .split_off(total.saturating_sub(n))
538 .iter()
539 .map(|(_, v)| format_turn(v, true))
540 .collect();
541 Ok(json!({
542 "status": "success",
543 "previous_session": prev_id,
544 "count": tail.len(),
545 "turns": tail,
546 }))
547 }
548 fn stats(&self) -> &ToolStats {
549 &self.stats
550 }
551}
552
553pub struct SessionDigestTool {
560 store: Arc<MemoryStore>,
561 stats: ToolStats,
562 effects: EffectRow,
563}
564
565impl SessionDigestTool {
566 #[must_use]
567 pub fn new(store: Arc<MemoryStore>) -> Self {
568 Self {
569 store,
570 stats: ToolStats::default(),
571 effects: EffectRow::read_only(vec![Resource::Galaxy("sessions".into())]),
572 }
573 }
574}
575
576const DIGEST_SECTION_ORDER: &[&str] = &["decision", "breakthrough", "error", "summary"];
578
579#[async_trait]
580impl Tool for SessionDigestTool {
581 fn name(&self) -> &str {
582 "session.digest"
583 }
584 fn gana(&self) -> Gana {
585 Gana::StraddlingLegs
586 }
587 fn effects(&self) -> &EffectRow {
588 &self.effects
589 }
590 fn input_schema(&self) -> Value {
591 super::common::schema(
592 &json!({
593 "session_id": super::common::str_prop("Session to digest (default: most recent)"),
594 "min_importance": super::common::num_prop("Importance floor (default 0.5)"),
595 "include_checkpoint": {
596 "type": "boolean",
597 "description": "Append the latest checkpoint's git/handoff state (default true)."
598 },
599 "since": super::common::str_prop("Time-range floor: epoch seconds, RFC 3339, or YYYY-MM-DD"),
600 "until": super::common::str_prop("Time-range ceiling: epoch seconds, RFC 3339, or YYYY-MM-DD"),
601 }),
602 &[],
603 )
604 }
605 fn description(&self) -> &str {
606 "Compile a session into a markdown handoff digest — turns grouped by type and importance-ordered, latest checkpoint state appended. Args: session_id (optional), min_importance (default 0.5), include_checkpoint (default true), since/until."
607 }
608 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
609 let session_id = match args.get("session_id").and_then(Value::as_str) {
610 Some(sid) if !sid.is_empty() => sid.to_string(),
611 _ => self
612 .store
613 .scan_all(Galaxy::Sessions)?
614 .iter()
615 .filter(|m| m.metadata.tags.contains(&"start".to_string()))
616 .max_by_key(|m| m.metadata.created_at)
617 .map(|m| m.metadata.id.to_string())
618 .ok_or_else(|| {
619 wm_core::CoreError::Tool("no session found — run session.start first".into())
620 })?,
621 };
622 let min_importance = args
623 .get("min_importance")
624 .and_then(Value::as_f64)
625 .unwrap_or(0.5);
626 let include_checkpoint = args
627 .get("include_checkpoint")
628 .and_then(Value::as_bool)
629 .unwrap_or(true);
630
631 let mut turns: Vec<_> = filter_by_time(
632 load_turns(&self.store, Some(&session_id), 10_000, false)?,
633 &args,
634 )?
635 .into_iter()
636 .filter(|(_, v)| {
637 v.get("importance").and_then(Value::as_f64).unwrap_or(0.0) >= min_importance
638 })
639 .collect();
640 turns.sort_by(|a, b| {
641 b.1.get("importance")
642 .and_then(Value::as_f64)
643 .unwrap_or(0.0)
644 .total_cmp(&a.1.get("importance").and_then(Value::as_f64).unwrap_or(0.0))
645 });
646
647 let mut groups: Vec<(String, Vec<&Value>)> = Vec::new();
649 for (_, v) in &turns {
650 let t = v
651 .get("turn_type")
652 .and_then(Value::as_str)
653 .unwrap_or("message")
654 .to_string();
655 match groups.iter_mut().find(|(name, _)| *name == t) {
656 Some((_, list)) => list.push(v),
657 None => groups.push((t, vec![v])),
658 }
659 }
660 groups.sort_by_key(|(name, _)| {
661 (
662 DIGEST_SECTION_ORDER
663 .iter()
664 .position(|k| k == name)
665 .unwrap_or(DIGEST_SECTION_ORDER.len()),
666 name.clone(),
667 )
668 });
669
670 let mut digest = format!("# Session handoff — {session_id}\n");
671 let mut included = 0usize;
672 for (turn_type, items) in &groups {
673 writeln!(
674 digest,
675 "\n## {} ({})",
676 capitalize(&pluralize(turn_type)),
677 items.len()
678 )
679 .expect("write to String cannot fail");
680 for v in items {
681 let importance = v.get("importance").and_then(Value::as_f64).unwrap_or(0.0);
682 let content = v.get("content").and_then(Value::as_str).unwrap_or("");
683 writeln!(digest, "- ({importance:.2}) {content}")
684 .expect("write to String cannot fail");
685 included += 1;
686 }
687 }
688
689 let mut checkpoint_state = Value::Null;
691 if include_checkpoint {
692 if let Some(cp) = self
693 .store
694 .scan_all(Galaxy::Sessions)?
695 .iter()
696 .filter(|m| {
697 m.metadata.tags.contains(&"checkpoint".to_string())
698 && m.content.contains(&session_id)
699 })
700 .filter_map(|m| {
701 let parsed: Value = serde_json::from_str(&m.content).ok()?;
702 parsed
703 .get("handoff")
704 .filter(|h| !h.is_null())
705 .cloned()
706 .map(|h| (m.metadata.created_at, h))
707 })
708 .max_by_key(|(created_at, _)| *created_at)
709 .map(|(_, handoff)| handoff)
710 {
711 digest.push_str("\n## Checkpoint state\n");
712 if let Some(git) = cp.get("git") {
713 writeln!(
714 digest,
715 "- commit `{}` on `{}` ({} dirty files)",
716 git.get("commit").and_then(Value::as_str).unwrap_or("?"),
717 git.get("branch").and_then(Value::as_str).unwrap_or("?"),
718 git.get("dirty_count").and_then(Value::as_i64).unwrap_or(0)
719 )
720 .expect("write to String cannot fail");
721 }
722 if let Some(q) = cp.get("next_queue").and_then(Value::as_array) {
723 if !q.is_empty() {
724 writeln!(
725 digest,
726 "- next queue: {}",
727 q.iter()
728 .filter_map(Value::as_str)
729 .collect::<Vec<_>>()
730 .join(" → ")
731 )
732 .expect("write to String cannot fail");
733 }
734 }
735 if let Some(f) = cp.get("open_flags").and_then(Value::as_array) {
736 if !f.is_empty() {
737 writeln!(
738 digest,
739 "- open flags: {}",
740 f.iter()
741 .filter_map(Value::as_str)
742 .collect::<Vec<_>>()
743 .join("; ")
744 )
745 .expect("write to String cannot fail");
746 }
747 }
748 if let Some(tg) = cp.get("tests_green") {
749 writeln!(digest, "- tests green: {tg}").expect("write to String cannot fail");
750 }
751 checkpoint_state = cp;
752 }
753 }
754
755 Ok(json!({
756 "status": "success",
757 "session_id": session_id,
758 "digest": digest,
759 "turns_included": included,
760 "turns_total_scanned": turns.len(),
761 "sections": groups.iter().map(|(t, items)| json!({"type": t, "count": items.len()})).collect::<Vec<_>>(),
762 "checkpoint": checkpoint_state,
763 }))
764 }
765 fn stats(&self) -> &ToolStats {
766 &self.stats
767 }
768}
769
770fn capitalize(s: &str) -> String {
772 let mut chars = s.chars();
773 match chars.next() {
774 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
775 None => String::new(),
776 }
777}
778
779fn pluralize(s: &str) -> String {
782 if let Some(stem) = s.strip_suffix('y') {
783 format!("{stem}ies")
784 } else {
785 format!("{s}s")
786 }
787}
788
789pub struct SessionHandoffTool {
791 store: Arc<MemoryStore>,
792 stats: ToolStats,
793 effects: EffectRow,
794}
795
796impl SessionHandoffTool {
797 #[must_use]
798 pub fn new(store: Arc<MemoryStore>) -> Self {
799 Self {
800 store,
801 stats: ToolStats::default(),
802 effects: EffectRow {
803 writes: vec![Resource::Galaxy("sessions".into())],
804 ..Default::default()
805 },
806 }
807 }
808}
809
810#[async_trait]
811impl Tool for SessionHandoffTool {
812 fn name(&self) -> &str {
813 "session.handoff"
814 }
815 fn gana(&self) -> Gana {
816 Gana::StraddlingLegs
817 }
818 fn effects(&self) -> &EffectRow {
819 &self.effects
820 }
821 fn input_schema(&self) -> Value {
822 super::common::schema(
823 &json!({
824 "action": super::common::str_prop("transfer | accept | list"),
825 "session_id": super::common::str_prop("transfer: session to hand off"),
826 "message": super::common::str_prop("transfer: handoff note"),
827 "handoff_id": super::common::str_prop("accept: handoff to accept"),
828 }),
829 &["action"],
830 )
831 }
832 fn description(&self) -> &str {
833 "Transfer or resume a session across devices (actions: transfer, accept, list). transfer: session_id (required) + message; accept: handoff_id; list: pending handoffs."
834 }
835 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
836 let action = args.get("action").and_then(Value::as_str).unwrap_or("list");
837 match action {
838 "transfer" => {
839 let session_id =
840 args.get("session_id")
841 .and_then(Value::as_str)
842 .ok_or_else(|| {
843 wm_core::CoreError::InvalidArgs(
844 "session_id required for transfer".into(),
845 )
846 })?;
847 let message = args.get("message").and_then(Value::as_str).unwrap_or("");
848 let turns = load_turns(&self.store, Some(session_id), 10_000, false)?;
849 if turns.is_empty() {
850 return Err(wm_core::CoreError::Tool(format!(
851 "session {session_id} has no recorded turns"
852 )));
853 }
854 let summary: Vec<Value> =
855 turns.iter().map(|(_, v)| format_turn(v, false)).collect();
856 let handoff_id = format!("handoff-{}", uuid::Uuid::new_v4());
857 let mut mem = Memory::new(
858 Galaxy::Sessions,
859 json!({
860 "type": "session_handoff",
861 "handoff_id": handoff_id,
862 "session_id": session_id,
863 "message": message,
864 "status": "pending",
865 "turn_count": summary.len(),
866 "summary": summary,
867 "created_at": wm_core::time::now_unix_millis(),
868 })
869 .to_string(),
870 );
871 mem.metadata.tags = vec![
872 "session".into(),
873 "handoff".into(),
874 format!("session:{session_id}"),
875 ];
876 mem.metadata.importance = 0.8;
877 self.store.put(Galaxy::Sessions, &mem)?;
878 Ok(json!({
879 "status": "success",
880 "action": "transfer",
881 "handoff_id": handoff_id,
882 "session_id": session_id,
883 "turn_count": summary.len(),
884 }))
885 }
886 "accept" => {
887 let handoff_id =
888 args.get("handoff_id")
889 .and_then(Value::as_str)
890 .ok_or_else(|| {
891 wm_core::CoreError::InvalidArgs("handoff_id required for accept".into())
892 })?;
893 let memories = self.store.scan_all(Galaxy::Sessions)?;
894 let found = memories.iter().find(|m| {
895 m.metadata.tags.contains(&"handoff".to_string())
896 && m.content.contains(handoff_id)
897 });
898 let Some(mem) = found else {
899 return Err(wm_core::CoreError::Tool(format!(
900 "handoff {handoff_id} not found"
901 )));
902 };
903 let mut updated = mem.clone();
904 if let Ok(mut v) = serde_json::from_str::<Value>(&updated.content) {
905 v["status"] = json!("accepted");
906 updated.content = v.to_string();
907 }
908 self.store.put(Galaxy::Sessions, &updated)?;
909 Ok(json!({
910 "status": "success",
911 "action": "accept",
912 "handoff_id": handoff_id,
913 }))
914 }
915 "list" => {
916 let memories = self.store.scan_all(Galaxy::Sessions)?;
917 let handoffs: Vec<Value> = memories
918 .iter()
919 .filter(|m| m.metadata.tags.contains(&"handoff".to_string()))
920 .filter_map(|m| serde_json::from_str::<Value>(&m.content).ok())
921 .filter(|v| v.get("status").and_then(Value::as_str) == Some("pending"))
922 .map(|v| {
923 json!({
924 "handoff_id": v.get("handoff_id"),
925 "session_id": v.get("session_id"),
926 "message": v.get("message"),
927 "turn_count": v.get("turn_count"),
928 })
929 })
930 .collect();
931 Ok(json!({
932 "status": "success",
933 "action": "list",
934 "pending_count": handoffs.len(),
935 "handoffs": handoffs,
936 }))
937 }
938 other => Err(wm_core::CoreError::InvalidArgs(format!(
939 "unknown session.handoff action: {other}"
940 ))),
941 }
942 }
943 fn stats(&self) -> &ToolStats {
944 &self.stats
945 }
946}
947
948#[must_use]
950pub struct SessionExportTool {
956 store: Arc<MemoryStore>,
957 stats: ToolStats,
958 effects: EffectRow,
959}
960
961impl SessionExportTool {
962 pub fn new(store: Arc<MemoryStore>) -> Self {
963 Self {
964 store,
965 stats: ToolStats::default(),
966 effects: EffectRow::read_only(vec![Resource::Galaxy("sessions".into())]),
967 }
968 }
969}
970
971#[async_trait]
972impl Tool for SessionExportTool {
973 fn name(&self) -> &str {
974 "session.export"
975 }
976 fn gana(&self) -> Gana {
977 Gana::StraddlingLegs
978 }
979 fn effects(&self) -> &EffectRow {
980 &self.effects
981 }
982 fn input_schema(&self) -> Value {
983 super::common::schema(
984 &json!({
985 "session_id": super::common::str_prop("Session to export (default: most recent)"),
986 "path": super::common::str_prop("Write JSONL to this file instead of returning inline"),
987 }),
988 &[],
989 )
990 }
991 fn description(&self) -> &str {
992 "Export a session as JSONL (start marker + turns + checkpoints, preserving ids/timestamps/tags). Args: session_id (optional), path (optional — writes to file; otherwise returns jsonl inline)."
993 }
994 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
995 let session_id = match args.get("session_id").and_then(Value::as_str) {
996 Some(sid) if !sid.is_empty() => sid.to_string(),
997 _ => self
998 .store
999 .scan_all(Galaxy::Sessions)?
1000 .iter()
1001 .filter(|m| m.metadata.tags.contains(&"start".to_string()))
1002 .max_by_key(|m| m.metadata.created_at)
1003 .map(|m| m.metadata.id.to_string())
1004 .ok_or_else(|| {
1005 wm_core::CoreError::Tool("no session found — run session.start first".into())
1006 })?,
1007 };
1008
1009 let mut members: Vec<Memory> = self
1013 .store
1014 .scan_all(Galaxy::Sessions)?
1015 .into_iter()
1016 .filter(|m| m.metadata.id.to_string() == session_id || m.content.contains(&session_id))
1017 .collect();
1018 members.sort_by_key(|m| m.metadata.created_at);
1019
1020 let mut jsonl = String::new();
1021 let header = wm_memory::envelope::EnvelopeHeader::new("session_export", members.len());
1027 jsonl.push_str(&header.header_line());
1028 jsonl.push('\n');
1029 for m in &members {
1030 let line = serde_json::to_string(m)
1031 .map_err(|e| wm_core::CoreError::Tool(format!("export serialize: {e}")))?;
1032 jsonl.push_str(&line);
1033 jsonl.push('\n');
1034 }
1035
1036 let path_arg = args
1037 .get("path")
1038 .and_then(Value::as_str)
1039 .filter(|s| !s.is_empty());
1040 if let Some(dest) = path_arg {
1041 std::fs::write(dest, &jsonl)
1042 .map_err(|e| wm_core::CoreError::Tool(format!("export write {dest}: {e}")))?;
1043 Ok(json!({
1044 "status": "success",
1045 "session_id": session_id,
1046 "records": members.len(),
1047 "path": dest,
1048 }))
1049 } else {
1050 Ok(json!({
1051 "status": "success",
1052 "session_id": session_id,
1053 "records": members.len(),
1054 "jsonl": jsonl,
1055 }))
1056 }
1057 }
1058 fn stats(&self) -> &ToolStats {
1059 &self.stats
1060 }
1061}
1062
1063pub struct SessionImportTool {
1074 store: Arc<MemoryStore>,
1075 search: Option<Arc<wm_memory::SearchEngine>>,
1076 stats: ToolStats,
1077 effects: EffectRow,
1078}
1079
1080impl SessionImportTool {
1081 pub fn new(store: Arc<MemoryStore>, search: Option<Arc<wm_memory::SearchEngine>>) -> Self {
1082 Self {
1083 store,
1084 search,
1085 stats: ToolStats::default(),
1086 effects: EffectRow {
1087 writes: vec![Resource::Galaxy("sessions".into())],
1088 ..Default::default()
1089 },
1090 }
1091 }
1092}
1093
1094#[async_trait]
1095impl Tool for SessionImportTool {
1096 fn name(&self) -> &str {
1097 "session.import"
1098 }
1099 fn gana(&self) -> Gana {
1100 Gana::StraddlingLegs
1101 }
1102 fn effects(&self) -> &EffectRow {
1103 &self.effects
1104 }
1105 fn input_schema(&self) -> Value {
1106 super::common::schema(
1107 &json!({
1108 "path": super::common::str_prop("Read JSONL from this file"),
1109 "jsonl": super::common::str_prop("Or pass the export payload inline"),
1110 }),
1111 &[],
1112 )
1113 }
1114 fn description(&self) -> &str {
1115 "Import sessions from session.export JSONL (path or inline jsonl) — envelope-v2 header validated when present, bare v1 accepted; preserves ids, timestamps, and tags; indexes into the search index; overwrites on id collision."
1116 }
1117 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
1118 let payload = match args
1119 .get("path")
1120 .and_then(Value::as_str)
1121 .filter(|s| !s.is_empty())
1122 {
1123 Some(path) => std::fs::read_to_string(path)
1124 .map_err(|e| wm_core::CoreError::Tool(format!("import read {path}: {e}")))?,
1125 None => args
1126 .get("jsonl")
1127 .and_then(Value::as_str)
1128 .filter(|s| !s.is_empty())
1129 .ok_or_else(|| {
1130 wm_core::CoreError::InvalidArgs(
1131 "provide either 'path' or inline 'jsonl'".into(),
1132 )
1133 })?
1134 .to_string(),
1135 };
1136
1137 let mut envelope: Option<wm_memory::envelope::EnvelopeHeader> = None;
1142 let mut record_lines: Vec<&str> = Vec::new();
1143 let mut header_consumed = false;
1144 for line in payload.lines() {
1145 if line.trim().is_empty() {
1146 continue;
1147 }
1148 if !header_consumed {
1149 header_consumed = true;
1150 match wm_memory::envelope::read_header_line(line) {
1151 wm_memory::envelope::HeaderRead::Header(h) => {
1152 envelope = Some(h);
1153 continue;
1154 }
1155 wm_memory::envelope::HeaderRead::Refused(msg) => {
1156 return Err(wm_core::CoreError::Tool(msg));
1157 }
1158 wm_memory::envelope::HeaderRead::NotAHeader => {}
1159 }
1160 }
1161 record_lines.push(line);
1162 }
1163
1164 let readonly_engine = self.search.as_ref().is_some_and(|s| s.is_readonly());
1169 if readonly_engine {
1170 tracing::warn!(
1171 "session.import running against a read-only search engine — records land \
1172 in LMDB unindexed; they become searchable at the next writable startup \
1173 (heal_index_drift)"
1174 );
1175 }
1176 let mut writer_slot = match (&self.search, readonly_engine) {
1177 (Some(s), false) => s.writer().ok(),
1178 _ => None,
1179 };
1180
1181 let mut imported = 0usize;
1182 let mut indexed = 0usize;
1183 let mut skipped = 0usize;
1184 let mut session_ids: Vec<String> = Vec::new();
1185 for (lineno, line) in record_lines.iter().enumerate() {
1186 let mem: Memory = match serde_json::from_str(line) {
1187 Ok(m) => m,
1188 Err(e) => {
1189 skipped += 1;
1190 tracing::warn!(line = lineno + 1, error = %e, "skipping unparseable export line");
1191 continue;
1192 }
1193 };
1194 if let Ok(parsed) = serde_json::from_str::<Value>(&mem.content) {
1195 if let Some(sid) = parsed.get("session_id").and_then(Value::as_str) {
1196 if !session_ids.iter().any(|s| s == sid) {
1197 session_ids.push(sid.to_string());
1198 }
1199 }
1200 }
1201 if let (Some(search), Some(writer)) = (&self.search, writer_slot.as_mut()) {
1205 let id_str = mem.metadata.id.to_string();
1206 let _ = search.delete_document(writer, &id_str);
1207 match search.add_document(
1208 writer,
1209 &id_str,
1210 mem.metadata.galaxy.db_name(),
1211 &mem.content,
1212 &mem.metadata.tags,
1213 mem.metadata.created_at.timestamp(),
1214 ) {
1215 Ok(()) => indexed += 1,
1216 Err(e) => {
1217 tracing::warn!(id = %id_str, error = %e, "import index add failed (LMDB record kept)");
1218 }
1219 }
1220 }
1221 self.store.put(Galaxy::Sessions, &mem)?;
1222 imported += 1;
1223 }
1224
1225 if let Some(search) = &self.search {
1226 if let Some(mut writer) = writer_slot {
1227 search
1228 .commit(&mut writer)
1229 .map_err(|e| wm_core::CoreError::Tool(format!("import index commit: {e}")))?;
1230 }
1231 }
1232
1233 let mut warnings: Vec<String> = Vec::new();
1234 if let Some(h) = &envelope {
1235 if h.count != imported {
1236 let msg = format!(
1237 "envelope declares count {} but {} records imported",
1238 h.count, imported
1239 );
1240 tracing::warn!("{msg}");
1241 warnings.push(msg);
1242 }
1243 }
1244
1245 let envelope_info = envelope.as_ref().map(|h| {
1246 json!({
1247 "format_version": h.format_version,
1248 "kind": h.kind,
1249 "generator": h.generator,
1250 "created_at": h.created_at,
1251 "declared_count": h.count,
1252 })
1253 });
1254
1255 Ok(json!({
1256 "status": "success",
1257 "imported": imported,
1258 "skipped": skipped,
1259 "session_ids": session_ids,
1260 "indexed": indexed,
1261 "envelope": envelope_info,
1262 "warnings": warnings,
1263 }))
1264 }
1265 fn stats(&self) -> &ToolStats {
1266 &self.stats
1267 }
1268}
1269
1270pub fn register_session_ops(
1271 registry: &wm_dispatch::ToolRegistry,
1272 store: &Arc<MemoryStore>,
1273 search: Option<Arc<wm_memory::SearchEngine>>,
1274) -> wm_dispatch::ToolRegistry {
1275 registry
1276 .register(Arc::new(SessionRecordTool::new(store.clone())))
1277 .register(Arc::new(SessionReplayTool::new(store.clone())))
1278 .register(Arc::new(SessionContinuityTool::new(store.clone())))
1279 .register(Arc::new(SessionHandoffTool::new(store.clone())))
1280 .register(Arc::new(SessionExportTool::new(store.clone())))
1281 .register(Arc::new(SessionImportTool::new(store.clone(), search)))
1282}
1283
1284#[cfg(test)]
1285mod tests {
1286 use super::*;
1287
1288 fn test_store() -> Arc<MemoryStore> {
1289 let dir = tempfile::tempdir().unwrap();
1290 let path = dir.path().join("lmdb");
1291 std::fs::create_dir_all(&path).unwrap();
1292 Arc::new(MemoryStore::open_default(path).unwrap())
1293 }
1294
1295 fn start_session(store: &MemoryStore) -> String {
1296 let mut mem = Memory::new(
1297 Galaxy::Sessions,
1298 json!({"type": "session_start"}).to_string(),
1299 );
1300 mem.metadata.tags = vec!["session".into(), "start".into()];
1301 store.put(Galaxy::Sessions, &mem).unwrap();
1302 mem.metadata.id.to_string()
1303 }
1304
1305 fn start_session_aged(store: &MemoryStore, age_secs: i64) -> String {
1308 let mut mem = Memory::new(
1309 Galaxy::Sessions,
1310 json!({"type": "session_start"}).to_string(),
1311 );
1312 mem.metadata.tags = vec!["session".into(), "start".into()];
1313 mem.metadata.created_at = chrono::Utc::now() - chrono::Duration::seconds(age_secs);
1314 store.put(Galaxy::Sessions, &mem).unwrap();
1315 mem.metadata.id.to_string()
1316 }
1317
1318 fn record_aged_turn(store: &MemoryStore, sid: &str, age_days: u32, content: &str) {
1320 let mut mem = Memory::new(
1321 Galaxy::Sessions,
1322 json!({
1323 "type": "session_turn",
1324 "session_id": sid,
1325 "role": "ai",
1326 "turn_type": "decision",
1327 "importance": 0.9,
1328 "content": content,
1329 })
1330 .to_string(),
1331 );
1332 mem.metadata.tags = vec!["session".into(), "turn".into(), format!("session:{sid}")];
1333 mem.metadata.created_at = Utc::now() - chrono::Duration::days(i64::from(age_days));
1334 store.put(Galaxy::Sessions, &mem).unwrap();
1335 }
1336
1337 #[tokio::test]
1338 async fn replay_time_filters_since_and_until() {
1339 let store = test_store();
1340 let sid = start_session(&store);
1341 record_aged_turn(&store, &sid, 3, "three days ago");
1342 record_aged_turn(&store, &sid, 2, "two days ago");
1343 record_aged_turn(&store, &sid, 1, "yesterday");
1344 record_aged_turn(&store, &sid, 0, "today");
1345
1346 let replay = SessionReplayTool::new(store);
1347 let mut ctx = Context::default();
1348
1349 let two_days_ago = (Utc::now() - chrono::Duration::days(2)).format("%Y-%m-%d");
1351 let v = replay
1352 .call(
1353 &mut ctx,
1354 json!({"session_id": sid, "since": two_days_ago.to_string()}),
1355 )
1356 .await
1357 .unwrap();
1358 assert_eq!(v["count"], 3, "since=date keeps day-of + later: {v}");
1359
1360 let until_epoch = (Utc::now() - chrono::Duration::hours(23)).timestamp();
1362 let v = replay
1363 .call(&mut ctx, json!({"session_id": sid, "until": until_epoch}))
1364 .await
1365 .unwrap();
1366 assert_eq!(
1367 v["count"], 3,
1368 "until=epoch(23h ago) keeps the three older turns: {v}"
1369 );
1370
1371 let since = (Utc::now() - chrono::Duration::hours(60)).to_rfc3339();
1373 let until = (Utc::now() - chrono::Duration::hours(12)).to_rfc3339();
1374 let v = replay
1375 .call(
1376 &mut ctx,
1377 json!({"session_id": sid, "since": since, "until": until}),
1378 )
1379 .await
1380 .unwrap();
1381 assert_eq!(v["count"], 2, "window keeps two/two-days-ago turns: {v}");
1382 for turn in v["turns"].as_array().unwrap() {
1383 assert_ne!(
1384 turn["content"], "today",
1385 "time filters must exclude out-of-window turns"
1386 );
1387 }
1388
1389 assert!(
1391 replay
1392 .call(&mut ctx, json!({"session_id": sid, "since": "not-a-date"}))
1393 .await
1394 .is_err()
1395 );
1396 }
1397
1398 #[tokio::test]
1399 async fn continuity_respects_since_filter() {
1400 let store = test_store();
1401 let sid1 = start_session(&store);
1402 record_aged_turn(&store, &sid1, 5, "ancient decision");
1403 record_aged_turn(&store, &sid1, 0, "fresh decision");
1404 let sid2 = start_session(&store);
1405
1406 let continuity = SessionContinuityTool::new(store);
1407 let mut ctx = Context::default();
1408 let cutoff = (Utc::now() - chrono::Duration::days(1))
1409 .format("%Y-%m-%d")
1410 .to_string();
1411 let v = continuity
1412 .call(
1413 &mut ctx,
1414 json!({"current_session_id": sid2, "since": cutoff, "n": 10}),
1415 )
1416 .await
1417 .unwrap();
1418 assert_eq!(v["count"], 1, "only the fresh turn is in range: {v}");
1419 assert_eq!(v["turns"][0]["content"], "fresh decision");
1420
1421 let all = continuity
1423 .call(&mut ctx, json!({"current_session_id": sid2, "n": 10}))
1424 .await
1425 .unwrap();
1426 assert_eq!(all["count"], 2);
1427 }
1428
1429 #[tokio::test]
1430 async fn digest_groups_by_type_and_respects_importance_floor() {
1431 let store = test_store();
1432 let sid = start_session(&store);
1433 for (turn_type, importance, content) in [
1435 ("summary", 0.6, "wrapped up"),
1436 ("decision", 0.9, "picked architecture CO over alternatives"),
1437 ("error", 0.95, "startForce root cause found"),
1438 ("breakthrough", 0.85, "watch resolution insight"),
1439 ("message", 0.3, "low-value chatter"),
1440 ] {
1441 let mut mem = Memory::new(
1442 Galaxy::Sessions,
1443 json!({
1444 "type": "session_turn",
1445 "session_id": sid,
1446 "role": "ai",
1447 "turn_type": turn_type,
1448 "importance": importance,
1449 "content": content,
1450 })
1451 .to_string(),
1452 );
1453 mem.metadata.tags = vec!["session".into(), "turn".into()];
1454 store.put(Galaxy::Sessions, &mem).unwrap();
1455 }
1456
1457 let tool = SessionDigestTool::new(store);
1458 let mut ctx = Context::default();
1459 let v = tool
1460 .call(&mut ctx, json!({"session_id": sid, "min_importance": 0.5}))
1461 .await
1462 .unwrap();
1463
1464 assert_eq!(v["status"], "success");
1465 let digest = v["digest"].as_str().unwrap();
1466 for expected in [
1468 "startForce root cause found",
1469 "picked architecture CO over alternatives",
1470 "watch resolution insight",
1471 ] {
1472 assert!(
1473 digest.contains(expected),
1474 "digest must contain '{expected}': {digest}"
1475 );
1476 }
1477 assert!(!digest.contains("low-value chatter"), "got: {digest}");
1479 let d = digest.find("## Decisions").unwrap();
1481 let b = digest.find("## Breakthroughs").unwrap();
1482 let e = digest.find("## Errors").unwrap();
1483 let s = digest.find("## Summaries").unwrap();
1484 assert!(
1485 d < b && b < e && e < s,
1486 "sections must follow canonical order: {digest}"
1487 );
1488 assert_eq!(v["turns_included"], 4);
1489
1490 let strict = tool
1492 .call(&mut ctx, json!({"session_id": sid, "min_importance": 0.9}))
1493 .await
1494 .unwrap();
1495 let strict_digest = strict["digest"].as_str().unwrap();
1496 assert!(strict_digest.contains("startForce"));
1497 assert!(!strict_digest.contains("watch resolution insight"));
1498 }
1499
1500 #[tokio::test]
1501 async fn digest_appends_checkpoint_state() {
1502 let store = test_store();
1503 let sid = start_session(&store);
1504
1505 let mut cp = Memory::new(
1507 Galaxy::Sessions,
1508 json!({
1509 "type": "checkpoint",
1510 "session_id": sid,
1511 "label": "wrap",
1512 "data": {},
1513 "handoff": {
1514 "git": {
1515 "commit": "abc1234",
1516 "branch": "main",
1517 "dirty_count": 2
1518 },
1519 "tests_green": true,
1520 "next_queue": ["first task", "second task"],
1521 "open_flags": ["flaky probe"]
1522 }
1523 })
1524 .to_string(),
1525 );
1526 cp.metadata.tags = vec!["session".into(), "checkpoint".into()];
1527 store.put(Galaxy::Sessions, &cp).unwrap();
1528
1529 let tool = SessionDigestTool::new(store);
1530 let mut ctx = Context::default();
1531 let v = tool
1532 .call(&mut ctx, json!({"session_id": sid}))
1533 .await
1534 .unwrap();
1535
1536 let digest = v["digest"].as_str().unwrap();
1537 assert!(digest.contains("## Checkpoint state"), "got: {digest}");
1538 assert!(digest.contains("abc1234"));
1539 assert!(digest.contains("first task → second task"));
1540 assert!(digest.contains("flaky probe"));
1541 assert_eq!(v["checkpoint"]["git"]["branch"], "main");
1542 }
1543
1544 #[tokio::test]
1545 async fn supersedes_hides_old_turn_until_requested() {
1546 let store = test_store();
1550 let sid = start_session(&store);
1551 let record = SessionRecordTool::new(store.clone());
1552 let mut ctx = Context::default();
1553
1554 let first = record
1555 .call(
1556 &mut ctx,
1557 json!({"role": "ai", "turn_type": "decision", "importance": 0.9,
1558 "content": "perf: 240ms", "session_id": sid}),
1559 )
1560 .await
1561 .unwrap();
1562 let old_id = first["memory_id"].as_str().unwrap().to_string();
1563
1564 let second = record
1565 .call(
1566 &mut ctx,
1567 json!({"role": "ai", "turn_type": "decision", "importance": 0.9,
1568 "content": "perf revised: 180ms after warm cache",
1569 "session_id": sid, "supersedes": old_id}),
1570 )
1571 .await
1572 .unwrap();
1573 assert_eq!(second["status"], "success");
1574 let _new_id = second["memory_id"].as_str().unwrap().to_string();
1575
1576 let replay = SessionReplayTool::new(store.clone());
1578 let v = replay
1579 .call(&mut ctx, json!({"session_id": sid}))
1580 .await
1581 .unwrap();
1582 assert_eq!(
1583 v["count"], 1,
1584 "superseded turn must be hidden by default: {v}"
1585 );
1586 assert_eq!(
1587 v["turns"][0]["content"],
1588 "perf revised: 180ms after warm cache"
1589 );
1590
1591 let with_history = replay
1593 .call(
1594 &mut ctx,
1595 json!({"session_id": sid, "include_superseded": true}),
1596 )
1597 .await
1598 .unwrap();
1599 assert_eq!(with_history["count"], 2, "got: {with_history}");
1600
1601 let sid2 = start_session(&store);
1603 let continuity = SessionContinuityTool::new(store.clone());
1604 let c = continuity
1605 .call(&mut ctx, json!({"current_session_id": sid2}))
1606 .await
1607 .unwrap();
1608 assert_eq!(c["count"], 1, "continuity must skip superseded turns: {c}");
1609 assert_eq!(
1610 c["turns"][0]["content"],
1611 "perf revised: 180ms after warm cache"
1612 );
1613
1614 let digest = SessionDigestTool::new(store);
1615 let d = digest
1616 .call(&mut ctx, json!({"session_id": sid, "min_importance": 0.5}))
1617 .await
1618 .unwrap();
1619 let digest_text = d["digest"].as_str().unwrap();
1620 assert!(digest_text.contains("180ms"), "got: {digest_text}");
1621 assert!(
1622 !digest_text.contains("240ms"),
1623 "superseded claim must not leak: {digest_text}"
1624 );
1625 }
1626
1627 #[tokio::test]
1628 async fn export_import_roundtrip_preserves_history() {
1629 let store_a = test_store();
1632 let sid = start_session(&store_a);
1633 let record = SessionRecordTool::new(store_a.clone());
1634 let mut ctx = Context::default();
1635 record_aged_turn(&store_a, &sid, 2, "day-one decision");
1636 record_aged_turn(&store_a, &sid, 1, "day-two decision");
1637 let first = record
1638 .call(
1639 &mut ctx,
1640 json!({"role": "ai", "turn_type": "decision", "importance": 0.9,
1641 "content": "original claim", "session_id": sid}),
1642 )
1643 .await
1644 .unwrap();
1645 record
1646 .call(
1647 &mut ctx,
1648 json!({"role": "ai", "turn_type": "decision", "importance": 0.9,
1649 "content": "corrected claim",
1650 "session_id": sid,
1651 "supersedes": first["memory_id"].as_str().unwrap()}),
1652 )
1653 .await
1654 .unwrap();
1655
1656 let export = SessionExportTool::new(store_a.clone());
1658 {
1660 let mut marker: Memory = store_a
1661 .scan_all(Galaxy::Sessions)
1662 .unwrap()
1663 .into_iter()
1664 .find(|m| m.metadata.tags.contains(&"start".to_string()))
1665 .unwrap();
1666 marker.metadata.title = Some("The Big Decision".to_string());
1667 marker.metadata.topic = Some("v8-slices".to_string());
1668 store_a.put(Galaxy::Sessions, &marker).unwrap();
1669 }
1670 let exported = export
1671 .call(&mut ctx, json!({"session_id": sid}))
1672 .await
1673 .unwrap();
1674 assert_eq!(exported["status"], "success");
1675 let jsonl = exported["jsonl"].as_str().unwrap();
1676 assert_eq!(
1678 exported["records"], 5,
1679 "start + 2 aged + 2 claims: {exported}"
1680 );
1681 assert_eq!(jsonl.lines().count(), 6);
1683 let header_line = jsonl.lines().next().unwrap();
1684 match wm_memory::envelope::read_header_line(header_line) {
1685 wm_memory::envelope::HeaderRead::Header(h) => {
1686 assert_eq!(h.kind, "session_export");
1687 assert_eq!(h.count, 5);
1688 }
1689 other => panic!("first line must be the envelope header, got {other:?}"),
1690 }
1691
1692 let store_b = test_store();
1694 let import = SessionImportTool::new(store_b.clone(), None);
1695 let imported = import
1696 .call(&mut ctx, json!({"jsonl": jsonl}))
1697 .await
1698 .unwrap();
1699 assert_eq!(imported["imported"], 5, "got: {imported}");
1700 assert_eq!(imported["skipped"], 0);
1701 assert_eq!(imported["session_ids"], json!([sid]));
1702 assert_eq!(imported["envelope"]["format_version"], 2);
1704 assert_eq!(imported["envelope"]["declared_count"], 5);
1705 assert_eq!(imported["warnings"], json!([]));
1706 let marker_b = store_b
1708 .scan_all(Galaxy::Sessions)
1709 .unwrap()
1710 .into_iter()
1711 .find(|m| m.metadata.tags.contains(&"start".to_string()))
1712 .unwrap();
1713 assert_eq!(marker_b.metadata.title.as_deref(), Some("The Big Decision"));
1714 assert_eq!(marker_b.metadata.topic.as_deref(), Some("v8-slices"));
1715
1716 let replay_b = SessionReplayTool::new(store_b.clone());
1718 let v = replay_b
1719 .call(&mut ctx, json!({"session_id": sid}))
1720 .await
1721 .unwrap();
1722 assert_eq!(v["count"], 3, "two aged turns + correction: {v}");
1723 let contents: Vec<&str> = v["turns"]
1724 .as_array()
1725 .unwrap()
1726 .iter()
1727 .filter_map(|t| t["content"].as_str())
1728 .collect();
1729 assert!(contents.contains(&"day-one decision"));
1730 assert!(contents.contains(&"corrected claim"));
1731 assert!(!contents.contains(&"original claim"));
1732
1733 let full = replay_b
1735 .call(
1736 &mut ctx,
1737 json!({"session_id": sid, "include_superseded": true}),
1738 )
1739 .await
1740 .unwrap();
1741 assert_eq!(full["count"], 4);
1742
1743 let new_sid = start_session(&store_b);
1745 let continuity = SessionContinuityTool::new(store_b);
1746 let c = continuity
1747 .call(
1748 &mut ctx,
1749 json!({"current_session_id": new_sid, "since":
1750 (Utc::now() - chrono::Duration::days(3)).format("%Y-%m-%d").to_string()}),
1751 )
1752 .await
1753 .unwrap();
1754 assert_eq!(
1755 c["previous_session"], sid,
1756 "import must preserve created_at so recency resolution works"
1757 );
1758 assert_eq!(c["count"], 3);
1759 }
1760
1761 #[tokio::test]
1762 async fn import_rejects_missing_payload() {
1763 let store = test_store();
1764 let tool = SessionImportTool::new(store, None);
1765 let mut ctx = Context::default();
1766 assert!(tool.call(&mut ctx, json!({})).await.is_err());
1767 }
1768
1769 #[tokio::test]
1770 async fn import_refuses_newer_envelope_format() {
1771 let store = test_store();
1772 let mut ctx = Context::default();
1773 let header = wm_memory::envelope::EnvelopeHeader {
1774 format_version: wm_memory::envelope::ENVELOPE_FORMAT_VERSION + 1,
1775 kind: "session_export".into(),
1776 created_at: chrono::Utc::now().to_rfc3339(),
1777 count: 1,
1778 generator: "wm 99.0.0".into(),
1779 };
1780 let record =
1781 serde_json::to_string(&Memory::new(Galaxy::Sessions, "future".into())).unwrap();
1782 let payload = format!("{}\n{record}\n", header.header_line());
1783 let tool = SessionImportTool::new(store, None);
1784 let result = tool.call(&mut ctx, json!({"jsonl": payload})).await;
1785 let err = format!("{:?}", result.unwrap_err());
1786 assert!(err.contains("newer than this build supports"), "{err}");
1787 }
1788
1789 #[tokio::test]
1793 async fn import_indexes_tantivy_no_drift_even_on_reimport() {
1794 let dir = tempfile::tempdir().unwrap();
1795 let lmdb = dir.path().join("lmdb");
1796 std::fs::create_dir_all(&lmdb).unwrap();
1797 let store = Arc::new(MemoryStore::open_default(&lmdb).unwrap());
1798 let tantivy = dir.path().join("tantivy");
1799 std::fs::create_dir_all(&tantivy).unwrap();
1800 let search = Arc::new(wm_memory::SearchEngine::open(&tantivy).unwrap());
1801
1802 let store_a = test_store();
1804 let sid = start_session(&store_a);
1805 let mut ctx = Context::default();
1806 SessionRecordTool::new(store_a.clone())
1807 .call(
1808 &mut ctx,
1809 json!({"role": "ai", "turn_type": "decision", "importance": 0.9,
1810 "content": "kumquat governance ratchet engaged", "session_id": sid}),
1811 )
1812 .await
1813 .unwrap();
1814 let exported = SessionExportTool::new(store_a.clone())
1815 .call(&mut ctx, json!({"session_id": sid}))
1816 .await
1817 .unwrap();
1818 let jsonl = exported["jsonl"].as_str().unwrap().to_string();
1819
1820 let import = SessionImportTool::new(store.clone(), Some(search.clone()));
1822 for round in 1..=2 {
1823 let r = import
1824 .call(&mut ctx, json!({"jsonl": jsonl}))
1825 .await
1826 .unwrap();
1827 assert_eq!(r["status"], "success", "round {round}: {r}");
1828 assert_eq!(r["skipped"], 0);
1829 assert_eq!(
1830 r["indexed"], r["imported"],
1831 "round {round}: every record indexed: {r}"
1832 );
1833 }
1834
1835 let report = wm_memory::reindex::check_consistency(&store, &search);
1838 let drifted: Vec<_> = report
1839 .galaxies
1840 .iter()
1841 .filter(|g| g.drift)
1842 .map(|g| g.galaxy.clone())
1843 .collect();
1844 assert!(
1845 drifted.is_empty(),
1846 "import must leave zero index drift, drifted: {drifted:?}"
1847 );
1848
1849 let needle_id = store
1851 .scan_all(Galaxy::Sessions)
1852 .unwrap()
1853 .iter()
1854 .find(|m| m.content.contains("kumquat"))
1855 .unwrap()
1856 .metadata
1857 .id
1858 .to_string();
1859 let hits = search.search("kumquat governance ratchet", 10).unwrap();
1860 assert!(
1861 hits.iter().any(|h| h.memory_id == needle_id),
1862 "imported record must be searchable via the index: {hits:?}"
1863 );
1864 }
1865
1866 #[tokio::test]
1867 async fn record_then_replay_full() {
1868 let store = test_store();
1869 let sid = start_session(&store);
1870 let record = SessionRecordTool::new(store.clone());
1871 let mut ctx = Context::default();
1872 for (i, role) in [("user", "hello"), ("ai", "hi there")].iter().enumerate() {
1873 let r = record
1874 .call(
1875 &mut ctx,
1876 json!({"role": role.0, "content": role.1, "session_id": sid}),
1877 )
1878 .await
1879 .unwrap();
1880 assert_eq!(r["sequence"], i as u64 + 1);
1881 }
1882
1883 let replay = SessionReplayTool::new(store.clone());
1884 let v = replay
1885 .call(&mut ctx, json!({"mode": "full", "session_id": sid}))
1886 .await
1887 .unwrap();
1888 assert_eq!(v["count"], 2);
1889 assert_eq!(v["turns"][0]["content"], "hello");
1890 assert_eq!(v["turns"][1]["role"], "ai");
1891 }
1892
1893 #[tokio::test]
1894 async fn record_requires_content_and_valid_role() {
1895 let store = test_store();
1896 let tool = SessionRecordTool::new(store);
1897 let mut ctx = Context::default();
1898 assert!(tool.call(&mut ctx, json!({})).await.is_err());
1899 assert!(
1900 tool.call(&mut ctx, json!({"role": "system", "content": "x"}))
1901 .await
1902 .is_err()
1903 );
1904 }
1905
1906 #[tokio::test]
1910 async fn record_stamps_provenance_from_role() {
1911 let store = test_store();
1912 let sid = start_session(&store);
1913 let record = SessionRecordTool::new(store.clone());
1914 let mut ctx = Context::default();
1915 let ai = record
1916 .call(
1917 &mut ctx,
1918 json!({"role": "ai", "content": "agent turn", "session_id": sid}),
1919 )
1920 .await
1921 .unwrap();
1922 let user = record
1923 .call(
1924 &mut ctx,
1925 json!({"role": "user", "content": "human turn", "session_id": sid}),
1926 )
1927 .await
1928 .unwrap();
1929
1930 let ai_mem = store
1931 .get(
1932 Galaxy::Sessions,
1933 uuid::Uuid::parse_str(ai["memory_id"].as_str().unwrap()).unwrap(),
1934 )
1935 .expect("ai turn stored")
1936 .expect("ai turn present");
1937 assert_eq!(ai_mem.metadata.source, "agent");
1938 assert!((ai_mem.metadata.source_trust - 0.7).abs() < 1e-5);
1939
1940 let user_mem = store
1941 .get(
1942 Galaxy::Sessions,
1943 uuid::Uuid::parse_str(user["memory_id"].as_str().unwrap()).unwrap(),
1944 )
1945 .expect("user turn stored")
1946 .expect("user turn present");
1947 assert_eq!(user_mem.metadata.source, "user");
1948 assert!((user_mem.metadata.source_trust - 1.0).abs() < f32::EPSILON);
1949 }
1950
1951 #[tokio::test]
1952 async fn continuity_returns_previous_session_tail() {
1953 let store = test_store();
1954 let sid1 = start_session(&store);
1955 let record = SessionRecordTool::new(store.clone());
1956 let mut ctx = Context::default();
1957 for i in 0..5 {
1958 record
1959 .call(
1960 &mut ctx,
1961 json!({"role": "user", "content": format!("turn {i}"), "session_id": sid1}),
1962 )
1963 .await
1964 .unwrap();
1965 }
1966 let sid2 = start_session(&store);
1967 let continuity = SessionContinuityTool::new(store);
1968 let v = continuity
1969 .call(&mut ctx, json!({"current_session_id": sid2, "n": 2}))
1970 .await
1971 .unwrap();
1972 assert_eq!(v["previous_session"], sid1);
1973 assert_eq!(v["count"], 2);
1974 assert_eq!(v["turns"][1]["content"], "turn 4");
1975 assert!(
1976 v.get("hint").is_none(),
1977 "non-empty continuity must not carry the scoping hint"
1978 );
1979 }
1980
1981 #[tokio::test]
1982 async fn continuity_empty_store_discloses_project_scoping() {
1983 let store = test_store();
1988 let continuity = SessionContinuityTool::new(store);
1989 let mut ctx = Context::default();
1990 let v = continuity.call(&mut ctx, json!({})).await.unwrap();
1991
1992 assert_eq!(v["status"], "success");
1993 assert_eq!(v["count"], 0);
1994 let hint = v["hint"].as_str().expect("hint present on empty store");
1995 assert!(hint.contains("project-scoped"), "got: {hint}");
1996 assert!(hint.contains("opencode config"), "got: {hint}");
1997 assert!(hint.contains("GET /status"), "got: {hint}");
1998 assert!(hint.contains("store "), "got: {hint}");
2000 }
2001
2002 #[tokio::test]
2003 async fn record_defaults_to_newest_start_by_time_not_key_order() {
2004 let store = test_store();
2009 for age in [50_000, 40_000, 30_000, 20_000, 10_000] {
2010 start_session_aged(&store, age);
2011 }
2012 let newest = start_session_aged(&store, 0);
2013
2014 let record = SessionRecordTool::new(store);
2015 let mut ctx = Context::default();
2016 let r = record
2017 .call(&mut ctx, json!({"role": "ai", "content": "latest turn"}))
2018 .await
2019 .unwrap();
2020 assert_eq!(
2021 r["session_id"], newest,
2022 "record without explicit session_id must target the newest start by created_at"
2023 );
2024 }
2025
2026 #[tokio::test]
2027 async fn continuity_picks_newest_prior_by_time_not_key_order() {
2028 let store = test_store();
2032 for age in [40_000, 30_000, 20_000] {
2033 start_session_aged(&store, age);
2034 }
2035 let newest_prior = start_session_aged(&store, 10);
2036 let current = start_session_aged(&store, 0);
2037
2038 let continuity = SessionContinuityTool::new(store);
2039 let mut ctx = Context::default();
2040 let v = continuity
2041 .call(&mut ctx, json!({"current_session_id": current, "n": 1}))
2042 .await
2043 .unwrap();
2044 assert_eq!(
2045 v["previous_session"], newest_prior,
2046 "continuity must select the newest prior session by created_at"
2047 );
2048 }
2049
2050 #[tokio::test]
2051 async fn handoff_transfer_accept_list() {
2052 let store = test_store();
2053 let sid = start_session(&store);
2054 let record = SessionRecordTool::new(store.clone());
2055 let mut ctx = Context::default();
2056 record
2057 .call(
2058 &mut ctx,
2059 json!({"role": "ai", "content": "context", "session_id": sid}),
2060 )
2061 .await
2062 .unwrap();
2063
2064 let handoff = SessionHandoffTool::new(store.clone());
2065 let t = handoff
2066 .call(
2067 &mut ctx,
2068 json!({"action": "transfer", "session_id": sid, "message": "take over"}),
2069 )
2070 .await
2071 .unwrap();
2072 assert_eq!(t["status"], "success");
2073 let hid = t["handoff_id"].as_str().unwrap().to_string();
2074
2075 let list = handoff
2076 .call(&mut ctx, json!({"action": "list"}))
2077 .await
2078 .unwrap();
2079 assert_eq!(list["pending_count"], 1);
2080
2081 let a = handoff
2082 .call(&mut ctx, json!({"action": "accept", "handoff_id": hid}))
2083 .await
2084 .unwrap();
2085 assert_eq!(a["status"], "success");
2086
2087 let list2 = handoff
2088 .call(&mut ctx, json!({"action": "list"}))
2089 .await
2090 .unwrap();
2091 assert_eq!(list2["pending_count"], 0);
2092 }
2093}