"""Command-line interface for Thicket. Three modes: - ``--version`` — print the package version and exit. - ``--dry-run`` — probe the environment (Python modules, Qdrant service, Ollama service), print a readiness report, exit. Does NOT launch the GUI and ingests nothing. - ``--ingest DIR --vault DIR`` — headless batch ingest using the same core pipeline modules the GUI drives. No Qt is imported on this path, so it works over SSH / cron. With no flags, ``main()`` defers to ``ui_window.launch_gui()``. Heavy imports (``ui_window``, pipeline modules) are deferred into the branches that need them so that ``--version`` / ``--dry-run`` start instantly and never touch PySide6. """ from __future__ import annotations import argparse import sys from pathlib import Path def build_parser() -> argparse.ArgumentParser: """Build the CLI argument parser.""" parser = argparse.ArgumentParser( prog="thicket", description="Super-ingest + RAG console: documents -> Obsidian + Qdrant + LightRAG", ) parser.add_argument( "--version", action="store_true", help="Print version and exit", ) parser.add_argument( "--dry-run", action="store_true", help="Probe environment (modules, Qdrant, Ollama) and print a " "readiness report — do NOT launch GUI or ingest anything", ) parser.add_argument( "--ingest", metavar="DIR", help="Headless mode: batch-ingest this directory (no GUI)", ) parser.add_argument( "--vault", metavar="DIR", help="Obsidian vault root (required with --ingest)", ) from .vector_stores import TARGETS as _VECTOR_TARGETS parser.add_argument( "--target", default="qdrant", choices=["obsidian"] + list(_VECTOR_TARGETS), help="Ingest destination: 'obsidian' = notes only; any vector " "store key = notes + that store (qdrant default; most run " "embedded under /.thicket/; pgvector/mariadb use " "PG* / MARIADB_* env)", ) parser.add_argument( "--host", default="localhost", help="Service host for service-backed targets (qdrant default " "6333, weaviate 8080; qdrant target only)", ) parser.add_argument( "--port", type=int, default=6333, help="Service port for service-backed targets (qdrant 6333, " "weaviate 8080)", ) parser.add_argument( "--collection", default="second_brain", help="Qdrant collection name (default: second_brain)", ) from .embedder import DEFAULT_EMBED_MODEL parser.add_argument( "--embed-model", default=DEFAULT_EMBED_MODEL, help=f"FastEmbed embedding model (default: {DEFAULT_EMBED_MODEL} — " "code/config-strong; see thicket.embedder catalog)", ) parser.add_argument( "--chunk-size", type=int, default=400, help="Chunk size in words (default: 400)", ) parser.add_argument( "--overlap", type=int, default=50, help="Chunk overlap in words (default: 50)", ) parser.add_argument( "--no-vault", action="store_true", help="Headless mode: skip the Obsidian vault-note stage " "(vectors/graph only)", ) parser.add_argument( "--no-qdrant", action="store_true", help="Headless mode: skip the Qdrant vector stage (vault notes only)", ) parser.add_argument( "--lightrag", action="store_true", help="Headless mode: also run the knowledge-graph stage", ) parser.add_argument( "--graph-engine", default="lightrag", choices=["lightrag", "graphify"], help="Knowledge-graph engine (default: lightrag; graphify builds " "graph.json + HTML report via one local Ollama pass)", ) parser.add_argument( "--notes-dir", default="Ingested_Brain", help="Vault subdirectory for generated notes (default: Ingested_Brain)", ) parser.add_argument( "--skip-unchanged", action="store_true", help="Skip files whose content hash matches the last ingest " "(manifest under /.thicket/)", ) parser.add_argument( "--max-mb", type=int, default=0, help="Skip files larger than this many megabytes (default: 0 = no limit)", ) parser.add_argument( "--archive", action="store_true", help="After verified ingest, move each source to the archive dir " "(default: /../ingested-archive) and bzip2 it — " "sources are never deleted", ) parser.add_argument( "--archive-dir", metavar="DIR", help="Override the ingested-archive directory (default: " "sibling of the input dir)", ) parser.add_argument( "--minio", action="store_true", help="Headless mode: archive source documents to MinIO " "(env: MINIO_ENDPOINT/ACCESS_KEY/SECRET_KEY/BUCKET)", ) parser.add_argument( "--ask", metavar="QUESTION", help="Ask a natural-language question over the SQL corpus of the " "selected target (pgvector / mariadb; Vanna 2 + Ollama) — " "no GUI, no ingest", ) parser.add_argument( "--ollama-llm", default="llama3", help="Ollama LLM for LightRAG entity extraction (default: llama3)", ) parser.add_argument( "--ollama-embed", default="nomic-embed-text", help="Ollama embedding model for LightRAG (default: nomic-embed-text)", ) return parser def run_dry_run(qdrant_host: str, qdrant_port: int) -> int: """Probe the environment and print a readiness report.""" from .env_probe import PROBED_MODULES, probe_environment env = probe_environment(qdrant_host=qdrant_host, qdrant_port=qdrant_port) def _module_line(name: str) -> str: tag = "OK" if env.modules.get(name) else "MISSING" note = " (optional — graph stage)" if name == "lightrag" else "" return f" {name:14s} {tag}{note}" def _service_line(name: str, up: bool, detail: str, error: str | None) -> str: state = f"UP — {detail}" if up else f"DOWN ({error or 'no response'})" return f"{name}: {state}" def _ollama_detail() -> str: shown = env.ollama_models[:12] extra = len(env.ollama_models) - len(shown) return ", ".join(shown) + (f" … (+{extra} more)" if extra > 0 else "") \ or "(no models pulled)" print("=== Thicket environment probe ===") print(f"Python: {env.python_version}") print("Modules:") print("\n".join(_module_line(name) for name in PROBED_MODULES)) print(_service_line("Qdrant", env.qdrant_up, ", ".join(env.qdrant_collections), env.qdrant_error)) print(_service_line("Postgres", env.pg_up, "reachable", env.pg_error)) print(_service_line("MinIO", env.minio_up, "reachable", env.minio_error)) print(_service_line("Ollama", env.ollama_up, _ollama_detail(), env.ollama_error)) print() if env.qdrant_ready: print("READY: vault + Qdrant stages available.") else: print("PARTIAL: vault stage available; install '.[ingest]' extras and/or") print("start Qdrant (docker run -p 6333:6333 qdrant/qdrant) for vectors.") return 0 def run_headless(args: argparse.Namespace) -> int: """Drive the core pipeline without Qt — the GUI's worker logic, reduced to sequential prints.""" from .pipeline_core import headless_ingest input_dir = Path(args.ingest).expanduser() vault_dir = Path(args.vault).expanduser() if args.vault else None if not input_dir.is_dir(): print(f"Error: input directory '{input_dir}' does not exist.", file=sys.stderr) return 1 if vault_dir is None: print("Error: --vault is required with --ingest.", file=sys.stderr) return 1 if not vault_dir.is_dir(): print(f"Error: vault directory '{vault_dir}' does not exist.", file=sys.stderr) return 1 try: ok, fail = headless_ingest( input_dir=input_dir, vault_dir=vault_dir, qdrant_host=args.host, qdrant_port=args.port, target=args.target if args.target != "obsidian" else "qdrant", collection=args.collection, embed_model=args.embed_model, chunk_size=args.chunk_size, overlap=args.overlap, use_vault=not args.no_vault, use_qdrant=(args.target != "obsidian") and not args.no_qdrant, use_lightrag=args.lightrag, use_minio=args.minio, graph_engine=args.graph_engine, notes_dir=args.notes_dir, skip_unchanged=args.skip_unchanged, use_fs_archive=args.archive, archive_dir=Path(args.archive_dir).expanduser() if args.archive_dir else None, max_mb=args.max_mb, ollama_llm=args.ollama_llm, ollama_embed=args.ollama_embed, ) except KeyboardInterrupt: return 130 # POSIX: 128 + SIGINT print(f"\nDone — {ok} succeeded, {fail} failed.") return 0 if fail == 0 else 2 def run_ask(args: argparse.Namespace) -> int: """Natural-language SQL over the corpus via Vanna + Ollama.""" from .ask_vanna import ask try: answer = ask(args.ask, target=args.target, collection=args.collection, llm_model=args.ollama_llm, log=lambda msg: print(f"> {msg}")) except Exception as e: # noqa: BLE001 — surface every failure readably print(f"ASK ERROR: {e}", file=sys.stderr) return 1 print(f"\n{answer}") return 0 def main(argv: list[str] | None = None) -> int: """Entry point — backend profiles first, then arg dispatch.""" from .layout import apply_backend_profiles apply_backend_profiles() # Step-down by mode: --version, --dry-run, and --ingest each run # Qt-free and exit; only the no-flag default loads the GUI. parser = build_parser() args = parser.parse_args(argv) if args.version: from . import __version__ print(f"thicket {__version__}") return 0 if args.dry_run: return run_dry_run(args.host, args.port) if args.ingest: return run_headless(args) if args.ask: return run_ask(args) # No flag — launch the GUI. try: from .ui_window import launch_gui except ImportError as e: print( f"GUI unavailable: {e}\n" f"Run the console with its environment:\n" f" thicket (~/.local/bin command)\n" f" /mnt/AI/runtime/thicket-venv/bin/python thicket.py\n" f"Or bootstrap it: scripts/bootstrap_sources.sh", file=sys.stderr, ) return 1 return launch_gui(argv) if __name__ == "__main__": sys.exit(main())