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, exposed to models as an append-only context record). 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 appends \
58             to model history as a versioned context record. It persists across turns; \
59             unchanged content is not appended again.\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
93const RECENT_EXCERPT_MESSAGE_CHARS: usize = 2_000;
94
95fn recent_turns_value(
96    message_count: u64,
97    turn_count: u64,
98    messages: Vec<crate::message::Message>,
99    excerpt_chars: Option<usize>,
100) -> Value {
101    let excerpt = excerpt_chars.map(|limit| bounded_recent_excerpt(&messages, limit));
102    let items = messages.into_iter().map(Value::Message).collect();
103    let mut fields = vec![
104        (
105            "total_message_count".into(),
106            Value::Int(message_count as i64),
107        ),
108        ("total_turn_count".into(), Value::Int(turn_count as i64)),
109        ("items".into(), Value::List(items)),
110    ];
111    if let Some((text, truncated)) = excerpt {
112        fields.push(("excerpt".into(), Value::Str(text)));
113        fields.push(("excerpt_truncated".into(), Value::Bool(truncated)));
114    }
115    Value::Struct(fields)
116}
117
118fn bounded_recent_excerpt(
119    messages: &[crate::message::Message],
120    max_chars: usize,
121) -> (String, bool) {
122    let mut remaining = max_chars;
123    let mut chunks = Vec::new();
124    let mut truncated = false;
125    for message in messages.iter().rev() {
126        let separator_chars = usize::from(!chunks.is_empty()) * 2;
127        if remaining <= separator_chars {
128            truncated = true;
129            break;
130        }
131        let message_limit = remaining
132            .saturating_sub(separator_chars)
133            .min(RECENT_EXCERPT_MESSAGE_CHARS);
134        let (chunk, message_truncated) = bounded_message_excerpt(message, message_limit);
135        remaining = remaining.saturating_sub(separator_chars + chunk.chars().count());
136        chunks.push(chunk);
137        truncated |= message_truncated;
138    }
139    if chunks.len() < messages.len() {
140        truncated = true;
141    }
142    chunks.reverse();
143    (chunks.join("\n\n"), truncated)
144}
145
146fn bounded_message_excerpt(message: &crate::message::Message, max_chars: usize) -> (String, bool) {
147    use crate::message::MessagePart;
148
149    fn push_bounded(out: &mut String, used: &mut usize, max: usize, text: &str) -> bool {
150        for ch in text.chars() {
151            if *used == max {
152                return true;
153            }
154            out.push(ch);
155            *used += 1;
156        }
157        false
158    }
159
160    let mut out = String::new();
161    let mut used = 0;
162    let mut truncated = push_bounded(
163        &mut out,
164        &mut used,
165        max_chars,
166        &format!("[{}]", message.role.as_str()),
167    );
168    for part in &message.parts {
169        if used == max_chars {
170            truncated = true;
171            break;
172        }
173        truncated |= match part {
174            MessagePart::ContextRecord(record) => push_bounded(
175                &mut out,
176                &mut used,
177                max_chars,
178                &format!(
179                    "\n[context {}@{}]\n{}",
180                    record.key(),
181                    record.revision(),
182                    record.render_for_model()
183                ),
184            ),
185            MessagePart::CompactSummary { summary, .. } => {
186                push_bounded(&mut out, &mut used, max_chars, "\nsummary: ")
187                    | push_bounded(&mut out, &mut used, max_chars, summary)
188            }
189            MessagePart::Text { text } => {
190                push_bounded(&mut out, &mut used, max_chars, "\ntext: ")
191                    | push_bounded(&mut out, &mut used, max_chars, text)
192            }
193            MessagePart::Thinking { .. } => {
194                push_bounded(&mut out, &mut used, max_chars, "\n[thinking omitted]")
195            }
196            MessagePart::Image { .. } => push_bounded(&mut out, &mut used, max_chars, "\n[image]"),
197            MessagePart::ToolUse { name, intent, .. } => {
198                let purpose = intent
199                    .as_ref()
200                    .map(|intent| format!(" — {}", intent.as_str()))
201                    .unwrap_or_default();
202                push_bounded(
203                    &mut out,
204                    &mut used,
205                    max_chars,
206                    &format!("\ntool_call: {name}{purpose}"),
207                )
208            }
209            MessagePart::ToolResult {
210                tool_use_id,
211                content,
212                is_error,
213            } => {
214                let status = if *is_error { "error" } else { "ok" };
215                push_bounded(
216                    &mut out,
217                    &mut used,
218                    max_chars,
219                    &format!("\ntool_result {tool_use_id} ({status}): "),
220                ) | push_bounded(&mut out, &mut used, max_chars, content)
221            }
222        };
223    }
224    (out, truncated)
225}
226
227impl Tool for MemoryRecentTurns {
228    fn name(&self) -> &str {
229        "memory.recent_turns"
230    }
231
232    fn tier(&self) -> Tier {
233        Tier::Zero
234    }
235
236    fn description(&self) -> Option<&str> {
237        Some(
238            "Return the last N Message values (user + assistant + tool_result) from the \
239             current session's event log so a flow can hand the code agent a sliding \
240             history window. `items` remain lossless; `excerpt_chars` additionally returns \
241             a bounded recent-first text excerpt. Reads from disk; cost O(events file size).",
242        )
243    }
244
245    fn input_schema(&self) -> serde_json::Value {
246        serde_json::json!({
247            "type": "object",
248            "properties": {
249                "n": {"type": "integer", "description": "Max complete turns to return (default 10)"},
250                "excerpt_chars": {"type": "integer", "minimum": 0, "description": "Also return an `excerpt` capped at this many characters without changing lossless `items`"}
251            }
252        })
253    }
254
255    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
256        Box::pin(async move {
257            let n = match args.named("n").or_else(|| args.positional(0).ok()) {
258                Some(Value::Int(k)) if *k >= 0 => *k as usize,
259                Some(other) => {
260                    return Err(RuntimeError::TypeMismatch {
261                        expected: "non-negative int".into(),
262                        actual: other.kind_name().into(),
263                    });
264                }
265                None => 10,
266            };
267            let excerpt_chars = match args.named("excerpt_chars") {
268                Some(Value::Int(k)) if *k >= 0 => Some(*k as usize),
269                Some(other) => {
270                    return Err(RuntimeError::TypeMismatch {
271                        expected: "non-negative int".into(),
272                        actual: other.kind_name().into(),
273                    });
274                }
275                None => None,
276            };
277            if n == 0 {
278                if let Some(cb) = &ctx.on_memory_recent {
279                    cb(0);
280                }
281                return Ok(recent_turns_value(0, 0, Vec::new(), excerpt_chars));
282            }
283            // Sub-agent path: session_messages has the child's local message list.
284            if let Some(msgs) = ctx.session_messages.as_ref() {
285                let (total, recent) = crate::history_store::recent_turn_messages(msgs, n);
286                if let Some(cb) = &ctx.on_memory_recent {
287                    cb(recent.len() as u16);
288                }
289                return Ok(recent_turns_value(
290                    msgs.len() as u64,
291                    total,
292                    recent,
293                    excerpt_chars,
294                ));
295            }
296            // Main-agent path: delegate to HistoryStore.
297            let Some(store) = ctx.history_store.clone() else {
298                return Err(RuntimeError::ToolFailed(
299                    "memory.recent_turns: no history store on context".into(),
300                ));
301            };
302            let (message_count, turn_count, msgs) =
303                tokio::task::spawn_blocking(move || store.recent(n))
304                    .await
305                    .map_err(|e| RuntimeError::ToolFailed(format!("recent_turns: {e}")))??;
306            if let Some(cb) = &ctx.on_memory_recent {
307                cb(msgs.len() as u16);
308            }
309            Ok(recent_turns_value(
310                message_count,
311                turn_count,
312                msgs,
313                excerpt_chars,
314            ))
315        })
316    }
317}
318
319pub struct MemoryGoalClear {
320    pub store: Arc<GoalStore>,
321}
322
323impl Tool for MemoryGoalClear {
324    fn name(&self) -> &str {
325        "memory.goal.clear"
326    }
327
328    fn tier(&self) -> Tier {
329        Tier::One
330    }
331
332    fn description(&self) -> Option<&str> {
333        Some(
334            "Clear the session goal. Call this when the task is complete or the user \
335             changes direction entirely. Returns nothing.",
336        )
337    }
338
339    fn input_schema(&self) -> serde_json::Value {
340        serde_json::json!({"type": "object"})
341    }
342
343    fn call<'a>(&'a self, _args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
344        Box::pin(async move {
345            self.store
346                .clear()
347                .map_err(|e| RuntimeError::ToolFailed(format!("goal.clear: {e}")))?;
348            Ok(Value::Unit)
349        })
350    }
351}
352
353pub struct MemoryTodoSet {
354    pub store: Arc<TodoStore>,
355}
356
357impl Tool for MemoryTodoSet {
358    fn name(&self) -> &str {
359        "memory.todo.set"
360    }
361
362    fn tier(&self) -> Tier {
363        Tier::One
364    }
365
366    fn description(&self) -> Option<&str> {
367        Some(
368            "Create a concrete execution todo. Returns the todo id (UUID string) — \
369             save it for memory.todo.done / memory.todo.cancel / memory.todo.delete.\n\n\
370             Todos are for short, trackable work items, usually inside the current \
371             plan step. Use plan.write/read/tick for the high-level ordered route \
372             through a multi-step task. Do not create todos that simply mirror plan \
373             steps; do not create a todo when one plan step is enough.\n\n\
374             Best practice: create a todo for each discrete execution item that \
375             should stay visible while you work. Keep `where` specific (file path \
376             or module), `why` one sentence, `how` a brief approach, \
377             `expected_result` the verification criteria. Don't create todos for \
378             trivial steps — only for things the user would want to track.\n\n\
379             To modify an existing todo, cancel the old one then create a new one. \
380             There is no update tool.",
381        )
382    }
383
384    fn input_schema(&self) -> serde_json::Value {
385        serde_json::json!({
386            "type": "object",
387            "properties": {
388                "where": {"type": "string", "description": "Where to do it (file path, module, etc.)"},
389                "why": {"type": "string", "description": "Why this needs doing"},
390                "how": {"type": "string", "description": "How to do it (brief approach)"},
391                "expected_result": {"type": "string", "description": "What success looks like"}
392            },
393            "required": ["where", "why", "how", "expected_result"]
394        })
395    }
396
397    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
398        Box::pin(async move {
399            let where_ = required_string(&args, "where")?;
400            let why = required_string(&args, "why")?;
401            let how = required_string(&args, "how")?;
402            let expected_result = required_string(&args, "expected_result")?;
403            let todo = Todo {
404                id: MemoryId::now(),
405                where_,
406                why,
407                how,
408                expected_result,
409                status: TodoStatus::Pending,
410            };
411            let id = self.store.add(todo).await?;
412            Ok(Value::Str(id.to_string()))
413        })
414    }
415}
416
417pub struct MemoryTodoDone {
418    pub store: Arc<TodoStore>,
419}
420
421impl Tool for MemoryTodoDone {
422    fn name(&self) -> &str {
423        "memory.todo.done"
424    }
425
426    fn tier(&self) -> Tier {
427        Tier::One
428    }
429
430    fn description(&self) -> Option<&str> {
431        Some(
432            "Mark a todo as done. Once done, a todo cannot be un-done. \
433             The id must be the UUID string returned by memory.todo.set. \
434             Returns \"ok\" on success (including if already done).",
435        )
436    }
437
438    fn input_schema(&self) -> serde_json::Value {
439        serde_json::json!({
440            "type": "object",
441            "properties": {
442                "id": {"type": "string", "description": "The UUID returned by memory.todo.set (e.g. \"019f5500-9a53-7800-8083-b608fdc4124a\")"}
443            },
444            "required": ["id"]
445        })
446    }
447
448    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
449        Box::pin(async move {
450            let id = required_string(&args, "id")?;
451            let uuid = uuid::Uuid::parse_str(&id).map_err(|e| {
452                RuntimeError::ToolFailed(format!(
453                    "bad todo id: {e}. The id must be the UUID returned by memory.todo.set."
454                ))
455            })?;
456            self.store
457                .set_status(&MemoryId(uuid), TodoStatus::Done)
458                .await?;
459            Ok(Value::Str("ok".into()))
460        })
461    }
462}
463
464pub struct MemoryTodoCancel {
465    pub store: Arc<TodoStore>,
466}
467
468impl Tool for MemoryTodoCancel {
469    fn name(&self) -> &str {
470        "memory.todo.cancel"
471    }
472
473    fn tier(&self) -> Tier {
474        Tier::One
475    }
476
477    fn description(&self) -> Option<&str> {
478        Some(
479            "Cancel a todo. Once cancelled, a todo cannot be re-activated. \
480             The id must be the UUID string returned by memory.todo.set. \
481             Returns \"ok\" on success (including if already cancelled).",
482        )
483    }
484
485    fn input_schema(&self) -> serde_json::Value {
486        serde_json::json!({
487            "type": "object",
488            "properties": {
489                "id": {"type": "string", "description": "The UUID returned by memory.todo.set"}
490            },
491            "required": ["id"]
492        })
493    }
494
495    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
496        Box::pin(async move {
497            let id = required_string(&args, "id")?;
498            let uuid = uuid::Uuid::parse_str(&id).map_err(|e| {
499                RuntimeError::ToolFailed(format!(
500                    "bad todo id: {e}. The id must be the UUID returned by memory.todo.set."
501                ))
502            })?;
503            self.store
504                .set_status(&MemoryId(uuid), TodoStatus::Cancelled)
505                .await?;
506            Ok(Value::Str("ok".into()))
507        })
508    }
509}
510
511pub struct MemoryTodoDelete {
512    pub store: Arc<TodoStore>,
513}
514
515impl Tool for MemoryTodoDelete {
516    fn name(&self) -> &str {
517        "memory.todo.delete"
518    }
519
520    fn tier(&self) -> Tier {
521        Tier::One
522    }
523
524    fn description(&self) -> Option<&str> {
525        Some(
526            "Permanently delete a todo. Unlike done/cancel, the todo is removed \
527             entirely from the list. Use for todos created by mistake. \
528             The id must be the UUID string returned by memory.todo.set.",
529        )
530    }
531
532    fn input_schema(&self) -> serde_json::Value {
533        serde_json::json!({
534            "type": "object",
535            "properties": {
536                "id": {"type": "string", "description": "The UUID returned by memory.todo.set"}
537            },
538            "required": ["id"]
539        })
540    }
541
542    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
543        Box::pin(async move {
544            let id = required_string(&args, "id")?;
545            let uuid = uuid::Uuid::parse_str(&id).map_err(|e| {
546                RuntimeError::ToolFailed(format!(
547                    "bad todo id: {e}. The id must be the UUID returned by memory.todo.set."
548                ))
549            })?;
550            self.store.delete(&MemoryId(uuid)).await?;
551            Ok(Value::Str("ok".into()))
552        })
553    }
554}
555
556pub struct MemoryTodoList {
557    pub store: Arc<TodoStore>,
558}
559
560impl Tool for MemoryTodoList {
561    fn name(&self) -> &str {
562        "memory.todo.list"
563    }
564
565    fn tier(&self) -> Tier {
566        Tier::Zero
567    }
568
569    fn description(&self) -> Option<&str> {
570        Some(
571            "List all todos in the current session. Returns an array of \
572             {id, where, why, how, expected_result, status}. \
573             status is one of: pending, done, cancelled. \
574             Call this to check concrete work items before starting or resuming a \
575             plan step.",
576        )
577    }
578
579    fn input_schema(&self) -> serde_json::Value {
580        serde_json::json!({"type": "object"})
581    }
582
583    fn call<'a>(&'a self, _args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
584        Box::pin(async move {
585            let todos = self.store.list().await?;
586            let items: Vec<Value> = todos
587                .into_iter()
588                .map(|t| {
589                    Value::Struct(vec![
590                        ("id".into(), Value::Str(t.id.to_string())),
591                        ("where".into(), Value::Str(t.where_)),
592                        ("why".into(), Value::Str(t.why)),
593                        ("how".into(), Value::Str(t.how)),
594                        ("expected_result".into(), Value::Str(t.expected_result)),
595                        (
596                            "status".into(),
597                            Value::Str(format!("{:?}", t.status).to_lowercase()),
598                        ),
599                    ])
600                })
601                .collect();
602            Ok(Value::List(items))
603        })
604    }
605}
606
607pub struct MemoryConfess {
608    pub store: Arc<ConfessionStore>,
609}
610
611impl Tool for MemoryConfess {
612    fn name(&self) -> &str {
613        "memory.confess"
614    }
615
616    fn tier(&self) -> Tier {
617        Tier::One
618    }
619
620    fn description(&self) -> Option<&str> {
621        Some(
622            "Record a confession when the agent broke a rule. Anchors are auto-filled from \
623             the current turn / flow_run / event_seq. Returns the new confession id.",
624        )
625    }
626
627    fn input_schema(&self) -> serde_json::Value {
628        serde_json::json!({
629            "type": "object",
630            "properties": {
631                "trigger": {"type": "string", "description": "What the user or watcher noticed."},
632                "rule_violated": {"type": "string", "description": "Name of the red-line rule."},
633                "what_i_did": {"type": "string", "description": "The concrete mistake."},
634                "why": {"type": "string", "description": "The reasoning that led there."},
635                "mitigation": {"type": "string", "description": "What will prevent recurrence."},
636                "anchors": {
637                    "type": "array",
638                    "items": {"type": "string"},
639                    "description": "Optional extra anchor strings (auto-filled ones stay)."
640                }
641            },
642            "required": ["trigger", "rule_violated", "what_i_did", "why", "mitigation"]
643        })
644    }
645
646    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
647        let anchors = collect_anchors(&args, ctx);
648        Box::pin(async move {
649            let trigger = required_string(&args, "trigger")?;
650            let rule_violated = required_string(&args, "rule_violated")?;
651            let what_i_did = required_string(&args, "what_i_did")?;
652            let why = required_string(&args, "why")?;
653            let mitigation = required_string(&args, "mitigation")?;
654            let confession = Confession {
655                id: MemoryId::now(),
656                trigger,
657                rule_violated,
658                what_i_did,
659                why,
660                mitigation,
661                anchors,
662                created_at: chrono::Utc::now(),
663            };
664            let id = self.store.append(confession).await?;
665            Ok(Value::Str(id.to_string()))
666        })
667    }
668}
669
670fn collect_anchors(args: &ToolArgs, ctx: &ToolCtx) -> Vec<String> {
671    let mut out = Vec::new();
672    if let Some(flow_run) = &ctx.flow_run_id {
673        out.push(format!("flow_run:{flow_run}"));
674    }
675    if let Some(turn) = &ctx.turn_id {
676        out.push(format!("turn:{turn}"));
677    }
678    if let Some(seq) = ctx.event_seq {
679        out.push(format!("event_seq:{seq}"));
680    }
681    if let Some(Value::List(items)) = args.named("anchors") {
682        for item in items {
683            if let Value::Str(s) = item {
684                out.push(s.clone());
685            }
686        }
687    }
688    out
689}
690
691pub struct MemorySpecStatus {
692    pub store: Arc<SpecStore>,
693}
694
695impl Tool for MemorySpecStatus {
696    fn name(&self) -> &str {
697        "memory.spec.status"
698    }
699    fn tier(&self) -> Tier {
700        Tier::Zero
701    }
702
703    fn description(&self) -> Option<&str> {
704        Some(
705            "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.",
706        )
707    }
708
709    fn input_schema(&self) -> serde_json::Value {
710        serde_json::json!({
711            "type": "object",
712            "properties": {
713                "feature": {"type": "string", "description": "Spec feature name to inspect."}
714            },
715            "required": ["feature"]
716        })
717    }
718
719    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
720        Box::pin(async move {
721            let feature = required_string(&args, "feature")?;
722            let st = self.store.status(&feature).await?;
723            Ok(Value::Struct(vec![
724                ("feature".into(), Value::Str(st.feature)),
725                ("phase".into(), Value::Str(st.phase)),
726                ("entry_count".into(), Value::Int(st.entry_count as i64)),
727                (
728                    "deviation_count".into(),
729                    Value::Int(st.deviation_count as i64),
730                ),
731            ]))
732        })
733    }
734}
735
736pub struct MemorySpecMaterialize {
737    pub store: Arc<SpecStore>,
738}
739
740impl Tool for MemorySpecMaterialize {
741    fn name(&self) -> &str {
742        "memory.spec.materialize"
743    }
744
745    fn tier(&self) -> Tier {
746        Tier::One
747    }
748
749    fn description(&self) -> Option<&str> {
750        Some("Materialize runtime JSONL spec state to Markdown with revision conflict protection.")
751    }
752
753    fn input_schema(&self) -> serde_json::Value {
754        serde_json::json!({
755            "type": "object",
756            "properties": {
757                "feature": {"type": "string"},
758                "expected_revision": {"type": "string"}
759            },
760            "required": ["feature"]
761        })
762    }
763
764    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
765        Box::pin(async move {
766            let feature = required_string(&args, "feature")?;
767            let expected = match args.named("expected_revision") {
768                Some(Value::Str(value)) => Some(value.as_str()),
769                _ => None,
770            };
771            let result = self.store.materialize(&feature, expected).await?;
772            Ok(Value::Struct(vec![
773                ("path".into(), Value::Str(result.path.display().to_string())),
774                ("revision".into(), Value::Str(result.revision)),
775                ("changed".into(), Value::Bool(result.changed)),
776            ]))
777        })
778    }
779}
780
781pub struct MemorySpecUpdate {
782    pub store: Arc<SpecStore>,
783}
784
785impl Tool for MemorySpecUpdate {
786    fn name(&self) -> &str {
787        "memory.spec.update"
788    }
789    fn tier(&self) -> Tier {
790        Tier::One
791    }
792
793    fn description(&self) -> Option<&str> {
794        Some(
795            "Append a progress entry for a spec feature and phase. Use it to persist research, design, implementation, or verification notes as spec work advances.",
796        )
797    }
798
799    fn input_schema(&self) -> serde_json::Value {
800        serde_json::json!({
801            "type": "object",
802            "properties": {
803                "feature": {"type": "string", "description": "Spec feature name to update."},
804                "phase": {"type": "string", "description": "Spec phase or section name, such as research, design, implementation, or verification."},
805                "content": {"type": "string", "description": "Progress entry content to append."}
806            },
807            "required": ["feature", "phase", "content"]
808        })
809    }
810
811    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
812        Box::pin(async move {
813            let feature = required_string(&args, "feature")?;
814            let phase = required_string(&args, "phase")?;
815            let content = required_string(&args, "content")?;
816            let entry = self.store.update(&feature, &phase, content).await?;
817            Ok(Value::Struct(vec![
818                ("id".into(), Value::Str(entry.id.to_string())),
819                ("feature".into(), Value::Str(entry.feature)),
820                ("phase".into(), Value::Str(entry.phase)),
821            ]))
822        })
823    }
824}
825
826pub struct MemorySpecDeviate {
827    pub store: Arc<SpecStore>,
828}
829
830impl Tool for MemorySpecDeviate {
831    fn name(&self) -> &str {
832        "memory.spec.deviate"
833    }
834    fn tier(&self) -> Tier {
835        Tier::One
836    }
837
838    fn description(&self) -> Option<&str> {
839        Some(
840            "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.",
841        )
842    }
843
844    fn input_schema(&self) -> serde_json::Value {
845        serde_json::json!({
846            "type": "object",
847            "properties": {
848                "feature": {"type": "string", "description": "Spec feature name that owns the deviation."},
849                "section": {"type": "string", "description": "Spec section or decision being changed."},
850                "delta": {"type": "string", "description": "What changed from the spec."},
851                "reason": {"type": "string", "description": "Why the deviation is necessary."}
852            },
853            "required": ["feature", "section", "delta", "reason"]
854        })
855    }
856
857    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
858        Box::pin(async move {
859            let feature = required_string(&args, "feature")?;
860            let section = required_string(&args, "section")?;
861            let delta = required_string(&args, "delta")?;
862            let reason = required_string(&args, "reason")?;
863            let dev = self.store.deviate(&feature, section, delta, reason).await?;
864            Ok(Value::Struct(vec![
865                ("id".into(), Value::Str(dev.id.to_string())),
866                ("feature".into(), Value::Str(dev.feature)),
867                ("section".into(), Value::Str(dev.section)),
868            ]))
869        })
870    }
871}
872
873pub struct MemoryFetchConfessions {
874    pub store: Arc<ConfessionStore>,
875}
876
877impl Tool for MemoryFetchConfessions {
878    fn name(&self) -> &str {
879        "memory.fetch_confessions"
880    }
881
882    fn tier(&self) -> Tier {
883        Tier::Zero
884    }
885
886    fn description(&self) -> Option<&str> {
887        Some(
888            "Fetch past confession records about rule violations, optionally filtered by trigger text. Use it to recall prior mistakes and mitigations before repeating risky work.",
889        )
890    }
891
892    fn input_schema(&self) -> serde_json::Value {
893        serde_json::json!({
894            "type": "object",
895            "properties": {
896                "trigger": {"type": "string", "description": "Optional trigger substring to search for; omit to list all confession records."}
897            }
898        })
899    }
900
901    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
902        Box::pin(async move {
903            let items = match args.named("trigger") {
904                Some(Value::Str(needle)) => self.store.find_by_trigger(needle).await?,
905                _ => self.store.list().await?,
906            };
907            let list = items
908                .into_iter()
909                .map(|c| {
910                    Value::Struct(vec![
911                        ("id".into(), Value::Str(c.id.to_string())),
912                        ("trigger".into(), Value::Str(c.trigger)),
913                        ("rule_violated".into(), Value::Str(c.rule_violated)),
914                        ("what_i_did".into(), Value::Str(c.what_i_did)),
915                        ("why".into(), Value::Str(c.why)),
916                        ("mitigation".into(), Value::Str(c.mitigation)),
917                    ])
918                })
919                .collect();
920            Ok(Value::List(list))
921        })
922    }
923}
924
925pub struct MemoryHistorySearch;
926
927impl Tool for MemoryHistorySearch {
928    fn name(&self) -> &str {
929        "memory.history.search"
930    }
931
932    fn tier(&self) -> Tier {
933        Tier::Zero
934    }
935
936    fn description(&self) -> Option<&str> {
937        Some(
938            "Full-text search the current session's chat history (or optionally every session \
939             in the same project). Use it to recall past turns that fell out of your working \
940             context — e.g. `plan we agreed on this morning`, `which files did we read`, \
941             `error the user reported earlier`. NOT for searching source code; use fs.grep for \
942             that. Params: query (FTS5 syntax, required), scope (\"session\"|\"project\", \
943             default \"session\"), limit (int, default 10, max 50).",
944        )
945    }
946
947    fn input_schema(&self) -> serde_json::Value {
948        serde_json::json!({
949            "type": "object",
950            "properties": {
951                "query": {"type": "string"},
952                "scope": {"type": "string", "enum": ["session", "project"], "default": "session"},
953                "limit": {"type": "integer", "default": 10}
954            },
955            "required": ["query"]
956        })
957    }
958
959    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
960        Box::pin(async move {
961            let query = required_string(&args, "query")?;
962            let scope = match args.named("scope") {
963                Some(Value::Str(s)) if s == "project" => HistoryScope::Project,
964                _ => HistoryScope::Session,
965            };
966            let limit = match args.named("limit") {
967                Some(Value::Int(n)) if *n > 0 => (*n as usize).min(50),
968                _ => 10,
969            };
970            let Some(store) = ctx.history_store.clone() else {
971                return Err(RuntimeError::ToolFailed(
972                    "memory.history.search: no history store on context".into(),
973                ));
974            };
975            let search_scope = match scope {
976                HistoryScope::Project => crate::history_store::SearchScope::Project,
977                HistoryScope::Session => crate::history_store::SearchScope::Session,
978            };
979            let result =
980                tokio::task::spawn_blocking(move || store.search(&query, search_scope, limit))
981                    .await
982                    .map_err(|e| RuntimeError::ToolFailed(format!("history.search: {e}")))??;
983            let hits: Vec<Value> = result
984                .hits
985                .into_iter()
986                .map(|hit| {
987                    Value::Struct(vec![
988                        ("session_id".into(), Value::Str(hit.session_id)),
989                        ("seq".into(), Value::Int(hit.seq as i64)),
990                        ("ts".into(), Value::Str(hit.ts)),
991                        ("kind".into(), Value::Str(hit.kind)),
992                        ("snippet".into(), Value::Str(hit.snippet)),
993                    ])
994                })
995                .collect();
996            Ok(Value::Struct(vec![
997                ("total".into(), Value::Int(result.total as i64)),
998                ("hits".into(), Value::List(hits)),
999            ]))
1000        })
1001    }
1002}
1003
1004pub struct MemoryHistoryRead;
1005
1006impl Tool for MemoryHistoryRead {
1007    fn name(&self) -> &str {
1008        "memory.history.read"
1009    }
1010
1011    fn tier(&self) -> Tier {
1012        Tier::Zero
1013    }
1014
1015    fn description(&self) -> Option<&str> {
1016        Some(
1017            "Paginate through past messages of a session by turn index. Prefer \
1018             memory.history.search first to find a hit, then call this for surrounding context. \
1019             Params: session_id (string, default current session's directory name), offset \
1020             (1-based turn index, default 1), limit (int, default 20, max 100), role_filter \
1021             (comma-separated: user,assistant,tool,system; default all).",
1022        )
1023    }
1024
1025    fn input_schema(&self) -> serde_json::Value {
1026        serde_json::json!({
1027            "type": "object",
1028            "properties": {
1029                "session_id": {"type": "string"},
1030                "offset": {"type": "integer", "default": 1},
1031                "limit": {"type": "integer", "default": 20},
1032                "role_filter": {"type": "string"}
1033            }
1034        })
1035    }
1036
1037    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1038        Box::pin(async move {
1039            let Some(current_dir) = ctx.session_dir.as_ref() else {
1040                return Err(RuntimeError::ToolFailed(
1041                    "memory.history.read: no session dir on context".into(),
1042                ));
1043            };
1044            let session_id = match args.named("session_id") {
1045                Some(Value::Str(sid)) if !sid.is_empty() => sid.clone(),
1046                _ => current_dir
1047                    .file_name()
1048                    .map(|n| n.to_string_lossy().into_owned())
1049                    .unwrap_or_default(),
1050            };
1051            let offset = match args.named("offset") {
1052                Some(Value::Int(n)) if *n >= 1 => *n as usize,
1053                _ => 1,
1054            };
1055            let limit = match args.named("limit") {
1056                Some(Value::Int(n)) if *n >= 1 => (*n as usize).min(100),
1057                _ => 20,
1058            };
1059            let role_filter: Option<Vec<String>> = match args.named("role_filter") {
1060                Some(Value::Str(s)) if !s.is_empty() => Some(
1061                    s.split(',')
1062                        .map(|t| t.trim().to_lowercase())
1063                        .filter(|t| !t.is_empty())
1064                        .collect(),
1065                ),
1066                _ => None,
1067            };
1068            let Some(store) = ctx.history_store.clone() else {
1069                return Err(RuntimeError::ToolFailed(
1070                    "memory.history.read: no history store on context".into(),
1071                ));
1072            };
1073            let query = crate::history_store::HistoryQuery {
1074                session_id,
1075                offset,
1076                limit,
1077                role_filter,
1078            };
1079            let page = tokio::task::spawn_blocking(move || store.read(query))
1080                .await
1081                .map_err(|e| RuntimeError::ToolFailed(format!("history.read: {e}")))??;
1082            let item_count = page.items.len();
1083            let items: Vec<Value> = page.items.into_iter().map(Value::Message).collect();
1084            let start = offset;
1085            let end = if item_count == 0 {
1086                start
1087            } else {
1088                start + item_count - 1
1089            };
1090            let header = format!("[history: turns {start}-{end} of {}]", page.total);
1091            Ok(Value::Struct(vec![
1092                ("total".into(), Value::Int(page.total as i64)),
1093                ("offset".into(), Value::Int(page.offset as i64)),
1094                ("limit".into(), Value::Int(page.limit as i64)),
1095                ("header".into(), Value::Str(header)),
1096                ("items".into(), Value::List(items)),
1097            ]))
1098        })
1099    }
1100}
1101
1102pub struct MemoryHistoryCount;
1103
1104impl Tool for MemoryHistoryCount {
1105    fn name(&self) -> &str {
1106        "memory.history.count"
1107    }
1108
1109    fn tier(&self) -> Tier {
1110        Tier::Zero
1111    }
1112
1113    fn description(&self) -> Option<&str> {
1114        Some(
1115            "Return the total message count for a session. Lightweight — use this to check \
1116             how many messages exist before paginating with memory.history.read. \
1117             Params: session_id (string, default current session), role_filter \
1118             (comma-separated: user,assistant,tool,system; default all).",
1119        )
1120    }
1121
1122    fn input_schema(&self) -> serde_json::Value {
1123        serde_json::json!({
1124            "type": "object",
1125            "properties": {
1126                "session_id": {"type": "string"},
1127                "role_filter": {"type": "string"}
1128            }
1129        })
1130    }
1131
1132    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1133        Box::pin(async move {
1134            let Some(current_dir) = ctx.session_dir.as_ref() else {
1135                return Err(RuntimeError::ToolFailed(
1136                    "memory.history.count: no session dir on context".into(),
1137                ));
1138            };
1139            let session_id = match args.named("session_id") {
1140                Some(Value::Str(sid)) if !sid.is_empty() => sid.clone(),
1141                _ => current_dir
1142                    .file_name()
1143                    .map(|n| n.to_string_lossy().into_owned())
1144                    .unwrap_or_default(),
1145            };
1146            let role_filter: Option<Vec<String>> = match args.named("role_filter") {
1147                Some(Value::Str(s)) if !s.is_empty() => Some(
1148                    s.split(',')
1149                        .map(|t| t.trim().to_lowercase())
1150                        .filter(|t| !t.is_empty())
1151                        .collect(),
1152                ),
1153                _ => None,
1154            };
1155            let Some(store) = ctx.history_store.clone() else {
1156                return Err(RuntimeError::ToolFailed(
1157                    "memory.history.count: no history store on context".into(),
1158                ));
1159            };
1160            let total = tokio::task::spawn_blocking(move || {
1161                let role_refs: Option<Vec<&str>> = role_filter
1162                    .as_ref()
1163                    .map(|rs| rs.iter().map(|s| s.as_str()).collect());
1164                store.count(&session_id, role_refs.as_deref())
1165            })
1166            .await
1167            .map_err(|e| RuntimeError::ToolFailed(format!("history.count: {e}")))??;
1168            Ok(Value::Int(total as i64))
1169        })
1170    }
1171}
1172
1173enum HistoryScope {
1174    Session,
1175    Project,
1176}
1177
1178fn required_string(args: &ToolArgs, name: &str) -> Result<String, RuntimeError> {
1179    match args.named(name) {
1180        Some(Value::Str(s)) => Ok(s.clone()),
1181        Some(other) => Err(RuntimeError::TypeMismatch {
1182            expected: "string".into(),
1183            actual: other.kind_name().into(),
1184        }),
1185        None => Err(RuntimeError::MissingArg(name.into())),
1186    }
1187}