notrack-cli-agent 0.6.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.
Potentially problematic release.
This version of notrack-cli-agent might be problematic. Click here for more details.
- notrack_cli/__init__.py +2 -0
- notrack_cli/client.py +196 -0
- notrack_cli/main.py +811 -0
- notrack_cli/memory.py +73 -0
- notrack_cli/parser.py +36 -0
- notrack_cli/tools.py +233 -0
- notrack_cli_agent-0.6.0.dist-info/METADATA +63 -0
- notrack_cli_agent-0.6.0.dist-info/RECORD +11 -0
- notrack_cli_agent-0.6.0.dist-info/WHEEL +5 -0
- notrack_cli_agent-0.6.0.dist-info/entry_points.txt +2 -0
- notrack_cli_agent-0.6.0.dist-info/top_level.txt +1 -0
notrack_cli/main.py
ADDED
|
@@ -0,0 +1,811 @@
|
|
|
1
|
+
"""
|
|
2
|
+
NoTrack CLI — v0.6.0
|
|
3
|
+
AGY-style autonomous AI terminal. Full shell, sub-agents, slash commands.
|
|
4
|
+
"""
|
|
5
|
+
import sys
|
|
6
|
+
import re
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
import threading
|
|
10
|
+
import subprocess
|
|
11
|
+
import shlex
|
|
12
|
+
import argparse
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.panel import Panel
|
|
17
|
+
from rich.text import Text
|
|
18
|
+
from rich.markdown import Markdown
|
|
19
|
+
from rich.live import Live
|
|
20
|
+
from rich.table import Table
|
|
21
|
+
from rich.rule import Rule
|
|
22
|
+
from rich.syntax import Syntax
|
|
23
|
+
from rich.columns import Columns
|
|
24
|
+
from prompt_toolkit import PromptSession
|
|
25
|
+
from prompt_toolkit.history import FileHistory
|
|
26
|
+
from prompt_toolkit.styles import Style as PTStyle
|
|
27
|
+
from prompt_toolkit.formatted_text import HTML
|
|
28
|
+
from prompt_toolkit.completion import WordCompleter
|
|
29
|
+
|
|
30
|
+
from .client import NoTrackClient
|
|
31
|
+
from .tools import LocalTools, SYSTEM_PROMPT
|
|
32
|
+
from .parser import extract_tool_calls
|
|
33
|
+
from .memory import MemoryManager
|
|
34
|
+
|
|
35
|
+
VERSION = "0.6.0"
|
|
36
|
+
PYPI_PACKAGE = "notrack-cli"
|
|
37
|
+
|
|
38
|
+
console = Console(highlight=False)
|
|
39
|
+
|
|
40
|
+
PT_STYLE = PTStyle.from_dict({
|
|
41
|
+
"prompt": "#00d787 bold",
|
|
42
|
+
"bottom-toolbar": "bg:#1a1a1a #555555",
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
HISTORY_PATH = os.path.expanduser("~/.notrack_cli/input_history")
|
|
46
|
+
|
|
47
|
+
# ──────────────────────────────────────────────────────────────
|
|
48
|
+
# SLASH COMMAND REGISTRY
|
|
49
|
+
# ──────────────────────────────────────────────────────────────
|
|
50
|
+
SLASH_COMMANDS = {
|
|
51
|
+
"/help": "Show this help",
|
|
52
|
+
"/clear": "Clear terminal screen",
|
|
53
|
+
"/history": "Show last 10 conversation turns",
|
|
54
|
+
"/chats": "List saved NoTrack conversations",
|
|
55
|
+
"/resume": "Resume a chat by chat_id",
|
|
56
|
+
"/reset": "Clear all history and start fresh",
|
|
57
|
+
"/new": "Start a new chat context (keeps history)",
|
|
58
|
+
"/model": "Show current model / API info",
|
|
59
|
+
"/tools": "List available system tools",
|
|
60
|
+
"/save": "Export conversation to a file",
|
|
61
|
+
"/shell": "Drop into interactive shell mode",
|
|
62
|
+
"/git": "Quick git status + log (3)",
|
|
63
|
+
"/ls": "List current directory",
|
|
64
|
+
"/pwd": "Print working directory",
|
|
65
|
+
"/cd": "Change working directory: /cd <path>",
|
|
66
|
+
"/version": "Show NoTrack CLI version",
|
|
67
|
+
"/exit": "Exit NoTrack CLI",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# ──────────────────────────────────────────────────────────────
|
|
71
|
+
# SESSION STATE
|
|
72
|
+
# ──────────────────────────────────────────────────────────────
|
|
73
|
+
class SessionState:
|
|
74
|
+
always_yes = False
|
|
75
|
+
always_no = False
|
|
76
|
+
cwd = os.getcwd()
|
|
77
|
+
shell_mode = False
|
|
78
|
+
|
|
79
|
+
state = SessionState()
|
|
80
|
+
|
|
81
|
+
DISPATCH_PROMPT_LIMIT = 3900 # Server error reported a 4,000-character maximum.
|
|
82
|
+
|
|
83
|
+
def fit_dispatch_prompt(prompt: str, max_chars: int = DISPATCH_PROMPT_LIMIT,
|
|
84
|
+
preserve_tail: bool = False) -> str:
|
|
85
|
+
"""Fit dispatch user_input to the observed server limit."""
|
|
86
|
+
if len(prompt) <= max_chars:
|
|
87
|
+
return prompt
|
|
88
|
+
marker = "[Earlier context omitted for NoTrack's 4000-character limit.]\n"
|
|
89
|
+
budget = max_chars - len(marker)
|
|
90
|
+
if preserve_tail:
|
|
91
|
+
return marker + prompt[-budget:]
|
|
92
|
+
return prompt[:budget] + "\n[Prompt shortened to fit NoTrack's limit.]"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ──────────────────────────────────────────────────────────────
|
|
96
|
+
# AUTO UPDATE CHECK
|
|
97
|
+
# ──────────────────────────────────────────────────────────────
|
|
98
|
+
def check_for_updates():
|
|
99
|
+
try:
|
|
100
|
+
import requests
|
|
101
|
+
r = requests.get(f"https://pypi.org/pypi/{PYPI_PACKAGE}/json", timeout=4)
|
|
102
|
+
if r.ok:
|
|
103
|
+
latest = r.json()["info"]["version"]
|
|
104
|
+
if latest != VERSION:
|
|
105
|
+
console.print(
|
|
106
|
+
f"\n[bold yellow]⬆ Update available:[/bold yellow] "
|
|
107
|
+
f"[bold white]v{latest}[/bold white] "
|
|
108
|
+
f"[dim](current v{VERSION})[/dim] → "
|
|
109
|
+
f"[bold cyan]pip install -U {PYPI_PACKAGE}[/bold cyan]\n"
|
|
110
|
+
)
|
|
111
|
+
except Exception:
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ──────────────────────────────────────────────────────────────
|
|
116
|
+
# BANNER
|
|
117
|
+
# ──────────────────────────────────────────────────────────────
|
|
118
|
+
def print_banner():
|
|
119
|
+
os.system("clear")
|
|
120
|
+
lines = [
|
|
121
|
+
(" ███╗ ██╗ ██████╗ ████████╗██████╗ █████╗ ██████╗██╗ ██╗", "bold green"),
|
|
122
|
+
(" ████╗ ██║██╔═══██╗╚══██╔══╝██╔══██╗██╔══██╗██╔════╝██║ ██╔╝", "bold green"),
|
|
123
|
+
(" ██╔██╗ ██║██║ ██║ ██║ ██████╔╝███████║██║ █████╔╝ ", "bold green"),
|
|
124
|
+
(" ██║╚██╗██║██║ ██║ ██║ ██╔══██╗██╔══██║██║ ██╔═██╗ ", "green"),
|
|
125
|
+
(" ██║ ╚████║╚██████╔╝ ██║ ██║ ██║██║ ██║╚██████╗██║ ██╗", "green"),
|
|
126
|
+
(" ╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝", "dim green"),
|
|
127
|
+
]
|
|
128
|
+
logo = Text()
|
|
129
|
+
for txt, style in lines:
|
|
130
|
+
logo.append(txt + "\n", style=style)
|
|
131
|
+
logo.append(
|
|
132
|
+
f"\n Autonomous Master Agent · v{VERSION} · Termux & Linux\n",
|
|
133
|
+
style="bold white",
|
|
134
|
+
)
|
|
135
|
+
logo.append(
|
|
136
|
+
" Type /help · Ctrl+C to pause · /exit to quit\n",
|
|
137
|
+
style="dim white",
|
|
138
|
+
)
|
|
139
|
+
console.print(Panel(logo, border_style="green", padding=(0, 1)))
|
|
140
|
+
console.print()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ──────────────────────────────────────────────────────────────
|
|
144
|
+
# YES / NO / ALWAYS / NEVER (AGY-style confirmation)
|
|
145
|
+
# ──────────────────────────────────────────────────────────────
|
|
146
|
+
def ask_confirm(question: str, default=True) -> bool:
|
|
147
|
+
if state.always_yes:
|
|
148
|
+
console.print(f"[dim green] ✔ Auto-yes:[/dim green] [dim]{question}[/dim]")
|
|
149
|
+
return True
|
|
150
|
+
if state.always_no:
|
|
151
|
+
console.print(f"[dim red] ✗ Auto-no:[/dim red] [dim]{question}[/dim]")
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
hint = "[Y]es [N]o [A]lways [never] (default: No)"
|
|
155
|
+
console.print(f"\n[bold white]❓ {question}[/bold white]")
|
|
156
|
+
console.print(f"[dim] {hint}[/dim]")
|
|
157
|
+
try:
|
|
158
|
+
ans = console.input("[bold green]❯ [/bold green]").strip().lower()
|
|
159
|
+
except (KeyboardInterrupt, EOFError):
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
if ans in ("y", "yes"):
|
|
163
|
+
return True
|
|
164
|
+
if ans in ("a", "always", "always yes"):
|
|
165
|
+
state.always_yes = True
|
|
166
|
+
console.print("[dim green] ✔ 'Always yes' set for this session.[/dim green]")
|
|
167
|
+
return True
|
|
168
|
+
if ans in ("n", "no", "", "never"):
|
|
169
|
+
state.always_no = True
|
|
170
|
+
console.print("[dim red] ✗ 'Never' set for this session.[/dim red]")
|
|
171
|
+
return False
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ──────────────────────────────────────────────────────────────
|
|
176
|
+
# STORAGE SELECTION MENU
|
|
177
|
+
# ──────────────────────────────────────────────────────────────
|
|
178
|
+
def choose_save_location(default_filename: str) -> str | None:
|
|
179
|
+
console.print("\n[bold white]📁 Where should the file be saved?[/bold white]")
|
|
180
|
+
options = [
|
|
181
|
+
("1", os.path.expanduser("~"), "Termux / Linux home"),
|
|
182
|
+
("2", "/sdcard", "Android internal storage"),
|
|
183
|
+
("3", state.cwd, "Current working directory"),
|
|
184
|
+
("4", "(custom)", "Enter a custom path"),
|
|
185
|
+
]
|
|
186
|
+
t = Table(show_header=False, box=None, padding=(0, 2))
|
|
187
|
+
for n, path, label in options:
|
|
188
|
+
t.add_row(f"[bold cyan]{n}[/bold cyan]", path, f"[dim]{label}[/dim]")
|
|
189
|
+
console.print(t)
|
|
190
|
+
try:
|
|
191
|
+
c = console.input("\n[bold green]❯ Select [1-4]:[/bold green] ").strip()
|
|
192
|
+
except (KeyboardInterrupt, EOFError):
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
dirs = {"1": os.path.expanduser("~"), "2": "/sdcard", "3": state.cwd}
|
|
196
|
+
if c in dirs:
|
|
197
|
+
return os.path.join(dirs[c], default_filename)
|
|
198
|
+
if c == "4":
|
|
199
|
+
try:
|
|
200
|
+
p = console.input("[bold green]❯ Path:[/bold green] ").strip()
|
|
201
|
+
return p or None
|
|
202
|
+
except (KeyboardInterrupt, EOFError):
|
|
203
|
+
return None
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ──────────────────────────────────────────────────────────────
|
|
208
|
+
# CODE CREATION ANIMATION
|
|
209
|
+
# ──────────────────────────────────────────────────────────────
|
|
210
|
+
def animate_code_creation(path: str, content: str) -> str:
|
|
211
|
+
console.print()
|
|
212
|
+
console.print(Rule(f"[bold cyan]Writing File[/bold cyan]", style="cyan"))
|
|
213
|
+
console.print(f"[bold green]✦[/bold green] [bold white]{path}[/bold white]\n")
|
|
214
|
+
|
|
215
|
+
ext = os.path.splitext(path)[1].lower()
|
|
216
|
+
lang_map = {
|
|
217
|
+
".py": "python", ".js": "javascript", ".ts": "typescript",
|
|
218
|
+
".json": "json", ".sh": "bash", ".html": "html",
|
|
219
|
+
".css": "css", ".md": "markdown", ".yaml": "yaml",
|
|
220
|
+
".yml": "yaml", ".toml": "toml", ".rs": "rust",
|
|
221
|
+
".go": "go", ".c": "c", ".cpp": "cpp",
|
|
222
|
+
".java": "java", ".sql": "sql", ".rb": "ruby",
|
|
223
|
+
".php": "php", ".kt": "kotlin", ".swift": "swift",
|
|
224
|
+
".dockerfile": "dockerfile",
|
|
225
|
+
}
|
|
226
|
+
lang = lang_map.get(ext, "text")
|
|
227
|
+
|
|
228
|
+
lines = content.splitlines()
|
|
229
|
+
display = ""
|
|
230
|
+
with Live(refresh_per_second=24, console=console) as live:
|
|
231
|
+
for line in lines:
|
|
232
|
+
display += line + "\n"
|
|
233
|
+
syn = Syntax(
|
|
234
|
+
display, lang,
|
|
235
|
+
theme="monokai",
|
|
236
|
+
line_numbers=True,
|
|
237
|
+
word_wrap=True,
|
|
238
|
+
)
|
|
239
|
+
live.update(Panel(
|
|
240
|
+
syn,
|
|
241
|
+
title=f"[bold cyan]📄 {os.path.basename(path)}[/bold cyan]",
|
|
242
|
+
border_style="cyan",
|
|
243
|
+
))
|
|
244
|
+
time.sleep(0.006)
|
|
245
|
+
|
|
246
|
+
result = LocalTools.write_file(path, content, cwd=state.cwd)
|
|
247
|
+
console.print(f"[bold green]✔[/bold green] {result}\n")
|
|
248
|
+
return result
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# ──────────────────────────────────────────────────────────────
|
|
252
|
+
# COMMAND EXECUTION (full shell, no restrictions)
|
|
253
|
+
# ──────────────────────────────────────────────────────────────
|
|
254
|
+
def show_command_exec(cmd: str, ask: bool = True) -> str:
|
|
255
|
+
if ask and not ask_confirm(f"Run: `{cmd}`?"):
|
|
256
|
+
return "Skipped by user."
|
|
257
|
+
console.print()
|
|
258
|
+
console.print(Rule(f"[bold yellow]Shell[/bold yellow]", style="yellow"))
|
|
259
|
+
console.print(f"[bold yellow]⚡[/bold yellow] [bold white]{cmd}[/bold white]\n")
|
|
260
|
+
with console.status("[bold green]Executing…[/bold green]", spinner="dots"):
|
|
261
|
+
result = LocalTools.run_command(cmd, cwd=state.cwd)
|
|
262
|
+
console.print(Panel(
|
|
263
|
+
result,
|
|
264
|
+
title="[bold yellow]Output[/bold yellow]",
|
|
265
|
+
border_style="yellow",
|
|
266
|
+
))
|
|
267
|
+
return result
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# ──────────────────────────────────────────────────────────────
|
|
271
|
+
# INTERACTIVE SHELL MODE
|
|
272
|
+
# ──────────────────────────────────────────────────────────────
|
|
273
|
+
def interactive_shell():
|
|
274
|
+
console.print(Panel(
|
|
275
|
+
"[bold white]Interactive Shell Mode[/bold white]\n"
|
|
276
|
+
"[dim]All commands run in your terminal. Type [bold]exit[/bold] or Ctrl+D to return.[/dim]",
|
|
277
|
+
border_style="yellow",
|
|
278
|
+
))
|
|
279
|
+
os.makedirs(os.path.expanduser("~/.notrack_cli"), exist_ok=True)
|
|
280
|
+
sh_hist = FileHistory(os.path.expanduser("~/.notrack_cli/shell_history"))
|
|
281
|
+
sh_sess = PromptSession(history=sh_hist, style=PT_STYLE)
|
|
282
|
+
|
|
283
|
+
while True:
|
|
284
|
+
try:
|
|
285
|
+
cwd_short = state.cwd.replace(os.path.expanduser("~"), "~")
|
|
286
|
+
cmd = sh_sess.prompt(
|
|
287
|
+
HTML(f'<ansiyellow><b>shell:{cwd_short}</b></ansiyellow><ansigreen><b> $ </b></ansigreen>')
|
|
288
|
+
).strip()
|
|
289
|
+
except (KeyboardInterrupt, EOFError):
|
|
290
|
+
console.print("\n[dim]Returning to NoTrack Agent…[/dim]\n")
|
|
291
|
+
return
|
|
292
|
+
|
|
293
|
+
if not cmd:
|
|
294
|
+
continue
|
|
295
|
+
if cmd in ("exit", "quit"):
|
|
296
|
+
console.print("[dim]Returning to NoTrack Agent…[/dim]\n")
|
|
297
|
+
return
|
|
298
|
+
|
|
299
|
+
if cmd.startswith("cd "):
|
|
300
|
+
target = cmd[3:].strip().strip('"').strip("'")
|
|
301
|
+
target = os.path.expanduser(target)
|
|
302
|
+
if not os.path.isabs(target):
|
|
303
|
+
target = os.path.join(state.cwd, target)
|
|
304
|
+
if os.path.isdir(target):
|
|
305
|
+
state.cwd = os.path.realpath(target)
|
|
306
|
+
console.print(f"[dim green]{state.cwd}[/dim green]")
|
|
307
|
+
else:
|
|
308
|
+
console.print(f"[bold red]cd: no such directory: {target}[/bold red]")
|
|
309
|
+
continue
|
|
310
|
+
|
|
311
|
+
result = LocalTools.run_command(cmd, cwd=state.cwd)
|
|
312
|
+
if result.strip():
|
|
313
|
+
console.print(result)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# ──────────────────────────────────────────────────────────────
|
|
317
|
+
# SUB-AGENT (parallel NoTrack session)
|
|
318
|
+
# ──────────────────────────────────────────────────────────────
|
|
319
|
+
class SubAgent:
|
|
320
|
+
def __init__(self, task: str):
|
|
321
|
+
self.task = task
|
|
322
|
+
self.result = None
|
|
323
|
+
self._t = None
|
|
324
|
+
|
|
325
|
+
def _run(self):
|
|
326
|
+
client = NoTrackClient()
|
|
327
|
+
prompt = fit_dispatch_prompt(
|
|
328
|
+
f"{SYSTEM_PROMPT}\n## Sub-Agent Task\n{self.task}",
|
|
329
|
+
max_chars=3900,
|
|
330
|
+
)
|
|
331
|
+
full = ""
|
|
332
|
+
try:
|
|
333
|
+
for ev in client.send_message_stream(prompt):
|
|
334
|
+
et = ev.get("type")
|
|
335
|
+
if et == "message":
|
|
336
|
+
full = ev.get("content", "")
|
|
337
|
+
elif et == "delta":
|
|
338
|
+
full += ev.get("chunk", "")
|
|
339
|
+
except Exception as e:
|
|
340
|
+
full = f"[Sub-Agent Error]: {e}"
|
|
341
|
+
self.result = full
|
|
342
|
+
|
|
343
|
+
def start(self):
|
|
344
|
+
self._t = threading.Thread(target=self._run, daemon=True)
|
|
345
|
+
self._t.start()
|
|
346
|
+
return self
|
|
347
|
+
|
|
348
|
+
def join(self, timeout=180):
|
|
349
|
+
if self._t:
|
|
350
|
+
self._t.join(timeout=timeout)
|
|
351
|
+
return self.result or ""
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def dispatch_sub_agent(task: str) -> str:
|
|
355
|
+
console.print()
|
|
356
|
+
console.print(Rule("[bold magenta]Sub-Agent Spawned[/bold magenta]", style="magenta"))
|
|
357
|
+
console.print(f"[bold magenta]▶[/bold magenta] [white]{task[:140]}[/white]\n")
|
|
358
|
+
agent = SubAgent(task).start()
|
|
359
|
+
with console.status("[bold magenta]Sub-agent working…[/bold magenta]", spinner="aesthetic"):
|
|
360
|
+
result = agent.join()
|
|
361
|
+
console.print(Panel(
|
|
362
|
+
Markdown(result or "(no output)"),
|
|
363
|
+
title="[bold magenta]Sub-Agent Result[/bold magenta]",
|
|
364
|
+
border_style="magenta",
|
|
365
|
+
))
|
|
366
|
+
return result
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# ──────────────────────────────────────────────────────────────
|
|
370
|
+
# TOOL EXECUTOR
|
|
371
|
+
# ──────────────────────────────────────────────────────────────
|
|
372
|
+
def execute_tool_call(tool_name: str, args: dict, client: NoTrackClient) -> str:
|
|
373
|
+
if tool_name == "write_file":
|
|
374
|
+
path = args.get("path", "output.txt")
|
|
375
|
+
content = args.get("content", "")
|
|
376
|
+
if ask_confirm(f"Write file '{path}' ({len(content):,} bytes)?"):
|
|
377
|
+
return animate_code_creation(path, content)
|
|
378
|
+
return "Skipped by user."
|
|
379
|
+
|
|
380
|
+
elif tool_name == "run_command":
|
|
381
|
+
cmd = args.get("command", "")
|
|
382
|
+
return show_command_exec(cmd, ask=True)
|
|
383
|
+
|
|
384
|
+
elif tool_name == "view_file":
|
|
385
|
+
path = args.get("path", "")
|
|
386
|
+
console.print(f"\n[bold cyan]👁[/bold cyan] [bold white]{path}[/bold white]")
|
|
387
|
+
result = LocalTools.view_file(path, cwd=state.cwd)
|
|
388
|
+
console.print(Panel(
|
|
389
|
+
result[:3000] + ("…" if len(result) > 3000 else ""),
|
|
390
|
+
title=f"[bold cyan]{os.path.basename(path)}[/bold cyan]",
|
|
391
|
+
border_style="cyan",
|
|
392
|
+
))
|
|
393
|
+
return result
|
|
394
|
+
|
|
395
|
+
elif tool_name == "list_dir":
|
|
396
|
+
path = args.get("path", state.cwd)
|
|
397
|
+
console.print(f"\n[bold magenta]📁[/bold magenta] [bold white]{path}[/bold white]")
|
|
398
|
+
result = LocalTools.list_dir(path, cwd=state.cwd)
|
|
399
|
+
console.print(Panel(result, title=path, border_style="magenta"))
|
|
400
|
+
return result
|
|
401
|
+
|
|
402
|
+
elif tool_name == "sub_agent":
|
|
403
|
+
return dispatch_sub_agent(args.get("task", ""))
|
|
404
|
+
|
|
405
|
+
# ── NOUVEAUX OUTILS ──────────────────────────────────────
|
|
406
|
+
elif tool_name == "fetch_url":
|
|
407
|
+
url = args.get("url", "")
|
|
408
|
+
console.print(f"\n[bold cyan]🌐[/bold cyan] [bold white]Fetching {url}[/bold white]")
|
|
409
|
+
result = LocalTools.fetch_url(url)
|
|
410
|
+
console.print(Panel(
|
|
411
|
+
result[:1500] + ("…" if len(result) > 1500 else ""),
|
|
412
|
+
title="[bold cyan]Web Content[/bold cyan]",
|
|
413
|
+
border_style="cyan"
|
|
414
|
+
))
|
|
415
|
+
return result
|
|
416
|
+
|
|
417
|
+
elif tool_name == "search_in_files":
|
|
418
|
+
query = args.get("query", "")
|
|
419
|
+
path = args.get("path", ".")
|
|
420
|
+
console.print(f"\n[bold magenta]🔍[/bold magenta] [bold white]Searching for '{query}' in {path}[/bold white]")
|
|
421
|
+
result = LocalTools.search_in_files(query, path=path, cwd=state.cwd)
|
|
422
|
+
console.print(Panel(result, title="[bold magenta]Search Results[/bold magenta]", border_style="magenta"))
|
|
423
|
+
return result
|
|
424
|
+
|
|
425
|
+
elif tool_name == "get_system_info":
|
|
426
|
+
console.print(f"\n[bold green]💻[/bold green] [bold white]Gathering system specs...[/bold white]")
|
|
427
|
+
result = LocalTools.get_system_info()
|
|
428
|
+
console.print(Panel(result, title="[bold green]System Info[/bold green]", border_style="green"))
|
|
429
|
+
return result
|
|
430
|
+
# ─────────────────────────────────────────────────────────
|
|
431
|
+
|
|
432
|
+
return f"[Unknown tool: {tool_name}]"
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
# ──────────────────────────────────────────────────────────────
|
|
436
|
+
# SLASH COMMAND HANDLER
|
|
437
|
+
# ──────────────────────────────────────────────────────────────
|
|
438
|
+
def handle_slash(cmd_raw: str, history: list, client: NoTrackClient) -> bool:
|
|
439
|
+
parts = cmd_raw.strip().split(None, 1)
|
|
440
|
+
cmd = parts[0].lower()
|
|
441
|
+
rest = parts[1] if len(parts) > 1 else ""
|
|
442
|
+
|
|
443
|
+
if cmd == "/help":
|
|
444
|
+
t = Table(title="[bold green]Slash Commands[/bold green]",
|
|
445
|
+
border_style="green", show_lines=False)
|
|
446
|
+
t.add_column("Command", style="bold cyan", no_wrap=True)
|
|
447
|
+
t.add_column("Description", style="white")
|
|
448
|
+
for k, v in SLASH_COMMANDS.items():
|
|
449
|
+
t.add_row(k, v)
|
|
450
|
+
console.print(t)
|
|
451
|
+
return True
|
|
452
|
+
|
|
453
|
+
if cmd == "/clear":
|
|
454
|
+
print_banner()
|
|
455
|
+
return True
|
|
456
|
+
|
|
457
|
+
if cmd == "/chats":
|
|
458
|
+
try:
|
|
459
|
+
chats = client.list_chats()
|
|
460
|
+
if not chats:
|
|
461
|
+
console.print("[dim]No chats returned for this anonymous session.[/dim]")
|
|
462
|
+
else:
|
|
463
|
+
t = Table("Chat ID", "Title", "Messages", "Status")
|
|
464
|
+
for item in chats[:50]:
|
|
465
|
+
t.add_row(str(item.get("chat_id", "")), str(item.get("title", "")),
|
|
466
|
+
str(item.get("message_count", "?")), str(item.get("status", "")))
|
|
467
|
+
console.print(t)
|
|
468
|
+
except Exception as e:
|
|
469
|
+
console.print(f"[bold red]Could not load chats:[/bold red] {e}")
|
|
470
|
+
return True
|
|
471
|
+
|
|
472
|
+
if cmd == "/resume":
|
|
473
|
+
chat_id = rest.strip()
|
|
474
|
+
if not chat_id:
|
|
475
|
+
console.print("[yellow]Usage: /resume <chat_id>[/yellow]")
|
|
476
|
+
else:
|
|
477
|
+
client.chat_id = chat_id
|
|
478
|
+
console.print(f"[green]Resuming chat {chat_id}[/green]")
|
|
479
|
+
return True
|
|
480
|
+
|
|
481
|
+
if cmd == "/history":
|
|
482
|
+
if not history:
|
|
483
|
+
console.print("[dim]No history yet.[/dim]")
|
|
484
|
+
return True
|
|
485
|
+
for i, item in enumerate(history[-10:], 1):
|
|
486
|
+
console.print(f"[bold cyan]{i}.[/bold cyan] [bold white]You:[/bold white] "
|
|
487
|
+
f"{str(item.get('user',''))[:90]}")
|
|
488
|
+
console.print(f" [dim green]Agent:[/dim green] "
|
|
489
|
+
f"{str(item.get('assistant',''))[:130]}…\n")
|
|
490
|
+
return True
|
|
491
|
+
|
|
492
|
+
if cmd == "/reset":
|
|
493
|
+
if ask_confirm("Clear ALL conversation history?"):
|
|
494
|
+
MemoryManager.clear_history()
|
|
495
|
+
history.clear()
|
|
496
|
+
client.new_chat()
|
|
497
|
+
console.print("[bold green]✔ History cleared.[/bold green]")
|
|
498
|
+
return True
|
|
499
|
+
|
|
500
|
+
if cmd == "/new":
|
|
501
|
+
client.new_chat()
|
|
502
|
+
console.print("[bold green]✔ New chat context started.[/bold green]")
|
|
503
|
+
return True
|
|
504
|
+
|
|
505
|
+
if cmd == "/model":
|
|
506
|
+
console.print(Panel(
|
|
507
|
+
f"[bold white]Package:[/bold white] [bold cyan]{PYPI_PACKAGE}[/bold cyan]\n"
|
|
508
|
+
f"[bold white]Version:[/bold white] [bold green]v{VERSION}[/bold green]\n"
|
|
509
|
+
f"[bold white]API:[/bold white] [dim]notrack.ai/api/dispatch[/dim]\n"
|
|
510
|
+
f"[bold white]Mode:[/bold white] [dim]Streaming · Autonomous · Multi-model fallback[/dim]\n"
|
|
511
|
+
f"[bold white]Chat ID:[/bold white] [dim]{client.chat_id or 'none (new)'}[/dim]",
|
|
512
|
+
title="[bold green]Model Info[/bold green]",
|
|
513
|
+
border_style="green",
|
|
514
|
+
))
|
|
515
|
+
return True
|
|
516
|
+
|
|
517
|
+
if cmd == "/tools":
|
|
518
|
+
t = Table(title="[bold cyan]Available Tools[/bold cyan]",
|
|
519
|
+
border_style="cyan", show_lines=False)
|
|
520
|
+
t.add_column("Tool", style="bold cyan")
|
|
521
|
+
t.add_column("Description", style="white")
|
|
522
|
+
rows = [
|
|
523
|
+
("write_file", "Create or overwrite any file (with animated preview)"),
|
|
524
|
+
("view_file", "Read any file on the system"),
|
|
525
|
+
("run_command", "Execute any shell command (git, pip, bash, etc.)"),
|
|
526
|
+
("list_dir", "List directory contents"),
|
|
527
|
+
("sub_agent", "Spawn a parallel AI sub-agent for complex sub-tasks"),
|
|
528
|
+
("fetch_url", "Download text content from a URL or documentation"),
|
|
529
|
+
("search_in_files", "Search text across project files recursively (Grep)"),
|
|
530
|
+
("get_system_info", "Return system specs, disk space, and OS details"),
|
|
531
|
+
]
|
|
532
|
+
for r in rows:
|
|
533
|
+
t.add_row(*r)
|
|
534
|
+
console.print(t)
|
|
535
|
+
return True
|
|
536
|
+
|
|
537
|
+
if cmd == "/save":
|
|
538
|
+
if not history:
|
|
539
|
+
console.print("[dim]Nothing to save.[/dim]")
|
|
540
|
+
return True
|
|
541
|
+
fname = f"notrack_{datetime.now().strftime('%Y%m%d_%H%M%S')}.md"
|
|
542
|
+
path = choose_save_location(fname)
|
|
543
|
+
if path:
|
|
544
|
+
md = [f"# NoTrack CLI Export — {datetime.now().strftime('%Y-%m-%d %H:%M')}\n"]
|
|
545
|
+
for item in history:
|
|
546
|
+
md.append(f"**You:** {item.get('user','')}\n\n"
|
|
547
|
+
f"**NoTrack:** {item.get('assistant','')}\n\n---")
|
|
548
|
+
try:
|
|
549
|
+
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
|
550
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
551
|
+
f.write("\n".join(md))
|
|
552
|
+
console.print(f"[bold green]✔ Saved:[/bold green] {path}")
|
|
553
|
+
except Exception as e:
|
|
554
|
+
console.print(f"[bold red]Save failed:[/bold red] {e}")
|
|
555
|
+
return True
|
|
556
|
+
|
|
557
|
+
if cmd == "/shell":
|
|
558
|
+
interactive_shell()
|
|
559
|
+
return True
|
|
560
|
+
|
|
561
|
+
if cmd == "/git":
|
|
562
|
+
out = LocalTools.run_command("git status && git log --oneline -3", cwd=state.cwd)
|
|
563
|
+
console.print(Panel(out, title="[bold green]git[/bold green]", border_style="green"))
|
|
564
|
+
return True
|
|
565
|
+
|
|
566
|
+
if cmd == "/ls":
|
|
567
|
+
out = LocalTools.list_dir(rest.strip() or state.cwd, cwd=state.cwd)
|
|
568
|
+
console.print(Panel(out, title=rest.strip() or state.cwd, border_style="magenta"))
|
|
569
|
+
return True
|
|
570
|
+
|
|
571
|
+
if cmd == "/pwd":
|
|
572
|
+
console.print(f"[bold cyan]{state.cwd}[/bold cyan]")
|
|
573
|
+
return True
|
|
574
|
+
|
|
575
|
+
if cmd == "/cd":
|
|
576
|
+
if not rest:
|
|
577
|
+
target = os.path.expanduser("~")
|
|
578
|
+
else:
|
|
579
|
+
target = os.path.expanduser(rest.strip())
|
|
580
|
+
if not os.path.isabs(target):
|
|
581
|
+
target = os.path.join(state.cwd, target)
|
|
582
|
+
if os.path.isdir(target):
|
|
583
|
+
state.cwd = os.path.realpath(target)
|
|
584
|
+
console.print(f"[bold green]✔[/bold green] [dim]{state.cwd}[/dim]")
|
|
585
|
+
else:
|
|
586
|
+
console.print(f"[bold red]cd: not a directory: {target}[/bold red]")
|
|
587
|
+
return True
|
|
588
|
+
|
|
589
|
+
if cmd == "/version":
|
|
590
|
+
console.print(f"[bold green]NoTrack CLI[/bold green] "
|
|
591
|
+
f"v[bold white]{VERSION}[/bold white]")
|
|
592
|
+
return True
|
|
593
|
+
|
|
594
|
+
if cmd in ("/exit", "/quit", "/q"):
|
|
595
|
+
console.print("[dim]Goodbye![/dim]")
|
|
596
|
+
sys.exit(0)
|
|
597
|
+
|
|
598
|
+
return False
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
# ──────────────────────────────────────────────────────────────
|
|
602
|
+
# STREAMING RESPONSE
|
|
603
|
+
# ──────────────────────────────────────────────────────────────
|
|
604
|
+
def stream_response(client: NoTrackClient, prompt: str) -> str:
|
|
605
|
+
full = ""
|
|
606
|
+
try:
|
|
607
|
+
with Live(console=console, refresh_per_second=15) as live:
|
|
608
|
+
for ev in client.send_message_stream(prompt):
|
|
609
|
+
et = ev.get("type")
|
|
610
|
+
if et == "message":
|
|
611
|
+
full = ev.get("content", "")
|
|
612
|
+
elif et == "delta":
|
|
613
|
+
full += ev.get("chunk", "")
|
|
614
|
+
else:
|
|
615
|
+
continue
|
|
616
|
+
live.update(Panel(
|
|
617
|
+
Markdown(full),
|
|
618
|
+
title="[bold green]NoTrack Agent[/bold green]",
|
|
619
|
+
border_style="green",
|
|
620
|
+
padding=(0, 1),
|
|
621
|
+
))
|
|
622
|
+
except KeyboardInterrupt:
|
|
623
|
+
console.print(
|
|
624
|
+
"\n[bold yellow]⚡ Interrupted — partial response saved.[/bold yellow]"
|
|
625
|
+
)
|
|
626
|
+
except RuntimeError as e:
|
|
627
|
+
console.print(f"\n[bold red]Connection Error:[/bold red] {e}")
|
|
628
|
+
return full
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
# ──────────────────────────────────────────────────────────────
|
|
632
|
+
# PROCESS TOOL CALLS + FEEDBACK LOOP (up to 8 levels deep)
|
|
633
|
+
# ──────────────────────────────────────────────────────────────
|
|
634
|
+
def run_tool_loop(full_response: str, client: NoTrackClient,
|
|
635
|
+
history: list, depth: int = 0):
|
|
636
|
+
if depth > 8:
|
|
637
|
+
return
|
|
638
|
+
tool_calls = extract_tool_calls(full_response)
|
|
639
|
+
if not tool_calls:
|
|
640
|
+
return
|
|
641
|
+
|
|
642
|
+
for tool_name, args, _raw in tool_calls:
|
|
643
|
+
tool_output = execute_tool_call(tool_name, args, client)
|
|
644
|
+
|
|
645
|
+
feedback = (
|
|
646
|
+
f"## Tool Result\n"
|
|
647
|
+
f"**Tool:** `{tool_name}`\n"
|
|
648
|
+
f"**Output:**\n```\n{tool_output}\n```\n\n"
|
|
649
|
+
f"Continue completing the task based on this result."
|
|
650
|
+
)
|
|
651
|
+
console.print(
|
|
652
|
+
f"\n[dim]↩ Sending [bold cyan]{tool_name}[/bold cyan] "
|
|
653
|
+
f"output back to agent… (depth {depth+1})[/dim]\n"
|
|
654
|
+
)
|
|
655
|
+
feedback_resp = stream_response(client, feedback)
|
|
656
|
+
if feedback_resp:
|
|
657
|
+
history.append({
|
|
658
|
+
"user": f"[tool result: {tool_name}]",
|
|
659
|
+
"assistant": feedback_resp,
|
|
660
|
+
})
|
|
661
|
+
MemoryManager.save_history(history)
|
|
662
|
+
run_tool_loop(feedback_resp, client, history, depth + 1)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
# ──────────────────────────────────────────────────────────────
|
|
666
|
+
# ONE-SHOT `notrack ask "..."`
|
|
667
|
+
# ──────────────────────────────────────────────────────────────
|
|
668
|
+
def one_shot(question: str):
|
|
669
|
+
"""Run a single question and exit. Used by `notrack ask '...'`"""
|
|
670
|
+
client = NoTrackClient()
|
|
671
|
+
history = MemoryManager.load_history()
|
|
672
|
+
prompt = f"{SYSTEM_PROMPT}\n\nUser: {question}"
|
|
673
|
+
|
|
674
|
+
console.print(
|
|
675
|
+
Panel(
|
|
676
|
+
f"[bold white]{question}[/bold white]",
|
|
677
|
+
title="[bold cyan]notrack ask[/bold cyan]",
|
|
678
|
+
border_style="cyan",
|
|
679
|
+
)
|
|
680
|
+
)
|
|
681
|
+
console.print()
|
|
682
|
+
|
|
683
|
+
response = stream_response(client, prompt)
|
|
684
|
+
console.print()
|
|
685
|
+
|
|
686
|
+
if response:
|
|
687
|
+
history.append({"user": question, "assistant": response})
|
|
688
|
+
MemoryManager.save_history(history)
|
|
689
|
+
run_tool_loop(response, client, history)
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
# ──────────────────────────────────────────────────────────────
|
|
693
|
+
# INTERACTIVE MAIN LOOP
|
|
694
|
+
# ──────────────────────────────────────────────────────────────
|
|
695
|
+
def interactive_loop():
|
|
696
|
+
print_banner()
|
|
697
|
+
#threading.Thread(target=check_for_updates, daemon=True).start()
|
|
698
|
+
|
|
699
|
+
os.makedirs(os.path.expanduser("~/.notrack_cli"), exist_ok=True)
|
|
700
|
+
client = NoTrackClient()
|
|
701
|
+
history = MemoryManager.load_history()
|
|
702
|
+
|
|
703
|
+
if history:
|
|
704
|
+
console.print(
|
|
705
|
+
f"[dim green]✔ Loaded {len(history)} turns from memory.[/dim green]\n"
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
# Checkpoint recovery
|
|
709
|
+
ckpt = MemoryManager.load_checkpoint()
|
|
710
|
+
if ckpt:
|
|
711
|
+
console.print("[bold yellow]⚠ Interrupted session detected.[/bold yellow]")
|
|
712
|
+
console.print(f"[dim] Last prompt: {str(ckpt.get('user_input',''))[:80]}…[/dim]")
|
|
713
|
+
if ask_confirm("Resume checkpoint?"):
|
|
714
|
+
client.chat_id = ckpt.get("chat_id")
|
|
715
|
+
MemoryManager.clear_checkpoint()
|
|
716
|
+
|
|
717
|
+
# Prompt completer (slash commands)
|
|
718
|
+
completer = WordCompleter(
|
|
719
|
+
list(SLASH_COMMANDS.keys()), pattern=re.compile(r"/\w*"), sentence=True
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
session = PromptSession(
|
|
723
|
+
history=FileHistory(HISTORY_PATH),
|
|
724
|
+
style=PT_STYLE,
|
|
725
|
+
completer=completer,
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
while True:
|
|
729
|
+
cwd_short = state.cwd.replace(os.path.expanduser("~"), "~")
|
|
730
|
+
try:
|
|
731
|
+
user_input = session.prompt(
|
|
732
|
+
HTML(f'<ansigreen><b>❯ </b></ansigreen>'),
|
|
733
|
+
)
|
|
734
|
+
except (KeyboardInterrupt, EOFError):
|
|
735
|
+
console.print("\n[dim yellow]Session paused. /exit to quit.[/dim yellow]")
|
|
736
|
+
break
|
|
737
|
+
|
|
738
|
+
user_input = user_input.strip()
|
|
739
|
+
if not user_input:
|
|
740
|
+
continue
|
|
741
|
+
|
|
742
|
+
# Slash command
|
|
743
|
+
if user_input.startswith("/"):
|
|
744
|
+
if handle_slash(user_input, history, client):
|
|
745
|
+
continue
|
|
746
|
+
console.print(
|
|
747
|
+
f"[bold red]Unknown command:[/bold red] {user_input} "
|
|
748
|
+
f"([bold cyan]/help[/bold cyan] for list)"
|
|
749
|
+
)
|
|
750
|
+
continue
|
|
751
|
+
|
|
752
|
+
# Quick exit
|
|
753
|
+
if user_input.lower() in ("exit", "quit", "q"):
|
|
754
|
+
console.print("[dim]Goodbye![/dim]")
|
|
755
|
+
break
|
|
756
|
+
|
|
757
|
+
# Build prompt avec SYSTEM_PROMPT garanti pour que l'agent voie toujours les outils
|
|
758
|
+
ctx = ""
|
|
759
|
+
if history:
|
|
760
|
+
ctx = "## Context (last 6 turns)\n"
|
|
761
|
+
for item in history[-6:]:
|
|
762
|
+
ctx += f"User: {item.get('user','')}\nAssistant: {item.get('assistant','')}\n---\n"
|
|
763
|
+
|
|
764
|
+
full_prompt = fit_dispatch_prompt(
|
|
765
|
+
f"{SYSTEM_PROMPT}\n\n{ctx}\nUser: {user_input}",
|
|
766
|
+
max_chars=3900,
|
|
767
|
+
preserve_tail=True,
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
# Save checkpoint before streaming
|
|
771
|
+
MemoryManager.save_checkpoint(user_input, "", client.chat_id)
|
|
772
|
+
|
|
773
|
+
console.print()
|
|
774
|
+
response = stream_response(client, full_prompt)
|
|
775
|
+
console.print()
|
|
776
|
+
|
|
777
|
+
if not response:
|
|
778
|
+
continue
|
|
779
|
+
|
|
780
|
+
history.append({"user": user_input, "assistant": response})
|
|
781
|
+
MemoryManager.save_history(history)
|
|
782
|
+
MemoryManager.clear_checkpoint()
|
|
783
|
+
|
|
784
|
+
# Tool call loop
|
|
785
|
+
run_tool_loop(response, client, history)
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
# ──────────────────────────────────────────────────────────────
|
|
789
|
+
# ENTRY POINT
|
|
790
|
+
# ──────────────────────────────────────────────────────────────
|
|
791
|
+
def main():
|
|
792
|
+
parser = argparse.ArgumentParser(
|
|
793
|
+
prog="notrack",
|
|
794
|
+
description=f"NoTrack CLI v{VERSION} — Autonomous AI terminal",
|
|
795
|
+
)
|
|
796
|
+
sub = parser.add_subparsers(dest="subcommand")
|
|
797
|
+
|
|
798
|
+
ask_p = sub.add_parser("ask", help="One-shot: notrack ask 'your question'")
|
|
799
|
+
ask_p.add_argument("question", nargs="+", help="Question or task")
|
|
800
|
+
|
|
801
|
+
args = parser.parse_args()
|
|
802
|
+
|
|
803
|
+
if args.subcommand == "ask":
|
|
804
|
+
question = " ".join(args.question)
|
|
805
|
+
one_shot(question)
|
|
806
|
+
else:
|
|
807
|
+
interactive_loop()
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
if __name__ == "__main__":
|
|
811
|
+
main()
|