notrack-cli-agent 0.0.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.
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()