Skip to main content

atman_runtime/tools/
memory.rs

1use std::sync::Arc;
2
3use crate::error::RuntimeError;
4use crate::memory::MemoryId;
5use crate::memory::confession::{Confession, ConfessionStore};
6use crate::memory::goal::GoalStore;
7use crate::memory::spec::SpecStore;
8use crate::memory::todo::{Todo, TodoStatus, TodoStore};
9use crate::tool::{BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
10use crate::value::Value;
11
12pub struct MemoryGoalGet {
13    pub store: Arc<GoalStore>,
14}
15
16impl Tool for MemoryGoalGet {
17    fn name(&self) -> &str {
18        "memory.goal.get"
19    }
20
21    fn tier(&self) -> Tier {
22        Tier::Zero
23    }
24
25    fn description(&self) -> Option<&str> {
26        Some(
27            "Return the current session goal (persistent, auto-injected as system prefix). Empty string when unset.",
28        )
29    }
30
31    fn call<'a>(&'a self, _args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
32        Box::pin(async move {
33            let text = self
34                .store
35                .get()
36                .map_err(|e| RuntimeError::ToolFailed(format!("goal.get: {e}")))?;
37            Ok(Value::Str(text))
38        })
39    }
40}
41
42pub struct MemoryGoalSet {
43    pub store: Arc<GoalStore>,
44}
45
46impl Tool for MemoryGoalSet {
47    fn name(&self) -> &str {
48        "memory.goal.set"
49    }
50
51    fn tier(&self) -> Tier {
52        Tier::One
53    }
54
55    fn description(&self) -> Option<&str> {
56        Some(
57            "Set the session goal — a short directive (1-2 sentences) that atman injects \
58             as a system-prompt prefix on every LLM call. It persists across turns, never \
59             enters message history, and is never compacted.\n\n\
60             Best practice: set the goal early (right after understanding the user's request), \
61             keep it concise and actionable. Update it if the user's intent changes. Clear it \
62             when the task is complete. Example: 'Fix the login bug in auth.rs and add a \
63             regression test.'",
64        )
65    }
66
67    fn input_schema(&self) -> serde_json::Value {
68        serde_json::json!({
69            "type": "object",
70            "properties": {
71                "text": {
72                    "type": "string",
73                    "description": "The goal text, 1-2 sentences. Be specific: what to do, where, and what 'done' looks like."
74                }
75            },
76            "required": ["text"]
77        })
78    }
79
80    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
81        Box::pin(async move {
82            let text = required_string(&args, "text")?;
83            self.store
84                .set(&text)
85                .map_err(|e| RuntimeError::ToolFailed(format!("goal.set: {e}")))?;
86            Ok(Value::Unit)
87        })
88    }
89}
90
91pub struct MemoryRecentTurns;
92
93impl Tool for MemoryRecentTurns {
94    fn name(&self) -> &str {
95        "memory.recent_turns"
96    }
97
98    fn tier(&self) -> Tier {
99        Tier::Zero
100    }
101
102    fn description(&self) -> Option<&str> {
103        Some(
104            "Return the last N Message values (user + assistant + tool_result) from the \
105             current session's event log so a flow can hand the code agent a sliding \
106             history window. Reads from disk; cost O(events file size).",
107        )
108    }
109
110    fn input_schema(&self) -> serde_json::Value {
111        serde_json::json!({
112            "type": "object",
113            "properties": {
114                "n": {"type": "integer", "description": "Max message count to return (default 10)"}
115            }
116        })
117    }
118
119    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
120        Box::pin(async move {
121            let n = match args.named("n").or_else(|| args.positional(0).ok()) {
122                Some(Value::Int(k)) if *k >= 0 => *k as usize,
123                Some(other) => {
124                    return Err(RuntimeError::TypeMismatch {
125                        expected: "non-negative int".into(),
126                        actual: other.kind_name().into(),
127                    });
128                }
129                None => 10,
130            };
131            if n == 0 {
132                return Ok(Value::List(Vec::new()));
133            }
134            let msgs = if let Some(session) = ctx.session_runtime.as_ref() {
135                session.messages_full()
136            } else if let Some(msgs) = ctx.session_messages.as_ref() {
137                std::sync::Arc::new((**msgs).clone())
138            } else {
139                return Ok(Value::List(Vec::new()));
140            };
141            let start = msgs.len().saturating_sub(n);
142            let out: Vec<Value> = msgs
143                .iter()
144                .skip(start)
145                .cloned()
146                .map(Value::Message)
147                .collect();
148            if let Some(cb) = &ctx.on_memory_recent {
149                cb(out.len() as u16);
150            }
151            Ok(Value::List(out))
152        })
153    }
154}
155
156pub struct MemoryGoalClear {
157    pub store: Arc<GoalStore>,
158}
159
160impl Tool for MemoryGoalClear {
161    fn name(&self) -> &str {
162        "memory.goal.clear"
163    }
164
165    fn tier(&self) -> Tier {
166        Tier::One
167    }
168
169    fn description(&self) -> Option<&str> {
170        Some(
171            "Clear the session goal. Call this when the task is complete or the user \
172             changes direction entirely. Returns nothing.",
173        )
174    }
175
176    fn input_schema(&self) -> serde_json::Value {
177        serde_json::json!({"type": "object"})
178    }
179
180    fn call<'a>(&'a self, _args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
181        Box::pin(async move {
182            self.store
183                .clear()
184                .map_err(|e| RuntimeError::ToolFailed(format!("goal.clear: {e}")))?;
185            Ok(Value::Unit)
186        })
187    }
188}
189
190pub struct MemoryTodoSet {
191    pub store: Arc<TodoStore>,
192}
193
194impl Tool for MemoryTodoSet {
195    fn name(&self) -> &str {
196        "memory.todo.set"
197    }
198
199    fn tier(&self) -> Tier {
200        Tier::One
201    }
202
203    fn description(&self) -> Option<&str> {
204        Some(
205            "Create a concrete execution todo. Returns the todo id (UUID string) — \
206             save it for memory.todo.done / memory.todo.cancel / memory.todo.delete.\n\n\
207             Todos are for short, trackable work items, usually inside the current \
208             plan step. Use plan.write/read/tick for the high-level ordered route \
209             through a multi-step task. Do not create todos that simply mirror plan \
210             steps; do not create a todo when one plan step is enough.\n\n\
211             Best practice: create a todo for each discrete execution item that \
212             should stay visible while you work. Keep `where` specific (file path \
213             or module), `why` one sentence, `how` a brief approach, \
214             `expected_result` the verification criteria. Don't create todos for \
215             trivial steps — only for things the user would want to track.\n\n\
216             To modify an existing todo, cancel the old one then create a new one. \
217             There is no update tool.",
218        )
219    }
220
221    fn input_schema(&self) -> serde_json::Value {
222        serde_json::json!({
223            "type": "object",
224            "properties": {
225                "where": {"type": "string", "description": "Where to do it (file path, module, etc.)"},
226                "why": {"type": "string", "description": "Why this needs doing"},
227                "how": {"type": "string", "description": "How to do it (brief approach)"},
228                "expected_result": {"type": "string", "description": "What success looks like"}
229            },
230            "required": ["where", "why", "how", "expected_result"]
231        })
232    }
233
234    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
235        Box::pin(async move {
236            let where_ = required_string(&args, "where")?;
237            let why = required_string(&args, "why")?;
238            let how = required_string(&args, "how")?;
239            let expected_result = required_string(&args, "expected_result")?;
240            let todo = Todo {
241                id: MemoryId::now(),
242                where_,
243                why,
244                how,
245                expected_result,
246                status: TodoStatus::Pending,
247            };
248            let id = self.store.add(todo).await?;
249            Ok(Value::Str(id.to_string()))
250        })
251    }
252}
253
254pub struct MemoryTodoDone {
255    pub store: Arc<TodoStore>,
256}
257
258impl Tool for MemoryTodoDone {
259    fn name(&self) -> &str {
260        "memory.todo.done"
261    }
262
263    fn tier(&self) -> Tier {
264        Tier::One
265    }
266
267    fn description(&self) -> Option<&str> {
268        Some(
269            "Mark a todo as done. Once done, a todo cannot be un-done. \
270             The id must be the UUID string returned by memory.todo.set. \
271             Returns \"ok\" on success (including if already done).",
272        )
273    }
274
275    fn input_schema(&self) -> serde_json::Value {
276        serde_json::json!({
277            "type": "object",
278            "properties": {
279                "id": {"type": "string", "description": "The UUID returned by memory.todo.set (e.g. \"019f5500-9a53-7800-8083-b608fdc4124a\")"}
280            },
281            "required": ["id"]
282        })
283    }
284
285    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
286        Box::pin(async move {
287            let id = required_string(&args, "id")?;
288            let uuid = uuid::Uuid::parse_str(&id).map_err(|e| {
289                RuntimeError::ToolFailed(format!(
290                    "bad todo id: {e}. The id must be the UUID returned by memory.todo.set."
291                ))
292            })?;
293            self.store
294                .set_status(&MemoryId(uuid), TodoStatus::Done)
295                .await?;
296            Ok(Value::Str("ok".into()))
297        })
298    }
299}
300
301pub struct MemoryTodoCancel {
302    pub store: Arc<TodoStore>,
303}
304
305impl Tool for MemoryTodoCancel {
306    fn name(&self) -> &str {
307        "memory.todo.cancel"
308    }
309
310    fn tier(&self) -> Tier {
311        Tier::One
312    }
313
314    fn description(&self) -> Option<&str> {
315        Some(
316            "Cancel a todo. Once cancelled, a todo cannot be re-activated. \
317             The id must be the UUID string returned by memory.todo.set. \
318             Returns \"ok\" on success (including if already cancelled).",
319        )
320    }
321
322    fn input_schema(&self) -> serde_json::Value {
323        serde_json::json!({
324            "type": "object",
325            "properties": {
326                "id": {"type": "string", "description": "The UUID returned by memory.todo.set"}
327            },
328            "required": ["id"]
329        })
330    }
331
332    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
333        Box::pin(async move {
334            let id = required_string(&args, "id")?;
335            let uuid = uuid::Uuid::parse_str(&id).map_err(|e| {
336                RuntimeError::ToolFailed(format!(
337                    "bad todo id: {e}. The id must be the UUID returned by memory.todo.set."
338                ))
339            })?;
340            self.store
341                .set_status(&MemoryId(uuid), TodoStatus::Cancelled)
342                .await?;
343            Ok(Value::Str("ok".into()))
344        })
345    }
346}
347
348pub struct MemoryTodoDelete {
349    pub store: Arc<TodoStore>,
350}
351
352impl Tool for MemoryTodoDelete {
353    fn name(&self) -> &str {
354        "memory.todo.delete"
355    }
356
357    fn tier(&self) -> Tier {
358        Tier::One
359    }
360
361    fn description(&self) -> Option<&str> {
362        Some(
363            "Permanently delete a todo. Unlike done/cancel, the todo is removed \
364             entirely from the list. Use for todos created by mistake. \
365             The id must be the UUID string returned by memory.todo.set.",
366        )
367    }
368
369    fn input_schema(&self) -> serde_json::Value {
370        serde_json::json!({
371            "type": "object",
372            "properties": {
373                "id": {"type": "string", "description": "The UUID returned by memory.todo.set"}
374            },
375            "required": ["id"]
376        })
377    }
378
379    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
380        Box::pin(async move {
381            let id = required_string(&args, "id")?;
382            let uuid = uuid::Uuid::parse_str(&id).map_err(|e| {
383                RuntimeError::ToolFailed(format!(
384                    "bad todo id: {e}. The id must be the UUID returned by memory.todo.set."
385                ))
386            })?;
387            self.store.delete(&MemoryId(uuid)).await?;
388            Ok(Value::Str("ok".into()))
389        })
390    }
391}
392
393pub struct MemoryTodoList {
394    pub store: Arc<TodoStore>,
395}
396
397impl Tool for MemoryTodoList {
398    fn name(&self) -> &str {
399        "memory.todo.list"
400    }
401
402    fn tier(&self) -> Tier {
403        Tier::Zero
404    }
405
406    fn description(&self) -> Option<&str> {
407        Some(
408            "List all todos in the current session. Returns an array of \
409             {id, where, why, how, expected_result, status}. \
410             status is one of: pending, done, cancelled. \
411             Call this to check concrete work items before starting or resuming a \
412             plan step.",
413        )
414    }
415
416    fn input_schema(&self) -> serde_json::Value {
417        serde_json::json!({"type": "object"})
418    }
419
420    fn call<'a>(&'a self, _args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
421        Box::pin(async move {
422            let todos = self.store.list().await?;
423            let items: Vec<Value> = todos
424                .into_iter()
425                .map(|t| {
426                    Value::Struct(vec![
427                        ("id".into(), Value::Str(t.id.to_string())),
428                        ("where".into(), Value::Str(t.where_)),
429                        ("why".into(), Value::Str(t.why)),
430                        ("how".into(), Value::Str(t.how)),
431                        ("expected_result".into(), Value::Str(t.expected_result)),
432                        (
433                            "status".into(),
434                            Value::Str(format!("{:?}", t.status).to_lowercase()),
435                        ),
436                    ])
437                })
438                .collect();
439            Ok(Value::List(items))
440        })
441    }
442}
443
444pub struct MemoryConfess {
445    pub store: Arc<ConfessionStore>,
446}
447
448impl Tool for MemoryConfess {
449    fn name(&self) -> &str {
450        "memory.confess"
451    }
452
453    fn tier(&self) -> Tier {
454        Tier::One
455    }
456
457    fn description(&self) -> Option<&str> {
458        Some(
459            "Record a confession when the agent broke a rule. Anchors are auto-filled from \
460             the current turn / flow_run / event_seq. Returns the new confession id.",
461        )
462    }
463
464    fn input_schema(&self) -> serde_json::Value {
465        serde_json::json!({
466            "type": "object",
467            "properties": {
468                "trigger": {"type": "string", "description": "What the user or watcher noticed."},
469                "rule_violated": {"type": "string", "description": "Name of the red-line rule."},
470                "what_i_did": {"type": "string", "description": "The concrete mistake."},
471                "why": {"type": "string", "description": "The reasoning that led there."},
472                "mitigation": {"type": "string", "description": "What will prevent recurrence."},
473                "anchors": {
474                    "type": "array",
475                    "items": {"type": "string"},
476                    "description": "Optional extra anchor strings (auto-filled ones stay)."
477                }
478            },
479            "required": ["trigger", "rule_violated", "what_i_did", "why", "mitigation"]
480        })
481    }
482
483    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
484        let anchors = collect_anchors(&args, ctx);
485        Box::pin(async move {
486            let trigger = required_string(&args, "trigger")?;
487            let rule_violated = required_string(&args, "rule_violated")?;
488            let what_i_did = required_string(&args, "what_i_did")?;
489            let why = required_string(&args, "why")?;
490            let mitigation = required_string(&args, "mitigation")?;
491            let confession = Confession {
492                id: MemoryId::now(),
493                trigger,
494                rule_violated,
495                what_i_did,
496                why,
497                mitigation,
498                anchors,
499                created_at: chrono::Utc::now(),
500            };
501            let id = self.store.append(confession).await?;
502            Ok(Value::Str(id.to_string()))
503        })
504    }
505}
506
507fn collect_anchors(args: &ToolArgs, ctx: &ToolCtx) -> Vec<String> {
508    let mut out = Vec::new();
509    if let Some(flow_run) = &ctx.flow_run_id {
510        out.push(format!("flow_run:{flow_run}"));
511    }
512    if let Some(turn) = &ctx.turn_id {
513        out.push(format!("turn:{turn}"));
514    }
515    if let Some(seq) = ctx.event_seq {
516        out.push(format!("event_seq:{seq}"));
517    }
518    if let Some(Value::List(items)) = args.named("anchors") {
519        for item in items {
520            if let Value::Str(s) = item {
521                out.push(s.clone());
522            }
523        }
524    }
525    out
526}
527
528pub struct MemorySpecStatus {
529    pub store: Arc<SpecStore>,
530}
531
532impl Tool for MemorySpecStatus {
533    fn name(&self) -> &str {
534        "memory.spec.status"
535    }
536    fn tier(&self) -> Tier {
537        Tier::Zero
538    }
539
540    fn description(&self) -> Option<&str> {
541        Some(
542            "Return progress counters for a named spec feature. Use it to check the current phase, update count, and deviation count before continuing spec-driven work.",
543        )
544    }
545
546    fn input_schema(&self) -> serde_json::Value {
547        serde_json::json!({
548            "type": "object",
549            "properties": {
550                "feature": {"type": "string", "description": "Spec feature name to inspect."}
551            },
552            "required": ["feature"]
553        })
554    }
555
556    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
557        Box::pin(async move {
558            let feature = required_string(&args, "feature")?;
559            let st = self.store.status(&feature).await?;
560            Ok(Value::Struct(vec![
561                ("feature".into(), Value::Str(st.feature)),
562                ("phase".into(), Value::Str(st.phase)),
563                ("entry_count".into(), Value::Int(st.entry_count as i64)),
564                (
565                    "deviation_count".into(),
566                    Value::Int(st.deviation_count as i64),
567                ),
568            ]))
569        })
570    }
571}
572
573pub struct MemorySpecUpdate {
574    pub store: Arc<SpecStore>,
575}
576
577impl Tool for MemorySpecUpdate {
578    fn name(&self) -> &str {
579        "memory.spec.update"
580    }
581    fn tier(&self) -> Tier {
582        Tier::One
583    }
584
585    fn description(&self) -> Option<&str> {
586        Some(
587            "Append a progress entry for a spec feature and phase. Use it to persist research, design, implementation, or verification notes as spec work advances.",
588        )
589    }
590
591    fn input_schema(&self) -> serde_json::Value {
592        serde_json::json!({
593            "type": "object",
594            "properties": {
595                "feature": {"type": "string", "description": "Spec feature name to update."},
596                "phase": {"type": "string", "description": "Spec phase or section name, such as research, design, implementation, or verification."},
597                "content": {"type": "string", "description": "Progress entry content to append."}
598            },
599            "required": ["feature", "phase", "content"]
600        })
601    }
602
603    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
604        Box::pin(async move {
605            let feature = required_string(&args, "feature")?;
606            let phase = required_string(&args, "phase")?;
607            let content = required_string(&args, "content")?;
608            let entry = self.store.update(&feature, &phase, content).await?;
609            Ok(Value::Struct(vec![
610                ("id".into(), Value::Str(entry.id.to_string())),
611                ("feature".into(), Value::Str(entry.feature)),
612                ("phase".into(), Value::Str(entry.phase)),
613            ]))
614        })
615    }
616}
617
618pub struct MemorySpecDeviate {
619    pub store: Arc<SpecStore>,
620}
621
622impl Tool for MemorySpecDeviate {
623    fn name(&self) -> &str {
624        "memory.spec.deviate"
625    }
626    fn tier(&self) -> Tier {
627        Tier::One
628    }
629
630    fn description(&self) -> Option<&str> {
631        Some(
632            "Record an intentional deviation from a spec section. Use it when implementation differs from the written plan and the delta plus reason must be preserved.",
633        )
634    }
635
636    fn input_schema(&self) -> serde_json::Value {
637        serde_json::json!({
638            "type": "object",
639            "properties": {
640                "feature": {"type": "string", "description": "Spec feature name that owns the deviation."},
641                "section": {"type": "string", "description": "Spec section or decision being changed."},
642                "delta": {"type": "string", "description": "What changed from the spec."},
643                "reason": {"type": "string", "description": "Why the deviation is necessary."}
644            },
645            "required": ["feature", "section", "delta", "reason"]
646        })
647    }
648
649    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
650        Box::pin(async move {
651            let feature = required_string(&args, "feature")?;
652            let section = required_string(&args, "section")?;
653            let delta = required_string(&args, "delta")?;
654            let reason = required_string(&args, "reason")?;
655            let dev = self.store.deviate(&feature, section, delta, reason).await?;
656            Ok(Value::Struct(vec![
657                ("id".into(), Value::Str(dev.id.to_string())),
658                ("feature".into(), Value::Str(dev.feature)),
659                ("section".into(), Value::Str(dev.section)),
660            ]))
661        })
662    }
663}
664
665pub struct MemoryFetchConfessions {
666    pub store: Arc<ConfessionStore>,
667}
668
669impl Tool for MemoryFetchConfessions {
670    fn name(&self) -> &str {
671        "memory.fetch_confessions"
672    }
673
674    fn tier(&self) -> Tier {
675        Tier::Zero
676    }
677
678    fn description(&self) -> Option<&str> {
679        Some(
680            "Fetch past confession records about rule violations, optionally filtered by trigger text. Use it to recall prior mistakes and mitigations before repeating risky work.",
681        )
682    }
683
684    fn input_schema(&self) -> serde_json::Value {
685        serde_json::json!({
686            "type": "object",
687            "properties": {
688                "trigger": {"type": "string", "description": "Optional trigger substring to search for; omit to list all confession records."}
689            }
690        })
691    }
692
693    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
694        Box::pin(async move {
695            let items = match args.named("trigger") {
696                Some(Value::Str(needle)) => self.store.find_by_trigger(needle).await?,
697                _ => self.store.list().await?,
698            };
699            let list = items
700                .into_iter()
701                .map(|c| {
702                    Value::Struct(vec![
703                        ("id".into(), Value::Str(c.id.to_string())),
704                        ("trigger".into(), Value::Str(c.trigger)),
705                        ("rule_violated".into(), Value::Str(c.rule_violated)),
706                        ("mitigation".into(), Value::Str(c.mitigation)),
707                    ])
708                })
709                .collect();
710            Ok(Value::List(list))
711        })
712    }
713}
714
715pub struct MemoryHistorySearch;
716
717impl Tool for MemoryHistorySearch {
718    fn name(&self) -> &str {
719        "memory.history.search"
720    }
721
722    fn tier(&self) -> Tier {
723        Tier::Zero
724    }
725
726    fn description(&self) -> Option<&str> {
727        Some(
728            "Full-text search the current session's chat history (or optionally every session \
729             in the same project). Use it to recall past turns that fell out of your working \
730             context — e.g. `plan we agreed on this morning`, `which files did we read`, \
731             `error the user reported earlier`. NOT for searching source code; use fs.grep for \
732             that. Params: query (FTS5 syntax, required), scope (\"session\"|\"project\", \
733             default \"session\"), limit (int, default 10, max 50).",
734        )
735    }
736
737    fn input_schema(&self) -> serde_json::Value {
738        serde_json::json!({
739            "type": "object",
740            "properties": {
741                "query": {"type": "string"},
742                "scope": {"type": "string", "enum": ["session", "project"], "default": "session"},
743                "limit": {"type": "integer", "default": 10}
744            },
745            "required": ["query"]
746        })
747    }
748
749    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
750        Box::pin(async move {
751            let query = required_string(&args, "query")?;
752            if query.trim().is_empty() {
753                return Err(RuntimeError::ToolFailed(
754                    "memory.history.search: empty query".into(),
755                ));
756            }
757            let scope = match args.named("scope") {
758                Some(Value::Str(s)) if s == "project" => HistoryScope::Project,
759                _ => HistoryScope::Session,
760            };
761            let limit = match args.named("limit") {
762                Some(Value::Int(n)) if *n > 0 => (*n as usize).min(50),
763                _ => 10,
764            };
765            let session_filter = match scope {
766                HistoryScope::Project => None,
767                HistoryScope::Session => ctx
768                    .session_dir
769                    .as_ref()
770                    .and_then(|d| d.file_name())
771                    .map(|n| n.to_string_lossy().to_string()),
772            };
773            let rows = if let Some(idx) = ctx.project_index.as_ref() {
774                idx.fts_search_project_events(&query, session_filter.as_deref(), limit)
775                    .map_err(|e| RuntimeError::ToolFailed(format!("memory.history.search: {e}")))?
776            } else if matches!(scope, HistoryScope::Project) {
777                return Err(RuntimeError::ToolFailed(
778                    "memory.history.search: project scope requires project index".into(),
779                ));
780            } else if let Some(session) = ctx.session_runtime.as_ref() {
781                // Fallback: grep current session's full messages
782                let msgs = session.messages_full();
783                let mut hits = Vec::new();
784                let query_lower = query.to_lowercase();
785                for (i, msg) in msgs.iter().enumerate() {
786                    let text = msg.text_concat();
787                    if text.to_lowercase().contains(&query_lower) {
788                        let snippet: String = text.chars().take(200).collect();
789                        hits.push(crate::index::ProjectEventRow {
790                            session_id: session.id().to_string(),
791                            seq: i as u64,
792                            ts: String::new(),
793                            kind: msg.role.as_str().to_string(),
794                            turn_id: None,
795                            flow_run_id: None,
796                            payload: snippet,
797                        });
798                        if hits.len() >= limit {
799                            break;
800                        }
801                    }
802                }
803                hits
804            } else {
805                return Err(RuntimeError::ToolFailed(
806                    "memory.history.search: no project index or session on context".into(),
807                ));
808            };
809            let hits: Vec<Value> = rows
810                .into_iter()
811                .map(|row| {
812                    let snippet: String = row
813                        .payload
814                        .chars()
815                        .take(200)
816                        .collect::<String>()
817                        .replace('\n', " ");
818                    Value::Struct(vec![
819                        ("session_id".into(), Value::Str(row.session_id)),
820                        ("seq".into(), Value::Int(row.seq as i64)),
821                        ("ts".into(), Value::Str(row.ts)),
822                        ("kind".into(), Value::Str(row.kind)),
823                        ("snippet".into(), Value::Str(snippet)),
824                    ])
825                })
826                .collect();
827            Ok(Value::List(hits))
828        })
829    }
830}
831
832pub struct MemoryHistoryRead;
833
834impl Tool for MemoryHistoryRead {
835    fn name(&self) -> &str {
836        "memory.history.read"
837    }
838
839    fn tier(&self) -> Tier {
840        Tier::Zero
841    }
842
843    fn description(&self) -> Option<&str> {
844        Some(
845            "Paginate through past messages of a session by turn index. Prefer \
846             memory.history.search first to find a hit, then call this for surrounding context. \
847             Params: session_id (string, default current session's directory name), offset \
848             (1-based turn index, default 1), limit (int, default 20, max 100), role_filter \
849             (comma-separated: user,assistant,tool; default all).",
850        )
851    }
852
853    fn input_schema(&self) -> serde_json::Value {
854        serde_json::json!({
855            "type": "object",
856            "properties": {
857                "session_id": {"type": "string"},
858                "offset": {"type": "integer", "default": 1},
859                "limit": {"type": "integer", "default": 20},
860                "role_filter": {"type": "string"}
861            }
862        })
863    }
864
865    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
866        Box::pin(async move {
867            let Some(current_dir) = ctx.session_dir.as_ref() else {
868                return Err(RuntimeError::ToolFailed(
869                    "memory.history.read: no session dir on context".into(),
870                ));
871            };
872            let dir = match args.named("session_id") {
873                Some(Value::Str(sid)) if !sid.is_empty() => {
874                    let sessions_parent = current_dir.parent().unwrap_or(current_dir);
875                    let candidate = sessions_parent.join(sid);
876                    if !candidate.is_dir() {
877                        return Err(RuntimeError::ToolFailed(format!(
878                            "memory.history.read: session `{sid}` not found at {}",
879                            candidate.display()
880                        )));
881                    }
882                    candidate
883                }
884                _ => current_dir.clone(),
885            };
886            let offset = match args.named("offset") {
887                Some(Value::Int(n)) if *n >= 1 => *n as usize,
888                _ => 1,
889            };
890            let limit = match args.named("limit") {
891                Some(Value::Int(n)) if *n >= 1 => (*n as usize).min(100),
892                _ => 20,
893            };
894            let role_filter: Option<Vec<String>> = match args.named("role_filter") {
895                Some(Value::Str(s)) if !s.is_empty() => Some(
896                    s.split(',')
897                        .map(|t| t.trim().to_lowercase())
898                        .filter(|t| !t.is_empty())
899                        .collect(),
900                ),
901                _ => None,
902            };
903            let messages = match crate::projection::message_window::replay_messages_from(
904                &dir.join("events.jsonl"),
905            ) {
906                Ok(msgs) => {
907                    if let Some(filter) = role_filter.as_deref() {
908                        msgs.into_iter()
909                            .filter(|m| filter.iter().any(|r| r == m.role.as_str()))
910                            .collect()
911                    } else {
912                        msgs
913                    }
914                }
915                Err(e) => {
916                    return Err(RuntimeError::ToolFailed(format!(
917                        "memory.history.read: {e}"
918                    )));
919                }
920            };
921            let total = messages.len();
922            let start = offset.saturating_sub(1);
923            let end = (start + limit).min(total);
924            let slice: Vec<Value> = if start >= total {
925                Vec::new()
926            } else {
927                messages[start..end]
928                    .iter()
929                    .cloned()
930                    .map(Value::Message)
931                    .collect()
932            };
933            let header = format!("[history: turns {start}-{end} of {total}]");
934            Ok(Value::Struct(vec![
935                ("header".into(), Value::Str(header)),
936                ("turns".into(), Value::List(slice)),
937            ]))
938        })
939    }
940}
941
942enum HistoryScope {
943    Session,
944    Project,
945}
946
947fn required_string(args: &ToolArgs, name: &str) -> Result<String, RuntimeError> {
948    match args.named(name) {
949        Some(Value::Str(s)) => Ok(s.clone()),
950        Some(other) => Err(RuntimeError::TypeMismatch {
951            expected: "string".into(),
952            actual: other.kind_name().into(),
953        }),
954        None => Err(RuntimeError::MissingArg(name.into())),
955    }
956}