personal memory agent
1# SPDX-License-Identifier: AGPL-3.0-only
2# Copyright (c) 2026 sol pbc
3
4"""Unified CLI for solstone - AI-driven desktop journaling toolkit.
5
6Usage:
7 sol Show status and available commands
8 sol <command> [args] Run a subcommand
9 sol <module> [args] Run by module path (e.g., sol think.importers.cli)
10
11Examples:
12 sol import data.json Import data into journal
13 sol dream 20250101 Run daily processing for a day
14 sol think.agents -h Show help for specific module
15"""
16
17from __future__ import annotations
18
19import importlib
20import os
21import sys
22from typing import Any
23
24import setproctitle
25
26# =============================================================================
27# Command Registry
28# =============================================================================
29# Maps short command names to module paths.
30# All modules must have a main() function as entry point.
31#
32# To add a new command:
33# 1. Add entry here: "name": "package.module"
34# 2. Ensure module has main() function
35#
36# Aliases for compound commands can be added to ALIASES dict below.
37# =============================================================================
38
39COMMANDS: dict[str, str] = {
40 # think package - daily processing and analysis
41 "import": "think.importers.cli",
42 "dream": "think.dream",
43 "planner": "think.planner",
44 "indexer": "think.indexer",
45 "supervisor": "think.supervisor",
46 "schedule": "think.scheduler",
47 "detect-created": "think.detect_created",
48 "top": "think.top",
49 "health": "think.health_cli",
50 "callosum": "think.callosum",
51 "notify": "think.notify_cli",
52 "streams": "think.streams",
53 "journal-stats": "think.journal_stats",
54 "config": "think.config_cli",
55 "formatter": "think.formatters",
56 # observe package - multimodal capture
57 "transcribe": "observe.transcribe",
58 "describe": "observe.describe",
59 "sense": "observe.sense",
60 "sync": "observe.sync",
61 "transfer": "observe.transfer",
62 "remote": "observe.remote_cli",
63 # AI agents (talent package)
64 "agents": "think.agents",
65 "cortex": "think.cortex",
66 "talent": "think.talent_cli",
67 "call": "think.call",
68 "engage": "think.engage",
69 "help": "think.help_cli",
70 "chat": "think.chat_cli",
71 "heartbeat": "think.heartbeat",
72 # convey package - web UI
73 "convey": "convey.cli",
74 "restart-convey": "convey.restart",
75 "screenshot": "convey.screenshot",
76 "maint": "convey.maint_cli",
77 "service": "think.service",
78}
79
80# =============================================================================
81# Aliases for Compound Commands
82# =============================================================================
83# Maps alias names to (module, default_args) tuples.
84# These provide shortcuts for common operations with preset arguments.
85#
86# Example: "reindex": ("think.indexer", ["--rescan"])
87# Running "sol reindex" is equivalent to "sol indexer --rescan"
88# =============================================================================
89
90ALIASES: dict[str, tuple[str, list[str]]] = {
91 "start": ("think.supervisor", []),
92 "up": ("think.service", ["up"]),
93 "down": ("think.service", ["down"]),
94}
95
96# Command groupings for help display
97GROUPS: dict[str, list[str]] = {
98 "Think (daily processing)": [
99 "import",
100 "dream",
101 "planner",
102 "indexer",
103 "supervisor",
104 "schedule",
105 "top",
106 "health",
107 "callosum",
108 "notify",
109 "heartbeat",
110 ],
111 "Service": ["service"],
112 "Observe (capture)": [
113 "transcribe",
114 "describe",
115 "sense",
116 "sync",
117 "transfer",
118 "remote",
119 ],
120 "Talent (AI agents)": [
121 "agents",
122 "cortex",
123 "talent",
124 "call",
125 "engage",
126 ],
127 "Convey (web UI)": [
128 "convey",
129 "restart-convey",
130 "screenshot",
131 "maint",
132 ],
133 "Specialized tools": [
134 "config",
135 "streams",
136 "journal-stats",
137 "formatter",
138 "detect-created",
139 ],
140 "Help": ["help", "chat"],
141}
142
143
144def get_status() -> dict[str, Any]:
145 """Return current journal status information."""
146 from think.utils import get_journal_info
147
148 path, source = get_journal_info()
149
150 return {
151 "journal_path": path,
152 "journal_source": source,
153 "journal_exists": os.path.isdir(path),
154 }
155
156
157def print_status() -> None:
158 """Print current journal status."""
159 status = get_status()
160
161 print(f"Journal: {status['journal_path']}")
162 if status["journal_exists"]:
163 # Count day directories
164 journal = status["journal_path"]
165 days = [
166 d
167 for d in os.listdir(journal)
168 if os.path.isdir(os.path.join(journal, d)) and d.isdigit() and len(d) == 8
169 ]
170 print(f"Days: {len(days)}")
171 print()
172
173
174def print_help() -> None:
175 """Print help with status and available commands."""
176 print("sol - solstone unified CLI\n")
177 print_status()
178
179 print("Usage: sol <command> [args...]\n")
180
181 # Print grouped commands
182 for group_name, commands in GROUPS.items():
183 print(f"{group_name}:")
184 for cmd in commands:
185 if cmd in COMMANDS:
186 module = COMMANDS[cmd]
187 print(f" {cmd:16} {module}")
188 print()
189
190 # Print aliases if any
191 if ALIASES:
192 print("Aliases:")
193 for alias, (module, args) in ALIASES.items():
194 args_str = " ".join(args) if args else ""
195 print(f" {alias:16} → {module} {args_str}")
196 print()
197
198 print("Direct module syntax: sol <module.path> [args]")
199 print("Example: sol think.importers.cli --help")
200
201
202def resolve_command(name: str) -> tuple[str, list[str]]:
203 """Resolve command name to module path and any preset args.
204
205 Args:
206 name: Command name, alias, or module path
207
208 Returns:
209 Tuple of (module_path, preset_args)
210
211 Raises:
212 ValueError: If command not found
213 """
214 # Check aliases first (they override commands)
215 if name in ALIASES:
216 module, preset_args = ALIASES[name]
217 return module, preset_args
218
219 # Check command registry
220 if name in COMMANDS:
221 return COMMANDS[name], []
222
223 # Check if it looks like a module path (contains ".")
224 if "." in name:
225 return name, []
226
227 # Not found
228 available = sorted(set(COMMANDS.keys()) | set(ALIASES.keys()))
229 raise ValueError(
230 f"Unknown command: {name}\nAvailable commands: {', '.join(available[:10])}..."
231 )
232
233
234def run_command(module_path: str) -> int:
235 """Import and run a module's main() function.
236
237 Args:
238 module_path: Dotted module path (e.g., "think.importers.cli")
239
240 Returns:
241 Exit code (0 for success)
242 """
243 try:
244 module = importlib.import_module(module_path)
245 except ImportError as e:
246 print(f"Error: Could not import module '{module_path}': {e}", file=sys.stderr)
247 return 1
248
249 if not hasattr(module, "main"):
250 print(f"Error: Module '{module_path}' has no main() function", file=sys.stderr)
251 return 1
252
253 # Call main - it may call sys.exit() internally
254 try:
255 module.main()
256 return 0
257 except SystemExit as e:
258 # Preserve exit code from subcommand
259 # SystemExit can have int code, string message, or None
260 if isinstance(e.code, int):
261 return e.code
262 elif isinstance(e.code, str):
263 print(e.code, file=sys.stderr)
264 return 1
265 else:
266 return 0 if not e.code else 1
267
268
269def main() -> None:
270 """Main entry point for sol CLI."""
271 # No arguments - show status and help
272 if len(sys.argv) < 2:
273 print_help()
274 return
275
276 cmd = sys.argv[1]
277
278 # Help flags
279 if cmd in ("--help", "-h"):
280 print_help()
281 return
282 if cmd == "help" and len(sys.argv) <= 2:
283 print_help()
284 return
285
286 # Version flag
287 if cmd in ("--version", "-V"):
288 print("sol (solstone) 0.1.0")
289 return
290
291 # Path flag
292 if cmd == "--path":
293 from think.utils import get_journal_info
294
295 path, _source = get_journal_info()
296 print(path)
297 return
298
299 # Resolve command to module path
300 try:
301 module_path, preset_args = resolve_command(cmd)
302 except ValueError as e:
303 print(f"Error: {e}", file=sys.stderr)
304 sys.exit(1)
305
306 # Set process title for ps/top visibility
307 setproctitle.setproctitle(f"sol:{cmd}")
308
309 # Adjust sys.argv for the subcommand
310 # Original: ["sol", "import", "--day", "20250101"]
311 # Becomes: ["sol import", "--day", "20250101"]
312 # This makes argparse show "usage: sol import ..." in help
313 remaining_args = sys.argv[2:]
314 sys.argv = [f"sol {cmd}"] + preset_args + remaining_args
315
316 # Run the command
317 exit_code = run_command(module_path)
318 sys.exit(exit_code)
319
320
321if __name__ == "__main__":
322 main()