dragon-cli 0.2.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.
dragon_cli/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ __version__ = "0.2.0"
2
+
3
+ from ._cuda import preload_cuda_runtime
4
+
5
+ preload_cuda_runtime()
dragon_cli/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
dragon_cli/_cuda.py ADDED
@@ -0,0 +1,42 @@
1
+ """Preload CUDA runtime shared libraries shipped via pip wheels.
2
+
3
+ The GPU-enabled llama-cpp-python wheel links against libcudart/libcublas/
4
+ libnvrtc, which the pip ``nvidia-*-cu12`` packages install under
5
+ ``site-packages/nvidia/*/lib`` (not on the system library path). Loading them
6
+ here by absolute path with ``RTLD_GLOBAL`` lets libllama.so resolve them
7
+ without LD_LIBRARY_PATH. No-op when the libs are already resolvable or absent
8
+ (e.g. the CPU-only wheel).
9
+
10
+ Kept dependency-free (stdlib only) so it can run at package import time
11
+ without risking circular imports.
12
+ """
13
+
14
+ import ctypes
15
+ import glob
16
+ import os
17
+ import sys
18
+
19
+ _CUDA_SONAMES = (
20
+ "libcudart.so.12",
21
+ "libcublas.so.12",
22
+ "libcublasLt.so.12",
23
+ "libnvrtc.so.12",
24
+ )
25
+
26
+
27
+ def preload_cuda_runtime() -> None:
28
+ loaded: set[str] = set()
29
+ for entry in sys.path:
30
+ nvidia_dir = os.path.join(entry, "nvidia")
31
+ if not entry or not os.path.isdir(nvidia_dir):
32
+ continue
33
+ for soname in _CUDA_SONAMES:
34
+ if soname in loaded:
35
+ continue
36
+ for match in glob.glob(os.path.join(nvidia_dir, "*", "lib", soname)):
37
+ try:
38
+ ctypes.CDLL(match, mode=ctypes.RTLD_GLOBAL)
39
+ loaded.add(soname)
40
+ break
41
+ except OSError:
42
+ continue
dragon_cli/app.py ADDED
@@ -0,0 +1,123 @@
1
+ """Async orchestration: model selection, MCP startup, and the chat session."""
2
+
3
+ from pathlib import Path
4
+
5
+ from .chat import ChatSession, chat_loop
6
+ from .config import C_RED, C_RESET
7
+ from .mcp import MCPManager
8
+ from .models import MODEL_CATALOG, available_ram_gb, download_model, list_catalog, load_llm
9
+ from .storage import ConversationStore
10
+
11
+
12
+ def _gpu_layers_arg(args) -> int | None:
13
+ """Translate ``--n-gpu-layers`` into a ``load_llm`` override.
14
+
15
+ The CLI default is -1 (auto). Any value >= 0 is an explicit override
16
+ (0 = CPU only, >0 = exact layer count). None means "let the runtime
17
+ auto-decide".
18
+ """
19
+ n = getattr(args, "n_gpu_layers", -1)
20
+ return n if n is not None and n >= 0 else None
21
+
22
+
23
+ def choose_model(args) -> dict | None:
24
+ # Handle --auto flag for automatic model selection
25
+ if getattr(args, 'auto', False):
26
+ from .recommendation import ModelRecommender
27
+ recommender = ModelRecommender()
28
+ best_model = recommender.recommend_best(priority=getattr(args, 'priority', 'balanced'))
29
+
30
+ if best_model:
31
+ print(f"\n{C_RESET}Auto-selected model based on your hardware:")
32
+ print(f" → {best_model['name']}")
33
+ reason = recommender.get_recommendation_reason(best_model)
34
+ print(f" {C_DIM}Why: {reason}{C_RESET}\n")
35
+ return best_model
36
+ else:
37
+ print(f"{C_RED}[error]{C_RESET} No compatible models found for your hardware.")
38
+ # Fall back to manual selection
39
+ pass
40
+
41
+ # Skip the plain catalog print when a model was already chosen upstream
42
+ # (e.g. by the rich interactive menu), so the user doesn't see it twice.
43
+ if not args.model:
44
+ list_catalog()
45
+ if args.model:
46
+ choice = args.model
47
+ else:
48
+ try:
49
+ choice = int(input("\nChoose a model number: ").strip())
50
+ except (ValueError, EOFError, KeyboardInterrupt):
51
+ print("Invalid choice.")
52
+ return None
53
+ if not (1 <= choice <= len(MODEL_CATALOG)):
54
+ print("Number not in the catalog.")
55
+ return None
56
+ model = MODEL_CATALOG[choice - 1]
57
+ ram = available_ram_gb()
58
+ if 0 < ram < model["ram_min_gb"]:
59
+ ok = input(f"{C_RED}[warning]{C_RESET} Model needs ~{model['ram_min_gb']} GB, "
60
+ f"you have {ram:.1f} GB free. Continue? (y/N) ").strip().lower()
61
+ if ok != "y":
62
+ return None
63
+ return model
64
+
65
+
66
+ def print_conversations(store: ConversationStore) -> None:
67
+ conversations = store.list()
68
+ if not conversations:
69
+ print("No conversations yet.")
70
+ return
71
+ for c in conversations:
72
+ print(f" #{c.id} {c.title} ({c.message_count} msgs, updated {c.updated_at})")
73
+
74
+
75
+ async def run(args) -> None:
76
+ if args.serve:
77
+ model = choose_model(args)
78
+ if not model:
79
+ return
80
+ path = download_model(model)
81
+ llm = load_llm(path, model["ctx"], model_info=model,
82
+ gpu_layers=_gpu_layers_arg(args),
83
+ force_cpu=getattr(args, "cpu", False),
84
+ force_gpu=getattr(args, "gpu", False))
85
+ from .server import run_server
86
+ await run_server(llm, model["name"], args.host, args.port)
87
+ return
88
+
89
+ store = ConversationStore(Path(args.db))
90
+
91
+ if args.list_conversations:
92
+ print_conversations(store)
93
+ store.close()
94
+ return
95
+
96
+ model = choose_model(args)
97
+ if not model:
98
+ store.close()
99
+ return
100
+
101
+ path = download_model(model)
102
+ llm = load_llm(path, model["ctx"], model_info=model,
103
+ gpu_layers=_gpu_layers_arg(args),
104
+ force_cpu=getattr(args, "cpu", False),
105
+ force_gpu=getattr(args, "gpu", False))
106
+
107
+ mcp = MCPManager()
108
+ if not args.no_mcp:
109
+ await mcp.start()
110
+
111
+ session = ChatSession(llm, mcp, store, model["name"])
112
+ if args.resume is not None:
113
+ if not session.resume(args.resume):
114
+ print(f"{C_RED}[error]{C_RESET} No conversation with id {args.resume}, starting a new one.")
115
+ session.start_new()
116
+ else:
117
+ session.start_new()
118
+
119
+ try:
120
+ await chat_loop(session)
121
+ finally:
122
+ await mcp.stop()
123
+ store.close()
dragon_cli/banner.py ADDED
@@ -0,0 +1,96 @@
1
+ """ASCII dragon banner shown at startup."""
2
+
3
+ BANNER = r'''
4
+ _,_
5
+ /7/Y/^\
6
+ vuVV|C)| __ _
7
+ \|^ / .' Y '>,
8
+ )| \) / _ _ \
9
+ //)|\\ )(_) (_)(|}
10
+ / ^| \ \ { 4A } /
11
+ //^| || \\ \uLuJJ/\l
12
+ >//||| |\\\| |3 p)/
13
+ | """"" 7/>l__ _____ ____ /nnm_n//
14
+ L>_ _-< 7/|_-__,__-)\,__)(". \_>-<_/D
15
+ )D" Y "c) 9) //V \_"-._.__G<D __
16
+ \ || . _/ ( \ "-._"> G<D-_.-"_=)_
17
+ ) __ / / " _/.__ />_c__.-__<"/ ( \
18
+ | | | |(| < "-._"> _.G_.___)\ \7\
19
+ \"=" // | (,"-.__.|\ \<.__.-" ) \ \
20
+ '---' | |,"-.__"| \!"-.__.-".\ \ \
21
+ |_;._/ (_"-.__"'\ \"-.__.-".| \_\
22
+ )(" V \"-.__"'|\ \-.__.-".) \ \
23
+ ( "-.__'"\_\ \.__.-"./ \ l
24
+ ) ".__"">>G\ \__.-"> V )
25
+ AI PROVIDERS "" G<\ \_.-" ./7
26
+ G<\ \ ///
27
+ ___ G<D\ \ /7^
28
+ /".-_"-"G"<\ \__ ///))
29
+ ||,-_ " '.\_"- /// (\
30
+ | (_) _ "/ __7 |X/ V
31
+ /\__""-/ "\ _""c_"GBV
32
+ _)( ""' "--.c__>D'
33
+ / / )E_>"
34
+ _/ ( | \()
35
+ / \ / | |
36
+ /\\| | |
37
+ / '(( | |
38
+ / / )\ \ |
39
+ / y \y |Y |
40
+ / / ( | |
41
+ L ." | | /
42
+ | \( | |
43
+ \_| | |
44
+ | \ { "|
45
+ | || | |
46
+ |x|| \_,/
47
+ } || / \'
48
+ | || |_/
49
+ | (| | }\
50
+ | || } ||
51
+ | || | ||
52
+ | || |\||
53
+ / || | ||
54
+ | || ( |!
55
+ | |/ ) ||
56
+ _/ \ | }|
57
+ _.-"_ ( )| jjs ! ||
58
+ c_"-_-"_ ) | ||
59
+ c,-_-'_--"" { ||
60
+ "C(_/" \ /|
61
+ (! )
62
+ /| \
63
+ / |(
64
+ /7||\\
65
+ ()U cUu"
66
+ '''
67
+
68
+ SUBTITLE = " DRAGON CLI — local LLM + MCP | /help for commands"
69
+
70
+ # Compact mascot icon shown as a splash on the interactive menu. Raw string so
71
+ # the backslashes in the art are preserved verbatim.
72
+ ICON = r""",--.
73
+ { }
74
+ K, }
75
+ / `Y`
76
+ _ / /
77
+ {_'-K.__/
78
+ `/-.__L._
79
+ / ' /`\_}
80
+ / ' / -AI PROVIDERS-
81
+ ____ / ' /
82
+ ,-'~~~~ ~~/ ' /_
83
+ ,' ``~~~%%',
84
+ ( % Y
85
+ { %% I
86
+ { - % `.
87
+ | ', % )
88
+ | | ,..__ __. Y
89
+ | .,_./ Y ' / ^Y J )|
90
+ \ |' / | | ||
91
+ \ L_/ . _ (_,.'(
92
+ \, , ^^""' / | )
93
+ \_ \ /,L] /
94
+ '-_`-, ` ` ./`
95
+ `-(_ )
96
+ ^^\..___,.--`"""
@@ -0,0 +1,4 @@
1
+ from .session import ChatSession, chat_loop
2
+ from .streaming import generate_streaming
3
+
4
+ __all__ = ["ChatSession", "chat_loop", "generate_streaming"]
@@ -0,0 +1,175 @@
1
+ """Slash-command dispatch for the chat loop: help, model info, and
2
+ conversation management (list/new/resume/rename/delete/search/export)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Callable
8
+
9
+ from ..config import C_DIM, C_GREEN, C_RED, C_RESET, MCP_CONFIG_FILE
10
+ from ..models import list_catalog
11
+
12
+ if TYPE_CHECKING:
13
+ from .session import ChatSession
14
+
15
+ HELP_TEXT = """Commands:
16
+ /help show this help
17
+ /models list the model catalog
18
+ /tools list connected MCP tools
19
+ /mcp show MCP server connection status
20
+ /clear clear the current in-memory history
21
+ /new [title] start a new conversation (saves and switches)
22
+ /list list saved conversations
23
+ /resume <id> resume a saved conversation
24
+ /rename <id> <title> rename a saved conversation
25
+ /delete <id> delete a saved conversation
26
+ /search <text> search saved messages for text
27
+ /export <id> [md|json] export a conversation to a file
28
+ /exit, /quit end the program"""
29
+
30
+
31
+ def cmd_help(session: "ChatSession", arg: str) -> None:
32
+ print(HELP_TEXT)
33
+
34
+
35
+ def cmd_models(session: "ChatSession", arg: str) -> None:
36
+ list_catalog()
37
+
38
+
39
+ def cmd_tools(session: "ChatSession", arg: str) -> None:
40
+ if not session.mcp.tools:
41
+ print("No MCP tools connected.")
42
+ return
43
+ for name, info in session.mcp.tools.items():
44
+ print(f" - {name}: {info['description'][:90]}")
45
+
46
+
47
+ def cmd_mcp(session: "ChatSession", arg: str) -> None:
48
+ servers = list(session.mcp.sessions.keys()) or "none"
49
+ print(f"Connected servers: {servers}")
50
+ print(f"Config: {MCP_CONFIG_FILE.resolve()}")
51
+
52
+
53
+ def cmd_clear(session: "ChatSession", arg: str) -> None:
54
+ session.clear()
55
+ print("History cleared.")
56
+
57
+
58
+ def cmd_new(session: "ChatSession", arg: str) -> None:
59
+ session.start_new(arg.strip() or None)
60
+ print(f"{C_GREEN}[ok]{C_RESET} Started conversation #{session.conv_id}.")
61
+
62
+
63
+ def cmd_list(session: "ChatSession", arg: str) -> None:
64
+ conversations = session.store.list()
65
+ if not conversations:
66
+ print("No saved conversations yet.")
67
+ return
68
+ for c in conversations:
69
+ marker = " *" if c.id == session.conv_id else ""
70
+ print(f" #{c.id}{marker} {c.title} ({c.message_count} msgs, updated {c.updated_at})")
71
+
72
+
73
+ def cmd_resume(session: "ChatSession", arg: str) -> None:
74
+ try:
75
+ conv_id = int(arg.strip())
76
+ except ValueError:
77
+ print("Usage: /resume <id>")
78
+ return
79
+ if session.resume(conv_id):
80
+ print(f"{C_GREEN}[ok]{C_RESET} Resumed conversation #{conv_id}.")
81
+ else:
82
+ print(f"{C_RED}[error]{C_RESET} No conversation with id {conv_id}.")
83
+
84
+
85
+ def cmd_rename(session: "ChatSession", arg: str) -> None:
86
+ parts = arg.strip().split(maxsplit=1)
87
+ if len(parts) != 2 or not parts[0].isdigit():
88
+ print("Usage: /rename <id> <new title>")
89
+ return
90
+ conv_id, title = int(parts[0]), parts[1]
91
+ if session.store.rename(conv_id, title):
92
+ print(f"{C_GREEN}[ok]{C_RESET} Renamed #{conv_id} to '{title}'.")
93
+ else:
94
+ print(f"{C_RED}[error]{C_RESET} No conversation with id {conv_id}.")
95
+
96
+
97
+ def cmd_delete(session: "ChatSession", arg: str) -> None:
98
+ if not arg.strip().isdigit():
99
+ print("Usage: /delete <id>")
100
+ return
101
+ conv_id = int(arg.strip())
102
+ confirm = input(f"Delete conversation #{conv_id}? This cannot be undone. (y/N) ").strip().lower()
103
+ if confirm != "y":
104
+ print("Cancelled.")
105
+ return
106
+ if session.store.delete(conv_id):
107
+ print(f"{C_GREEN}[ok]{C_RESET} Deleted #{conv_id}.")
108
+ if conv_id == session.conv_id:
109
+ session.start_new()
110
+ else:
111
+ print(f"{C_RED}[error]{C_RESET} No conversation with id {conv_id}.")
112
+
113
+
114
+ def cmd_search(session: "ChatSession", arg: str) -> None:
115
+ text = arg.strip()
116
+ if not text:
117
+ print("Usage: /search <text>")
118
+ return
119
+ results = session.store.search(text)
120
+ if not results:
121
+ print("No matches.")
122
+ return
123
+ for conv_id, title, role, content in results[:20]:
124
+ snippet = content.replace("\n", " ")[:80]
125
+ print(f" #{conv_id} {title} [{role}]: {snippet}")
126
+
127
+
128
+ def cmd_export(session: "ChatSession", arg: str) -> None:
129
+ parts = arg.strip().split()
130
+ if not parts or not parts[0].isdigit():
131
+ print("Usage: /export <id> [md|json]")
132
+ return
133
+ conv_id = int(parts[0])
134
+ fmt = parts[1] if len(parts) > 1 else "md"
135
+ if fmt not in ("md", "json"):
136
+ print("Format must be 'md' or 'json'.")
137
+ return
138
+ path = Path(f"conversation_{conv_id}.{fmt}")
139
+ result = session.store.export(conv_id, path, fmt)
140
+ if result is None:
141
+ print(f"{C_RED}[error]{C_RESET} No conversation with id {conv_id}.")
142
+ else:
143
+ print(f"{C_GREEN}[ok]{C_RESET} Exported to {result.resolve()}")
144
+
145
+
146
+ def cmd_exit(session: "ChatSession", arg: str) -> bool:
147
+ return False
148
+
149
+
150
+ COMMANDS: dict[str, Callable[["ChatSession", str], bool | None]] = {
151
+ "help": cmd_help,
152
+ "models": cmd_models,
153
+ "tools": cmd_tools,
154
+ "mcp": cmd_mcp,
155
+ "clear": cmd_clear,
156
+ "new": cmd_new,
157
+ "list": cmd_list,
158
+ "resume": cmd_resume,
159
+ "rename": cmd_rename,
160
+ "delete": cmd_delete,
161
+ "search": cmd_search,
162
+ "export": cmd_export,
163
+ "exit": cmd_exit,
164
+ "quit": cmd_exit,
165
+ }
166
+
167
+
168
+ def dispatch(session: "ChatSession", raw_input: str) -> bool | None:
169
+ """Runs a slash command. Returns False if the chat loop should stop."""
170
+ name, _, arg = raw_input[1:].partition(" ")
171
+ handler = COMMANDS.get(name.lower())
172
+ if handler is None:
173
+ print(f"{C_DIM}Unknown command '{raw_input.split()[0]}'. Try /help.{C_RESET}")
174
+ return True
175
+ return handler(session, arg)
@@ -0,0 +1,97 @@
1
+ """Chat session state: history, current conversation, and the main loop."""
2
+
3
+ import asyncio
4
+
5
+ from ..config import C_DIM, C_GREEN, C_YELLOW, C_RESET
6
+ from ..mcp import MCPManager, extract_tool_call
7
+ from ..storage import ConversationStore
8
+ from .commands import dispatch
9
+ from .streaming import generate_streaming
10
+
11
+ SYSTEM_PROMPT = "You are a helpful assistant running locally on the user's machine."
12
+
13
+
14
+ class ChatSession:
15
+ """Bundles the LLM, MCP manager, conversation store, and in-memory history."""
16
+
17
+ def __init__(self, llm, mcp: MCPManager, store: ConversationStore, model_name: str):
18
+ self.llm = llm
19
+ self.mcp = mcp
20
+ self.store = store
21
+ self.model_name = model_name
22
+ self.conv_id: int | None = None
23
+ self.history: list[dict] = []
24
+
25
+ def _system_message(self) -> str:
26
+ return SYSTEM_PROMPT + self.mcp.tools_prompt()
27
+
28
+ def start_new(self, title: str | None = None) -> None:
29
+ title = title or "New conversation"
30
+ self.conv_id = self.store.create(title, self.model_name)
31
+ self.history = [{"role": "system", "content": self._system_message()}]
32
+ self.store.append_message(self.conv_id, "system", self.history[0]["content"])
33
+
34
+ def resume(self, conv_id: int) -> bool:
35
+ found = self.store.get(conv_id)
36
+ if found is None:
37
+ return False
38
+ conv, messages = found
39
+ self.conv_id = conv.id
40
+ self.history = [{"role": m.role, "content": m.content} for m in messages]
41
+ if not self.history or self.history[0]["role"] != "system":
42
+ self.history.insert(0, {"role": "system", "content": self._system_message()})
43
+ return True
44
+
45
+ def clear(self) -> None:
46
+ self.history = self.history[:1]
47
+
48
+ async def turn(self, user_input: str) -> None:
49
+ self.history.append({"role": "user", "content": user_input})
50
+ self.store.append_message(self.conv_id, "user", user_input)
51
+
52
+ for _ in range(5):
53
+ response = await asyncio.to_thread(generate_streaming, self.llm, self.history)
54
+ self.history.append({"role": "assistant", "content": response})
55
+ self.store.append_message(self.conv_id, "assistant", response)
56
+
57
+ call = extract_tool_call(response)
58
+ if not call or not self.mcp.tools:
59
+ break
60
+ tool_name, arguments = call
61
+ print(f"{C_DIM}[tool] running {tool_name}({arguments}){C_RESET}")
62
+ result = await self.mcp.call(tool_name, arguments)
63
+ print(f"{C_DIM}[tool] result: {result[:200]}...{C_RESET}")
64
+ followup = (
65
+ f"[TOOL RESULT for {tool_name}]:\n{result}\n\n"
66
+ f"Now answer the user's original question based on this."
67
+ )
68
+ self.history.append({"role": "user", "content": followup})
69
+ self.store.append_message(self.conv_id, "user", followup)
70
+
71
+
72
+ async def chat_loop(session: ChatSession) -> None:
73
+ if session.conv_id is None:
74
+ session.start_new()
75
+
76
+ backend = getattr(session.llm, "exec_mode", "CPU")
77
+ n_tools = len(session.mcp.tools)
78
+ tools_msg = f" | {n_tools} MCP tool{'s' if n_tools != 1 else ''}" if n_tools else " | MCP off"
79
+ print(f"\n{C_GREEN}=== Chat started — {session.model_name} on {backend}{tools_msg} "
80
+ f"— /help for commands, /exit to quit ==={C_RESET}\n")
81
+ while True:
82
+ try:
83
+ user_input = (await asyncio.to_thread(input, f"{C_YELLOW}You:{C_RESET} ")).strip()
84
+ except (EOFError, KeyboardInterrupt):
85
+ break
86
+ if not user_input:
87
+ continue
88
+
89
+ if user_input.startswith("/"):
90
+ keep_going = dispatch(session, user_input)
91
+ if keep_going is False:
92
+ break
93
+ continue
94
+
95
+ await session.turn(user_input)
96
+
97
+ print("Shutting down. See you next time!")
@@ -0,0 +1,18 @@
1
+ """Streaming text generation via llama.cpp."""
2
+
3
+ from ..config import C_CYAN, C_RESET
4
+
5
+
6
+ def generate_streaming(llm, history: list[dict]) -> str:
7
+ print(f"{C_CYAN}Model:{C_RESET} ", end="", flush=True)
8
+ response = ""
9
+ stream = llm.create_chat_completion(
10
+ messages=history, max_tokens=1024, temperature=0.7, stream=True,
11
+ )
12
+ for chunk in stream:
13
+ token = chunk["choices"][0]["delta"].get("content", "")
14
+ if token:
15
+ print(token, end="", flush=True)
16
+ response += token
17
+ print()
18
+ return response