Files
crewAI/lib/cli/src/crewai_cli/memory_tui.py
Greyson LaLonde bad64b1ee6
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
chore(cli): drop self-explanatory comments
2026-05-26 01:05:25 -07:00

394 lines
14 KiB
Python

"""Textual TUI for browsing and recalling unified memory."""
from __future__ import annotations
import asyncio
from typing import Any
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from textual.widgets import Footer, Header, Input, OptionList, Static, Tree
_PRIMARY = "#eb6658"
_SECONDARY = "#1F7982"
_TERTIARY = "#ffffff"
def _format_scope_info(info: Any) -> str:
"""Format ScopeInfo with Rich markup."""
return (
f"[bold {_PRIMARY}]{info.path}[/]\n\n"
f"[dim]Records:[/] [bold]{info.record_count}[/]\n"
f"[dim]Categories:[/] {', '.join(info.categories) or 'none'}\n"
f"[dim]Oldest:[/] {info.oldest_record or '-'}\n"
f"[dim]Newest:[/] {info.newest_record or '-'}\n"
f"[dim]Children:[/] {', '.join(info.child_scopes) or 'none'}"
)
class MemoryTUI(App[None]):
"""TUI to browse memory scopes and run recall queries."""
TITLE = "CrewAI Memory"
SUB_TITLE = "Browse scopes and recall memories"
CSS = f"""
Header {{
background: {_PRIMARY};
color: {_TERTIARY};
}}
Footer {{
background: {_SECONDARY};
color: {_TERTIARY};
}}
Footer > .footer-key--key {{
background: {_PRIMARY};
color: {_TERTIARY};
}}
Horizontal {{
height: 1fr;
}}
#scope-tree {{
width: 30%;
padding: 1 2;
background: {_SECONDARY} 8%;
border-right: solid {_SECONDARY};
}}
#scope-tree:focus > .tree--cursor {{
background: {_SECONDARY};
color: {_TERTIARY};
}}
#scope-tree > .tree--guides {{
color: {_SECONDARY} 50%;
}}
#scope-tree > .tree--guides-hover {{
color: {_PRIMARY};
}}
#scope-tree > .tree--guides-selected {{
color: {_SECONDARY};
}}
#right-panel {{
width: 70%;
padding: 0 1;
}}
#info-panel {{
height: 2fr;
padding: 1 2;
overflow-y: auto;
border: round {_SECONDARY};
}}
#info-panel:focus {{
border: round {_PRIMARY};
}}
#info-panel LoadingIndicator {{
color: {_PRIMARY};
}}
#entry-list {{
height: 1fr;
border: round {_SECONDARY};
padding: 0 1;
scrollbar-color: {_PRIMARY};
}}
#entry-list:focus {{
border: round {_PRIMARY};
}}
#entry-list > .option-list--option-highlighted {{
background: {_SECONDARY};
color: {_TERTIARY};
}}
#recall-input {{
margin: 0 1 1 1;
border: tall {_SECONDARY};
}}
#recall-input:focus {{
border: tall {_PRIMARY};
}}
"""
def __init__(
self,
storage_path: str | None = None,
embedder_config: dict[str, Any] | None = None,
) -> None:
super().__init__()
self._memory: Any = None
self._init_error: str | None = None
self._selected_scope: str = "/"
self._entries: list[Any] = []
self._view_mode: str = "list" # "list" | "recall"
self._recall_matches: list[Any] = []
self._last_scope_info: Any = None
self._custom_embedder = embedder_config is not None
try:
from crewai.memory.storage.lancedb_storage import LanceDBStorage
from crewai.memory.unified_memory import Memory
storage = (
LanceDBStorage(path=storage_path) if storage_path else LanceDBStorage()
)
embedder = None
if embedder_config is not None:
from crewai.rag.embeddings.factory import build_embedder
embedder = build_embedder(embedder_config)
self._memory = (
Memory(storage=storage, embedder=embedder)
if embedder
else Memory(storage=storage)
)
except Exception as e:
self._init_error = str(e)
def compose(self) -> ComposeResult:
yield Header(show_clock=False)
with Horizontal():
yield self._build_scope_tree()
initial = (
self._init_error
if self._init_error
else "Select a scope or type a recall query."
)
with Vertical(id="right-panel"):
yield Static(initial, id="info-panel")
yield OptionList(id="entry-list")
yield Input(
placeholder="Type a query and press Enter to recall...",
id="recall-input",
)
yield Footer()
def on_mount(self) -> None:
"""Set initial border titles on mounted widgets."""
self.query_one("#info-panel", Static).border_title = "Detail"
self.query_one("#entry-list", OptionList).border_title = "Entries"
def _build_scope_tree(self) -> Tree[str]:
tree: Tree[str] = Tree("/", id="scope-tree")
if self._memory is None:
tree.root.data = "/"
tree.root.label = "/ (0 records)"
return tree
info = self._memory.info("/")
tree.root.label = f"/ ({info.record_count} records)"
tree.root.data = "/"
self._add_scope_children(tree.root, "/", depth=0, max_depth=3)
tree.root.expand()
return tree
def _add_scope_children(
self,
parent_node: Any,
path: str,
depth: int,
max_depth: int,
) -> None:
if depth >= max_depth or self._memory is None:
return
info = self._memory.info(path)
for child in info.child_scopes:
child_info = self._memory.info(child)
label = f"{child} ({child_info.record_count})"
node = parent_node.add(label, data=child)
self._add_scope_children(node, child, depth + 1, max_depth)
def _populate_entry_list(self) -> None:
"""Clear the OptionList and fill it with the current scope's entries."""
option_list = self.query_one("#entry-list", OptionList)
option_list.clear_options()
for record in self._entries:
date_str = record.created_at.strftime("%Y-%m-%d")
preview = (
(record.content[:80] + "")
if len(record.content) > 80
else record.content
)
label = f"{date_str} [bold]{record.importance:.1f}[/] {preview}"
option_list.add_option(label)
def _populate_recall_list(self) -> None:
"""Clear the OptionList and fill it with the current recall matches."""
option_list = self.query_one("#entry-list", OptionList)
option_list.clear_options()
if not self._recall_matches:
return
for m in self._recall_matches:
preview = (
(m.record.content[:80] + "")
if len(m.record.content) > 80
else m.record.content
)
label = (
f"[bold]\\[{m.score:.2f}][/] {preview} [dim]scope={m.record.scope}[/]"
)
option_list.add_option(label)
def _format_record_detail(self, record: Any, context_line: str = "") -> str:
"""Format a full MemoryRecord as Rich markup for the detail view.
Args:
record: A MemoryRecord instance.
context_line: Optional header line shown above the fields
(e.g. "Entry 3 of 47").
Returns:
A Rich-markup string with all meaningful record fields.
"""
sep = f"[bold {_PRIMARY}]{'' * 44}[/]"
lines: list[str] = []
if context_line:
lines.append(context_line)
lines.append("")
lines.append(f"[dim]ID:[/] {record.id}")
lines.append(f"[dim]Scope:[/] [bold]{record.scope}[/]")
lines.append(f"[dim]Importance:[/] [bold]{record.importance:.2f}[/]")
lines.append(
f"[dim]Created:[/] {record.created_at.strftime('%Y-%m-%d %H:%M:%S')}"
)
lines.append(
f"[dim]Last accessed:[/] "
f"{record.last_accessed.strftime('%Y-%m-%d %H:%M:%S')}"
)
lines.append(
f"[dim]Categories:[/] "
f"{', '.join(record.categories) if record.categories else 'none'}"
)
lines.append(f"[dim]Source:[/] {record.source or '-'}")
lines.append(f"[dim]Private:[/] {'Yes' if record.private else 'No'}")
lines.append(f"\n{sep}")
lines.append("[bold]Content[/]\n")
lines.append(record.content)
if record.metadata:
lines.append(f"\n{sep}")
lines.append("[bold]Metadata[/]\n")
for k, v in record.metadata.items():
lines.append(f"[dim]{k}:[/] {v}")
return "\n".join(lines)
def on_tree_node_selected(self, event: Tree.NodeSelected[str]) -> None:
"""Load entries for the selected scope and populate the OptionList."""
path = event.node.data if event.node.data is not None else "/"
self._selected_scope = path
self._view_mode = "list"
panel = self.query_one("#info-panel", Static)
if self._memory is None:
panel.update(self._init_error or "No memory loaded.")
return
display_limit = 1000
info = self._memory.info(path)
self._last_scope_info = info
self._entries = self._memory.list_records(scope=path, limit=display_limit)
panel.update(_format_scope_info(info))
panel.border_title = "Detail"
entry_list = self.query_one("#entry-list", OptionList)
capped = info.record_count > display_limit
count_label = (
f"Entries (showing {display_limit} of {info.record_count} — display limit)"
if capped
else f"Entries ({len(self._entries)})"
)
entry_list.border_title = count_label
self._populate_entry_list()
def on_option_list_option_highlighted(
self, event: OptionList.OptionHighlighted
) -> None:
"""Live-update the info panel with the detail of the highlighted entry."""
panel = self.query_one("#info-panel", Static)
idx = event.option_index
if self._view_mode == "list":
if idx < len(self._entries):
record = self._entries[idx]
total = len(self._entries)
context = (
f"[bold {_PRIMARY}]Entry {idx + 1} of {total}[/] "
f"[dim]in[/] [bold]{self._selected_scope}[/]"
)
panel.border_title = f"Entry {idx + 1} of {total}"
panel.update(self._format_record_detail(record, context_line=context))
elif self._view_mode == "recall":
if idx < len(self._recall_matches):
match = self._recall_matches[idx]
total = len(self._recall_matches)
panel.border_title = f"Match {idx + 1} of {total}"
score_color = _PRIMARY if match.score >= 0.5 else "dim"
header_lines: list[str] = [
f"[bold {_PRIMARY}]Recall Match {idx + 1} of {total}[/]\n",
f"[dim]Score:[/] [{score_color}][bold]{match.score:.2f}[/][/]",
(
f"[dim]Match reasons:[/] "
f"{', '.join(match.match_reasons) if match.match_reasons else '-'}"
),
(
f"[dim]Evidence gaps:[/] "
f"{', '.join(match.evidence_gaps) if match.evidence_gaps else 'none'}"
),
f"\n[bold {_PRIMARY}]{'' * 44}[/]",
]
record_detail = self._format_record_detail(match.record)
header_lines.append(record_detail)
panel.update("\n".join(header_lines))
def on_input_submitted(self, event: Input.Submitted) -> None:
query = event.value.strip()
if not query:
return
if self._memory is None:
panel = self.query_one("#info-panel", Static)
panel.update(self._init_error or "No memory loaded. Cannot recall.")
return
self.run_worker(self._do_recall(query), exclusive=True)
async def _do_recall(self, query: str) -> None:
"""Execute a recall query and display results in the OptionList."""
panel = self.query_one("#info-panel", Static)
panel.loading = True
try:
scope = self._selected_scope if self._selected_scope != "/" else None
loop = asyncio.get_event_loop()
matches = await loop.run_in_executor(
None,
lambda: self._memory.recall(query, scope=scope, limit=10, depth="deep"),
)
self._recall_matches = matches or []
self._view_mode = "recall"
if not self._recall_matches:
panel.update("[dim]No memories found.[/]")
self.query_one("#entry-list", OptionList).clear_options()
return
info_lines: list[str] = []
info_lines.append(
"[dim italic]Searched the full dataset"
+ (f" within [bold]{scope}[/]" if scope else "")
+ " using the recall flow (semantic + recency + importance).[/]\n"
)
if not self._custom_embedder:
info_lines.append(
"[dim italic]Note: Using default OpenAI embedder. "
"If memories were created with a different embedder, "
"pass --embedder-provider to match.[/]\n"
)
info_lines.append(
f"[bold]Recall Results[/] [dim]"
f"({len(self._recall_matches)} matches)[/]\n"
f"[dim]Navigate the list below to view details.[/]"
)
panel.update("\n".join(info_lines))
panel.border_title = "Recall Detail"
entry_list = self.query_one("#entry-list", OptionList)
entry_list.border_title = f"Recall Results ({len(self._recall_matches)})"
self._populate_recall_list()
except Exception as e:
panel.update(f"[bold red]Error:[/] {e}")
finally:
panel.loading = False