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