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.
- codebase_navigator/__init__.py +6 -0
- codebase_navigator/ask.py +412 -0
- codebase_navigator/cli.py +230 -0
- codebase_navigator/config.py +102 -0
- codebase_navigator/extractor.py +281 -0
- codebase_navigator/index.py +279 -0
- codebase_navigator/ipc.py +221 -0
- codebase_navigator/tags.py +198 -0
- codebase_navigator/watcher.py +134 -0
- codebase_navigator-0.1.0.dist-info/METADATA +114 -0
- codebase_navigator-0.1.0.dist-info/RECORD +13 -0
- codebase_navigator-0.1.0.dist-info/WHEEL +4 -0
- codebase_navigator-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -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))
|