codebase-navigator 0.1.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.
@@ -0,0 +1,6 @@
1
+ """codebase-navigator: Git-aware ctags indexing, live watchers, and LanceDB semantic search."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["__version__"]
@@ -0,0 +1,412 @@
1
+ """LLM-assisted codebase questioning with iterative semantic search."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ import sys
10
+ import tomllib
11
+ from typing import Any
12
+ import urllib.error
13
+ import urllib.request
14
+
15
+ from .config import get_socket_path
16
+ from .index import VectorIndex
17
+ from .ipc import query_socket
18
+
19
+ DEFAULT_ENDPOINT = "https://openrouter.ai/api/v1"
20
+ DEFAULT_MODEL = "google/gemini-2.5-flash"
21
+ DEFAULT_MAX_SEARCHES = 5
22
+ DEFAULT_INITIAL_LIMIT = 10
23
+
24
+
25
+ @dataclass
26
+ class LLMConfig:
27
+ """Configuration settings for LLM queries."""
28
+ endpoint: str = DEFAULT_ENDPOINT
29
+ api_key: str | None = None
30
+ model: str = DEFAULT_MODEL
31
+ max_searches: int = DEFAULT_MAX_SEARCHES
32
+ initial_limit: int = DEFAULT_INITIAL_LIMIT
33
+
34
+
35
+ def _parse_toml_file(p: Path) -> dict[str, Any]:
36
+ """Safely parse a TOML file if it exists."""
37
+ if p.is_file():
38
+ try:
39
+ with open(p, "rb") as f:
40
+ return tomllib.load(f)
41
+ except Exception:
42
+ return {}
43
+ return {}
44
+
45
+
46
+ def load_llm_config(
47
+ folder: Path | None = None,
48
+ cli_overrides: dict[str, Any] | None = None,
49
+ ) -> LLMConfig:
50
+ """Load LLM configuration with hierarchical resolution:
51
+
52
+ 1. CLI argument overrides (highest priority)
53
+ 2. Environment variables (CN_API_KEY, OPENROUTER_API_KEY, etc.)
54
+ 3. Project configuration (.codebase-navigator/config.toml, etc.)
55
+ 4. User global configuration (~/.config/codebase-navigator/config.toml, etc.)
56
+ 5. Built-in defaults
57
+ """
58
+ cli = {k: v for k, v in (cli_overrides or {}).items() if v is not None}
59
+
60
+ # 1. Global user configs
61
+ home = Path.home()
62
+ user_candidates = [
63
+ home / ".config" / "codebase-navigator" / "config.toml",
64
+ home / ".config" / "codebase-navigator.toml",
65
+ home / ".config" / "codebase-navigator" / "config",
66
+ ]
67
+ user_data: dict[str, Any] = {}
68
+ for uc in user_candidates:
69
+ if uc.is_file():
70
+ user_data = _parse_toml_file(uc)
71
+ break
72
+
73
+ # 2. Project local configs
74
+ project_data: dict[str, Any] = {}
75
+ if folder:
76
+ project_candidates = [
77
+ folder / ".codebase-navigator" / "config.toml",
78
+ folder / "codebase-navigator.toml",
79
+ folder / ".codebase-navigator.toml",
80
+ ]
81
+ for pc in project_candidates:
82
+ if pc.is_file():
83
+ project_data = _parse_toml_file(pc)
84
+ break
85
+
86
+ # Merge TOML layers (user < project)
87
+ merged_toml: dict[str, Any] = {}
88
+ for src in [user_data, project_data]:
89
+ # Handle top-level keys
90
+ for k in ["endpoint", "base_url", "api_key", "model", "max_searches", "initial_limit", "limit"]:
91
+ if k in src:
92
+ merged_toml[k] = src[k]
93
+ # Handle [llm] section
94
+ llm_sec = src.get("llm", {})
95
+ if isinstance(llm_sec, dict):
96
+ for k, v in llm_sec.items():
97
+ merged_toml[k] = v
98
+
99
+ # 3. Environment variables
100
+ env_api_key = (
101
+ os.environ.get("CN_API_KEY")
102
+ or os.environ.get("CODEBASE_NAVIGATOR_API_KEY")
103
+ or os.environ.get("OPENROUTER_API_KEY")
104
+ or os.environ.get("OPENAI_API_KEY")
105
+ )
106
+ env_endpoint = (
107
+ os.environ.get("CN_ENDPOINT")
108
+ or os.environ.get("CN_BASE_URL")
109
+ or os.environ.get("CODEBASE_NAVIGATOR_BASE_URL")
110
+ or os.environ.get("OPENROUTER_BASE_URL")
111
+ or os.environ.get("OPENAI_BASE_URL")
112
+ )
113
+ env_model = (
114
+ os.environ.get("CN_MODEL")
115
+ or os.environ.get("CODEBASE_NAVIGATOR_MODEL")
116
+ or os.environ.get("OPENROUTER_MODEL")
117
+ or os.environ.get("OPENAI_MODEL")
118
+ )
119
+ env_max_searches = os.environ.get("CN_MAX_SEARCHES")
120
+ env_initial_limit = os.environ.get("CN_ASK_LIMIT") or os.environ.get("CN_INITIAL_LIMIT")
121
+
122
+ # Resolve endpoint
123
+ endpoint = (
124
+ cli.get("endpoint")
125
+ or env_endpoint
126
+ or merged_toml.get("endpoint")
127
+ or merged_toml.get("base_url")
128
+ or DEFAULT_ENDPOINT
129
+ )
130
+
131
+ # Resolve api_key
132
+ api_key = (
133
+ cli.get("api_key")
134
+ or env_api_key
135
+ or merged_toml.get("api_key")
136
+ )
137
+
138
+ # Resolve model
139
+ model = (
140
+ cli.get("model")
141
+ or env_model
142
+ or merged_toml.get("model")
143
+ or DEFAULT_MODEL
144
+ )
145
+
146
+ # Resolve max_searches
147
+ max_searches_raw = (
148
+ cli.get("max_searches")
149
+ or env_max_searches
150
+ or merged_toml.get("max_searches")
151
+ or DEFAULT_MAX_SEARCHES
152
+ )
153
+ try:
154
+ max_searches = int(max_searches_raw)
155
+ except (ValueError, TypeError):
156
+ max_searches = DEFAULT_MAX_SEARCHES
157
+
158
+ # Resolve initial_limit
159
+ initial_limit_raw = (
160
+ cli.get("limit")
161
+ or env_initial_limit
162
+ or merged_toml.get("limit")
163
+ or merged_toml.get("initial_limit")
164
+ or DEFAULT_INITIAL_LIMIT
165
+ )
166
+ try:
167
+ initial_limit = int(initial_limit_raw)
168
+ except (ValueError, TypeError):
169
+ initial_limit = DEFAULT_INITIAL_LIMIT
170
+
171
+ return LLMConfig(
172
+ endpoint=endpoint,
173
+ api_key=api_key,
174
+ model=model,
175
+ max_searches=max_searches,
176
+ initial_limit=initial_limit,
177
+ )
178
+
179
+
180
+ def execute_search(
181
+ folder: Path,
182
+ query: str,
183
+ limit: int = 5,
184
+ doc_type: str = "all",
185
+ custom_index_dir: str | None = None,
186
+ ) -> list[dict[str, Any]]:
187
+ """Perform semantic vector search using socket daemon if available, else in-process."""
188
+ socket_path = get_socket_path(folder, custom_index_dir)
189
+ results = query_socket(socket_path, query, limit=limit, doc_type=doc_type)
190
+ if results is not None:
191
+ return results
192
+
193
+ idx = VectorIndex(folder, custom_index_dir)
194
+ return idx.search(query, limit=limit, doc_type=doc_type)
195
+
196
+
197
+ def format_chunks_for_llm(results: list[dict[str, Any]]) -> str:
198
+ """Format search results cleanly for LLM consumption."""
199
+ if not results:
200
+ return "No relevant code or documentation chunks found."
201
+
202
+ chunks_text = []
203
+ for idx, r in enumerate(results, start=1):
204
+ rel_p = r.get("path", "")
205
+ abs_p = r.get("abs_path", "")
206
+ s_line = r.get("start_line", 1)
207
+ e_line = r.get("end_line", 1)
208
+ title = r.get("title", "")
209
+ doc_type = r.get("doc_type", "")
210
+ score_pct = int(r.get("score", 0.0) * 100)
211
+ content = r.get("content", "")
212
+
213
+ header = f"[{idx}] File: {rel_p}:{s_line}-{e_line} ({doc_type}) — {title} (Relevance: {score_pct}%)\nAbsURI: file://{abs_p}#L{s_line}-L{e_line}"
214
+ body = f"```\n{content}\n```"
215
+ chunks_text.append(f"{header}\n{body}")
216
+
217
+ return "\n\n".join(chunks_text)
218
+
219
+
220
+ SEARCH_TOOL_SPEC = {
221
+ "type": "function",
222
+ "function": {
223
+ "name": "search",
224
+ "description": "Perform semantic and keyword search across the codebase and documentation to find relevant functions, classes, definitions, architecture, and comments.",
225
+ "parameters": {
226
+ "type": "object",
227
+ "properties": {
228
+ "query": {
229
+ "type": "string",
230
+ "description": "Semantic query describing what you are searching for.",
231
+ },
232
+ "type": {
233
+ "type": "string",
234
+ "enum": ["all", "md", "code_doc", "markdown", "code"],
235
+ "description": "Filter by document type (optional, default: all).",
236
+ },
237
+ "limit": {
238
+ "type": "integer",
239
+ "description": "Maximum number of search results to return (optional, default: 5).",
240
+ },
241
+ },
242
+ "required": ["query"],
243
+ },
244
+ },
245
+ }
246
+
247
+
248
+ def call_chat_completions(
249
+ endpoint: str,
250
+ api_key: str | None,
251
+ payload: dict[str, Any],
252
+ timeout: float = 90.0,
253
+ ) -> dict[str, Any]:
254
+ """Send a request to an OpenAI-compatible /chat/completions endpoint."""
255
+ url = endpoint.strip()
256
+ if not url.endswith("/chat/completions"):
257
+ url = url.rstrip("/") + "/chat/completions"
258
+
259
+ headers = {
260
+ "Content-Type": "application/json",
261
+ "User-Agent": "codebase-navigator/0.1.0",
262
+ "HTTP-Referer": "https://github.com/9gel/devel-tools",
263
+ "X-Title": "codebase-navigator",
264
+ }
265
+ if api_key:
266
+ headers["Authorization"] = f"Bearer {api_key}"
267
+
268
+ data_bytes = json.dumps(payload).encode("utf-8")
269
+ req = urllib.request.Request(url, data=data_bytes, headers=headers, method="POST")
270
+
271
+ try:
272
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
273
+ resp_body = resp.read().decode("utf-8")
274
+ return json.loads(resp_body)
275
+ except urllib.error.HTTPError as e:
276
+ error_content = ""
277
+ try:
278
+ error_content = e.read().decode("utf-8")
279
+ except Exception:
280
+ pass
281
+ raise RuntimeError(
282
+ f"LLM API request failed with HTTP {e.code} ({e.reason}): {error_content}"
283
+ ) from e
284
+ except urllib.error.URLError as e:
285
+ raise RuntimeError(f"Failed to connect to LLM endpoint ({url}): {e.reason}") from e
286
+
287
+
288
+ SYSTEM_PROMPT = """You are an expert codebase intelligence assistant.
289
+ Your job is to answer the user's question accurately and thoroughly based on the codebase.
290
+
291
+ Context instructions:
292
+ 1. You are provided with initial semantic search results from the codebase.
293
+ 2. If you need more information, specific function definitions, or related docs to answer accurately, you can invoke the `search` function.
294
+ 3. When referencing files and line ranges, use markdown format [path:Lstart-Lend](file:///abs_path#Lstart-Lend) or path:Lstart-Lend so developers can jump directly to the code.
295
+ 4. If something is not in the codebase or search results, clearly state what is known and what cannot be determined.
296
+ 5. Provide concise, clear, and technically precise explanations with code snippets where helpful.
297
+ """
298
+
299
+
300
+ def ask_codebase(
301
+ folder: Path,
302
+ question: str,
303
+ config: LLMConfig,
304
+ custom_index_dir: str | None = None,
305
+ verbose: bool = True,
306
+ output_stream=sys.stderr,
307
+ ) -> str:
308
+ """Execute LLM codebase Q&A with bounded iterative semantic search."""
309
+ if not config.api_key:
310
+ raise RuntimeError(
311
+ "No LLM API key found.\n"
312
+ "Please provide an API key via:\n"
313
+ " 1. Environment variable: export OPENROUTER_API_KEY=your_key (or CN_API_KEY)\n"
314
+ " 2. Project config: .codebase-navigator/config.toml (api_key = \"...\")\n"
315
+ " 3. Global config: ~/.config/codebase-navigator/config.toml\n"
316
+ " 4. CLI argument: cn ask --api-key your_key \"question\""
317
+ )
318
+
319
+ # Step 1: Initial semantic search
320
+ if verbose:
321
+ print(f"🔍 Searching codebase for: \"{question}\" (limit: {config.initial_limit})...", file=output_stream)
322
+
323
+ initial_chunks = execute_search(
324
+ folder,
325
+ question,
326
+ limit=config.initial_limit,
327
+ custom_index_dir=custom_index_dir,
328
+ )
329
+
330
+ if verbose:
331
+ print(f"✓ Found {len(initial_chunks)} relevant code/doc chunks.", file=output_stream)
332
+
333
+ initial_context_text = format_chunks_for_llm(initial_chunks)
334
+
335
+ user_prompt = (
336
+ f"User Question:\n{question}\n\n"
337
+ f"Initial Codebase Search Results:\n{initial_context_text}"
338
+ )
339
+
340
+ messages: list[dict[str, Any]] = [
341
+ {"role": "system", "content": SYSTEM_PROMPT},
342
+ {"role": "user", "content": user_prompt},
343
+ ]
344
+
345
+ searches_remaining = config.max_searches
346
+
347
+ while True:
348
+ payload: dict[str, Any] = {
349
+ "model": config.model,
350
+ "messages": messages,
351
+ "temperature": 0.2,
352
+ }
353
+ if searches_remaining > 0:
354
+ payload["tools"] = [SEARCH_TOOL_SPEC]
355
+ payload["tool_choice"] = "auto"
356
+
357
+ response_data = call_chat_completions(config.endpoint, config.api_key, payload)
358
+ choices = response_data.get("choices", [])
359
+ if not choices:
360
+ raise RuntimeError(f"Unexpected empty response from LLM: {response_data}")
361
+
362
+ choice = choices[0]
363
+ msg = choice.get("message", {})
364
+ tool_calls = msg.get("tool_calls")
365
+
366
+ # If model responded with tool calls and we still have budget
367
+ if tool_calls and searches_remaining > 0:
368
+ messages.append(msg)
369
+ for tool_call in tool_calls:
370
+ fn = tool_call.get("function", {})
371
+ fn_name = fn.get("name")
372
+ fn_args_raw = fn.get("arguments", "{}")
373
+ try:
374
+ fn_args = json.loads(fn_args_raw) if isinstance(fn_args_raw, str) else fn_args_raw
375
+ except Exception:
376
+ fn_args = {}
377
+
378
+ if fn_name == "search":
379
+ query_term = fn_args.get("query", "")
380
+ doc_type = fn_args.get("type", "all")
381
+ limit = int(fn_args.get("limit", 5))
382
+
383
+ search_num = (config.max_searches - searches_remaining) + 1
384
+ if verbose:
385
+ print(
386
+ f"🔎 [Search {search_num}/{config.max_searches}] Query: \"{query_term}\" (type: {doc_type}, limit: {limit})...",
387
+ file=output_stream,
388
+ )
389
+
390
+ search_results = execute_search(
391
+ folder,
392
+ query_term,
393
+ limit=limit,
394
+ doc_type=doc_type,
395
+ custom_index_dir=custom_index_dir,
396
+ )
397
+ tool_content = format_chunks_for_llm(search_results)
398
+ messages.append({
399
+ "role": "tool",
400
+ "tool_call_id": tool_call.get("id"),
401
+ "name": "search",
402
+ "content": tool_content,
403
+ })
404
+
405
+ searches_remaining -= 1
406
+ if searches_remaining <= 0 and verbose:
407
+ print("ℹ️ Search budget limit reached. Generating final answer...", file=output_stream)
408
+ continue
409
+
410
+ # Final answer received
411
+ content = msg.get("content") or ""
412
+ return content
@@ -0,0 +1,230 @@
1
+ """CLI command line interfaces and formatting for codebase-navigator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+ import sys
8
+
9
+ from .config import get_socket_path
10
+ from .ipc import ping_socket, query_socket
11
+ from .tags import TagsManager, get_available_files
12
+
13
+
14
+ def format_search_results(results: list[dict], base_folder: Path) -> str:
15
+ """Format results into GitHub markdown with clickable links."""
16
+ if not results:
17
+ return "No matching documentation or code comments found."
18
+
19
+ lines = []
20
+ for idx, r in enumerate(results, start=1):
21
+ rel_p = r["path"]
22
+ abs_p = r["abs_path"]
23
+ s_line = r["start_line"]
24
+ e_line = r["end_line"]
25
+ title = r["title"]
26
+ score_pct = int(r["score"] * 100)
27
+ content = r["content"]
28
+
29
+ lines.append(
30
+ f"### {idx}. [{rel_p}:L{s_line}-L{e_line}](file://{abs_p}#L{s_line}-L{e_line}) — {title} (Match: {score_pct}%)"
31
+ )
32
+ lines.append("```")
33
+ c_lines = content.splitlines()
34
+ if len(c_lines) > 8:
35
+ lines.extend(c_lines[:6])
36
+ lines.append("...")
37
+ else:
38
+ lines.extend(c_lines)
39
+ lines.append("```\n")
40
+
41
+ return "\n".join(lines)
42
+
43
+
44
+ def format_tag_results(results: list[dict]) -> str:
45
+ """Format ctags symbol results."""
46
+ if not results:
47
+ return "No symbols found."
48
+
49
+ lines = []
50
+ for idx, r in enumerate(results, start=1):
51
+ sym = r["symbol"]
52
+ kind = r["kind"]
53
+ rel_p = r["path"]
54
+ abs_p = r["abs_path"]
55
+ line_no = r["line"]
56
+ preview = r["preview"]
57
+
58
+ lines.append(
59
+ f"{idx}. `{sym}` ({kind}) -> [{rel_p}:L{line_no}](file://{abs_p}#L{line_no})"
60
+ )
61
+ if preview:
62
+ lines.append(f" `{preview}`")
63
+
64
+ return "\n".join(lines)
65
+
66
+
67
+ def build_parser() -> argparse.ArgumentParser:
68
+ """Build the unified cn CLI argument parser."""
69
+ parser = argparse.ArgumentParser(
70
+ prog="cn",
71
+ description="Generic Code & Documentation Navigation Engine",
72
+ )
73
+ subparsers = parser.add_subparsers(dest="command", required=True)
74
+
75
+ # status
76
+ p_status = subparsers.add_parser("status", help="Check indexing status")
77
+ p_status.add_argument("folder", nargs="?", default=".", help="Target folder (default: current directory)")
78
+ p_status.add_argument("--index-dir", default=None, help="Custom LanceDB directory")
79
+
80
+ # sync
81
+ p_sync = subparsers.add_parser("sync", help="Synchronize .tags and LanceDB index")
82
+ p_sync.add_argument("folder", nargs="?", default=".", help="Target folder (default: current directory)")
83
+ p_sync.add_argument("--force", action="store_true", help="Force complete re-indexing")
84
+ p_sync.add_argument("--index-dir", default=None, help="Custom LanceDB directory")
85
+
86
+ # watch
87
+ p_watch = subparsers.add_parser("watch", help="Watch folder and continuously update indexes")
88
+ p_watch.add_argument("folder", nargs="?", default=".", help="Target folder (default: current directory)")
89
+ p_watch.add_argument("--debounce", type=int, default=1000, help="Debounce milliseconds (default: 1000)")
90
+ p_watch.add_argument("--index-dir", default=None, help="Custom LanceDB directory")
91
+
92
+ # search
93
+ p_search = subparsers.add_parser("search", help="Semantic search in docs and code")
94
+ p_search.add_argument("query", help="Semantic query string")
95
+ p_search.add_argument("folder", nargs="?", default=".", help="Target folder (default: current directory)")
96
+ p_search.add_argument("--limit", type=int, default=5, help="Maximum results (default: 5)")
97
+ p_search.add_argument(
98
+ "--type",
99
+ choices=["all", "md", "code_doc", "markdown", "code"],
100
+ default="all",
101
+ help="Filter document types (default: all)",
102
+ )
103
+ p_search.add_argument("--index-dir", default=None, help="Custom LanceDB directory")
104
+
105
+ # tags
106
+ p_tags = subparsers.add_parser("tags", help="Lookup symbol definition in .tags")
107
+ p_tags.add_argument("symbol", help="Symbol name or regex pattern")
108
+ p_tags.add_argument("folder", nargs="?", default=".", help="Target folder (default: current directory)")
109
+ p_tags.add_argument("--exact", action="store_true", help="Match exact symbol name")
110
+ p_tags.add_argument("--limit", type=int, default=20, help="Maximum results (default: 20)")
111
+
112
+ # ask
113
+ p_ask = subparsers.add_parser("ask", help="Ask an LLM questions about the codebase using iterative semantic search")
114
+ p_ask.add_argument("question", help="Question about the codebase")
115
+ p_ask.add_argument("folder", nargs="?", default=".", help="Target folder (default: current directory)")
116
+ p_ask.add_argument("--model", default=None, help="LLM model name (default: google/gemini-2.5-flash)")
117
+ p_ask.add_argument("--endpoint", "--base-url", dest="endpoint", default=None, help="OpenAI-compatible LLM endpoint (default: https://openrouter.ai/api/v1)")
118
+ p_ask.add_argument("--api-key", default=None, help="LLM API key")
119
+ p_ask.add_argument("--limit", type=int, default=None, help="Initial search results count (default: 10)")
120
+ p_ask.add_argument("--max-searches", type=int, default=None, help="Max additional LLM-driven searches (default: 5)")
121
+ p_ask.add_argument("--index-dir", default=None, help="Custom LanceDB directory")
122
+ p_ask.add_argument("-q", "--quiet", action="store_true", help="Suppress progress output")
123
+
124
+ return parser
125
+
126
+
127
+ def main(argv: list[str] | None = None):
128
+ """Main cn entrypoint with subcommands."""
129
+ parser = build_parser()
130
+ args = parser.parse_args(argv)
131
+ folder = Path(args.folder).resolve()
132
+
133
+ if args.command == "status":
134
+ _run_status(folder, custom_index_dir=args.index_dir)
135
+ elif args.command == "sync":
136
+ _run_sync(folder, force=args.force, custom_index_dir=args.index_dir)
137
+ elif args.command == "watch":
138
+ _run_watch(folder, debounce_ms=args.debounce, custom_index_dir=args.index_dir)
139
+ elif args.command == "search":
140
+ _run_search(folder, args.query, limit=args.limit, doc_type=args.type, custom_index_dir=args.index_dir)
141
+ elif args.command == "tags":
142
+ _run_tags(folder, args.symbol, exact=args.exact, limit=args.limit)
143
+ elif args.command == "ask":
144
+ _run_ask(
145
+ folder,
146
+ args.question,
147
+ model=args.model,
148
+ endpoint=args.endpoint,
149
+ api_key=args.api_key,
150
+ limit=args.limit,
151
+ max_searches=args.max_searches,
152
+ custom_index_dir=args.index_dir,
153
+ quiet=args.quiet,
154
+ )
155
+
156
+
157
+ # Backward compatibility aliases
158
+ main_nav = main
159
+
160
+
161
+ def _run_status(folder: Path, custom_index_dir: str | None = None):
162
+ print(f"📊 Navigation Status for: {folder}")
163
+ code_files, doc_files = get_available_files(folder)
164
+ print(f" Available files: {len(code_files)} source code files, {len(doc_files)} doc files")
165
+
166
+ mgr = TagsManager(folder)
167
+ tf = mgr.find_tag_file()
168
+ if tf and tf.exists():
169
+ sz = tf.stat().st_size / (1024 * 1024)
170
+ print(f" 🏷️ Tags file: {tf} ({sz:.2f} MB)")
171
+ else:
172
+ print(" 🏷️ Tags file: Not found (run cn sync)")
173
+
174
+ socket_path = get_socket_path(folder, custom_index_dir)
175
+ daemon_status = ping_socket(socket_path)
176
+ if daemon_status:
177
+ print(f" 🟢 cn watch daemon: ACTIVE (socket: {socket_path})")
178
+ else:
179
+ print(f" ⚪ cn watch daemon: NOT RUNNING (socket: {socket_path})")
180
+
181
+ from .index import VectorIndex
182
+ idx = VectorIndex(folder, custom_index_dir)
183
+ meta = idx.load_meta()
184
+ chunk_count = sum(m.get("chunks", 0) for m in meta.values())
185
+ print(f" 🧠 Vector index: {idx.cache_dir}")
186
+ print(f" Indexed files: {len(meta)}, Total chunks: {chunk_count}")
187
+
188
+
189
+ def _run_sync(folder: Path, force: bool = False, custom_index_dir: str | None = None):
190
+ print(f"Discovering git/source files in {folder}...")
191
+ code_files, doc_files = get_available_files(folder)
192
+ print(f" Found {len(code_files)} source files, {len(doc_files)} doc files.")
193
+
194
+ print(f"Updating {folder / '.tags'}...")
195
+ mgr = TagsManager(folder)
196
+ ok, msg = mgr.generate()
197
+ print(f" .tags generation: {msg if ok else 'FAILED: ' + msg}")
198
+
199
+ print("Syncing LanceDB embeddings...")
200
+ from .index import VectorIndex
201
+ idx = VectorIndex(folder, custom_index_dir)
202
+ u_files, u_chunks, p_files = idx.sync(force=force)
203
+ print(f"✓ Complete: {u_files} files updated ({u_chunks} chunks indexed), {p_files} deleted files pruned.")
204
+ print(f"📦 Embedding index location: {idx.cache_dir}")
205
+
206
+
207
+ def _run_watch(folder: Path, debounce_ms: int = 1000, custom_index_dir: str | None = None):
208
+ from .watcher import DirectoryWatcher
209
+ watcher = DirectoryWatcher(folder, debounce_ms=debounce_ms, custom_index_dir=custom_index_dir)
210
+ watcher.start()
211
+
212
+
213
+ def _run_search(folder: Path, query: str, limit: int = 5, doc_type: str = "all", custom_index_dir: str | None = None):
214
+ socket_path = get_socket_path(folder, custom_index_dir)
215
+ results = query_socket(socket_path, query, limit=limit, doc_type=doc_type)
216
+ if results is not None:
217
+ print(format_search_results(results, folder))
218
+ return
219
+
220
+ # Fallback to direct in-process search
221
+ from .index import VectorIndex
222
+ idx = VectorIndex(folder, custom_index_dir)
223
+ results = idx.search(query, limit=limit, doc_type=doc_type)
224
+ print(format_search_results(results, folder))
225
+
226
+
227
+ def _run_tags(folder: Path, symbol: str, exact: bool = False, limit: int = 20):
228
+ mgr = TagsManager(folder)
229
+ results = mgr.lookup_symbol(symbol, exact=exact, limit=limit)
230
+ print(format_tag_results(results))