asky-cli 0.1.7__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.
asky/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """asky - AI-powered web search CLI with LLM tool-calling capabilities."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from asky.cli import main
6
+
7
+ __all__ = ["main", "__version__"]
asky/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for `python -m asky`."""
2
+
3
+ from asky.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
asky/banner.py ADDED
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Cute Terminal Icon - ASCII Art
4
+ """
5
+
6
+ from rich.console import Console
7
+ from rich.text import Text
8
+ from rich.panel import Panel
9
+ from rich.table import Table
10
+ from rich import box
11
+
12
+ # Color shortcuts for templates
13
+ G = "bold #39ff14" # neon green (eyes)
14
+ P = "#ff8fa3" # pink (blush)
15
+ D = "dim" # dim (border)
16
+ N = "#39ff14" # nose (not bold = dimmer)
17
+
18
+ # Metallic shiny effect colors
19
+ M1 = "bold #ffffff" # bright highlight
20
+ M2 = "bold #c0c0c0" # silver
21
+ M3 = "#a0a0a0" # medium gray
22
+ M4 = "#707070" # shadow
23
+
24
+
25
+ def display(lines):
26
+ """Display lines with markup"""
27
+ console = Console()
28
+ console.print()
29
+ for line in lines:
30
+ console.print(Text.from_markup(line))
31
+ console.print()
32
+
33
+
34
+ def mini():
35
+ """Mini version"""
36
+ display(
37
+ [
38
+ f"[{G}] ∩ ∩[/{G}] [{M1}]a[/{M1}]",
39
+ f"[{D}] ╭┴─────┴╮[/{D}] [{M2}]s[/{M2}]",
40
+ f"[{D}] │[/{D}] [{G}]▷[/{G}] [{D}][{N}]ω[/{N}][/{D}] [{G}]_[/{G}] [{D}]│[/{D}] [{M3}]k[/{M3}]",
41
+ f"[{D}] │[/{D}] [{P}]◠[/{P}] [{P}]◠[/{P}] [{D}]│[/{D}] [{M4}]y[/{M4}]",
42
+ f"[{D}] ╰───────╯[/{D}] ",
43
+ ]
44
+ )
45
+
46
+
47
+ def get_banner(
48
+ model_alias: str,
49
+ model_id: str,
50
+ sum_alias: str,
51
+ sum_id: str,
52
+ default_model: str,
53
+ search_provider: str,
54
+ model_ctx: int,
55
+ sum_ctx: int,
56
+ max_turns: int,
57
+ db_count: int,
58
+ ) -> Panel:
59
+ """Create a side-by-side banner with an icon and configuration info in two columns."""
60
+ icon_lines = [
61
+ f"[{G}] ∩ ∩[/{G}] [{M1}]a[/{M1}]",
62
+ f"[{D}] ╭┴─────┴╮[/{D}] [{M2}]s[/{M2}]",
63
+ f"[{D}] │[/{D}] [{G}]▷[/{G}] [{D}][{N}]ω[/{N}][/{D}] [{G}]_[/{G}] [{D}]│[/{D}] [{M3}]k[/{M3}]",
64
+ f"[{D}] │[/{D}] [{P}]◠[/{P}] [{P}]◠[/{P}] [{D}]│[/{D}] [{M4}]y[/{M4}]",
65
+ f"[{D}] ╰───────╯[/{D}] ",
66
+ ]
67
+ icon_text = Text.from_markup("\n".join(icon_lines))
68
+
69
+ # --- Configuration Columns ---
70
+ col1 = Table.grid(padding=(0, 1))
71
+ col1.add_column(justify="left", style="bold cyan")
72
+ col1.add_column(justify="left")
73
+ col1.add_row(
74
+ " Main Model :", f" [white]{model_alias}[/white] ([dim]{model_id}[/dim])"
75
+ )
76
+ col1.add_row(" Summarizer :", f" [white]{sum_alias}[/white] ([dim]{sum_id}[/dim])")
77
+ col1.add_row(" Default :", f" [white]{default_model}[/white]")
78
+
79
+ col2 = Table.grid(padding=(0, 1))
80
+ col2.add_column(justify="left", style="bold cyan")
81
+ col2.add_column(justify="left")
82
+ col2.add_row(" Search :", f" [white]{search_provider}[/white]")
83
+ col2.add_row(
84
+ " Context :",
85
+ f" [white]{model_ctx:,}[/white]/[white]{sum_ctx:,}[/white] [dim]tokens[/dim]",
86
+ )
87
+ col2.add_row(
88
+ " System :",
89
+ f" [white]{max_turns}[/white] [dim]turns[/dim] | [white]{db_count}[/white] [dim]records[/dim]",
90
+ )
91
+
92
+ info_layout = Table.grid(padding=(0, 3))
93
+ info_layout.add_column()
94
+ info_layout.add_column()
95
+ info_layout.add_row(col1, col2)
96
+
97
+ # --- Main Layout ---
98
+ layout_table = Table.grid(padding=(0, 2))
99
+ layout_table.add_column()
100
+ layout_table.add_column(ratio=1)
101
+ layout_table.add_row(icon_text, info_layout)
102
+
103
+ return Panel(layout_table, box=box.ROUNDED, border_style="dim", padding=(0, 1))
104
+
105
+
106
+ if __name__ == "__main__":
107
+ mini()
108
+ # Test get_banner
109
+ console = Console()
110
+ console.print(
111
+ get_banner(
112
+ "gf",
113
+ "gemini-flash-latest",
114
+ "lfm",
115
+ "llama3",
116
+ "gf",
117
+ "searxng",
118
+ 1000000,
119
+ 4096,
120
+ 20,
121
+ 123,
122
+ )
123
+ )
asky/cli/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """CLI package for asky."""
2
+
3
+ from asky.cli.history import (
4
+ show_history_command as show_history,
5
+ print_answers_command as print_answers,
6
+ handle_cleanup_command as handle_cleanup,
7
+ )
8
+ from asky.cli.prompts import list_prompts_command as list_prompts
9
+ from asky.cli.chat import run_chat, load_context, build_messages
10
+ from asky.cli.utils import expand_query_text, print_config
11
+ from asky.cli.main import main, parse_args, handle_print_answer_implicit
asky/cli/chat.py ADDED
@@ -0,0 +1,172 @@
1
+ """Chat implementation for asky CLI."""
2
+
3
+ import sys
4
+ import argparse
5
+ from typing import List, Dict, Any, Optional
6
+
7
+ from asky.config import (
8
+ MODELS,
9
+ SUMMARIZATION_MODEL,
10
+ QUERY_EXPANSION_MAX_DEPTH,
11
+ )
12
+ from asky.core import (
13
+ ConversationEngine,
14
+ create_default_tool_registry,
15
+ create_deep_dive_tool_registry,
16
+ UsageTracker,
17
+ construct_system_prompt,
18
+ generate_summaries,
19
+ )
20
+ from asky.storage import (
21
+ get_history,
22
+ get_interaction_context,
23
+ save_interaction,
24
+ )
25
+
26
+
27
+ def load_context(continue_ids: str, summarize: bool) -> Optional[str]:
28
+ """Load context from previous interactions."""
29
+ try:
30
+ raw_ids = [x.strip() for x in continue_ids.split(",")]
31
+ resolved_ids = []
32
+ relative_indices = []
33
+
34
+ for raw_id in raw_ids:
35
+ if raw_id.startswith("~"):
36
+ try:
37
+ rel_val = int(raw_id[1:])
38
+ if rel_val < 1:
39
+ print(f"Error: Relative ID must be >= 1 (got {raw_id})")
40
+ return None
41
+ relative_indices.append(rel_val)
42
+ except ValueError:
43
+ print(f"Error: Invalid relative ID format: {raw_id}")
44
+ return None
45
+ else:
46
+ resolved_ids.append(int(raw_id))
47
+
48
+ if relative_indices:
49
+ max_depth = max(relative_indices)
50
+ history_rows = get_history(limit=max_depth)
51
+
52
+ for rel_val in relative_indices:
53
+ list_index = rel_val - 1
54
+ if list_index < len(history_rows):
55
+ real_id = history_rows[list_index][0]
56
+ resolved_ids.append(real_id)
57
+ else:
58
+ print(
59
+ f"Error: Relative ID {rel_val} is out of range (only {len(history_rows)} records available)."
60
+ )
61
+ return None
62
+
63
+ resolved_ids = sorted(list(set(resolved_ids)))
64
+ full_content = not summarize
65
+ context_str = get_interaction_context(resolved_ids, full=full_content)
66
+ if context_str:
67
+ print(f"\n[Loaded context from IDs: {', '.join(map(str, resolved_ids))}]")
68
+ return context_str
69
+ except ValueError:
70
+ print(
71
+ "Error: Invalid format for -c/--continue-chat. Use comma-separated integers or ~N for relative."
72
+ )
73
+ return None
74
+
75
+
76
+ def build_messages(
77
+ args: argparse.Namespace, context_str: str, query_text: str
78
+ ) -> List[Dict[str, str]]:
79
+ """Build the initial message list for the conversation."""
80
+ messages = [
81
+ {
82
+ "role": "system",
83
+ "content": construct_system_prompt(
84
+ args.deep_research, args.deep_dive, args.force_search
85
+ ),
86
+ },
87
+ ]
88
+
89
+ if context_str:
90
+ messages.append(
91
+ {
92
+ "role": "user",
93
+ "content": f"Context from previous queries:\n{context_str}\n\nMy new query is below.",
94
+ }
95
+ )
96
+
97
+ messages.append({"role": "user", "content": query_text})
98
+ return messages
99
+
100
+
101
+ def run_chat(args: argparse.Namespace, query_text: str) -> None:
102
+ """Run the chat conversation flow."""
103
+ # Handle Context
104
+ context_str = ""
105
+ if args.continue_ids:
106
+ context_str = load_context(args.continue_ids, args.summarize)
107
+ if context_str is None:
108
+ return
109
+
110
+ messages = build_messages(args, context_str, query_text)
111
+
112
+ # Initialize Components
113
+ usage_tracker = UsageTracker()
114
+
115
+ if args.deep_dive:
116
+ registry = create_deep_dive_tool_registry()
117
+ else:
118
+ registry = create_default_tool_registry()
119
+
120
+ model_config = MODELS[args.model]
121
+
122
+ engine = ConversationEngine(
123
+ model_config=model_config,
124
+ tool_registry=registry,
125
+ summarize=args.summarize,
126
+ verbose=args.verbose,
127
+ usage_tracker=usage_tracker,
128
+ open_browser=args.open,
129
+ deep_dive=args.deep_dive,
130
+ )
131
+
132
+ # Run loop
133
+ try:
134
+ final_answer = engine.run(messages)
135
+
136
+ # Save Interaction
137
+ if final_answer:
138
+ print("\n[Saving interaction...]")
139
+ query_summary, answer_summary = generate_summaries(
140
+ query_text, final_answer, usage_tracker=usage_tracker
141
+ )
142
+ save_interaction(
143
+ query_text, final_answer, args.model, query_summary, answer_summary
144
+ )
145
+
146
+ # Send Email if requested
147
+ if final_answer and getattr(args, "mail_recipients", None):
148
+ from asky.email_sender import send_email
149
+
150
+ recipients = [x.strip() for x in args.mail_recipients.split(",")]
151
+ email_subject = args.subject or f"asky Result: {query_text[:50]}"
152
+ send_email(recipients, email_subject, final_answer)
153
+
154
+ except KeyboardInterrupt:
155
+ print("\nAborted by user.")
156
+ except Exception as e:
157
+ print(f"\nAn error occurred: {e}")
158
+ if args.verbose:
159
+ import traceback
160
+
161
+ traceback.print_exc()
162
+ finally:
163
+ # Report usage
164
+ if usage_tracker.usage:
165
+ print("\n=== SESSION TOKEN USAGE ===")
166
+ total_session_tokens = 0
167
+ for m_alias, tokens in usage_tracker.usage.items():
168
+ print(f" {m_alias:<15}: {tokens:,} tokens")
169
+ total_session_tokens += tokens
170
+ print("-" * 30)
171
+ print(f" {'TOTAL':<15}: {total_session_tokens:,} tokens")
172
+ print("===========================\n")
asky/cli/history.py ADDED
@@ -0,0 +1,98 @@
1
+ """History-related CLI commands for asky."""
2
+
3
+ from rich.console import Console
4
+ from rich.markdown import Markdown
5
+
6
+ from asky.core import is_markdown
7
+ from asky.rendering import render_to_browser
8
+ from asky.storage import (
9
+ get_history,
10
+ get_interaction_context,
11
+ cleanup_db,
12
+ )
13
+
14
+
15
+ def show_history_command(history_arg: int) -> None:
16
+ """Display recent query history."""
17
+ limit = history_arg if history_arg > 0 else 10
18
+ rows = get_history(limit)
19
+ print(f"\nLast {len(rows)} Queries:")
20
+ print("-" * 60)
21
+ for row in rows:
22
+ rid = row[0]
23
+ query = row[2]
24
+ query_summary = row[3]
25
+ answer_summary = row[4]
26
+
27
+ display_query = query_summary if query_summary else query
28
+ a_sum = answer_summary if answer_summary else ""
29
+
30
+ if len(display_query) > 50:
31
+ display_query = display_query[:47] + "..."
32
+ if len(a_sum) > 50:
33
+ a_sum = a_sum[:47] + "..."
34
+
35
+ print(f"{rid:<4} | {display_query:<50} | {a_sum:<50}")
36
+ print("-" * 60)
37
+
38
+
39
+ def print_answers_command(
40
+ ids_str: str,
41
+ summarize: bool,
42
+ open_browser: bool = False,
43
+ mail_recipients: str = None,
44
+ subject: str = None,
45
+ ) -> None:
46
+ """Print answers for specific history IDs."""
47
+ try:
48
+ ids = [int(x.strip()) for x in ids_str.split(",")]
49
+ except ValueError:
50
+ print("Error: Invalid ID format. Use comma-separated integers.")
51
+ return
52
+
53
+ context = get_interaction_context(ids, full=not summarize)
54
+ if not context:
55
+ print("No records found for the given IDs.")
56
+ return
57
+
58
+ print(f"\n[Retrieving answers for IDs: {ids_str}]\n")
59
+ print("-" * 60)
60
+ if is_markdown(context):
61
+ console = Console()
62
+ console.print(Markdown(context))
63
+ else:
64
+ print(context)
65
+ print("-" * 60)
66
+
67
+ if open_browser:
68
+ render_to_browser(context)
69
+
70
+ if mail_recipients:
71
+ from asky.email_sender import send_email
72
+
73
+ recipients = [x.strip() for x in mail_recipients.split(",")]
74
+ # Use provided subject or default
75
+ email_subject = subject or f"asky History: {ids_str}"
76
+ send_email(recipients, email_subject, context)
77
+
78
+
79
+ def handle_cleanup_command(args) -> bool:
80
+ """Handle cleanup-db flag."""
81
+ if args.cleanup_db or (args.cleanup_db is None and getattr(args, "all", False)):
82
+ if args.all:
83
+ count = cleanup_db(delete_all=True)
84
+ print(f"Deleted all {count} records from history.")
85
+ elif "-" in args.cleanup_db or "," in args.cleanup_db:
86
+ count = cleanup_db(ids=args.cleanup_db)
87
+ print(f"Deleted {count} records from history.")
88
+ else:
89
+ try:
90
+ days = int(args.cleanup_db)
91
+ count = cleanup_db(days=days)
92
+ print(f"Deleted {count} records older than {days} days.")
93
+ except ValueError:
94
+ print(
95
+ "Error: Invalid cleanup format. Use days (int), range (start-end), or list (1,2,3)."
96
+ )
97
+ return True
98
+ return False
asky/cli/main.py ADDED
@@ -0,0 +1,230 @@
1
+ """Command-line interface for asky."""
2
+
3
+ import argparse
4
+ import re
5
+ import sys
6
+
7
+ from rich.console import Console
8
+
9
+ from asky.config import (
10
+ DEFAULT_MODEL,
11
+ MODELS,
12
+ SUMMARIZATION_MODEL,
13
+ SEARCH_PROVIDER,
14
+ DEFAULT_CONTEXT_SIZE,
15
+ MAX_TURNS,
16
+ QUERY_SUMMARY_MAX_CHARS,
17
+ ANSWER_SUMMARY_MAX_CHARS,
18
+ LOG_LEVEL,
19
+ LOG_FILE,
20
+ )
21
+ from asky.banner import get_banner
22
+ from asky.logger import setup_logging
23
+ from asky.storage import init_db, get_db_record_count
24
+ from . import history, prompts, chat, utils
25
+
26
+
27
+ def parse_args() -> argparse.Namespace:
28
+ """Parse command-line arguments."""
29
+ parser = argparse.ArgumentParser(
30
+ description="Tool-calling CLI with model selection.",
31
+ formatter_class=argparse.RawTextHelpFormatter,
32
+ )
33
+ parser.add_argument(
34
+ "-m",
35
+ "--model",
36
+ default=DEFAULT_MODEL,
37
+ choices=MODELS.keys(),
38
+ help="Select the model alias",
39
+ )
40
+ parser.add_argument(
41
+ "-d",
42
+ "--deep-research",
43
+ nargs="?",
44
+ type=int,
45
+ const=5,
46
+ default=0,
47
+ help="Enable deep research mode (optional: specify min number of queries, default 5)",
48
+ )
49
+ parser.add_argument(
50
+ "-dd",
51
+ "--deep-dive",
52
+ action="store_true",
53
+ help="Enable deep dive mode (extracts links and encourages reading more pages from same domain)",
54
+ )
55
+ parser.add_argument(
56
+ "-c",
57
+ "--continue-chat",
58
+ dest="continue_ids",
59
+ help="Continue conversation with context from specific history IDs (comma-separated, e.g. '1,2').",
60
+ )
61
+ parser.add_argument(
62
+ "-s",
63
+ "--summarize",
64
+ action="store_true",
65
+ help="Enable summarize mode (summarizes URL content and uses summaries for chat context)",
66
+ )
67
+ parser.add_argument(
68
+ "-fs",
69
+ "--force-search",
70
+ action="store_true",
71
+ help="Force the model to use web search (default: False).\n"
72
+ "Helpful for avoiding hallucinations with small models",
73
+ )
74
+ parser.add_argument(
75
+ "--cleanup-db",
76
+ nargs="?",
77
+ const="interactive",
78
+ help="Delete history records. usage: --cleanup-db [ID|ID-ID|ID,ID] or --cleanup-db --all",
79
+ )
80
+ parser.add_argument(
81
+ "--all",
82
+ action="store_true",
83
+ help="Used with --cleanup-db to delete ALL history.",
84
+ )
85
+ parser.add_argument(
86
+ "-H",
87
+ "--history",
88
+ nargs="?",
89
+ type=int,
90
+ const=10,
91
+ help="Show last N queries and answer summaries (default 10).\n"
92
+ "Use with --print-answer to print the full answer(s).",
93
+ )
94
+ parser.add_argument(
95
+ "-pa",
96
+ "--print-answer",
97
+ dest="print_ids",
98
+ help="Print the answer(s) for specific history IDs (comma-separated).",
99
+ )
100
+ parser.add_argument(
101
+ "-p",
102
+ "--prompts",
103
+ action="store_true",
104
+ help="List all configured user prompts.",
105
+ )
106
+ parser.add_argument(
107
+ "-v",
108
+ "--verbose",
109
+ action="store_true",
110
+ help="Enable verbose output (prints config and LLM inputs).",
111
+ )
112
+ parser.add_argument(
113
+ "-o",
114
+ "--open",
115
+ action="store_true",
116
+ help="Open the final answer in a browser using a markdown template.",
117
+ )
118
+ parser.add_argument(
119
+ "--mail",
120
+ dest="mail_recipients",
121
+ help="Send the final answer via email to comma-separated addresses.",
122
+ )
123
+ parser.add_argument(
124
+ "--subject",
125
+ help="Subject line for the email (used with --mail).",
126
+ )
127
+ parser.add_argument("query", nargs="*", help="The query string")
128
+ return parser.parse_args()
129
+
130
+
131
+ def show_banner(args) -> None:
132
+ """Display the application banner."""
133
+ model_alias = args.model
134
+ model_id = MODELS[model_alias]["id"]
135
+ sum_alias = SUMMARIZATION_MODEL
136
+ sum_id = MODELS[sum_alias]["id"]
137
+
138
+ model_ctx = MODELS[model_alias].get("context_size", DEFAULT_CONTEXT_SIZE)
139
+ sum_ctx = MODELS[sum_alias].get("context_size", DEFAULT_CONTEXT_SIZE)
140
+ db_count = get_db_record_count()
141
+
142
+ banner = get_banner(
143
+ model_alias,
144
+ model_id,
145
+ sum_alias,
146
+ sum_id,
147
+ DEFAULT_MODEL,
148
+ SEARCH_PROVIDER,
149
+ model_ctx,
150
+ sum_ctx,
151
+ MAX_TURNS,
152
+ db_count,
153
+ )
154
+ Console().print(banner)
155
+
156
+
157
+ def handle_print_answer_implicit(args) -> bool:
158
+ """Handle implicit print answer (query is list of ints)."""
159
+ if not args.query:
160
+ return False
161
+ query_str = " ".join(args.query).strip()
162
+ if re.match(r"^(\d+\s*,?\s*)+$", query_str):
163
+ possible_ids = re.split(r"[,\s]+", query_str)
164
+ clean_ids_str = ",".join([x for x in possible_ids if x])
165
+ history.print_answers_command(
166
+ clean_ids_str,
167
+ args.summarize,
168
+ open_browser=args.open,
169
+ mail_recipients=args.mail_recipients,
170
+ subject=args.subject,
171
+ )
172
+ return True
173
+ return False
174
+
175
+
176
+ def main() -> None:
177
+ """Main entry point."""
178
+ setup_logging(LOG_LEVEL, LOG_FILE)
179
+ args = parse_args()
180
+ init_db()
181
+
182
+ # Commands that don't require banner or query
183
+ if args.history is not None:
184
+ history.show_history_command(args.history)
185
+ return
186
+ if history.handle_cleanup_command(args):
187
+ return
188
+ if args.print_ids:
189
+ history.print_answers_command(
190
+ args.print_ids,
191
+ args.summarize,
192
+ open_browser=args.open,
193
+ mail_recipients=args.mail_recipients,
194
+ subject=args.subject,
195
+ )
196
+ return
197
+ if handle_print_answer_implicit(args):
198
+ return
199
+ if args.prompts:
200
+ prompts.list_prompts_command()
201
+ return
202
+
203
+ # From here on, we need a query
204
+ if not args.query:
205
+ print("Error: Query argument is required.")
206
+ return
207
+
208
+ # Expand query
209
+ query_text = utils.expand_query_text(" ".join(args.query), verbose=args.verbose)
210
+
211
+ # Verbose config
212
+ if args.verbose:
213
+ utils.print_config(
214
+ args,
215
+ MODELS,
216
+ DEFAULT_MODEL,
217
+ MAX_TURNS,
218
+ QUERY_SUMMARY_MAX_CHARS,
219
+ ANSWER_SUMMARY_MAX_CHARS,
220
+ )
221
+
222
+ # Show banner
223
+ show_banner(args)
224
+
225
+ # Run Chat
226
+ chat.run_chat(args, query_text)
227
+
228
+
229
+ if __name__ == "__main__":
230
+ main()
asky/cli/prompts.py ADDED
@@ -0,0 +1,20 @@
1
+ """Prompt-related CLI commands for asky."""
2
+
3
+ from asky.config import USER_PROMPTS
4
+
5
+
6
+ def list_prompts_command() -> None:
7
+ """List all configured user prompts."""
8
+ if not USER_PROMPTS:
9
+ print("\nNo user prompts configured.")
10
+ return
11
+
12
+ print("\nConfigured User Prompts:")
13
+ print("-" * 40)
14
+ for alias, prompt in USER_PROMPTS.items():
15
+ print(
16
+ f"[{alias}]: {prompt[:100]}..."
17
+ if len(prompt) > 100
18
+ else f"[{alias}]: {prompt}"
19
+ )
20
+ print("-" * 40)