abstractassistant 0.3.4__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
abstractassistant/app.py CHANGED
@@ -6,6 +6,8 @@ Handles system tray integration, UI coordination, and application lifecycle.
6
6
 
7
7
  import threading
8
8
  import time
9
+ import signal
10
+ from pathlib import Path
9
11
  from typing import Optional
10
12
 
11
13
  import pystray
@@ -131,17 +133,26 @@ class EnhancedClickableIcon(pystray.Icon):
131
133
  class AbstractAssistantApp:
132
134
  """Main application class coordinating all components."""
133
135
 
134
- def __init__(self, config: Optional[Config] = None, debug: bool = False, listening_mode: str = "wait"):
136
+ def __init__(
137
+ self,
138
+ config: Optional[Config] = None,
139
+ debug: bool = False,
140
+ listening_mode: str = "wait",
141
+ *,
142
+ data_dir: Optional[Path] = None,
143
+ ):
135
144
  """Initialize the AbstractAssistant application.
136
145
 
137
146
  Args:
138
147
  config: Configuration object (uses default if None)
139
148
  debug: Enable debug mode
140
149
  listening_mode: Voice listening mode (none, stop, wait, full)
150
+ data_dir: Assistant base data dir (sessions + runtime stores)
141
151
  """
142
152
  self.config = config or Config.default()
143
153
  self.debug = debug
144
154
  self.listening_mode = listening_mode
155
+ self.data_dir = Path(data_dir).expanduser() if data_dir is not None else None
145
156
 
146
157
  # Validate configuration
147
158
  if not self.config.validate():
@@ -152,7 +163,7 @@ class AbstractAssistantApp:
152
163
  # Initialize components
153
164
  self.icon: Optional[pystray.Icon] = None
154
165
  self.bubble_manager: Optional[QtBubbleManager] = None
155
- self.llm_manager: LLMManager = LLMManager(config=self.config, debug=self.debug)
166
+ self.llm_manager: LLMManager = LLMManager(config=self.config, debug=self.debug, data_dir=self.data_dir)
156
167
  self.icon_generator: IconGenerator = IconGenerator(size=self.config.system_tray.icon_size)
157
168
 
158
169
  # Application state
@@ -786,6 +797,20 @@ class AbstractAssistantApp:
786
797
 
787
798
  if self.icon:
788
799
  self.icon.stop()
800
+
801
+ # Stop/hide Qt tray icon if we are running in Qt mode.
802
+ try:
803
+ if hasattr(self, "qt_tray_icon") and self.qt_tray_icon is not None:
804
+ self.qt_tray_icon.hide()
805
+ except Exception:
806
+ pass
807
+
808
+ # Stop click timer used for single/double click detection (Qt mode).
809
+ try:
810
+ if hasattr(self, "click_timer") and self.click_timer is not None:
811
+ self.click_timer.stop()
812
+ except Exception:
813
+ pass
789
814
 
790
815
  # Clean up bubble manager
791
816
  if self.bubble_manager:
@@ -797,6 +822,20 @@ class AbstractAssistantApp:
797
822
 
798
823
  if self.debug:
799
824
  print("✅ AbstractAssistant quit successfully")
825
+
826
+ def _request_qt_quit(self) -> None:
827
+ """Request a graceful quit on the Qt event loop (safe to call from SIGINT handler)."""
828
+ # Always run cleanup first; then quit the Qt event loop.
829
+ try:
830
+ self.quit_application()
831
+ except Exception:
832
+ pass
833
+
834
+ try:
835
+ if hasattr(self, "qt_app") and self.qt_app:
836
+ self.qt_app.quit()
837
+ except Exception:
838
+ pass
800
839
 
801
840
  def run(self):
802
841
  """Start the application using Qt event loop for proper threading."""
@@ -833,15 +872,39 @@ class AbstractAssistantApp:
833
872
  print("AbstractAssistant started. Check your menu bar!")
834
873
  print("Click the icon to open the chat interface.")
835
874
 
875
+ # Ctrl+C / SIGTERM should shut down cleanly (avoid macOS "python quit unexpectedly").
876
+ # We schedule the quit on the Qt loop to keep teardown ordered.
877
+ def _handle_sigint(_signum, _frame):
878
+ try:
879
+ QTimer.singleShot(0, self._request_qt_quit)
880
+ except Exception:
881
+ self._request_qt_quit()
882
+
883
+ try:
884
+ signal.signal(signal.SIGINT, _handle_sigint)
885
+ signal.signal(signal.SIGTERM, _handle_sigint)
886
+ except Exception:
887
+ # If signals are not available/allowed in this context, we still handle KeyboardInterrupt below.
888
+ pass
889
+
836
890
  # Run Qt event loop (this blocks until quit)
837
- self.qt_app.exec_()
891
+ try:
892
+ self.qt_app.exec_()
893
+ except KeyboardInterrupt:
894
+ # Ensure a graceful shutdown when Ctrl+C interrupts the event loop.
895
+ self._request_qt_quit()
896
+ return
838
897
 
839
898
  except ImportError:
840
899
  if self.debug:
841
900
  print("❌ PyQt5 not available. Falling back to pystray...")
842
901
  # Fallback to original pystray implementation
843
902
  self.icon = self.create_system_tray_icon()
844
- self.icon.run()
903
+ try:
904
+ self.icon.run()
905
+ except KeyboardInterrupt:
906
+ self.quit_application()
907
+ return
845
908
 
846
909
  def _create_qt_system_tray_icon(self):
847
910
  """Create Qt-based system tray icon with smooth animations."""
@@ -996,5 +1059,5 @@ class AbstractAssistantApp:
996
1059
  if self.debug:
997
1060
  print("🔄 Qt: Quit requested")
998
1061
 
999
- if hasattr(self, 'qt_app') and self.qt_app:
1000
- self.qt_app.quit()
1062
+ # Route through the same cleanup path (menu Quit should behave like Ctrl+C).
1063
+ self._request_qt_quit()
abstractassistant/cli.py CHANGED
@@ -1,76 +1,47 @@
1
1
  #!/usr/bin/env python3
2
- """
3
- CLI entry point for AbstractAssistant.
2
+ """CLI entry point for AbstractAssistant.
4
3
 
5
- This module provides the command-line interface for launching AbstractAssistant.
4
+ Packaging invariant:
5
+ - `assistant --help` must not import GUI/voice stacks (optional dependencies).
6
6
  """
7
7
 
8
- import sys
8
+ from __future__ import annotations
9
+
9
10
  import argparse
11
+ import sys
10
12
  from pathlib import Path
11
13
  from typing import Optional
12
14
 
13
- from .app import AbstractAssistantApp
14
- from .config import Config
15
-
16
15
 
17
16
  def create_parser() -> argparse.ArgumentParser:
18
17
  """Create the command-line argument parser."""
19
- parser = argparse.ArgumentParser(
20
- prog="assistant",
21
- description="AbstractAssistant - AI at your fingertips",
22
- formatter_class=argparse.RawDescriptionHelpFormatter,
23
- epilog="""
24
- Examples:
25
- assistant # Launch with default settings
26
- assistant --config custom.toml # Use custom config file
27
- assistant --provider openai # Set default provider
28
- assistant --model gpt-4o # Set default model
29
- assistant --debug # Enable debug mode
30
-
31
- For more information, visit: https://github.com/yourusername/abstractassistant
32
- """,
33
- )
18
+ prog = Path(sys.argv[0]).name if sys.argv and sys.argv[0] else "abstractassistant"
19
+ parser = argparse.ArgumentParser(prog=prog, description="AbstractAssistant (agentic tray + CLI)")
34
20
 
35
- parser.add_argument(
36
- "--config",
37
- type=str,
38
- help="Path to configuration file (default: config.toml)",
39
- default=None,
40
- )
21
+ parser.add_argument("--version", action="version", version="abstractassistant (agentic) v1")
41
22
 
42
- parser.add_argument(
43
- "--provider",
44
- type=str,
45
- choices=["lmstudio", "openai", "anthropic", "ollama"],
46
- help="Default LLM provider",
47
- )
23
+ parser.add_argument("--config", type=str, default=None, help="Path to config.toml (optional)")
24
+ parser.add_argument("--provider", type=str, default=None, help="LLM provider id (e.g. ollama, lmstudio, openai)")
25
+ parser.add_argument("--model", type=str, default=None, help="Model name/id for the provider")
26
+ parser.add_argument("--agent", type=str, default=None, choices=["react", "codeact", "memact"], help="Agent kind")
27
+ parser.add_argument("--data-dir", type=str, default=None, help="Assistant data dir (runtime stores + session)")
28
+ parser.add_argument("--workspace-root", type=str, default=None, help="Workspace root for filesystem-ish tools")
29
+ parser.add_argument("--debug", action="store_true", help="Enable debug logging")
48
30
 
49
- parser.add_argument(
50
- "--model",
51
- type=str,
52
- help="Default model name",
53
- )
31
+ sub = parser.add_subparsers(dest="command")
54
32
 
55
- parser.add_argument(
56
- "--debug",
57
- action="store_true",
58
- help="Enable debug mode",
59
- )
60
-
61
- parser.add_argument(
33
+ tray = sub.add_parser("tray", help="Run the macOS tray app")
34
+ tray.add_argument(
62
35
  "--listening-mode",
63
36
  type=str,
64
- choices=["none", "stop", "wait", "full"],
65
- help="Voice listening mode (none: no STT, stop: continuous listen/stop on 'STOP' keyword, wait: listen when TTS idle, full: continuous listen/interrupt on any speech)",
37
+ choices=["none", "stop", "wait", "full", "ptt"],
66
38
  default="wait",
39
+ help="Voice listening mode (requires [full] extra for STT/TTS)",
67
40
  )
68
-
69
- parser.add_argument(
70
- "--version",
71
- action="version",
72
- version="AbstractAssistant 1.0.0",
73
- )
41
+
42
+ run = sub.add_parser("run", help="Run one agentic turn in the terminal")
43
+ run.add_argument("--prompt", type=str, required=True, help="User prompt text")
44
+ run.add_argument("--approve-all-tools", action="store_true", help="Auto-approve all tool calls (dangerous)")
74
45
 
75
46
  return parser
76
47
 
@@ -103,37 +74,84 @@ def main() -> int:
103
74
  args = parser.parse_args()
104
75
 
105
76
  try:
106
- # Load configuration
107
- config_file = find_config_file(args.config)
108
- if config_file:
109
- config = Config.from_file(config_file)
110
- if args.debug:
111
- print(f"Loaded config from: {config_file}")
112
- else:
113
- config = Config.default()
114
- if args.debug:
115
- print("Using default configuration")
116
-
117
- # Override config with CLI arguments
118
- if args.provider:
119
- config.llm.default_provider = args.provider
120
- if args.model:
121
- config.llm.default_model = args.model
122
-
123
- # Create and run the application
124
- app = AbstractAssistantApp(config=config, debug=args.debug, listening_mode=args.listening_mode)
125
-
126
- print("🤖 Starting AbstractAssistant...")
127
- print("Look for the icon in your macOS menu bar!")
128
-
129
- if args.debug:
130
- print("Debug mode enabled")
131
- print(f"Provider: {config.llm.default_provider}")
132
- print(f"Model: {config.llm.default_model}")
133
- print(f"Listening mode: {args.listening_mode}")
134
-
77
+ command = args.command or "tray"
78
+
79
+ if command == "run":
80
+ from .core.agent_host import AgentHost, AgentHostConfig
81
+
82
+ provider = str(args.provider or "ollama")
83
+ model = str(args.model or "qwen3:4b-instruct")
84
+ agent_kind = str(args.agent or "react")
85
+ data_dir = Path(args.data_dir).expanduser() if args.data_dir else (Path.home() / ".abstractassistant")
86
+
87
+ host = AgentHost(
88
+ AgentHostConfig(
89
+ provider=provider,
90
+ model=model,
91
+ agent_kind=agent_kind,
92
+ data_dir=data_dir,
93
+ workspace_root=args.workspace_root,
94
+ )
95
+ )
96
+
97
+ def _approve(tool_calls):
98
+ if args.approve_all_tools:
99
+ return True
100
+ # Prompt for dangerous/unknown batches; safe-only batches auto-approve.
101
+ if not host.tool_policy.requires_approval(tool_calls):
102
+ return True
103
+ print("\nTool approval required:")
104
+ for tc in tool_calls:
105
+ name = tc.get("name")
106
+ arguments = tc.get("arguments")
107
+ print(f"- {name}({arguments})")
108
+ ans = input("Approve this batch? [y/N] ").strip().lower()
109
+ return ans in {"y", "yes"}
110
+
111
+ def _ask_user(wait):
112
+ prompt = str(getattr(wait, "prompt", "") or "Input required:")
113
+ return input(f"\n{prompt}\n> ").strip()
114
+
115
+ final = ""
116
+ for ev in host.run_turn(user_text=args.prompt, approve_tools=_approve, ask_user=_ask_user):
117
+ typ = ev.get("type")
118
+ if typ == "assistant":
119
+ final = str(ev.get("content") or "")
120
+ if typ == "error":
121
+ raise RuntimeError(str(ev.get("error") or "error"))
122
+ print(final)
123
+ return 0
124
+
125
+ # tray (default)
126
+ try:
127
+ from .config import Config # lightweight
128
+
129
+ config_file = find_config_file(args.config)
130
+ config = Config.from_file(config_file) if config_file else Config.default()
131
+ if args.provider:
132
+ config.llm.default_provider = str(args.provider)
133
+ if args.model:
134
+ config.llm.default_model = str(args.model)
135
+ except Exception:
136
+ config = None
137
+
138
+ try:
139
+ from .app import AbstractAssistantApp
140
+ except Exception as e:
141
+ print("AbstractAssistant tray mode requires GUI dependencies.")
142
+ print('Install (tray): pip install -U "abstractassistant"')
143
+ print('Install (tray + voice): pip install -U "abstractassistant[full]"')
144
+ print('From source (editable): pip install -e ".[lite]"')
145
+ print(f"Import error: {e}")
146
+ return 2
147
+
148
+ app = AbstractAssistantApp(
149
+ config=config,
150
+ debug=bool(args.debug),
151
+ listening_mode=str(getattr(args, "listening_mode", "wait")),
152
+ data_dir=Path(args.data_dir).expanduser() if getattr(args, "data_dir", None) else None,
153
+ )
135
154
  app.run()
136
-
137
155
  return 0
138
156
 
139
157
  except KeyboardInterrupt:
@@ -141,8 +159,9 @@ def main() -> int:
141
159
  return 0
142
160
  except Exception as e:
143
161
  print(f"❌ Error starting AbstractAssistant: {e}")
144
- if args.debug:
162
+ if getattr(args, "debug", False):
145
163
  import traceback
164
+
146
165
  traceback.print_exc()
147
166
  return 1
148
167