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 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 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}