personal memory agent
1# SPDX-License-Identifier: AGPL-3.0-only
2# Copyright (c) 2026 sol pbc
3
4"""CLI command for chatting with the journal agent."""
5
6from __future__ import annotations
7
8import argparse
9import sys
10import threading
11
12from think.callosum import CallosumConnection
13from think.cortex_client import cortex_request, read_use_events
14from think.utils import require_solstone, setup_cli
15
16
17def main() -> None:
18 """Entry point for ``sol chat``."""
19 parser = argparse.ArgumentParser(
20 prog="sol chat",
21 description="Chat with your journal",
22 )
23 parser.add_argument("message", nargs="*", help="Chat message")
24 parser.add_argument("--facet", help="Facet context")
25 parser.add_argument("--provider", help="AI provider override")
26 parser.add_argument(
27 "--talent", default="chat", help="Talent agent name (default: chat)"
28 )
29 args = setup_cli(parser)
30 require_solstone()
31
32 from think.identity import ensure_identity_directory
33
34 ensure_identity_directory()
35
36 if not args.message:
37 parser.print_help()
38 return
39
40 message = " ".join(args.message).strip()
41
42 config: dict[str, str] = {}
43 if args.facet:
44 config["facet"] = args.facet
45
46 use_id = cortex_request(
47 prompt=message,
48 name=args.talent,
49 provider=args.provider,
50 config=config if config else None,
51 )
52 if use_id is None:
53 print(
54 "Error: failed to connect to cortex (is the stack running?)",
55 file=sys.stderr,
56 )
57 sys.exit(1)
58
59 result: dict[str, str] = {}
60 done = threading.Event()
61 listener = CallosumConnection()
62
63 def on_event(msg: dict) -> None:
64 if msg.get("tract") != "cortex":
65 return
66 if msg.get("use_id") != use_id:
67 return
68
69 event_type = msg.get("event")
70 if event_type == "start":
71 if args.verbose:
72 print(
73 f"Agent started (model={msg.get('model')}, provider={msg.get('provider')})",
74 file=sys.stderr,
75 )
76 elif event_type == "thinking":
77 if args.verbose:
78 print(
79 f"Thinking: {msg.get('summary', '')[:200]}",
80 file=sys.stderr,
81 )
82 elif event_type == "tool_start":
83 if args.verbose:
84 print(f"Tool: {msg.get('tool', 'unknown')}", file=sys.stderr)
85 elif event_type == "tool_end":
86 if args.verbose:
87 print(f"Tool done: {msg.get('tool', '')}", file=sys.stderr)
88 elif event_type == "finish":
89 result["text"] = msg.get("result", "")
90 done.set()
91 elif event_type == "error":
92 result["error"] = msg.get("error", "Unknown error")
93 done.set()
94
95 listener.start(callback=on_event)
96
97 if not args.verbose:
98 print("Thinking...", end="", file=sys.stderr, flush=True)
99
100 try:
101 done.wait(timeout=600)
102 except KeyboardInterrupt:
103 print("\nInterrupted.", file=sys.stderr)
104 listener.stop()
105 sys.exit(1)
106
107 listener.stop()
108
109 if not args.verbose:
110 print("\r \r", end="", file=sys.stderr, flush=True)
111
112 if "error" in result:
113 print(f"Error: {result['error']}", file=sys.stderr)
114 sys.exit(1)
115
116 if "text" in result and result["text"].strip():
117 print(result["text"])
118 return
119
120 if "text" in result:
121 print("Error: agent returned an empty result.", file=sys.stderr)
122 sys.exit(1)
123
124 try:
125 events = read_use_events(use_id)
126 for event in reversed(events):
127 event_type = event.get("event")
128 if event_type == "finish":
129 text = event.get("result", "")
130 if str(text).strip():
131 print(text)
132 return
133 print("Error: agent returned an empty result.", file=sys.stderr)
134 sys.exit(1)
135 if event_type == "error":
136 print(
137 f"Error: {event.get('error', 'Unknown error')}",
138 file=sys.stderr,
139 )
140 sys.exit(1)
141 except FileNotFoundError:
142 pass
143
144 print("Error: request timed out.", file=sys.stderr)
145 sys.exit(1)