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