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