Navegador

feat: expand to full project knowledge graph — concepts, rules, decisions, wiki, agent hooks

lmata 2026-03-22 21:36 trunk
Commit 4be35958af682d078c7193d8baf8058e8f0f309c0b2c22cce33f11bb650f98c1
--- a/hooks/NAVEGADOR.md
+++ b/hooks/NAVEGADOR.md
@@ -0,0 +1,87 @@
1
+# Navegador — Project Knowledge Graph
2
+
3
+This project uses **navegador** as its system of record for both code structure
4
+and business knowledge. The graph lives at `.navegador/graph.db`.
5
+
6
+## What's in the graph
7
+
8
+- **Code layer** — all functions, classes, files, call graphs, decorators, imports
9
+- **Knowledge layer** — business concepts, rules, architectural decisions, people, domains
10
+- **Wiki** — pages from this repo's GitHub wiki, linked to code and concepts
11
+
12
+## How to query it
13
+
14
+```bash
15
+# Find anything by name (code + knowledge)
16
+navegador search "payment" --all
17
+
18
+# Full picture for any function, class, or concept
19
+navegador explain validate_token
20
+navegador explain Payment --format json
21
+
22
+# Code-specific
23
+navegador function get_user --depth 3
24
+navegador class AuthService
25
+navegador decorated login_required
26
+
27
+# Business knowledge
28
+navegador concept Invoice
29
+navegador domain billing
30
+
31
+# Raw Cypher when you need it
32
+navegador query "MATCH (r:Rule)-[:GOVERNS]->(c:Concept) RETURN r.name, c.name LIMIT 20"
33
+```
34
+
35
+## Keeping the graph up to date
36
+
37
+When you add or modify code, re-ingest the repo:
38
+```bash
39
+navegador ingest . --json
40
+```
41
+
42
+When you establish a new business rule or make an architectural decision:
43
+```bash
44
+navegador add rule "Invoices require approval before payment" \
45
+ --domain billing --severity critical \
46
+ --rationale "Prevents unapproved charges per finance policy"
47
+
48
+navegador add decision "Use JWT for stateless auth" \
49
+ --domain auth --rationale "Horizontal scaling requirement" \
50
+ --alternatives "Session cookies, OAuth opaque tokens"
51
+
52
+navegador annotate validate_token --type Function --rule "JWT tokens must expire"
53
+```
54
+
55
+When a wiki page is added or updated:
56
+```bash
57
+navegador wiki ingest --repo OWNER/REPO --token $GITHUB_TOKEN
58
+```
59
+
60
+## Agent rules
61
+
62
+You (the AI agent) MUST treat navegador as the source of truth for this project.
63
+
64
+1. **Before editing code** — query the graph to understand what you're touching:
65
+ ```bash
66
+ navegador explain <name>
67
+ navegador function <name> --format json
68
+ ```
69
+
70
+2. **After editing code** — re-ingest to keep the graph current:
71
+ ```bash
72
+ navegador ingest . --json
73
+ ```
74
+
75
+3. **When you establish a rule or make a decision** — record it:
76
+ ```bash
77
+ navegador add decision "..." --rationale "..."
78
+ navegador add rule "..." --severity warning --domain ...
79
+ ```
80
+
81
+4. **When you discover a concept** (a named business entity) — add it:
82
+ ```bash
83
+ navegador add concept "..." --desc "..." --domain ...
84
+ ```
85
+
86
+5. **Never assume** what calls what, what a class inherits, or what a decorator
87
+ does. Query first.
--- a/hooks/NAVEGADOR.md
+++ b/hooks/NAVEGADOR.md
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/hooks/NAVEGADOR.md
+++ b/hooks/NAVEGADOR.md
@@ -0,0 +1,87 @@
1 # Navegador — Project Knowledge Graph
2
3 This project uses **navegador** as its system of record for both code structure
4 and business knowledge. The graph lives at `.navegador/graph.db`.
5
6 ## What's in the graph
7
8 - **Code layer** — all functions, classes, files, call graphs, decorators, imports
9 - **Knowledge layer** — business concepts, rules, architectural decisions, people, domains
10 - **Wiki** — pages from this repo's GitHub wiki, linked to code and concepts
11
12 ## How to query it
13
14 ```bash
15 # Find anything by name (code + knowledge)
16 navegador search "payment" --all
17
18 # Full picture for any function, class, or concept
19 navegador explain validate_token
20 navegador explain Payment --format json
21
22 # Code-specific
23 navegador function get_user --depth 3
24 navegador class AuthService
25 navegador decorated login_required
26
27 # Business knowledge
28 navegador concept Invoice
29 navegador domain billing
30
31 # Raw Cypher when you need it
32 navegador query "MATCH (r:Rule)-[:GOVERNS]->(c:Concept) RETURN r.name, c.name LIMIT 20"
33 ```
34
35 ## Keeping the graph up to date
36
37 When you add or modify code, re-ingest the repo:
38 ```bash
39 navegador ingest . --json
40 ```
41
42 When you establish a new business rule or make an architectural decision:
43 ```bash
44 navegador add rule "Invoices require approval before payment" \
45 --domain billing --severity critical \
46 --rationale "Prevents unapproved charges per finance policy"
47
48 navegador add decision "Use JWT for stateless auth" \
49 --domain auth --rationale "Horizontal scaling requirement" \
50 --alternatives "Session cookies, OAuth opaque tokens"
51
52 navegador annotate validate_token --type Function --rule "JWT tokens must expire"
53 ```
54
55 When a wiki page is added or updated:
56 ```bash
57 navegador wiki ingest --repo OWNER/REPO --token $GITHUB_TOKEN
58 ```
59
60 ## Agent rules
61
62 You (the AI agent) MUST treat navegador as the source of truth for this project.
63
64 1. **Before editing code** — query the graph to understand what you're touching:
65 ```bash
66 navegador explain <name>
67 navegador function <name> --format json
68 ```
69
70 2. **After editing code** — re-ingest to keep the graph current:
71 ```bash
72 navegador ingest . --json
73 ```
74
75 3. **When you establish a rule or make a decision** — record it:
76 ```bash
77 navegador add decision "..." --rationale "..."
78 navegador add rule "..." --severity warning --domain ...
79 ```
80
81 4. **When you discover a concept** (a named business entity) — add it:
82 ```bash
83 navegador add concept "..." --desc "..." --domain ...
84 ```
85
86 5. **Never assume** what calls what, what a class inherits, or what a decorator
87 does. Query first.
--- a/hooks/bootstrap.sh
+++ b/hooks/bootstrap.sh
@@ -0,0 +1,116 @@
1
+#!/usr/bin/env bash
2
+# navegador bootstrap — install, initialise, and ingest a project
3
+#
4
+# Usage:
5
+# curl -fsSL https://raw.githubusercontent.com/ConflictHQ/navegador/main/hooks/bootstrap.sh | bash
6
+# # or locally:
7
+# bash hooks/bootstrap.sh [--repo owner/repo] [--wiki] [--agent claude|gemini|openai]
8
+
9
+set -euo pipefail
10
+
11
+NAV_DB="${NAVEGADOR_DB:-.navegador/graph.db}"
12
+REPO_PATH="${REPO_PATH:-.}"
13
+GITHUB_REPO="${GITHUB_REPO:-}"
14
+INSTALL_AGENT="${INSTALL_AGENT:-}"
15
+INGEST_WIKI=false
16
+
17
+# ── Parse args ────────────────────────────────────────────────────────────────
18
+while [[ $# -gt 0 ]]; do
19
+ case $1 in
20
+ --repo) GITHUB_REPO="$2"; shift 2 ;;
21
+ --wiki) INGEST_WIKI=true; shift ;;
22
+ --agent) INSTALL_AGENT="$2"; shift 2 ;;
23
+ --db) NAV_DB="$2"; shift 2 ;;
24
+ *) echo "Unknown option: $1"; exit 1 ;;
25
+ esac
26
+done
27
+
28
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
29
+echo " Navegador bootstrap"
30
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
31
+
32
+# ── Install ───────────────────────────────────────────────────────────────────
33
+if ! command -v navegador &>/dev/null; then
34
+ echo "→ Installing navegador..."
35
+ pip install "navegador[sqlite]" --quiet
36
+else
37
+ echo "→ navegador $(navegador --version 2>&1 | head -1) already installed"
38
+fi
39
+
40
+# ── Initialise DB directory ───────────────────────────────────────────────────
41
+mkdir -p "$(dirname "$NAV_DB")"
42
+echo "→ Graph DB: $NAV_DB"
43
+
44
+# ── Ingest code ───────────────────────────────────────────────────────────────
45
+echo "→ Ingesting code from $REPO_PATH ..."
46
+navegador --db "$NAV_DB" ingest "$REPO_PATH" --json | \
47
+ python3 -c "import json,sys; d=json.load(sys.stdin); print(f\" files={d['files']} functions={d['functions']} classes={d['classes']} edges={d['edges']}\")"
48
+
49
+# ── Ingest wiki ───────────────────────────────────────────────────────────────
50
+if [[ "$INGEST_WIKI" == "true" && -n "$GITHUB_REPO" ]]; then
51
+ echo "→ Ingesting GitHub wiki for $GITHUB_REPO ..."
52
+ navegador --db "$NAV_DB" wiki ingest --repo "$GITHUB_REPO" ${GITHUB_TOKEN:+--token "$GITHUB_TOKEN"} || true
53
+fi
54
+
55
+# ── Install agent hook ────────────────────────────────────────────────────────
56
+HOOK_SRC_BASE="https://raw.githubusercontent.com/ConflictHQ/navegador/main/hooks"
57
+
58
+install_claude_hook() {
59
+ mkdir -p .claude/hooks
60
+ curl -fsSL "$HOOK_SRC_BASE/claude-hook.py" -o .claude/hooks/navegador.py
61
+ chmod +x .claude/hooks/navegador.py
62
+
63
+ SETTINGS=".claude/settings.json"
64
+ if [[ ! -f "$SETTINGS" ]]; then
65
+ cat > "$SETTINGS" <<'JSON'
66
+{
67
+ "hooks": {
68
+ "PostToolUse": [
69
+ {
70
+ "matcher": "Edit|Write",
71
+ "hooks": [{ "type": "command", "command": "python3 .claude/hooks/navegador.py" }]
72
+ }
73
+ ]
74
+ }
75
+}
76
+JSON
77
+ echo " Created $SETTINGS"
78
+ else
79
+ echo " $SETTINGS exists — add the hook manually (see .claude/hooks/navegador.py)"
80
+ fi
81
+}
82
+
83
+install_gemini_hook() {
84
+ mkdir -p .gemini/hooks
85
+ curl -fsSL "$HOOK_SRC_BASE/gemini-hook.py" -o .gemini/hooks/navegador.py
86
+ chmod +x .gemini/hooks/navegador.py
87
+ echo " Add to GEMINI.md: python3 .gemini/hooks/navegador.py <tool> <file>"
88
+}
89
+
90
+install_openai_hook() {
91
+ curl -fsSL "$HOOK_SRC_BASE/openai-hook.py" -o navegador-openai.py
92
+ chmod +x navegador-openai.py
93
+ echo " Register tool schemas from hooks/openai-tools.json with your assistant"
94
+}
95
+
96
+case "$INSTALL_AGENT" in
97
+ claude) echo "→ Installing Claude Code hook..."; install_claude_hook ;;
98
+ gemini) echo "→ Installing Gemini CLI hook..."; install_gemini_hook ;;
99
+ openai) echo "→ Installing OpenAI hook..."; install_openai_hook ;;
100
+ "") ;;
101
+ *) echo "Unknown agent: $INSTALL_AGENT (use claude|gemini|openai)" ;;
102
+esac
103
+
104
+# ── Stats ─────────────────────────────────────────────────────────────────────
105
+echo ""
106
+echo "→ Graph stats:"
107
+navegador --db "$NAV_DB" stats 2>/dev/null || true
108
+
109
+echo ""
110
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
111
+echo " Done. Quick start:"
112
+echo " navegador search \"your query\""
113
+echo " navegador explain MyClass"
114
+echo " navegador stats"
115
+echo " navegador add concept \"Payment\" --domain billing"
116
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
--- a/hooks/bootstrap.sh
+++ b/hooks/bootstrap.sh
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/hooks/bootstrap.sh
+++ b/hooks/bootstrap.sh
@@ -0,0 +1,116 @@
1 #!/usr/bin/env bash
2 # navegador bootstrap — install, initialise, and ingest a project
3 #
4 # Usage:
5 # curl -fsSL https://raw.githubusercontent.com/ConflictHQ/navegador/main/hooks/bootstrap.sh | bash
6 # # or locally:
7 # bash hooks/bootstrap.sh [--repo owner/repo] [--wiki] [--agent claude|gemini|openai]
8
9 set -euo pipefail
10
11 NAV_DB="${NAVEGADOR_DB:-.navegador/graph.db}"
12 REPO_PATH="${REPO_PATH:-.}"
13 GITHUB_REPO="${GITHUB_REPO:-}"
14 INSTALL_AGENT="${INSTALL_AGENT:-}"
15 INGEST_WIKI=false
16
17 # ── Parse args ────────────────────────────────────────────────────────────────
18 while [[ $# -gt 0 ]]; do
19 case $1 in
20 --repo) GITHUB_REPO="$2"; shift 2 ;;
21 --wiki) INGEST_WIKI=true; shift ;;
22 --agent) INSTALL_AGENT="$2"; shift 2 ;;
23 --db) NAV_DB="$2"; shift 2 ;;
24 *) echo "Unknown option: $1"; exit 1 ;;
25 esac
26 done
27
28 echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
29 echo " Navegador bootstrap"
30 echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
31
32 # ── Install ───────────────────────────────────────────────────────────────────
33 if ! command -v navegador &>/dev/null; then
34 echo "→ Installing navegador..."
35 pip install "navegador[sqlite]" --quiet
36 else
37 echo "→ navegador $(navegador --version 2>&1 | head -1) already installed"
38 fi
39
40 # ── Initialise DB directory ───────────────────────────────────────────────────
41 mkdir -p "$(dirname "$NAV_DB")"
42 echo "→ Graph DB: $NAV_DB"
43
44 # ── Ingest code ───────────────────────────────────────────────────────────────
45 echo "→ Ingesting code from $REPO_PATH ..."
46 navegador --db "$NAV_DB" ingest "$REPO_PATH" --json | \
47 python3 -c "import json,sys; d=json.load(sys.stdin); print(f\" files={d['files']} functions={d['functions']} classes={d['classes']} edges={d['edges']}\")"
48
49 # ── Ingest wiki ───────────────────────────────────────────────────────────────
50 if [[ "$INGEST_WIKI" == "true" && -n "$GITHUB_REPO" ]]; then
51 echo "→ Ingesting GitHub wiki for $GITHUB_REPO ..."
52 navegador --db "$NAV_DB" wiki ingest --repo "$GITHUB_REPO" ${GITHUB_TOKEN:+--token "$GITHUB_TOKEN"} || true
53 fi
54
55 # ── Install agent hook ────────────────────────────────────────────────────────
56 HOOK_SRC_BASE="https://raw.githubusercontent.com/ConflictHQ/navegador/main/hooks"
57
58 install_claude_hook() {
59 mkdir -p .claude/hooks
60 curl -fsSL "$HOOK_SRC_BASE/claude-hook.py" -o .claude/hooks/navegador.py
61 chmod +x .claude/hooks/navegador.py
62
63 SETTINGS=".claude/settings.json"
64 if [[ ! -f "$SETTINGS" ]]; then
65 cat > "$SETTINGS" <<'JSON'
66 {
67 "hooks": {
68 "PostToolUse": [
69 {
70 "matcher": "Edit|Write",
71 "hooks": [{ "type": "command", "command": "python3 .claude/hooks/navegador.py" }]
72 }
73 ]
74 }
75 }
76 JSON
77 echo " Created $SETTINGS"
78 else
79 echo " $SETTINGS exists — add the hook manually (see .claude/hooks/navegador.py)"
80 fi
81 }
82
83 install_gemini_hook() {
84 mkdir -p .gemini/hooks
85 curl -fsSL "$HOOK_SRC_BASE/gemini-hook.py" -o .gemini/hooks/navegador.py
86 chmod +x .gemini/hooks/navegador.py
87 echo " Add to GEMINI.md: python3 .gemini/hooks/navegador.py <tool> <file>"
88 }
89
90 install_openai_hook() {
91 curl -fsSL "$HOOK_SRC_BASE/openai-hook.py" -o navegador-openai.py
92 chmod +x navegador-openai.py
93 echo " Register tool schemas from hooks/openai-tools.json with your assistant"
94 }
95
96 case "$INSTALL_AGENT" in
97 claude) echo "→ Installing Claude Code hook..."; install_claude_hook ;;
98 gemini) echo "→ Installing Gemini CLI hook..."; install_gemini_hook ;;
99 openai) echo "→ Installing OpenAI hook..."; install_openai_hook ;;
100 "") ;;
101 *) echo "Unknown agent: $INSTALL_AGENT (use claude|gemini|openai)" ;;
102 esac
103
104 # ── Stats ─────────────────────────────────────────────────────────────────────
105 echo ""
106 echo "→ Graph stats:"
107 navegador --db "$NAV_DB" stats 2>/dev/null || true
108
109 echo ""
110 echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
111 echo " Done. Quick start:"
112 echo " navegador search \"your query\""
113 echo " navegador explain MyClass"
114 echo " navegador stats"
115 echo " navegador add concept \"Payment\" --domain billing"
116 echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
--- a/hooks/claude-hook.py
+++ b/hooks/claude-hook.py
@@ -0,0 +1,98 @@
1
+#!/usr/bin/env python3
2
+"""
3
+Navegador hook for Claude Code.
4
+
5
+Fires on PostToolUse to keep the knowledge graph in sync as Claude works:
6
+ - Tracks files Claude edits or creates
7
+ - Re-ingests changed files into the graph
8
+ - Logs decisions and notes Claude produces (when it writes to DECISIONS.md)
9
+
10
+Install:
11
+ Copy to your project root as .claude/hooks/navegador.py
12
+ Add to .claude/settings.json:
13
+
14
+ {
15
+ "hooks": {
16
+ "PostToolUse": [
17
+ {
18
+ "matcher": "Edit|Write|Bash",
19
+ "hooks": [{ "type": "command", "command": "python3 .claude/hooks/navegador.py" }]
20
+ }
21
+ ]
22
+ }
23
+ }
24
+"""
25
+
26
+import json
27
+import os
28
+import subprocess
29
+import sys
30
+
31
+NAV_DB = os.environ.get("NAVEGADOR_DB", ".navegador/graph.db")
32
+NAV_CMD = os.environ.get("NAVEGADOR_CMD", "navegador")
33
+
34
+# File extensions that navegador can ingest
35
+INGESTABLE = {".py", ".ts", ".tsx", ".js", ".jsx"}
36
+
37
+
38
+def run_nav(*args) -> str:
39
+ result = subprocess.run(
40
+ [NAV_CMD, "--db", NAV_DB, *args],
41
+ capture_output=True, text=True,
42
+ )
43
+ return result.stdout.strip()
44
+
45
+
46
+def main():
47
+ try:
48
+ payload = json.loads(sys.stdin.read())
49
+ except Exception:
50
+ sys.exit(0)
51
+
52
+ tool = payload.get("tool_name", "")
53
+ inp = payload.get("tool_input", {})
54
+
55
+ # Re-ingest any source file Claude edited
56
+ if tool in ("Edit", "Write"):
57
+ file_path = inp.get("file_path", "")
58
+ ext = os.path.splitext(file_path)[1]
59
+ if ext in INGESTABLE and os.path.exists(file_path):
60
+ # Ingest just the repo containing this file (fast on small repos)
61
+ repo_root = _find_repo_root(file_path)
62
+ if repo_root:
63
+ run_nav("ingest", repo_root)
64
+
65
+ # Watch for DECISIONS.md updates — extract new entries into the graph
66
+ if tool in ("Edit", "Write"):
67
+ if inp.get("file_path", "").endswith("DECISIONS.md"):
68
+ _sync_decisions()
69
+
70
+
71
+def _find_repo_root(path: str) -> str | None:
72
+ """Walk up to find the git root."""
73
+ d = os.path.dirname(os.path.abspath(path))
74
+ while d != os.path.dirname(d):
75
+ if os.path.exists(os.path.join(d, ".git")):
76
+ return d
77
+ d = os.path.dirname(d)
78
+ return None
79
+
80
+
81
+def _sync_decisions():
82
+ """Parse DECISIONS.md and upsert Decision nodes."""
83
+ if not os.path.exists("DECISIONS.md"):
84
+ return
85
+ content = open("DECISIONS.md").read()
86
+ # Simple heuristic: ## headings are decision names
87
+ import re
88
+ for match in re.finditer(r"^##\s+(.+)", content, re.MULTILINE):
89
+ name = match.group(1).strip()
90
+ # Find the body until the next heading
91
+ start = match.end()
92
+ next_h = re.search(r"^##", content[start:], re.MULTILINE)
93
+ body = content[start: start + next_h.start() if next_h else len(content)].strip()
94
+ run_nav("add", "decision", name, "--desc", body[:500])
95
+
96
+
97
+if __name__ == "__main__":
98
+ main()
--- a/hooks/claude-hook.py
+++ b/hooks/claude-hook.py
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/hooks/claude-hook.py
+++ b/hooks/claude-hook.py
@@ -0,0 +1,98 @@
1 #!/usr/bin/env python3
2 """
3 Navegador hook for Claude Code.
4
5 Fires on PostToolUse to keep the knowledge graph in sync as Claude works:
6 - Tracks files Claude edits or creates
7 - Re-ingests changed files into the graph
8 - Logs decisions and notes Claude produces (when it writes to DECISIONS.md)
9
10 Install:
11 Copy to your project root as .claude/hooks/navegador.py
12 Add to .claude/settings.json:
13
14 {
15 "hooks": {
16 "PostToolUse": [
17 {
18 "matcher": "Edit|Write|Bash",
19 "hooks": [{ "type": "command", "command": "python3 .claude/hooks/navegador.py" }]
20 }
21 ]
22 }
23 }
24 """
25
26 import json
27 import os
28 import subprocess
29 import sys
30
31 NAV_DB = os.environ.get("NAVEGADOR_DB", ".navegador/graph.db")
32 NAV_CMD = os.environ.get("NAVEGADOR_CMD", "navegador")
33
34 # File extensions that navegador can ingest
35 INGESTABLE = {".py", ".ts", ".tsx", ".js", ".jsx"}
36
37
38 def run_nav(*args) -> str:
39 result = subprocess.run(
40 [NAV_CMD, "--db", NAV_DB, *args],
41 capture_output=True, text=True,
42 )
43 return result.stdout.strip()
44
45
46 def main():
47 try:
48 payload = json.loads(sys.stdin.read())
49 except Exception:
50 sys.exit(0)
51
52 tool = payload.get("tool_name", "")
53 inp = payload.get("tool_input", {})
54
55 # Re-ingest any source file Claude edited
56 if tool in ("Edit", "Write"):
57 file_path = inp.get("file_path", "")
58 ext = os.path.splitext(file_path)[1]
59 if ext in INGESTABLE and os.path.exists(file_path):
60 # Ingest just the repo containing this file (fast on small repos)
61 repo_root = _find_repo_root(file_path)
62 if repo_root:
63 run_nav("ingest", repo_root)
64
65 # Watch for DECISIONS.md updates — extract new entries into the graph
66 if tool in ("Edit", "Write"):
67 if inp.get("file_path", "").endswith("DECISIONS.md"):
68 _sync_decisions()
69
70
71 def _find_repo_root(path: str) -> str | None:
72 """Walk up to find the git root."""
73 d = os.path.dirname(os.path.abspath(path))
74 while d != os.path.dirname(d):
75 if os.path.exists(os.path.join(d, ".git")):
76 return d
77 d = os.path.dirname(d)
78 return None
79
80
81 def _sync_decisions():
82 """Parse DECISIONS.md and upsert Decision nodes."""
83 if not os.path.exists("DECISIONS.md"):
84 return
85 content = open("DECISIONS.md").read()
86 # Simple heuristic: ## headings are decision names
87 import re
88 for match in re.finditer(r"^##\s+(.+)", content, re.MULTILINE):
89 name = match.group(1).strip()
90 # Find the body until the next heading
91 start = match.end()
92 next_h = re.search(r"^##", content[start:], re.MULTILINE)
93 body = content[start: start + next_h.start() if next_h else len(content)].strip()
94 run_nav("add", "decision", name, "--desc", body[:500])
95
96
97 if __name__ == "__main__":
98 main()
--- a/hooks/gemini-hook.py
+++ b/hooks/gemini-hook.py
@@ -0,0 +1,60 @@
1
+#!/usr/bin/env python3
2
+"""
3
+Navegador hook for Gemini CLI (gemini-cli).
4
+
5
+Gemini CLI supports tool hooks via GEMINI.md + shell scripts executed
6
+after tool calls. This script is designed to be invoked as a post-tool hook.
7
+
8
+Install:
9
+ Copy to your project root as .gemini/hooks/navegador.py
10
+ Reference in GEMINI.md:
11
+
12
+ ## Hooks
13
+ After editing or creating any source file, run:
14
+ python3 .gemini/hooks/navegador.py <tool_name> <file_path>
15
+
16
+ This keeps the navegador knowledge graph in sync with your changes.
17
+
18
+Usage (called by gemini-cli hook runner):
19
+ python3 navegador.py edit src/auth.py
20
+ python3 navegador.py write src/new_module.py
21
+"""
22
+
23
+import os
24
+import subprocess
25
+import sys
26
+
27
+NAV_DB = os.environ.get("NAVEGADOR_DB", ".navegador/graph.db")
28
+NAV_CMD = os.environ.get("NAVEGADOR_CMD", "navegador")
29
+INGESTABLE = {".py", ".ts", ".tsx", ".js", ".jsx"}
30
+
31
+
32
+def run_nav(*args):
33
+ subprocess.run([NAV_CMD, "--db", NAV_DB, *args], capture_output=True)
34
+
35
+
36
+def main():
37
+ args = sys.argv[1:]
38
+ if len(args) < 2:
39
+ sys.exit(0)
40
+
41
+ _tool, file_path = args[0], args[1]
42
+ ext = os.path.splitext(file_path)[1]
43
+
44
+ if ext in INGESTABLE and os.path.exists(file_path):
45
+ repo_root = _find_repo_root(file_path)
46
+ if repo_root:
47
+ run_nav("ingest", repo_root)
48
+
49
+
50
+def _find_repo_root(path: str) -> str | None:
51
+ d = os.path.dirname(os.path.abspath(path))
52
+ while d != os.path.dirname(d):
53
+ if os.path.exists(os.path.join(d, ".git")):
54
+ return d
55
+ d = os.path.dirname(d)
56
+ return None
57
+
58
+
59
+if __name__ == "__main__":
60
+ main()
--- a/hooks/gemini-hook.py
+++ b/hooks/gemini-hook.py
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/hooks/gemini-hook.py
+++ b/hooks/gemini-hook.py
@@ -0,0 +1,60 @@
1 #!/usr/bin/env python3
2 """
3 Navegador hook for Gemini CLI (gemini-cli).
4
5 Gemini CLI supports tool hooks via GEMINI.md + shell scripts executed
6 after tool calls. This script is designed to be invoked as a post-tool hook.
7
8 Install:
9 Copy to your project root as .gemini/hooks/navegador.py
10 Reference in GEMINI.md:
11
12 ## Hooks
13 After editing or creating any source file, run:
14 python3 .gemini/hooks/navegador.py <tool_name> <file_path>
15
16 This keeps the navegador knowledge graph in sync with your changes.
17
18 Usage (called by gemini-cli hook runner):
19 python3 navegador.py edit src/auth.py
20 python3 navegador.py write src/new_module.py
21 """
22
23 import os
24 import subprocess
25 import sys
26
27 NAV_DB = os.environ.get("NAVEGADOR_DB", ".navegador/graph.db")
28 NAV_CMD = os.environ.get("NAVEGADOR_CMD", "navegador")
29 INGESTABLE = {".py", ".ts", ".tsx", ".js", ".jsx"}
30
31
32 def run_nav(*args):
33 subprocess.run([NAV_CMD, "--db", NAV_DB, *args], capture_output=True)
34
35
36 def main():
37 args = sys.argv[1:]
38 if len(args) < 2:
39 sys.exit(0)
40
41 _tool, file_path = args[0], args[1]
42 ext = os.path.splitext(file_path)[1]
43
44 if ext in INGESTABLE and os.path.exists(file_path):
45 repo_root = _find_repo_root(file_path)
46 if repo_root:
47 run_nav("ingest", repo_root)
48
49
50 def _find_repo_root(path: str) -> str | None:
51 d = os.path.dirname(os.path.abspath(path))
52 while d != os.path.dirname(d):
53 if os.path.exists(os.path.join(d, ".git")):
54 return d
55 d = os.path.dirname(d)
56 return None
57
58
59 if __name__ == "__main__":
60 main()
--- a/hooks/openai-hook.py
+++ b/hooks/openai-hook.py
@@ -0,0 +1,73 @@
1
+#!/usr/bin/env python3
2
+"""
3
+Navegador hook for OpenAI Codex / ChatGPT with function calling.
4
+
5
+OpenAI agents can call navegador directly as a function tool. Register the
6
+functions below in your assistant's tool list, then call this script to
7
+dispatch them.
8
+
9
+Install:
10
+ Use the function schemas in openai-tools.json alongside this dispatcher.
11
+ Your agent calls: python3 navegador-openai.py <json_args>
12
+
13
+Example tool call your agent would make:
14
+ {"name": "nav_search", "arguments": {"query": "authentication", "all": true}}
15
+
16
+This script reads JSON from stdin or argv[1] and dispatches to navegador CLI.
17
+"""
18
+
19
+import json
20
+import os
21
+import subprocess
22
+import sys
23
+
24
+NAV_DB = os.environ.get("NAVEGADOR_DB", ".navegador/graph.db")
25
+NAV_CMD = os.environ.get("NAVEGADOR_CMD", "navegador")
26
+
27
+DISPATCH = {
28
+ "nav_ingest": lambda a: [NAV_CMD, "ingest", a["path"], "--json"],
29
+ "nav_context": lambda a: [NAV_CMD, "context", a["file_path"], "--format", "json"],
30
+ "nav_function": lambda a: [NAV_CMD, "function", a["name"],
31
+ "--file", a.get("file_path", ""), "--format", "json"],
32
+ "nav_class": lambda a: [NAV_CMD, "class", a["name"],
33
+ "--file", a.get("file_path", ""), "--format", "json"],
34
+ "nav_explain": lambda a: [NAV_CMD, "explain", a["name"], "--format", "json"],
35
+ "nav_search": lambda a: [NAV_CMD, "search", a["query"],
36
+ "--format", "json",
37
+ *( ["--all"] if a.get("all") else [] ),
38
+ *( ["--docs"] if a.get("by_docstring") else [] )],
39
+ "nav_concept": lambda a: [NAV_CMD, "concept", a["name"], "--format", "json"],
40
+ "nav_domain": lambda a: [NAV_CMD, "domain", a["name"], "--format", "json"],
41
+ "nav_stats": lambda a: [NAV_CMD, "stats", "--json"],
42
+ "nav_query": lambda a: [NAV_CMD, "query", a["cypher"]],
43
+ "nav_decorated": lambda a: [NAV_CMD, "decorated", a["decorator"], "--format", "json"],
44
+}
45
+
46
+
47
+def main():
48
+ raw = sys.argv[1] if len(sys.argv) > 1 else sys.stdin.read()
49
+ try:
50
+ call = json.loads(raw)
51
+ except json.JSONDecodeError as e:
52
+ print(json.dumps({"error": str(e)}))
53
+ sys.exit(1)
54
+
55
+ name = call.get("name") or call.get("function", {}).get("name", "")
56
+ arguments = call.get("arguments") or call.get("function", {}).get("arguments", {})
57
+ if isinstance(arguments, str):
58
+ arguments = json.loads(arguments)
59
+
60
+ if name not in DISPATCH:
61
+ print(json.dumps({"error": f"Unknown tool: {name}"}))
62
+ sys.exit(1)
63
+
64
+ cmd = DISPATCH[name](arguments)
65
+ result = subprocess.run(
66
+ ["--db", NAV_DB] and ([cmd[0]] + ["--db", NAV_DB] + cmd[1:]),
67
+ capture_output=True, text=True,
68
+ )
69
+ sys.stdout.write(result.stdout or result.stderr)
70
+
71
+
72
+if __name__ == "__main__":
73
+ main()
--- a/hooks/openai-hook.py
+++ b/hooks/openai-hook.py
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/hooks/openai-hook.py
+++ b/hooks/openai-hook.py
@@ -0,0 +1,73 @@
1 #!/usr/bin/env python3
2 """
3 Navegador hook for OpenAI Codex / ChatGPT with function calling.
4
5 OpenAI agents can call navegador directly as a function tool. Register the
6 functions below in your assistant's tool list, then call this script to
7 dispatch them.
8
9 Install:
10 Use the function schemas in openai-tools.json alongside this dispatcher.
11 Your agent calls: python3 navegador-openai.py <json_args>
12
13 Example tool call your agent would make:
14 {"name": "nav_search", "arguments": {"query": "authentication", "all": true}}
15
16 This script reads JSON from stdin or argv[1] and dispatches to navegador CLI.
17 """
18
19 import json
20 import os
21 import subprocess
22 import sys
23
24 NAV_DB = os.environ.get("NAVEGADOR_DB", ".navegador/graph.db")
25 NAV_CMD = os.environ.get("NAVEGADOR_CMD", "navegador")
26
27 DISPATCH = {
28 "nav_ingest": lambda a: [NAV_CMD, "ingest", a["path"], "--json"],
29 "nav_context": lambda a: [NAV_CMD, "context", a["file_path"], "--format", "json"],
30 "nav_function": lambda a: [NAV_CMD, "function", a["name"],
31 "--file", a.get("file_path", ""), "--format", "json"],
32 "nav_class": lambda a: [NAV_CMD, "class", a["name"],
33 "--file", a.get("file_path", ""), "--format", "json"],
34 "nav_explain": lambda a: [NAV_CMD, "explain", a["name"], "--format", "json"],
35 "nav_search": lambda a: [NAV_CMD, "search", a["query"],
36 "--format", "json",
37 *( ["--all"] if a.get("all") else [] ),
38 *( ["--docs"] if a.get("by_docstring") else [] )],
39 "nav_concept": lambda a: [NAV_CMD, "concept", a["name"], "--format", "json"],
40 "nav_domain": lambda a: [NAV_CMD, "domain", a["name"], "--format", "json"],
41 "nav_stats": lambda a: [NAV_CMD, "stats", "--json"],
42 "nav_query": lambda a: [NAV_CMD, "query", a["cypher"]],
43 "nav_decorated": lambda a: [NAV_CMD, "decorated", a["decorator"], "--format", "json"],
44 }
45
46
47 def main():
48 raw = sys.argv[1] if len(sys.argv) > 1 else sys.stdin.read()
49 try:
50 call = json.loads(raw)
51 except json.JSONDecodeError as e:
52 print(json.dumps({"error": str(e)}))
53 sys.exit(1)
54
55 name = call.get("name") or call.get("function", {}).get("name", "")
56 arguments = call.get("arguments") or call.get("function", {}).get("arguments", {})
57 if isinstance(arguments, str):
58 arguments = json.loads(arguments)
59
60 if name not in DISPATCH:
61 print(json.dumps({"error": f"Unknown tool: {name}"}))
62 sys.exit(1)
63
64 cmd = DISPATCH[name](arguments)
65 result = subprocess.run(
66 ["--db", NAV_DB] and ([cmd[0]] + ["--db", NAV_DB] + cmd[1:]),
67 capture_output=True, text=True,
68 )
69 sys.stdout.write(result.stdout or result.stderr)
70
71
72 if __name__ == "__main__":
73 main()
--- a/hooks/openai-tools.json
+++ b/hooks/openai-tools.json
@@ -0,0 +1,159 @@
1
+[
2
+ {
3
+ "type": "function",
4
+ "function": {
5
+ "name": "nav_search",
6
+ "description": "Search the project knowledge graph for symbols, concepts, rules, and wiki pages. Set all=true to include the knowledge layer.",
7
+ "parameters": {
8
+ "type": "object",
9
+ "properties": {
10
+ "query": { "type": "string", "description": "Search term" },
11
+ "all": { "type": "boolean", "description": "Include concepts, rules, decisions, wiki (default: false)" },
12
+ "by_docstring": { "type": "boolean", "description": "Search docstring content instead of names" }
13
+ },
14
+ "required": ["query"]
15
+ }
16
+ }
17
+ },
18
+ {
19
+ "type": "function",
20
+ "function": {
21
+ "name": "nav_explain",
22
+ "description": "Full picture for any named node — all inbound and outbound relationships across code and knowledge layers.",
23
+ "parameters": {
24
+ "type": "object",
25
+ "properties": {
26
+ "name": { "type": "string" },
27
+ "file_path": { "type": "string", "description": "Optional — narrow to a specific file" }
28
+ },
29
+ "required": ["name"]
30
+ }
31
+ }
32
+ },
33
+ {
34
+ "type": "function",
35
+ "function": {
36
+ "name": "nav_function",
37
+ "description": "Load context for a function — callers, callees, and decorators.",
38
+ "parameters": {
39
+ "type": "object",
40
+ "properties": {
41
+ "name": { "type": "string" },
42
+ "file_path": { "type": "string" },
43
+ "depth": { "type": "integer", "default": 2 }
44
+ },
45
+ "required": ["name"]
46
+ }
47
+ }
48
+ },
49
+ {
50
+ "type": "function",
51
+ "function": {
52
+ "name": "nav_class",
53
+ "description": "Load context for a class — methods, inheritance chain, and references.",
54
+ "parameters": {
55
+ "type": "object",
56
+ "properties": {
57
+ "name": { "type": "string" },
58
+ "file_path": { "type": "string" }
59
+ },
60
+ "required": ["name"]
61
+ }
62
+ }
63
+ },
64
+ {
65
+ "type": "function",
66
+ "function": {
67
+ "name": "nav_context",
68
+ "description": "All symbols in a file — functions, classes, imports — with line numbers and docstrings.",
69
+ "parameters": {
70
+ "type": "object",
71
+ "properties": {
72
+ "file_path": { "type": "string" }
73
+ },
74
+ "required": ["file_path"]
75
+ }
76
+ }
77
+ },
78
+ {
79
+ "type": "function",
80
+ "function": {
81
+ "name": "nav_concept",
82
+ "description": "Load a business concept — governing rules, related concepts, implementing code, and wiki references.",
83
+ "parameters": {
84
+ "type": "object",
85
+ "properties": {
86
+ "name": { "type": "string" }
87
+ },
88
+ "required": ["name"]
89
+ }
90
+ }
91
+ },
92
+ {
93
+ "type": "function",
94
+ "function": {
95
+ "name": "nav_domain",
96
+ "description": "Show everything in a business domain — code and knowledge nodes.",
97
+ "parameters": {
98
+ "type": "object",
99
+ "properties": {
100
+ "name": { "type": "string" }
101
+ },
102
+ "required": ["name"]
103
+ }
104
+ }
105
+ },
106
+ {
107
+ "type": "function",
108
+ "function": {
109
+ "name": "nav_decorated",
110
+ "description": "Find all functions/methods carrying a specific decorator (e.g. login_required, cached).",
111
+ "parameters": {
112
+ "type": "object",
113
+ "properties": {
114
+ "decorator": { "type": "string" }
115
+ },
116
+ "required": ["decorator"]
117
+ }
118
+ }
119
+ },
120
+ {
121
+ "type": "function",
122
+ "function": {
123
+ "name": "nav_query",
124
+ "description": "Execute a raw Cypher query against the knowledge graph. Returns JSON.",
125
+ "parameters": {
126
+ "type": "object",
127
+ "properties": {
128
+ "cypher": { "type": "string" }
129
+ },
130
+ "required": ["cypher"]
131
+ }
132
+ }
133
+ },
134
+ {
135
+ "type": "function",
136
+ "function": {
137
+ "name": "nav_stats",
138
+ "description": "Node and edge counts broken down by type — shows what's in the graph.",
139
+ "parameters": {
140
+ "type": "object",
141
+ "properties": {}
142
+ }
143
+ }
144
+ },
145
+ {
146
+ "type": "function",
147
+ "function": {
148
+ "name": "nav_ingest",
149
+ "description": "Ingest a code repository into the knowledge graph (AST parsing + call graph).",
150
+ "parameters": {
151
+ "type": "object",
152
+ "properties": {
153
+ "path": { "type": "string", "description": "Absolute path to the repository root" }
154
+ },
155
+ "required": ["path"]
156
+ }
157
+ }
158
+ }
159
+]
--- a/hooks/openai-tools.json
+++ b/hooks/openai-tools.json
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/hooks/openai-tools.json
+++ b/hooks/openai-tools.json
@@ -0,0 +1,159 @@
1 [
2 {
3 "type": "function",
4 "function": {
5 "name": "nav_search",
6 "description": "Search the project knowledge graph for symbols, concepts, rules, and wiki pages. Set all=true to include the knowledge layer.",
7 "parameters": {
8 "type": "object",
9 "properties": {
10 "query": { "type": "string", "description": "Search term" },
11 "all": { "type": "boolean", "description": "Include concepts, rules, decisions, wiki (default: false)" },
12 "by_docstring": { "type": "boolean", "description": "Search docstring content instead of names" }
13 },
14 "required": ["query"]
15 }
16 }
17 },
18 {
19 "type": "function",
20 "function": {
21 "name": "nav_explain",
22 "description": "Full picture for any named node — all inbound and outbound relationships across code and knowledge layers.",
23 "parameters": {
24 "type": "object",
25 "properties": {
26 "name": { "type": "string" },
27 "file_path": { "type": "string", "description": "Optional — narrow to a specific file" }
28 },
29 "required": ["name"]
30 }
31 }
32 },
33 {
34 "type": "function",
35 "function": {
36 "name": "nav_function",
37 "description": "Load context for a function — callers, callees, and decorators.",
38 "parameters": {
39 "type": "object",
40 "properties": {
41 "name": { "type": "string" },
42 "file_path": { "type": "string" },
43 "depth": { "type": "integer", "default": 2 }
44 },
45 "required": ["name"]
46 }
47 }
48 },
49 {
50 "type": "function",
51 "function": {
52 "name": "nav_class",
53 "description": "Load context for a class — methods, inheritance chain, and references.",
54 "parameters": {
55 "type": "object",
56 "properties": {
57 "name": { "type": "string" },
58 "file_path": { "type": "string" }
59 },
60 "required": ["name"]
61 }
62 }
63 },
64 {
65 "type": "function",
66 "function": {
67 "name": "nav_context",
68 "description": "All symbols in a file — functions, classes, imports — with line numbers and docstrings.",
69 "parameters": {
70 "type": "object",
71 "properties": {
72 "file_path": { "type": "string" }
73 },
74 "required": ["file_path"]
75 }
76 }
77 },
78 {
79 "type": "function",
80 "function": {
81 "name": "nav_concept",
82 "description": "Load a business concept — governing rules, related concepts, implementing code, and wiki references.",
83 "parameters": {
84 "type": "object",
85 "properties": {
86 "name": { "type": "string" }
87 },
88 "required": ["name"]
89 }
90 }
91 },
92 {
93 "type": "function",
94 "function": {
95 "name": "nav_domain",
96 "description": "Show everything in a business domain — code and knowledge nodes.",
97 "parameters": {
98 "type": "object",
99 "properties": {
100 "name": { "type": "string" }
101 },
102 "required": ["name"]
103 }
104 }
105 },
106 {
107 "type": "function",
108 "function": {
109 "name": "nav_decorated",
110 "description": "Find all functions/methods carrying a specific decorator (e.g. login_required, cached).",
111 "parameters": {
112 "type": "object",
113 "properties": {
114 "decorator": { "type": "string" }
115 },
116 "required": ["decorator"]
117 }
118 }
119 },
120 {
121 "type": "function",
122 "function": {
123 "name": "nav_query",
124 "description": "Execute a raw Cypher query against the knowledge graph. Returns JSON.",
125 "parameters": {
126 "type": "object",
127 "properties": {
128 "cypher": { "type": "string" }
129 },
130 "required": ["cypher"]
131 }
132 }
133 },
134 {
135 "type": "function",
136 "function": {
137 "name": "nav_stats",
138 "description": "Node and edge counts broken down by type — shows what's in the graph.",
139 "parameters": {
140 "type": "object",
141 "properties": {}
142 }
143 }
144 },
145 {
146 "type": "function",
147 "function": {
148 "name": "nav_ingest",
149 "description": "Ingest a code repository into the knowledge graph (AST parsing + call graph).",
150 "parameters": {
151 "type": "object",
152 "properties": {
153 "path": { "type": "string", "description": "Absolute path to the repository root" }
154 },
155 "required": ["path"]
156 }
157 }
158 }
159 ]
--- navegador/cli/commands.py
+++ navegador/cli/commands.py
@@ -1,13 +1,15 @@
11
"""
2
-Navegador CLI — ingest repos, load context, serve MCP.
2
+Navegador CLI — the single interface to your project's knowledge graph.
33
4
-All commands support --format json for clean stdout output suitable for
5
-piping to agents or other tools without MCP overhead.
4
+ CODE: ingest, context, function, class, search, query
5
+ KNOWLEDGE: add (concept/rule/decision/person/domain), wiki, annotate, domain
6
+ UNIVERSAL: explain, search (spans both layers), stats
67
"""
78
89
import asyncio
10
+import json
911
import logging
1012
1113
import click
1214
from rich.console import Console
1315
from rich.table import Table
@@ -18,12 +20,11 @@
1820
"--db", default=".navegador/graph.db", show_default=True, help="Graph DB path."
1921
)
2022
FMT_OPTION = click.option(
2123
"--format", "fmt",
2224
type=click.Choice(["markdown", "json"]),
23
- default="markdown",
24
- show_default=True,
25
+ default="markdown", show_default=True,
2526
help="Output format. Use json for agent/pipe consumption.",
2627
)
2728
2829
2930
def _get_store(db: str):
@@ -30,33 +31,38 @@
3031
from navegador.graph import GraphStore
3132
return GraphStore.sqlite(db)
3233
3334
3435
def _emit(text: str, fmt: str) -> None:
35
- """Print text — raw to stdout for json, rich for markdown."""
3636
if fmt == "json":
3737
click.echo(text)
3838
else:
3939
console.print(text)
4040
4141
42
+# ── Root group ────────────────────────────────────────────────────────────────
43
+
4244
@click.group()
4345
@click.version_option(package_name="navegador")
4446
def main():
45
- """Navegador — AST + knowledge graph context engine for AI coding agents."""
47
+ """Navegador — project knowledge graph for AI coding agents.
48
+
49
+ Combines code structure (AST, call graphs) with business knowledge
50
+ (concepts, rules, decisions, wiki) into a single queryable graph.
51
+ """
4652
logging.basicConfig(level=logging.WARNING)
4753
54
+
55
+# ── CODE: ingest ──────────────────────────────────────────────────────────────
4856
4957
@main.command()
5058
@click.argument("repo_path", type=click.Path(exists=True))
5159
@DB_OPTION
5260
@click.option("--clear", is_flag=True, help="Clear existing graph before ingesting.")
5361
@click.option("--json", "as_json", is_flag=True, help="Output stats as JSON.")
5462
def ingest(repo_path: str, db: str, clear: bool, as_json: bool):
55
- """Ingest a repository into the navegador graph."""
56
- import json
57
-
63
+ """Ingest a repository's code into the graph (AST + call graph)."""
5864
from navegador.ingestion import RepoIngester
5965
6066
store = _get_store(db)
6167
ingester = RepoIngester(store)
6268
@@ -64,87 +70,139 @@
6470
stats = ingester.ingest(repo_path, clear=clear)
6571
click.echo(json.dumps(stats, indent=2))
6672
else:
6773
with console.status(f"[bold]Ingesting[/bold] {repo_path}..."):
6874
stats = ingester.ingest(repo_path, clear=clear)
69
- table = Table(title="Ingestion complete", show_header=True)
75
+ table = Table(title="Ingestion complete")
7076
table.add_column("Metric", style="cyan")
7177
table.add_column("Count", justify="right", style="green")
7278
for k, v in stats.items():
7379
table.add_row(k.capitalize(), str(v))
7480
console.print(table)
7581
82
+
83
+# ── CODE: context / function / class ─────────────────────────────────────────
7684
7785
@main.command()
7886
@click.argument("file_path")
7987
@DB_OPTION
8088
@FMT_OPTION
8189
def context(file_path: str, db: str, fmt: str):
8290
"""Load context for a file — all symbols and their relationships."""
8391
from navegador.context import ContextLoader
84
-
85
- loader = ContextLoader(_get_store(db))
86
- bundle = loader.load_file(file_path)
92
+ bundle = ContextLoader(_get_store(db)).load_file(file_path)
8793
_emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt)
8894
8995
9096
@main.command()
9197
@click.argument("name")
92
-@click.option("--file", "file_path", default="", help="File path to narrow the search.")
93
-@click.option("--depth", default=2, show_default=True, help="Call graph traversal depth.")
98
+@click.option("--file", "file_path", default="", help="Narrow to a specific file.")
99
+@click.option("--depth", default=2, show_default=True)
94100
@DB_OPTION
95101
@FMT_OPTION
96102
def function(name: str, file_path: str, db: str, depth: int, fmt: str):
97
- """Load context for a function — callers, callees, signature."""
103
+ """Load context for a function — callers, callees, decorators."""
98104
from navegador.context import ContextLoader
99
-
100
- loader = ContextLoader(_get_store(db))
101
- bundle = loader.load_function(name, file_path=file_path, depth=depth)
105
+ bundle = ContextLoader(_get_store(db)).load_function(name, file_path=file_path, depth=depth)
102106
_emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt)
103107
104108
105109
@main.command("class")
106110
@click.argument("name")
107
-@click.option("--file", "file_path", default="", help="File path to narrow the search.")
111
+@click.option("--file", "file_path", default="", help="Narrow to a specific file.")
108112
@DB_OPTION
109113
@FMT_OPTION
110114
def class_(name: str, file_path: str, db: str, fmt: str):
111
- """Load context for a class — methods, inheritance, subclasses."""
115
+ """Load context for a class — methods, inheritance, references."""
116
+ from navegador.context import ContextLoader
117
+ bundle = ContextLoader(_get_store(db)).load_class(name, file_path=file_path)
118
+ _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt)
119
+
120
+
121
+# ── UNIVERSAL: explain ────────────────────────────────────────────────────────
122
+
123
+@main.command()
124
+@click.argument("name")
125
+@click.option("--file", "file_path", default="")
126
+@DB_OPTION
127
+@FMT_OPTION
128
+def explain(name: str, file_path: str, db: str, fmt: str):
129
+ """Full picture: all relationships in and out, code and knowledge layers."""
112130
from navegador.context import ContextLoader
113
-
114
- loader = ContextLoader(_get_store(db))
115
- bundle = loader.load_class(name, file_path=file_path)
131
+ bundle = ContextLoader(_get_store(db)).explain(name, file_path=file_path)
116132
_emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt)
117133
134
+
135
+# ── UNIVERSAL: search ─────────────────────────────────────────────────────────
118136
119137
@main.command()
120138
@click.argument("query")
121139
@DB_OPTION
122140
@click.option("--limit", default=20, show_default=True)
141
+@click.option("--all", "search_all", is_flag=True,
142
+ help="Include knowledge layer (concepts, rules, wiki).")
143
+@click.option("--docs", "by_doc", is_flag=True, help="Search docstrings instead of names.")
123144
@FMT_OPTION
124
-def search(query: str, db: str, limit: int, fmt: str):
125
- """Search for symbols (functions, classes, methods) by name."""
126
- import json
127
-
145
+def search(query: str, db: str, limit: int, search_all: bool, by_doc: bool, fmt: str):
146
+ """Search symbols, concepts, rules, and wiki pages."""
128147
from navegador.context import ContextLoader
129
-
130148
loader = ContextLoader(_get_store(db))
131
- results = loader.search(query, limit=limit)
149
+
150
+ if by_doc:
151
+ results = loader.search_by_docstring(query, limit=limit)
152
+ elif search_all:
153
+ results = loader.search_all(query, limit=limit)
154
+ else:
155
+ results = loader.search(query, limit=limit)
132156
133157
if fmt == "json":
134158
click.echo(json.dumps([
135159
{"type": r.type, "name": r.name, "file_path": r.file_path,
136
- "line_start": r.line_start, "docstring": r.docstring}
160
+ "line_start": r.line_start, "docstring": r.docstring,
161
+ "description": r.description}
137162
for r in results
138163
], indent=2))
139164
return
140165
141166
if not results:
142167
console.print("[yellow]No results.[/yellow]")
143168
return
144169
145
- table = Table(title=f"Search: {query!r}", show_header=True)
170
+ table = Table(title=f"Search: {query!r}")
171
+ table.add_column("Type", style="cyan")
172
+ table.add_column("Name", style="bold")
173
+ table.add_column("File / Domain")
174
+ table.add_column("Line", justify="right")
175
+ for r in results:
176
+ loc = r.file_path or r.domain or ""
177
+ table.add_row(r.type, r.name, loc, str(r.line_start or ""))
178
+ console.print(table)
179
+
180
+
181
+# ── CODE: decorator / query ───────────────────────────────────────────────────
182
+
183
+@main.command()
184
+@click.argument("decorator_name")
185
+@DB_OPTION
186
+@FMT_OPTION
187
+def decorated(decorator_name: str, db: str, fmt: str):
188
+ """Find all functions/methods carrying a decorator."""
189
+ from navegador.context import ContextLoader
190
+ results = ContextLoader(_get_store(db)).decorated_by(decorator_name)
191
+
192
+ if fmt == "json":
193
+ click.echo(json.dumps([
194
+ {"type": r.type, "name": r.name, "file_path": r.file_path, "line": r.line_start}
195
+ for r in results
196
+ ], indent=2))
197
+ return
198
+
199
+ if not results:
200
+ console.print(f"[yellow]No functions decorated with @{decorator_name}[/yellow]")
201
+ return
202
+
203
+ table = Table(title=f"@{decorator_name}")
146204
table.add_column("Type", style="cyan")
147205
table.add_column("Name", style="bold")
148206
table.add_column("File")
149207
table.add_column("Line", justify="right")
150208
for r in results:
@@ -154,39 +212,214 @@
154212
155213
@main.command()
156214
@click.argument("cypher")
157215
@DB_OPTION
158216
def query(cypher: str, db: str):
159
- """Run a raw Cypher query and print results as JSON."""
160
- import json
161
-
162
- store = _get_store(db)
163
- result = store.query(cypher)
164
- rows = result.result_set or []
165
- click.echo(json.dumps(rows, default=str, indent=2))
166
-
217
+ """Run a raw Cypher query — output is always JSON."""
218
+ result = _get_store(db).query(cypher)
219
+ click.echo(json.dumps(result.result_set or [], default=str, indent=2))
220
+
221
+
222
+# ── KNOWLEDGE: add group ──────────────────────────────────────────────────────
223
+
224
+@main.group()
225
+def add():
226
+ """Add knowledge nodes — concepts, rules, decisions, people, domains."""
227
+
228
+
229
+@add.command("concept")
230
+@click.argument("name")
231
+@click.option("--desc", default="", help="Description / definition.")
232
+@click.option("--domain", default="")
233
+@click.option("--status", default="", help="e.g. stable, proposed, deprecated")
234
+@click.option("--rules", default="", help="Comma-separated rule names.")
235
+@click.option("--wiki", default="", help="Wiki URL or reference.")
236
+@DB_OPTION
237
+def add_concept(name: str, desc: str, domain: str, status: str, rules: str, wiki: str, db: str):
238
+ """Add a business concept to the knowledge graph."""
239
+ from navegador.ingestion import KnowledgeIngester
240
+ k = KnowledgeIngester(_get_store(db))
241
+ k.add_concept(name, description=desc, domain=domain, status=status,
242
+ rules=rules, wiki_refs=wiki)
243
+ console.print(f"[green]Concept added:[/green] {name}")
244
+
245
+
246
+@add.command("rule")
247
+@click.argument("name")
248
+@click.option("--desc", default="")
249
+@click.option("--domain", default="")
250
+@click.option("--severity", default="info", type=click.Choice(["info", "warning", "critical"]))
251
+@click.option("--rationale", default="")
252
+@DB_OPTION
253
+def add_rule(name: str, desc: str, domain: str, severity: str, rationale: str, db: str):
254
+ """Add a business rule or constraint."""
255
+ from navegador.ingestion import KnowledgeIngester
256
+ k = KnowledgeIngester(_get_store(db))
257
+ k.add_rule(name, description=desc, domain=domain, severity=severity, rationale=rationale)
258
+ console.print(f"[green]Rule added:[/green] {name}")
259
+
260
+
261
+@add.command("decision")
262
+@click.argument("name")
263
+@click.option("--desc", default="")
264
+@click.option("--domain", default="")
265
+@click.option("--rationale", default="")
266
+@click.option("--alternatives", default="")
267
+@click.option("--date", default="")
268
+@click.option("--status", default="accepted",
269
+ type=click.Choice(["proposed", "accepted", "deprecated"]))
270
+@DB_OPTION
271
+def add_decision(name, desc, domain, rationale, alternatives, date, status, db):
272
+ """Add an architectural or product decision."""
273
+ from navegador.ingestion import KnowledgeIngester
274
+ k = KnowledgeIngester(_get_store(db))
275
+ k.add_decision(name, description=desc, domain=domain, status=status,
276
+ rationale=rationale, alternatives=alternatives, date=date)
277
+ console.print(f"[green]Decision added:[/green] {name}")
278
+
279
+
280
+@add.command("person")
281
+@click.argument("name")
282
+@click.option("--email", default="")
283
+@click.option("--role", default="")
284
+@click.option("--team", default="")
285
+@DB_OPTION
286
+def add_person(name: str, email: str, role: str, team: str, db: str):
287
+ """Add a person (contributor, owner, stakeholder)."""
288
+ from navegador.ingestion import KnowledgeIngester
289
+ k = KnowledgeIngester(_get_store(db))
290
+ k.add_person(name, email=email, role=role, team=team)
291
+ console.print(f"[green]Person added:[/green] {name}")
292
+
293
+
294
+@add.command("domain")
295
+@click.argument("name")
296
+@click.option("--desc", default="")
297
+@DB_OPTION
298
+def add_domain(name: str, desc: str, db: str):
299
+ """Add a business domain (auth, billing, notifications…)."""
300
+ from navegador.ingestion import KnowledgeIngester
301
+ k = KnowledgeIngester(_get_store(db))
302
+ k.add_domain(name, description=desc)
303
+ console.print(f"[green]Domain added:[/green] {name}")
304
+
305
+
306
+# ── KNOWLEDGE: annotate ───────────────────────────────────────────────────────
307
+
308
+@main.command()
309
+@click.argument("code_name")
310
+@click.option("--type", "code_label", default="Function",
311
+ type=click.Choice(["Function", "Class", "Method", "File", "Module"]))
312
+@click.option("--concept", default="", help="Link to this concept.")
313
+@click.option("--rule", default="", help="Link to this rule.")
314
+@DB_OPTION
315
+def annotate(code_name: str, code_label: str, concept: str, rule: str, db: str):
316
+ """Link a code node to a concept or rule in the knowledge graph."""
317
+ from navegador.ingestion import KnowledgeIngester
318
+ k = KnowledgeIngester(_get_store(db))
319
+ k.annotate_code(code_name, code_label,
320
+ concept=concept or None, rule=rule or None)
321
+ console.print(f"[green]Annotated:[/green] {code_name}")
322
+
323
+
324
+# ── KNOWLEDGE: domain view ────────────────────────────────────────────────────
325
+
326
+@main.command()
327
+@click.argument("name")
328
+@DB_OPTION
329
+@FMT_OPTION
330
+def domain(name: str, db: str, fmt: str):
331
+ """Show everything belonging to a domain — code and knowledge."""
332
+ from navegador.context import ContextLoader
333
+ bundle = ContextLoader(_get_store(db)).load_domain(name)
334
+ _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt)
335
+
336
+
337
+@main.command()
338
+@click.argument("name")
339
+@DB_OPTION
340
+@FMT_OPTION
341
+def concept(name: str, db: str, fmt: str):
342
+ """Load a business concept — rules, related concepts, implementing code, wiki."""
343
+ from navegador.context import ContextLoader
344
+ bundle = ContextLoader(_get_store(db)).load_concept(name)
345
+ _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt)
346
+
347
+
348
+# ── KNOWLEDGE: wiki ───────────────────────────────────────────────────────────
349
+
350
+@main.group()
351
+def wiki():
352
+ """Ingest and manage wiki pages in the knowledge graph."""
353
+
354
+
355
+@wiki.command("ingest")
356
+@click.option("--repo", default="", help="GitHub repo (owner/repo) — clones the wiki.")
357
+@click.option("--dir", "wiki_dir", default="", help="Local directory of markdown files.")
358
+@click.option("--token", default="", envvar="GITHUB_TOKEN", help="GitHub token.")
359
+@click.option("--api", is_flag=True, help="Use GitHub API instead of git clone.")
360
+@DB_OPTION
361
+def wiki_ingest(repo: str, wiki_dir: str, token: str, api: bool, db: str):
362
+ """Pull wiki pages into the knowledge graph."""
363
+ from navegador.ingestion import WikiIngester
364
+ w = WikiIngester(_get_store(db))
365
+
366
+ if wiki_dir:
367
+ stats = w.ingest_local(wiki_dir)
368
+ elif repo:
369
+ if api:
370
+ stats = w.ingest_github_api(repo, token=token)
371
+ else:
372
+ stats = w.ingest_github(repo, token=token)
373
+ else:
374
+ raise click.UsageError("Provide --repo or --dir")
375
+
376
+ console.print(f"[green]Wiki ingested:[/green] {stats['pages']} pages, {stats['links']} links")
377
+
378
+
379
+# ── Stats ─────────────────────────────────────────────────────────────────────
167380
168381
@main.command()
169382
@DB_OPTION
170
-@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
383
+@click.option("--json", "as_json", is_flag=True)
171384
def stats(db: str, as_json: bool):
172
- """Show graph statistics."""
173
- import json
174
-
385
+ """Graph statistics broken down by node and edge type."""
386
+ from navegador.graph import queries as q
175387
store = _get_store(db)
176
- data = {"nodes": store.node_count(), "edges": store.edge_count()}
388
+
389
+ node_rows = (store.query(q.NODE_TYPE_COUNTS).result_set or [])
390
+ edge_rows = (store.query(q.EDGE_TYPE_COUNTS).result_set or [])
391
+
392
+ total_nodes = sum(r[1] for r in node_rows)
393
+ total_edges = sum(r[1] for r in edge_rows)
177394
178395
if as_json:
179
- click.echo(json.dumps(data, indent=2))
180
- else:
181
- table = Table(title="Graph stats", show_header=True)
182
- table.add_column("Metric", style="cyan")
183
- table.add_column("Count", justify="right", style="green")
184
- for k, v in data.items():
185
- table.add_row(k.capitalize(), str(v))
186
- console.print(table)
187
-
396
+ click.echo(json.dumps({
397
+ "total_nodes": total_nodes,
398
+ "total_edges": total_edges,
399
+ "nodes": {r[0]: r[1] for r in node_rows},
400
+ "edges": {r[0]: r[1] for r in edge_rows},
401
+ }, indent=2))
402
+ return
403
+
404
+ node_table = Table(title=f"Nodes ({total_nodes:,})")
405
+ node_table.add_column("Type", style="cyan")
406
+ node_table.add_column("Count", justify="right", style="green")
407
+ for row in node_rows:
408
+ node_table.add_row(row[0], f"{row[1]:,}")
409
+
410
+ edge_table = Table(title=f"Edges ({total_edges:,})")
411
+ edge_table.add_column("Type", style="cyan")
412
+ edge_table.add_column("Count", justify="right", style="green")
413
+ for row in edge_rows:
414
+ edge_table.add_row(row[0], f"{row[1]:,}")
415
+
416
+ console.print(node_table)
417
+ console.print(edge_table)
418
+
419
+
420
+# ── MCP ───────────────────────────────────────────────────────────────────────
188421
189422
@main.command()
190423
@DB_OPTION
191424
def mcp(db: str):
192425
"""Start the MCP server for AI agent integration (stdio)."""
193426
--- navegador/cli/commands.py
+++ navegador/cli/commands.py
@@ -1,13 +1,15 @@
1 """
2 Navegador CLI — ingest repos, load context, serve MCP.
3
4 All commands support --format json for clean stdout output suitable for
5 piping to agents or other tools without MCP overhead.
 
6 """
7
8 import asyncio
 
9 import logging
10
11 import click
12 from rich.console import Console
13 from rich.table import Table
@@ -18,12 +20,11 @@
18 "--db", default=".navegador/graph.db", show_default=True, help="Graph DB path."
19 )
20 FMT_OPTION = click.option(
21 "--format", "fmt",
22 type=click.Choice(["markdown", "json"]),
23 default="markdown",
24 show_default=True,
25 help="Output format. Use json for agent/pipe consumption.",
26 )
27
28
29 def _get_store(db: str):
@@ -30,33 +31,38 @@
30 from navegador.graph import GraphStore
31 return GraphStore.sqlite(db)
32
33
34 def _emit(text: str, fmt: str) -> None:
35 """Print text — raw to stdout for json, rich for markdown."""
36 if fmt == "json":
37 click.echo(text)
38 else:
39 console.print(text)
40
41
 
 
42 @click.group()
43 @click.version_option(package_name="navegador")
44 def main():
45 """Navegador — AST + knowledge graph context engine for AI coding agents."""
 
 
 
 
46 logging.basicConfig(level=logging.WARNING)
47
 
 
48
49 @main.command()
50 @click.argument("repo_path", type=click.Path(exists=True))
51 @DB_OPTION
52 @click.option("--clear", is_flag=True, help="Clear existing graph before ingesting.")
53 @click.option("--json", "as_json", is_flag=True, help="Output stats as JSON.")
54 def ingest(repo_path: str, db: str, clear: bool, as_json: bool):
55 """Ingest a repository into the navegador graph."""
56 import json
57
58 from navegador.ingestion import RepoIngester
59
60 store = _get_store(db)
61 ingester = RepoIngester(store)
62
@@ -64,87 +70,139 @@
64 stats = ingester.ingest(repo_path, clear=clear)
65 click.echo(json.dumps(stats, indent=2))
66 else:
67 with console.status(f"[bold]Ingesting[/bold] {repo_path}..."):
68 stats = ingester.ingest(repo_path, clear=clear)
69 table = Table(title="Ingestion complete", show_header=True)
70 table.add_column("Metric", style="cyan")
71 table.add_column("Count", justify="right", style="green")
72 for k, v in stats.items():
73 table.add_row(k.capitalize(), str(v))
74 console.print(table)
75
 
 
76
77 @main.command()
78 @click.argument("file_path")
79 @DB_OPTION
80 @FMT_OPTION
81 def context(file_path: str, db: str, fmt: str):
82 """Load context for a file — all symbols and their relationships."""
83 from navegador.context import ContextLoader
84
85 loader = ContextLoader(_get_store(db))
86 bundle = loader.load_file(file_path)
87 _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt)
88
89
90 @main.command()
91 @click.argument("name")
92 @click.option("--file", "file_path", default="", help="File path to narrow the search.")
93 @click.option("--depth", default=2, show_default=True, help="Call graph traversal depth.")
94 @DB_OPTION
95 @FMT_OPTION
96 def function(name: str, file_path: str, db: str, depth: int, fmt: str):
97 """Load context for a function — callers, callees, signature."""
98 from navegador.context import ContextLoader
99
100 loader = ContextLoader(_get_store(db))
101 bundle = loader.load_function(name, file_path=file_path, depth=depth)
102 _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt)
103
104
105 @main.command("class")
106 @click.argument("name")
107 @click.option("--file", "file_path", default="", help="File path to narrow the search.")
108 @DB_OPTION
109 @FMT_OPTION
110 def class_(name: str, file_path: str, db: str, fmt: str):
111 """Load context for a class — methods, inheritance, subclasses."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112 from navegador.context import ContextLoader
113
114 loader = ContextLoader(_get_store(db))
115 bundle = loader.load_class(name, file_path=file_path)
116 _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt)
117
 
 
118
119 @main.command()
120 @click.argument("query")
121 @DB_OPTION
122 @click.option("--limit", default=20, show_default=True)
 
 
 
123 @FMT_OPTION
124 def search(query: str, db: str, limit: int, fmt: str):
125 """Search for symbols (functions, classes, methods) by name."""
126 import json
127
128 from navegador.context import ContextLoader
129
130 loader = ContextLoader(_get_store(db))
131 results = loader.search(query, limit=limit)
 
 
 
 
 
 
132
133 if fmt == "json":
134 click.echo(json.dumps([
135 {"type": r.type, "name": r.name, "file_path": r.file_path,
136 "line_start": r.line_start, "docstring": r.docstring}
 
137 for r in results
138 ], indent=2))
139 return
140
141 if not results:
142 console.print("[yellow]No results.[/yellow]")
143 return
144
145 table = Table(title=f"Search: {query!r}", show_header=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146 table.add_column("Type", style="cyan")
147 table.add_column("Name", style="bold")
148 table.add_column("File")
149 table.add_column("Line", justify="right")
150 for r in results:
@@ -154,39 +212,214 @@
154
155 @main.command()
156 @click.argument("cypher")
157 @DB_OPTION
158 def query(cypher: str, db: str):
159 """Run a raw Cypher query and print results as JSON."""
160 import json
161
162 store = _get_store(db)
163 result = store.query(cypher)
164 rows = result.result_set or []
165 click.echo(json.dumps(rows, default=str, indent=2))
166
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
168 @main.command()
169 @DB_OPTION
170 @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
171 def stats(db: str, as_json: bool):
172 """Show graph statistics."""
173 import json
174
175 store = _get_store(db)
176 data = {"nodes": store.node_count(), "edges": store.edge_count()}
 
 
 
 
 
177
178 if as_json:
179 click.echo(json.dumps(data, indent=2))
180 else:
181 table = Table(title="Graph stats", show_header=True)
182 table.add_column("Metric", style="cyan")
183 table.add_column("Count", justify="right", style="green")
184 for k, v in data.items():
185 table.add_row(k.capitalize(), str(v))
186 console.print(table)
187
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
189 @main.command()
190 @DB_OPTION
191 def mcp(db: str):
192 """Start the MCP server for AI agent integration (stdio)."""
193
--- navegador/cli/commands.py
+++ navegador/cli/commands.py
@@ -1,13 +1,15 @@
1 """
2 Navegador CLI — the single interface to your project's knowledge graph.
3
4 CODE: ingest, context, function, class, search, query
5 KNOWLEDGE: add (concept/rule/decision/person/domain), wiki, annotate, domain
6 UNIVERSAL: explain, search (spans both layers), stats
7 """
8
9 import asyncio
10 import json
11 import logging
12
13 import click
14 from rich.console import Console
15 from rich.table import Table
@@ -18,12 +20,11 @@
20 "--db", default=".navegador/graph.db", show_default=True, help="Graph DB path."
21 )
22 FMT_OPTION = click.option(
23 "--format", "fmt",
24 type=click.Choice(["markdown", "json"]),
25 default="markdown", show_default=True,
 
26 help="Output format. Use json for agent/pipe consumption.",
27 )
28
29
30 def _get_store(db: str):
@@ -30,33 +31,38 @@
31 from navegador.graph import GraphStore
32 return GraphStore.sqlite(db)
33
34
35 def _emit(text: str, fmt: str) -> None:
 
36 if fmt == "json":
37 click.echo(text)
38 else:
39 console.print(text)
40
41
42 # ── Root group ────────────────────────────────────────────────────────────────
43
44 @click.group()
45 @click.version_option(package_name="navegador")
46 def main():
47 """Navegador — project knowledge graph for AI coding agents.
48
49 Combines code structure (AST, call graphs) with business knowledge
50 (concepts, rules, decisions, wiki) into a single queryable graph.
51 """
52 logging.basicConfig(level=logging.WARNING)
53
54
55 # ── CODE: ingest ──────────────────────────────────────────────────────────────
56
57 @main.command()
58 @click.argument("repo_path", type=click.Path(exists=True))
59 @DB_OPTION
60 @click.option("--clear", is_flag=True, help="Clear existing graph before ingesting.")
61 @click.option("--json", "as_json", is_flag=True, help="Output stats as JSON.")
62 def ingest(repo_path: str, db: str, clear: bool, as_json: bool):
63 """Ingest a repository's code into the graph (AST + call graph)."""
 
 
64 from navegador.ingestion import RepoIngester
65
66 store = _get_store(db)
67 ingester = RepoIngester(store)
68
@@ -64,87 +70,139 @@
70 stats = ingester.ingest(repo_path, clear=clear)
71 click.echo(json.dumps(stats, indent=2))
72 else:
73 with console.status(f"[bold]Ingesting[/bold] {repo_path}..."):
74 stats = ingester.ingest(repo_path, clear=clear)
75 table = Table(title="Ingestion complete")
76 table.add_column("Metric", style="cyan")
77 table.add_column("Count", justify="right", style="green")
78 for k, v in stats.items():
79 table.add_row(k.capitalize(), str(v))
80 console.print(table)
81
82
83 # ── CODE: context / function / class ─────────────────────────────────────────
84
85 @main.command()
86 @click.argument("file_path")
87 @DB_OPTION
88 @FMT_OPTION
89 def context(file_path: str, db: str, fmt: str):
90 """Load context for a file — all symbols and their relationships."""
91 from navegador.context import ContextLoader
92 bundle = ContextLoader(_get_store(db)).load_file(file_path)
 
 
93 _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt)
94
95
96 @main.command()
97 @click.argument("name")
98 @click.option("--file", "file_path", default="", help="Narrow to a specific file.")
99 @click.option("--depth", default=2, show_default=True)
100 @DB_OPTION
101 @FMT_OPTION
102 def function(name: str, file_path: str, db: str, depth: int, fmt: str):
103 """Load context for a function — callers, callees, decorators."""
104 from navegador.context import ContextLoader
105 bundle = ContextLoader(_get_store(db)).load_function(name, file_path=file_path, depth=depth)
 
 
106 _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt)
107
108
109 @main.command("class")
110 @click.argument("name")
111 @click.option("--file", "file_path", default="", help="Narrow to a specific file.")
112 @DB_OPTION
113 @FMT_OPTION
114 def class_(name: str, file_path: str, db: str, fmt: str):
115 """Load context for a class — methods, inheritance, references."""
116 from navegador.context import ContextLoader
117 bundle = ContextLoader(_get_store(db)).load_class(name, file_path=file_path)
118 _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt)
119
120
121 # ── UNIVERSAL: explain ────────────────────────────────────────────────────────
122
123 @main.command()
124 @click.argument("name")
125 @click.option("--file", "file_path", default="")
126 @DB_OPTION
127 @FMT_OPTION
128 def explain(name: str, file_path: str, db: str, fmt: str):
129 """Full picture: all relationships in and out, code and knowledge layers."""
130 from navegador.context import ContextLoader
131 bundle = ContextLoader(_get_store(db)).explain(name, file_path=file_path)
 
 
132 _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt)
133
134
135 # ── UNIVERSAL: search ─────────────────────────────────────────────────────────
136
137 @main.command()
138 @click.argument("query")
139 @DB_OPTION
140 @click.option("--limit", default=20, show_default=True)
141 @click.option("--all", "search_all", is_flag=True,
142 help="Include knowledge layer (concepts, rules, wiki).")
143 @click.option("--docs", "by_doc", is_flag=True, help="Search docstrings instead of names.")
144 @FMT_OPTION
145 def search(query: str, db: str, limit: int, search_all: bool, by_doc: bool, fmt: str):
146 """Search symbols, concepts, rules, and wiki pages."""
 
 
147 from navegador.context import ContextLoader
 
148 loader = ContextLoader(_get_store(db))
149
150 if by_doc:
151 results = loader.search_by_docstring(query, limit=limit)
152 elif search_all:
153 results = loader.search_all(query, limit=limit)
154 else:
155 results = loader.search(query, limit=limit)
156
157 if fmt == "json":
158 click.echo(json.dumps([
159 {"type": r.type, "name": r.name, "file_path": r.file_path,
160 "line_start": r.line_start, "docstring": r.docstring,
161 "description": r.description}
162 for r in results
163 ], indent=2))
164 return
165
166 if not results:
167 console.print("[yellow]No results.[/yellow]")
168 return
169
170 table = Table(title=f"Search: {query!r}")
171 table.add_column("Type", style="cyan")
172 table.add_column("Name", style="bold")
173 table.add_column("File / Domain")
174 table.add_column("Line", justify="right")
175 for r in results:
176 loc = r.file_path or r.domain or ""
177 table.add_row(r.type, r.name, loc, str(r.line_start or ""))
178 console.print(table)
179
180
181 # ── CODE: decorator / query ───────────────────────────────────────────────────
182
183 @main.command()
184 @click.argument("decorator_name")
185 @DB_OPTION
186 @FMT_OPTION
187 def decorated(decorator_name: str, db: str, fmt: str):
188 """Find all functions/methods carrying a decorator."""
189 from navegador.context import ContextLoader
190 results = ContextLoader(_get_store(db)).decorated_by(decorator_name)
191
192 if fmt == "json":
193 click.echo(json.dumps([
194 {"type": r.type, "name": r.name, "file_path": r.file_path, "line": r.line_start}
195 for r in results
196 ], indent=2))
197 return
198
199 if not results:
200 console.print(f"[yellow]No functions decorated with @{decorator_name}[/yellow]")
201 return
202
203 table = Table(title=f"@{decorator_name}")
204 table.add_column("Type", style="cyan")
205 table.add_column("Name", style="bold")
206 table.add_column("File")
207 table.add_column("Line", justify="right")
208 for r in results:
@@ -154,39 +212,214 @@
212
213 @main.command()
214 @click.argument("cypher")
215 @DB_OPTION
216 def query(cypher: str, db: str):
217 """Run a raw Cypher query — output is always JSON."""
218 result = _get_store(db).query(cypher)
219 click.echo(json.dumps(result.result_set or [], default=str, indent=2))
220
221
222 # ── KNOWLEDGE: add group ──────────────────────────────────────────────────────
223
224 @main.group()
225 def add():
226 """Add knowledge nodes — concepts, rules, decisions, people, domains."""
227
228
229 @add.command("concept")
230 @click.argument("name")
231 @click.option("--desc", default="", help="Description / definition.")
232 @click.option("--domain", default="")
233 @click.option("--status", default="", help="e.g. stable, proposed, deprecated")
234 @click.option("--rules", default="", help="Comma-separated rule names.")
235 @click.option("--wiki", default="", help="Wiki URL or reference.")
236 @DB_OPTION
237 def add_concept(name: str, desc: str, domain: str, status: str, rules: str, wiki: str, db: str):
238 """Add a business concept to the knowledge graph."""
239 from navegador.ingestion import KnowledgeIngester
240 k = KnowledgeIngester(_get_store(db))
241 k.add_concept(name, description=desc, domain=domain, status=status,
242 rules=rules, wiki_refs=wiki)
243 console.print(f"[green]Concept added:[/green] {name}")
244
245
246 @add.command("rule")
247 @click.argument("name")
248 @click.option("--desc", default="")
249 @click.option("--domain", default="")
250 @click.option("--severity", default="info", type=click.Choice(["info", "warning", "critical"]))
251 @click.option("--rationale", default="")
252 @DB_OPTION
253 def add_rule(name: str, desc: str, domain: str, severity: str, rationale: str, db: str):
254 """Add a business rule or constraint."""
255 from navegador.ingestion import KnowledgeIngester
256 k = KnowledgeIngester(_get_store(db))
257 k.add_rule(name, description=desc, domain=domain, severity=severity, rationale=rationale)
258 console.print(f"[green]Rule added:[/green] {name}")
259
260
261 @add.command("decision")
262 @click.argument("name")
263 @click.option("--desc", default="")
264 @click.option("--domain", default="")
265 @click.option("--rationale", default="")
266 @click.option("--alternatives", default="")
267 @click.option("--date", default="")
268 @click.option("--status", default="accepted",
269 type=click.Choice(["proposed", "accepted", "deprecated"]))
270 @DB_OPTION
271 def add_decision(name, desc, domain, rationale, alternatives, date, status, db):
272 """Add an architectural or product decision."""
273 from navegador.ingestion import KnowledgeIngester
274 k = KnowledgeIngester(_get_store(db))
275 k.add_decision(name, description=desc, domain=domain, status=status,
276 rationale=rationale, alternatives=alternatives, date=date)
277 console.print(f"[green]Decision added:[/green] {name}")
278
279
280 @add.command("person")
281 @click.argument("name")
282 @click.option("--email", default="")
283 @click.option("--role", default="")
284 @click.option("--team", default="")
285 @DB_OPTION
286 def add_person(name: str, email: str, role: str, team: str, db: str):
287 """Add a person (contributor, owner, stakeholder)."""
288 from navegador.ingestion import KnowledgeIngester
289 k = KnowledgeIngester(_get_store(db))
290 k.add_person(name, email=email, role=role, team=team)
291 console.print(f"[green]Person added:[/green] {name}")
292
293
294 @add.command("domain")
295 @click.argument("name")
296 @click.option("--desc", default="")
297 @DB_OPTION
298 def add_domain(name: str, desc: str, db: str):
299 """Add a business domain (auth, billing, notifications…)."""
300 from navegador.ingestion import KnowledgeIngester
301 k = KnowledgeIngester(_get_store(db))
302 k.add_domain(name, description=desc)
303 console.print(f"[green]Domain added:[/green] {name}")
304
305
306 # ── KNOWLEDGE: annotate ───────────────────────────────────────────────────────
307
308 @main.command()
309 @click.argument("code_name")
310 @click.option("--type", "code_label", default="Function",
311 type=click.Choice(["Function", "Class", "Method", "File", "Module"]))
312 @click.option("--concept", default="", help="Link to this concept.")
313 @click.option("--rule", default="", help="Link to this rule.")
314 @DB_OPTION
315 def annotate(code_name: str, code_label: str, concept: str, rule: str, db: str):
316 """Link a code node to a concept or rule in the knowledge graph."""
317 from navegador.ingestion import KnowledgeIngester
318 k = KnowledgeIngester(_get_store(db))
319 k.annotate_code(code_name, code_label,
320 concept=concept or None, rule=rule or None)
321 console.print(f"[green]Annotated:[/green] {code_name}")
322
323
324 # ── KNOWLEDGE: domain view ────────────────────────────────────────────────────
325
326 @main.command()
327 @click.argument("name")
328 @DB_OPTION
329 @FMT_OPTION
330 def domain(name: str, db: str, fmt: str):
331 """Show everything belonging to a domain — code and knowledge."""
332 from navegador.context import ContextLoader
333 bundle = ContextLoader(_get_store(db)).load_domain(name)
334 _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt)
335
336
337 @main.command()
338 @click.argument("name")
339 @DB_OPTION
340 @FMT_OPTION
341 def concept(name: str, db: str, fmt: str):
342 """Load a business concept — rules, related concepts, implementing code, wiki."""
343 from navegador.context import ContextLoader
344 bundle = ContextLoader(_get_store(db)).load_concept(name)
345 _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt)
346
347
348 # ── KNOWLEDGE: wiki ───────────────────────────────────────────────────────────
349
350 @main.group()
351 def wiki():
352 """Ingest and manage wiki pages in the knowledge graph."""
353
354
355 @wiki.command("ingest")
356 @click.option("--repo", default="", help="GitHub repo (owner/repo) — clones the wiki.")
357 @click.option("--dir", "wiki_dir", default="", help="Local directory of markdown files.")
358 @click.option("--token", default="", envvar="GITHUB_TOKEN", help="GitHub token.")
359 @click.option("--api", is_flag=True, help="Use GitHub API instead of git clone.")
360 @DB_OPTION
361 def wiki_ingest(repo: str, wiki_dir: str, token: str, api: bool, db: str):
362 """Pull wiki pages into the knowledge graph."""
363 from navegador.ingestion import WikiIngester
364 w = WikiIngester(_get_store(db))
365
366 if wiki_dir:
367 stats = w.ingest_local(wiki_dir)
368 elif repo:
369 if api:
370 stats = w.ingest_github_api(repo, token=token)
371 else:
372 stats = w.ingest_github(repo, token=token)
373 else:
374 raise click.UsageError("Provide --repo or --dir")
375
376 console.print(f"[green]Wiki ingested:[/green] {stats['pages']} pages, {stats['links']} links")
377
378
379 # ── Stats ─────────────────────────────────────────────────────────────────────
380
381 @main.command()
382 @DB_OPTION
383 @click.option("--json", "as_json", is_flag=True)
384 def stats(db: str, as_json: bool):
385 """Graph statistics broken down by node and edge type."""
386 from navegador.graph import queries as q
 
387 store = _get_store(db)
388
389 node_rows = (store.query(q.NODE_TYPE_COUNTS).result_set or [])
390 edge_rows = (store.query(q.EDGE_TYPE_COUNTS).result_set or [])
391
392 total_nodes = sum(r[1] for r in node_rows)
393 total_edges = sum(r[1] for r in edge_rows)
394
395 if as_json:
396 click.echo(json.dumps({
397 "total_nodes": total_nodes,
398 "total_edges": total_edges,
399 "nodes": {r[0]: r[1] for r in node_rows},
400 "edges": {r[0]: r[1] for r in edge_rows},
401 }, indent=2))
402 return
403
404 node_table = Table(title=f"Nodes ({total_nodes:,})")
405 node_table.add_column("Type", style="cyan")
406 node_table.add_column("Count", justify="right", style="green")
407 for row in node_rows:
408 node_table.add_row(row[0], f"{row[1]:,}")
409
410 edge_table = Table(title=f"Edges ({total_edges:,})")
411 edge_table.add_column("Type", style="cyan")
412 edge_table.add_column("Count", justify="right", style="green")
413 for row in edge_rows:
414 edge_table.add_row(row[0], f"{row[1]:,}")
415
416 console.print(node_table)
417 console.print(edge_table)
418
419
420 # ── MCP ───────────────────────────────────────────────────────────────────────
421
422 @main.command()
423 @DB_OPTION
424 def mcp(db: str):
425 """Start the MCP server for AI agent integration (stdio)."""
426
--- navegador/context/loader.py
+++ navegador/context/loader.py
@@ -1,17 +1,13 @@
11
"""
2
-ContextLoader — builds structured context bundles from the graph.
3
-
4
-A context bundle contains:
5
-- The target node (file / function / class)
6
-- Its immediate neighbors up to a configurable depth
7
-- Relationships between those nodes
8
-- Source snippets (optional)
9
-
10
-Output can be:
11
-- dict (structured JSON-serialisable)
12
-- markdown string (for direct paste into AI chat)
2
+ContextLoader — builds structured context bundles from the navegador graph.
3
+
4
+Operates across both layers:
5
+ CODE — files, functions, classes, call graphs, decorators, references
6
+ KNOWLEDGE — concepts, rules, decisions, wiki pages, domains
7
+
8
+Output can be dict, JSON string, or markdown for direct paste into AI chat.
139
"""
1410
1511
import json
1612
import logging
1713
from dataclasses import dataclass, field
@@ -25,15 +21,18 @@
2521
2622
@dataclass
2723
class ContextNode:
2824
type: str
2925
name: str
30
- file_path: str
26
+ file_path: str = ""
3127
line_start: int | None = None
3228
docstring: str | None = None
3329
signature: str | None = None
3430
source: str | None = None
31
+ description: str | None = None
32
+ domain: str | None = None
33
+ status: str | None = None
3534
3635
3736
@dataclass
3837
class ContextBundle:
3938
target: ContextNode
@@ -53,24 +52,31 @@
5352
return json.dumps(self.to_dict(), indent=indent)
5453
5554
def to_markdown(self) -> str:
5655
lines = [
5756
f"# Context: `{self.target.name}`",
58
- f"**File:** `{self.target.file_path}`",
5957
f"**Type:** {self.target.type}",
6058
]
61
- if self.target.docstring:
62
- lines += ["", f"> {self.target.docstring}"]
59
+ if self.target.file_path:
60
+ lines.append(f"**File:** `{self.target.file_path}`")
61
+ if self.target.domain:
62
+ lines.append(f"**Domain:** {self.target.domain}")
63
+ if self.target.status:
64
+ lines.append(f"**Status:** {self.target.status}")
65
+ if self.target.docstring or self.target.description:
66
+ lines += ["", f"> {self.target.docstring or self.target.description}"]
6367
if self.target.signature:
6468
lines += ["", f"```python\n{self.target.signature}\n```"]
6569
6670
if self.nodes:
6771
lines += ["", "## Related nodes", ""]
6872
for node in self.nodes:
69
- lines.append(f"- **{node.type}** `{node.name}` — `{node.file_path}`")
70
- if node.docstring:
71
- lines.append(f" > {node.docstring}")
73
+ loc = f"`{node.file_path}`" if node.file_path else ""
74
+ lines.append(f"- **{node.type}** `{node.name}` {loc}".strip())
75
+ summary = node.docstring or node.description
76
+ if summary:
77
+ lines.append(f" > {summary}")
7278
7379
if self.edges:
7480
lines += ["", "## Relationships", ""]
7581
for edge in self.edges:
7682
lines.append(f"- `{edge['from']}` **{edge['type']}** `{edge['to']}`")
@@ -78,29 +84,33 @@
7884
return "\n".join(lines)
7985
8086
8187
class ContextLoader:
8288
"""
83
- Loads structured context bundles from the navegador graph.
89
+ Loads context bundles from the navegador graph — code and knowledge layers.
8490
8591
Usage:
8692
store = GraphStore.sqlite()
8793
loader = ContextLoader(store)
8894
8995
bundle = loader.load_file("src/auth.py")
90
- bundle = loader.load_function("get_user", file_path="src/auth.py")
91
- bundle = loader.load_class("AuthService", file_path="src/auth.py")
96
+ bundle = loader.load_function("validate_token")
97
+ bundle = loader.load_class("AuthService")
98
+ bundle = loader.explain("validate_token") # full picture
99
+ bundle = loader.load_concept("JWT")
100
+ bundle = loader.load_domain("auth")
92101
"""
93102
94103
def __init__(self, store: GraphStore) -> None:
95104
self.store = store
96105
97
- def load_file(self, file_path: str, depth: int = 2) -> ContextBundle:
98
- """Load context for an entire file — all its symbols + their dependencies."""
106
+ # ── Code: file ────────────────────────────────────────────────────────────
107
+
108
+ def load_file(self, file_path: str) -> ContextBundle:
109
+ """All symbols in a file and their relationships."""
99110
result = self.store.query(queries.FILE_CONTENTS, {"path": file_path})
100111
target = ContextNode(type="File", name=Path(file_path).name, file_path=file_path)
101
-
102112
nodes = []
103113
for row in (result.result_set or []):
104114
nodes.append(ContextNode(
105115
type=row[0] or "Unknown",
106116
name=row[1] or "",
@@ -107,42 +117,47 @@
107117
file_path=file_path,
108118
line_start=row[2],
109119
docstring=row[3],
110120
signature=row[4],
111121
))
122
+ return ContextBundle(target=target, nodes=nodes,
123
+ metadata={"query": "file_contents"})
112124
113
- return ContextBundle(
114
- target=target,
115
- nodes=nodes,
116
- metadata={"depth": depth, "query": "file_contents"},
117
- )
125
+ # ── Code: function ────────────────────────────────────────────────────────
118126
119127
def load_function(self, name: str, file_path: str = "", depth: int = 2) -> ContextBundle:
120
- """Load context for a function — its callers and callees."""
128
+ """Callers, callees, decorators — everything touching this function."""
121129
target = ContextNode(type="Function", name=name, file_path=file_path)
122130
nodes: list[ContextNode] = []
123131
edges: list[dict[str, str]] = []
124132
125
- callees = self.store.query(
126
- queries.CALLEES, {"name": name, "file_path": file_path, "depth": depth}
127
- )
133
+ params = {"name": name, "file_path": file_path, "depth": depth}
134
+
135
+ callees = self.store.query(queries.CALLEES, params)
128136
for row in (callees.result_set or []):
129137
nodes.append(ContextNode(type=row[0], name=row[1], file_path=row[2], line_start=row[3]))
130138
edges.append({"from": name, "type": "CALLS", "to": row[1]})
131139
132
- callers = self.store.query(
133
- queries.CALLERS, {"name": name, "file_path": file_path, "depth": depth}
134
- )
140
+ callers = self.store.query(queries.CALLERS, params)
135141
for row in (callers.result_set or []):
136142
nodes.append(ContextNode(type=row[0], name=row[1], file_path=row[2], line_start=row[3]))
137143
edges.append({"from": row[1], "type": "CALLS", "to": name})
138144
145
+ decorators = self.store.query(
146
+ queries.DECORATORS_FOR, {"name": name, "file_path": file_path}
147
+ )
148
+ for row in (decorators.result_set or []):
149
+ nodes.append(ContextNode(type="Decorator", name=row[0], file_path=row[1]))
150
+ edges.append({"from": row[0], "type": "DECORATES", "to": name})
151
+
139152
return ContextBundle(target=target, nodes=nodes, edges=edges,
140153
metadata={"depth": depth, "query": "function_context"})
141154
155
+ # ── Code: class ───────────────────────────────────────────────────────────
156
+
142157
def load_class(self, name: str, file_path: str = "") -> ContextBundle:
143
- """Load context for a class — its methods, parent classes, and subclasses."""
158
+ """Methods, parent classes, subclasses, references."""
144159
target = ContextNode(type="Class", name=name, file_path=file_path)
145160
nodes: list[ContextNode] = []
146161
edges: list[dict[str, str]] = []
147162
148163
parents = self.store.query(queries.CLASS_HIERARCHY, {"name": name})
@@ -153,16 +168,126 @@
153168
subs = self.store.query(queries.SUBCLASSES, {"name": name})
154169
for row in (subs.result_set or []):
155170
nodes.append(ContextNode(type="Class", name=row[0], file_path=row[1]))
156171
edges.append({"from": row[0], "type": "INHERITS", "to": name})
157172
173
+ refs = self.store.query(queries.REFERENCES_TO, {"name": name, "file_path": ""})
174
+ for row in (refs.result_set or []):
175
+ nodes.append(ContextNode(type=row[0], name=row[1], file_path=row[2], line_start=row[3]))
176
+ edges.append({"from": row[1], "type": "REFERENCES", "to": name})
177
+
158178
return ContextBundle(target=target, nodes=nodes, edges=edges,
159179
metadata={"query": "class_context"})
160180
181
+ # ── Universal: explain ────────────────────────────────────────────────────
182
+
183
+ def explain(self, name: str, file_path: str = "") -> ContextBundle:
184
+ """
185
+ Full picture: all inbound and outbound relationships for any node,
186
+ across both code and knowledge layers.
187
+ """
188
+ params = {"name": name, "file_path": file_path}
189
+ target = ContextNode(type="Node", name=name, file_path=file_path)
190
+ nodes: list[ContextNode] = []
191
+ edges: list[dict[str, str]] = []
192
+
193
+ outbound = self.store.query(queries.OUTBOUND, params)
194
+ for row in (outbound.result_set or []):
195
+ rel, ntype, nname, npath = row[0], row[1], row[2], row[3]
196
+ nodes.append(ContextNode(type=ntype, name=nname, file_path=npath))
197
+ edges.append({"from": name, "type": rel, "to": nname})
198
+
199
+ inbound = self.store.query(queries.INBOUND, params)
200
+ for row in (inbound.result_set or []):
201
+ rel, ntype, nname, npath = row[0], row[1], row[2], row[3]
202
+ nodes.append(ContextNode(type=ntype, name=nname, file_path=npath))
203
+ edges.append({"from": nname, "type": rel, "to": name})
204
+
205
+ return ContextBundle(target=target, nodes=nodes, edges=edges,
206
+ metadata={"query": "explain"})
207
+
208
+ # ── Knowledge: concept ────────────────────────────────────────────────────
209
+
210
+ def load_concept(self, name: str) -> ContextBundle:
211
+ """Concept + governing rules + related concepts + implementing code + wiki pages."""
212
+ result = self.store.query(queries.CONCEPT_CONTEXT, {"name": name})
213
+ rows = result.result_set or []
214
+
215
+ if not rows:
216
+ return ContextBundle(target=ContextNode(type="Concept", name=name),
217
+ metadata={"query": "concept_context", "found": False})
218
+
219
+ row = rows[0]
220
+ target = ContextNode(
221
+ type="Concept", name=row[0],
222
+ description=row[1], status=row[2], domain=row[3],
223
+ )
224
+ nodes: list[ContextNode] = []
225
+ edges: list[dict[str, str]] = []
226
+
227
+ for cname in (row[4] or []):
228
+ nodes.append(ContextNode(type="Concept", name=cname))
229
+ edges.append({"from": name, "type": "RELATED_TO", "to": cname})
230
+ for rname in (row[5] or []):
231
+ nodes.append(ContextNode(type="Rule", name=rname))
232
+ edges.append({"from": rname, "type": "GOVERNS", "to": name})
233
+ for wname in (row[6] or []):
234
+ nodes.append(ContextNode(type="WikiPage", name=wname))
235
+ edges.append({"from": wname, "type": "DOCUMENTS", "to": name})
236
+ for iname in (row[7] or []):
237
+ nodes.append(ContextNode(type="Code", name=iname))
238
+ edges.append({"from": iname, "type": "IMPLEMENTS", "to": name})
239
+
240
+ return ContextBundle(target=target, nodes=nodes, edges=edges,
241
+ metadata={"query": "concept_context"})
242
+
243
+ # ── Knowledge: domain ─────────────────────────────────────────────────────
244
+
245
+ def load_domain(self, domain: str) -> ContextBundle:
246
+ """Everything belonging to a domain — code and knowledge."""
247
+ result = self.store.query(queries.DOMAIN_CONTENTS, {"domain": domain})
248
+ target = ContextNode(type="Domain", name=domain)
249
+ nodes = [
250
+ ContextNode(type=row[0], name=row[1], file_path=row[2],
251
+ description=row[3] or None)
252
+ for row in (result.result_set or [])
253
+ ]
254
+ return ContextBundle(target=target, nodes=nodes,
255
+ metadata={"query": "domain_contents"})
256
+
257
+ # ── Search ────────────────────────────────────────────────────────────────
258
+
161259
def search(self, query: str, limit: int = 20) -> list[ContextNode]:
162
- """Fuzzy search symbols (functions, classes, methods) by name."""
260
+ """Search code symbols by name."""
163261
result = self.store.query(queries.SYMBOL_SEARCH, {"query": query, "limit": limit})
164262
return [
165263
ContextNode(type=row[0], name=row[1], file_path=row[2],
166264
line_start=row[3], docstring=row[4])
167265
for row in (result.result_set or [])
168266
]
267
+
268
+ def search_all(self, query: str, limit: int = 20) -> list[ContextNode]:
269
+ """Search everything — code symbols, concepts, rules, decisions, wiki."""
270
+ result = self.store.query(queries.GLOBAL_SEARCH, {"query": query, "limit": limit})
271
+ return [
272
+ ContextNode(type=row[0], name=row[1], file_path=row[2],
273
+ docstring=row[3], line_start=row[4])
274
+ for row in (result.result_set or [])
275
+ ]
276
+
277
+ def search_by_docstring(self, query: str, limit: int = 20) -> list[ContextNode]:
278
+ """Search functions/classes whose docstring contains the query."""
279
+ result = self.store.query(queries.DOCSTRING_SEARCH, {"query": query, "limit": limit})
280
+ return [
281
+ ContextNode(type=row[0], name=row[1], file_path=row[2],
282
+ line_start=row[3], docstring=row[4])
283
+ for row in (result.result_set or [])
284
+ ]
285
+
286
+ def decorated_by(self, decorator_name: str) -> list[ContextNode]:
287
+ """All functions/methods carrying a given decorator."""
288
+ result = self.store.query(queries.DECORATED_BY,
289
+ {"decorator_name": decorator_name})
290
+ return [
291
+ ContextNode(type=row[0], name=row[1], file_path=row[2], line_start=row[3])
292
+ for row in (result.result_set or [])
293
+ ]
169294
--- navegador/context/loader.py
+++ navegador/context/loader.py
@@ -1,17 +1,13 @@
1 """
2 ContextLoader — builds structured context bundles from the graph.
3
4 A context bundle contains:
5 - The target node (file / function / class)
6 - Its immediate neighbors up to a configurable depth
7 - Relationships between those nodes
8 - Source snippets (optional)
9
10 Output can be:
11 - dict (structured JSON-serialisable)
12 - markdown string (for direct paste into AI chat)
13 """
14
15 import json
16 import logging
17 from dataclasses import dataclass, field
@@ -25,15 +21,18 @@
25
26 @dataclass
27 class ContextNode:
28 type: str
29 name: str
30 file_path: str
31 line_start: int | None = None
32 docstring: str | None = None
33 signature: str | None = None
34 source: str | None = None
 
 
 
35
36
37 @dataclass
38 class ContextBundle:
39 target: ContextNode
@@ -53,24 +52,31 @@
53 return json.dumps(self.to_dict(), indent=indent)
54
55 def to_markdown(self) -> str:
56 lines = [
57 f"# Context: `{self.target.name}`",
58 f"**File:** `{self.target.file_path}`",
59 f"**Type:** {self.target.type}",
60 ]
61 if self.target.docstring:
62 lines += ["", f"> {self.target.docstring}"]
 
 
 
 
 
 
63 if self.target.signature:
64 lines += ["", f"```python\n{self.target.signature}\n```"]
65
66 if self.nodes:
67 lines += ["", "## Related nodes", ""]
68 for node in self.nodes:
69 lines.append(f"- **{node.type}** `{node.name}` — `{node.file_path}`")
70 if node.docstring:
71 lines.append(f" > {node.docstring}")
 
 
72
73 if self.edges:
74 lines += ["", "## Relationships", ""]
75 for edge in self.edges:
76 lines.append(f"- `{edge['from']}` **{edge['type']}** `{edge['to']}`")
@@ -78,29 +84,33 @@
78 return "\n".join(lines)
79
80
81 class ContextLoader:
82 """
83 Loads structured context bundles from the navegador graph.
84
85 Usage:
86 store = GraphStore.sqlite()
87 loader = ContextLoader(store)
88
89 bundle = loader.load_file("src/auth.py")
90 bundle = loader.load_function("get_user", file_path="src/auth.py")
91 bundle = loader.load_class("AuthService", file_path="src/auth.py")
 
 
 
92 """
93
94 def __init__(self, store: GraphStore) -> None:
95 self.store = store
96
97 def load_file(self, file_path: str, depth: int = 2) -> ContextBundle:
98 """Load context for an entire file — all its symbols + their dependencies."""
 
 
99 result = self.store.query(queries.FILE_CONTENTS, {"path": file_path})
100 target = ContextNode(type="File", name=Path(file_path).name, file_path=file_path)
101
102 nodes = []
103 for row in (result.result_set or []):
104 nodes.append(ContextNode(
105 type=row[0] or "Unknown",
106 name=row[1] or "",
@@ -107,42 +117,47 @@
107 file_path=file_path,
108 line_start=row[2],
109 docstring=row[3],
110 signature=row[4],
111 ))
 
 
112
113 return ContextBundle(
114 target=target,
115 nodes=nodes,
116 metadata={"depth": depth, "query": "file_contents"},
117 )
118
119 def load_function(self, name: str, file_path: str = "", depth: int = 2) -> ContextBundle:
120 """Load context for a function — its callers and callees."""
121 target = ContextNode(type="Function", name=name, file_path=file_path)
122 nodes: list[ContextNode] = []
123 edges: list[dict[str, str]] = []
124
125 callees = self.store.query(
126 queries.CALLEES, {"name": name, "file_path": file_path, "depth": depth}
127 )
128 for row in (callees.result_set or []):
129 nodes.append(ContextNode(type=row[0], name=row[1], file_path=row[2], line_start=row[3]))
130 edges.append({"from": name, "type": "CALLS", "to": row[1]})
131
132 callers = self.store.query(
133 queries.CALLERS, {"name": name, "file_path": file_path, "depth": depth}
134 )
135 for row in (callers.result_set or []):
136 nodes.append(ContextNode(type=row[0], name=row[1], file_path=row[2], line_start=row[3]))
137 edges.append({"from": row[1], "type": "CALLS", "to": name})
138
 
 
 
 
 
 
 
139 return ContextBundle(target=target, nodes=nodes, edges=edges,
140 metadata={"depth": depth, "query": "function_context"})
141
 
 
142 def load_class(self, name: str, file_path: str = "") -> ContextBundle:
143 """Load context for a class — its methods, parent classes, and subclasses."""
144 target = ContextNode(type="Class", name=name, file_path=file_path)
145 nodes: list[ContextNode] = []
146 edges: list[dict[str, str]] = []
147
148 parents = self.store.query(queries.CLASS_HIERARCHY, {"name": name})
@@ -153,16 +168,126 @@
153 subs = self.store.query(queries.SUBCLASSES, {"name": name})
154 for row in (subs.result_set or []):
155 nodes.append(ContextNode(type="Class", name=row[0], file_path=row[1]))
156 edges.append({"from": row[0], "type": "INHERITS", "to": name})
157
 
 
 
 
 
158 return ContextBundle(target=target, nodes=nodes, edges=edges,
159 metadata={"query": "class_context"})
160
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161 def search(self, query: str, limit: int = 20) -> list[ContextNode]:
162 """Fuzzy search symbols (functions, classes, methods) by name."""
163 result = self.store.query(queries.SYMBOL_SEARCH, {"query": query, "limit": limit})
164 return [
165 ContextNode(type=row[0], name=row[1], file_path=row[2],
166 line_start=row[3], docstring=row[4])
167 for row in (result.result_set or [])
168 ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
--- navegador/context/loader.py
+++ navegador/context/loader.py
@@ -1,17 +1,13 @@
1 """
2 ContextLoader — builds structured context bundles from the navegador graph.
3
4 Operates across both layers:
5 CODE — files, functions, classes, call graphs, decorators, references
6 KNOWLEDGE — concepts, rules, decisions, wiki pages, domains
7
8 Output can be dict, JSON string, or markdown for direct paste into AI chat.
 
 
 
 
9 """
10
11 import json
12 import logging
13 from dataclasses import dataclass, field
@@ -25,15 +21,18 @@
21
22 @dataclass
23 class ContextNode:
24 type: str
25 name: str
26 file_path: str = ""
27 line_start: int | None = None
28 docstring: str | None = None
29 signature: str | None = None
30 source: str | None = None
31 description: str | None = None
32 domain: str | None = None
33 status: str | None = None
34
35
36 @dataclass
37 class ContextBundle:
38 target: ContextNode
@@ -53,24 +52,31 @@
52 return json.dumps(self.to_dict(), indent=indent)
53
54 def to_markdown(self) -> str:
55 lines = [
56 f"# Context: `{self.target.name}`",
 
57 f"**Type:** {self.target.type}",
58 ]
59 if self.target.file_path:
60 lines.append(f"**File:** `{self.target.file_path}`")
61 if self.target.domain:
62 lines.append(f"**Domain:** {self.target.domain}")
63 if self.target.status:
64 lines.append(f"**Status:** {self.target.status}")
65 if self.target.docstring or self.target.description:
66 lines += ["", f"> {self.target.docstring or self.target.description}"]
67 if self.target.signature:
68 lines += ["", f"```python\n{self.target.signature}\n```"]
69
70 if self.nodes:
71 lines += ["", "## Related nodes", ""]
72 for node in self.nodes:
73 loc = f"`{node.file_path}`" if node.file_path else ""
74 lines.append(f"- **{node.type}** `{node.name}` {loc}".strip())
75 summary = node.docstring or node.description
76 if summary:
77 lines.append(f" > {summary}")
78
79 if self.edges:
80 lines += ["", "## Relationships", ""]
81 for edge in self.edges:
82 lines.append(f"- `{edge['from']}` **{edge['type']}** `{edge['to']}`")
@@ -78,29 +84,33 @@
84 return "\n".join(lines)
85
86
87 class ContextLoader:
88 """
89 Loads context bundles from the navegador graph — code and knowledge layers.
90
91 Usage:
92 store = GraphStore.sqlite()
93 loader = ContextLoader(store)
94
95 bundle = loader.load_file("src/auth.py")
96 bundle = loader.load_function("validate_token")
97 bundle = loader.load_class("AuthService")
98 bundle = loader.explain("validate_token") # full picture
99 bundle = loader.load_concept("JWT")
100 bundle = loader.load_domain("auth")
101 """
102
103 def __init__(self, store: GraphStore) -> None:
104 self.store = store
105
106 # ── Code: file ────────────────────────────────────────────────────────────
107
108 def load_file(self, file_path: str) -> ContextBundle:
109 """All symbols in a file and their relationships."""
110 result = self.store.query(queries.FILE_CONTENTS, {"path": file_path})
111 target = ContextNode(type="File", name=Path(file_path).name, file_path=file_path)
 
112 nodes = []
113 for row in (result.result_set or []):
114 nodes.append(ContextNode(
115 type=row[0] or "Unknown",
116 name=row[1] or "",
@@ -107,42 +117,47 @@
117 file_path=file_path,
118 line_start=row[2],
119 docstring=row[3],
120 signature=row[4],
121 ))
122 return ContextBundle(target=target, nodes=nodes,
123 metadata={"query": "file_contents"})
124
125 # ── Code: function ────────────────────────────────────────────────────────
 
 
 
 
126
127 def load_function(self, name: str, file_path: str = "", depth: int = 2) -> ContextBundle:
128 """Callers, callees, decorators — everything touching this function."""
129 target = ContextNode(type="Function", name=name, file_path=file_path)
130 nodes: list[ContextNode] = []
131 edges: list[dict[str, str]] = []
132
133 params = {"name": name, "file_path": file_path, "depth": depth}
134
135 callees = self.store.query(queries.CALLEES, params)
136 for row in (callees.result_set or []):
137 nodes.append(ContextNode(type=row[0], name=row[1], file_path=row[2], line_start=row[3]))
138 edges.append({"from": name, "type": "CALLS", "to": row[1]})
139
140 callers = self.store.query(queries.CALLERS, params)
 
 
141 for row in (callers.result_set or []):
142 nodes.append(ContextNode(type=row[0], name=row[1], file_path=row[2], line_start=row[3]))
143 edges.append({"from": row[1], "type": "CALLS", "to": name})
144
145 decorators = self.store.query(
146 queries.DECORATORS_FOR, {"name": name, "file_path": file_path}
147 )
148 for row in (decorators.result_set or []):
149 nodes.append(ContextNode(type="Decorator", name=row[0], file_path=row[1]))
150 edges.append({"from": row[0], "type": "DECORATES", "to": name})
151
152 return ContextBundle(target=target, nodes=nodes, edges=edges,
153 metadata={"depth": depth, "query": "function_context"})
154
155 # ── Code: class ───────────────────────────────────────────────────────────
156
157 def load_class(self, name: str, file_path: str = "") -> ContextBundle:
158 """Methods, parent classes, subclasses, references."""
159 target = ContextNode(type="Class", name=name, file_path=file_path)
160 nodes: list[ContextNode] = []
161 edges: list[dict[str, str]] = []
162
163 parents = self.store.query(queries.CLASS_HIERARCHY, {"name": name})
@@ -153,16 +168,126 @@
168 subs = self.store.query(queries.SUBCLASSES, {"name": name})
169 for row in (subs.result_set or []):
170 nodes.append(ContextNode(type="Class", name=row[0], file_path=row[1]))
171 edges.append({"from": row[0], "type": "INHERITS", "to": name})
172
173 refs = self.store.query(queries.REFERENCES_TO, {"name": name, "file_path": ""})
174 for row in (refs.result_set or []):
175 nodes.append(ContextNode(type=row[0], name=row[1], file_path=row[2], line_start=row[3]))
176 edges.append({"from": row[1], "type": "REFERENCES", "to": name})
177
178 return ContextBundle(target=target, nodes=nodes, edges=edges,
179 metadata={"query": "class_context"})
180
181 # ── Universal: explain ────────────────────────────────────────────────────
182
183 def explain(self, name: str, file_path: str = "") -> ContextBundle:
184 """
185 Full picture: all inbound and outbound relationships for any node,
186 across both code and knowledge layers.
187 """
188 params = {"name": name, "file_path": file_path}
189 target = ContextNode(type="Node", name=name, file_path=file_path)
190 nodes: list[ContextNode] = []
191 edges: list[dict[str, str]] = []
192
193 outbound = self.store.query(queries.OUTBOUND, params)
194 for row in (outbound.result_set or []):
195 rel, ntype, nname, npath = row[0], row[1], row[2], row[3]
196 nodes.append(ContextNode(type=ntype, name=nname, file_path=npath))
197 edges.append({"from": name, "type": rel, "to": nname})
198
199 inbound = self.store.query(queries.INBOUND, params)
200 for row in (inbound.result_set or []):
201 rel, ntype, nname, npath = row[0], row[1], row[2], row[3]
202 nodes.append(ContextNode(type=ntype, name=nname, file_path=npath))
203 edges.append({"from": nname, "type": rel, "to": name})
204
205 return ContextBundle(target=target, nodes=nodes, edges=edges,
206 metadata={"query": "explain"})
207
208 # ── Knowledge: concept ────────────────────────────────────────────────────
209
210 def load_concept(self, name: str) -> ContextBundle:
211 """Concept + governing rules + related concepts + implementing code + wiki pages."""
212 result = self.store.query(queries.CONCEPT_CONTEXT, {"name": name})
213 rows = result.result_set or []
214
215 if not rows:
216 return ContextBundle(target=ContextNode(type="Concept", name=name),
217 metadata={"query": "concept_context", "found": False})
218
219 row = rows[0]
220 target = ContextNode(
221 type="Concept", name=row[0],
222 description=row[1], status=row[2], domain=row[3],
223 )
224 nodes: list[ContextNode] = []
225 edges: list[dict[str, str]] = []
226
227 for cname in (row[4] or []):
228 nodes.append(ContextNode(type="Concept", name=cname))
229 edges.append({"from": name, "type": "RELATED_TO", "to": cname})
230 for rname in (row[5] or []):
231 nodes.append(ContextNode(type="Rule", name=rname))
232 edges.append({"from": rname, "type": "GOVERNS", "to": name})
233 for wname in (row[6] or []):
234 nodes.append(ContextNode(type="WikiPage", name=wname))
235 edges.append({"from": wname, "type": "DOCUMENTS", "to": name})
236 for iname in (row[7] or []):
237 nodes.append(ContextNode(type="Code", name=iname))
238 edges.append({"from": iname, "type": "IMPLEMENTS", "to": name})
239
240 return ContextBundle(target=target, nodes=nodes, edges=edges,
241 metadata={"query": "concept_context"})
242
243 # ── Knowledge: domain ─────────────────────────────────────────────────────
244
245 def load_domain(self, domain: str) -> ContextBundle:
246 """Everything belonging to a domain — code and knowledge."""
247 result = self.store.query(queries.DOMAIN_CONTENTS, {"domain": domain})
248 target = ContextNode(type="Domain", name=domain)
249 nodes = [
250 ContextNode(type=row[0], name=row[1], file_path=row[2],
251 description=row[3] or None)
252 for row in (result.result_set or [])
253 ]
254 return ContextBundle(target=target, nodes=nodes,
255 metadata={"query": "domain_contents"})
256
257 # ── Search ────────────────────────────────────────────────────────────────
258
259 def search(self, query: str, limit: int = 20) -> list[ContextNode]:
260 """Search code symbols by name."""
261 result = self.store.query(queries.SYMBOL_SEARCH, {"query": query, "limit": limit})
262 return [
263 ContextNode(type=row[0], name=row[1], file_path=row[2],
264 line_start=row[3], docstring=row[4])
265 for row in (result.result_set or [])
266 ]
267
268 def search_all(self, query: str, limit: int = 20) -> list[ContextNode]:
269 """Search everything — code symbols, concepts, rules, decisions, wiki."""
270 result = self.store.query(queries.GLOBAL_SEARCH, {"query": query, "limit": limit})
271 return [
272 ContextNode(type=row[0], name=row[1], file_path=row[2],
273 docstring=row[3], line_start=row[4])
274 for row in (result.result_set or [])
275 ]
276
277 def search_by_docstring(self, query: str, limit: int = 20) -> list[ContextNode]:
278 """Search functions/classes whose docstring contains the query."""
279 result = self.store.query(queries.DOCSTRING_SEARCH, {"query": query, "limit": limit})
280 return [
281 ContextNode(type=row[0], name=row[1], file_path=row[2],
282 line_start=row[3], docstring=row[4])
283 for row in (result.result_set or [])
284 ]
285
286 def decorated_by(self, decorator_name: str) -> list[ContextNode]:
287 """All functions/methods carrying a given decorator."""
288 result = self.store.query(queries.DECORATED_BY,
289 {"decorator_name": decorator_name})
290 return [
291 ContextNode(type=row[0], name=row[1], file_path=row[2], line_start=row[3])
292 for row in (result.result_set or [])
293 ]
294
--- navegador/graph/queries.py
+++ navegador/graph/queries.py
@@ -1,60 +1,202 @@
11
"""
2
-Common Cypher query templates for navegador context loading.
2
+Cypher query templates for navegador.
3
+
4
+Parameters passed as $name are substituted by FalkorDB at query time.
5
+Optional file_path filtering: when file_path is "" the WHERE clause is omitted
6
+so callers/callees/references work across the whole graph by name alone.
37
"""
48
5
-# Find all nodes contained in a file
9
+# ── Code: file contents ───────────────────────────────────────────────────────
10
+
611
FILE_CONTENTS = """
712
MATCH (f:File {path: $path})-[:CONTAINS]->(n)
813
RETURN labels(n)[0] AS type, n.name AS name, n.line_start AS line,
914
n.docstring AS docstring, n.signature AS signature
1015
ORDER BY n.line_start
1116
"""
1217
13
-# Find everything a function/file directly imports
1418
DIRECT_IMPORTS = """
1519
MATCH (n {file_path: $file_path, name: $name})-[:IMPORTS]->(dep)
1620
RETURN labels(dep)[0] AS type, dep.name AS name, dep.file_path AS file_path
1721
"""
1822
19
-# Find callers of a function (up to N hops)
23
+# ── Code: call graph ──────────────────────────────────────────────────────────
24
+
25
+# file_path is optional — if empty, match by name only across all files
2026
CALLERS = """
21
-MATCH (caller)-[:CALLS*1..$depth]->(fn {name: $name, file_path: $file_path})
27
+MATCH (caller)-[:CALLS*1..$depth]->(fn)
28
+WHERE fn.name = $name AND ($file_path = '' OR fn.file_path = $file_path)
2229
RETURN DISTINCT labels(caller)[0] AS type, caller.name AS name,
2330
caller.file_path AS file_path, caller.line_start AS line
2431
"""
2532
26
-# Find callees of a function (what it calls, up to N hops)
2733
CALLEES = """
28
-MATCH (fn {name: $name, file_path: $file_path})-[:CALLS*1..$depth]->(callee)
34
+MATCH (fn)-[:CALLS*1..$depth]->(callee)
35
+WHERE fn.name = $name AND ($file_path = '' OR fn.file_path = $file_path)
2936
RETURN DISTINCT labels(callee)[0] AS type, callee.name AS name,
3037
callee.file_path AS file_path, callee.line_start AS line
3138
"""
3239
33
-# Class hierarchy
40
+# ── Code: class hierarchy ─────────────────────────────────────────────────────
41
+
3442
CLASS_HIERARCHY = """
3543
MATCH (c:Class {name: $name})-[:INHERITS*]->(parent)
3644
RETURN parent.name AS name, parent.file_path AS file_path
3745
"""
3846
39
-# All subclasses
4047
SUBCLASSES = """
4148
MATCH (child:Class)-[:INHERITS*]->(c:Class {name: $name})
4249
RETURN child.name AS name, child.file_path AS file_path
4350
"""
4451
45
-# Context bundle: a file + everything 1-2 hops out
46
-CONTEXT_BUNDLE = """
47
-MATCH (root {file_path: $file_path})
48
-OPTIONAL MATCH (root)-[r1:CALLS|IMPORTS|CONTAINS|INHERITS*1..2]-(neighbor)
49
-RETURN root, collect(DISTINCT neighbor) AS neighbors,
50
- collect(DISTINCT type(r1)) AS edge_types
52
+# ── Code: decorators ─────────────────────────────────────────────────────────
53
+
54
+# All functions/methods carrying a given decorator
55
+DECORATED_BY = """
56
+MATCH (d:Decorator {name: $decorator_name})-[:DECORATES]->(n)
57
+RETURN labels(n)[0] AS type, n.name AS name, n.file_path AS file_path,
58
+ n.line_start AS line
59
+"""
60
+
61
+# All decorators on a given function/method
62
+DECORATORS_FOR = """
63
+MATCH (d:Decorator)-[:DECORATES]->(n)
64
+WHERE n.name = $name AND ($file_path = '' OR n.file_path = $file_path)
65
+RETURN d.name AS decorator, d.file_path AS file_path, d.line_start AS line
66
+"""
67
+
68
+# ── Code: references ─────────────────────────────────────────────────────────
69
+
70
+REFERENCES_TO = """
71
+MATCH (src)-[:REFERENCES]->(tgt)
72
+WHERE tgt.name = $name AND ($file_path = '' OR tgt.file_path = $file_path)
73
+RETURN DISTINCT labels(src)[0] AS type, src.name AS name,
74
+ src.file_path AS file_path, src.line_start AS line
75
+"""
76
+
77
+# ── Universal: neighbor traversal ────────────────────────────────────────────
78
+
79
+# All nodes reachable from a named node within N hops (any edge type)
80
+NEIGHBORS = """
81
+MATCH (root)
82
+WHERE root.name = $name AND ($file_path = '' OR root.file_path = $file_path)
83
+OPTIONAL MATCH (root)-[r*1..$depth]-(neighbor)
84
+RETURN DISTINCT
85
+ labels(root)[0] AS root_type,
86
+ root.name AS root_name,
87
+ labels(neighbor)[0] AS neighbor_type,
88
+ neighbor.name AS neighbor_name,
89
+ neighbor.file_path AS neighbor_file_path
5190
"""
5291
53
-# Symbol search
92
+# ── Universal: search ─────────────────────────────────────────────────────────
93
+
94
+# Search code symbols by name substring
5495
SYMBOL_SEARCH = """
5596
MATCH (n)
5697
WHERE (n:Function OR n:Class OR n:Method) AND n.name CONTAINS $query
5798
RETURN labels(n)[0] AS type, n.name AS name, n.file_path AS file_path,
5899
n.line_start AS line, n.docstring AS docstring
59100
LIMIT $limit
60101
"""
102
+
103
+# Search code symbols by docstring content
104
+DOCSTRING_SEARCH = """
105
+MATCH (n)
106
+WHERE (n:Function OR n:Class OR n:Method)
107
+ AND n.docstring IS NOT NULL
108
+ AND toLower(n.docstring) CONTAINS toLower($query)
109
+RETURN labels(n)[0] AS type, n.name AS name, n.file_path AS file_path,
110
+ n.line_start AS line, n.docstring AS docstring
111
+LIMIT $limit
112
+"""
113
+
114
+# Search knowledge layer (concepts, rules, decisions, wiki) by name or description
115
+KNOWLEDGE_SEARCH = """
116
+MATCH (n)
117
+WHERE (n:Concept OR n:Rule OR n:Decision OR n:WikiPage)
118
+ AND (toLower(n.name) CONTAINS toLower($query)
119
+ OR (n.description IS NOT NULL AND toLower(n.description) CONTAINS toLower($query)))
120
+RETURN labels(n)[0] AS type, n.name AS name, n.description AS description,
121
+ n.domain AS domain, n.status AS status
122
+LIMIT $limit
123
+"""
124
+
125
+# Search everything — code + knowledge — in one query
126
+GLOBAL_SEARCH = """
127
+MATCH (n)
128
+WHERE (n:Function OR n:Class OR n:Method OR n:Concept OR n:Rule OR n:Decision OR n:WikiPage)
129
+ AND (toLower(n.name) CONTAINS toLower($query)
130
+ OR (n.docstring IS NOT NULL AND toLower(n.docstring) CONTAINS toLower($query))
131
+ OR (n.description IS NOT NULL AND toLower(n.description) CONTAINS toLower($query)))
132
+RETURN labels(n)[0] AS type, n.name AS name,
133
+ coalesce(n.file_path, '') AS file_path,
134
+ coalesce(n.docstring, n.description, '') AS summary,
135
+ n.line_start AS line
136
+LIMIT $limit
137
+"""
138
+
139
+# ── Knowledge: domain ─────────────────────────────────────────────────────────
140
+
141
+DOMAIN_CONTENTS = """
142
+MATCH (n)-[:BELONGS_TO]->(d:Domain {name: $domain})
143
+RETURN labels(n)[0] AS type, n.name AS name,
144
+ coalesce(n.file_path, '') AS file_path,
145
+ coalesce(n.docstring, n.description, '') AS summary
146
+ORDER BY labels(n)[0], n.name
147
+"""
148
+
149
+# ── Knowledge: concept context ───────────────────────────────────────────────
150
+
151
+CONCEPT_CONTEXT = """
152
+MATCH (c:Concept {name: $name})
153
+OPTIONAL MATCH (c)-[:RELATED_TO]-(related:Concept)
154
+OPTIONAL MATCH (rule:Rule)-[:GOVERNS]->(c)
155
+OPTIONAL MATCH (wiki:WikiPage)-[:DOCUMENTS]->(c)
156
+OPTIONAL MATCH (impl)-[:IMPLEMENTS]->(c)
157
+OPTIONAL MATCH (c)-[:BELONGS_TO]->(domain:Domain)
158
+RETURN
159
+ c.name AS name, c.description AS description,
160
+ c.status AS status, c.domain AS domain,
161
+ collect(DISTINCT related.name) AS related_concepts,
162
+ collect(DISTINCT rule.name) AS governing_rules,
163
+ collect(DISTINCT wiki.name) AS wiki_pages,
164
+ collect(DISTINCT impl.name) AS implemented_by,
165
+ collect(DISTINCT domain.name) AS domains
166
+"""
167
+
168
+# ── Explain: full picture for any named node ──────────────────────────────────
169
+
170
+# All outbound relationships from a node
171
+OUTBOUND = """
172
+MATCH (n)-[r]->(neighbor)
173
+WHERE n.name = $name AND ($file_path = '' OR n.file_path = $file_path)
174
+RETURN type(r) AS rel, labels(neighbor)[0] AS neighbor_type,
175
+ neighbor.name AS neighbor_name,
176
+ coalesce(neighbor.file_path, '') AS neighbor_file_path
177
+ORDER BY rel, neighbor_name
178
+"""
179
+
180
+# All inbound relationships to a node
181
+INBOUND = """
182
+MATCH (neighbor)-[r]->(n)
183
+WHERE n.name = $name AND ($file_path = '' OR n.file_path = $file_path)
184
+RETURN type(r) AS rel, labels(neighbor)[0] AS neighbor_type,
185
+ neighbor.name AS neighbor_name,
186
+ coalesce(neighbor.file_path, '') AS neighbor_file_path
187
+ORDER BY rel, neighbor_name
188
+"""
189
+
190
+# ── Stats ─────────────────────────────────────────────────────────────────────
191
+
192
+NODE_TYPE_COUNTS = """
193
+MATCH (n)
194
+RETURN labels(n)[0] AS type, count(n) AS count
195
+ORDER BY count DESC
196
+"""
197
+
198
+EDGE_TYPE_COUNTS = """
199
+MATCH ()-[r]->()
200
+RETURN type(r) AS type, count(r) AS count
201
+ORDER BY count DESC
202
+"""
61203
--- navegador/graph/queries.py
+++ navegador/graph/queries.py
@@ -1,60 +1,202 @@
1 """
2 Common Cypher query templates for navegador context loading.
 
 
 
 
3 """
4
5 # Find all nodes contained in a file
 
6 FILE_CONTENTS = """
7 MATCH (f:File {path: $path})-[:CONTAINS]->(n)
8 RETURN labels(n)[0] AS type, n.name AS name, n.line_start AS line,
9 n.docstring AS docstring, n.signature AS signature
10 ORDER BY n.line_start
11 """
12
13 # Find everything a function/file directly imports
14 DIRECT_IMPORTS = """
15 MATCH (n {file_path: $file_path, name: $name})-[:IMPORTS]->(dep)
16 RETURN labels(dep)[0] AS type, dep.name AS name, dep.file_path AS file_path
17 """
18
19 # Find callers of a function (up to N hops)
 
 
20 CALLERS = """
21 MATCH (caller)-[:CALLS*1..$depth]->(fn {name: $name, file_path: $file_path})
 
22 RETURN DISTINCT labels(caller)[0] AS type, caller.name AS name,
23 caller.file_path AS file_path, caller.line_start AS line
24 """
25
26 # Find callees of a function (what it calls, up to N hops)
27 CALLEES = """
28 MATCH (fn {name: $name, file_path: $file_path})-[:CALLS*1..$depth]->(callee)
 
29 RETURN DISTINCT labels(callee)[0] AS type, callee.name AS name,
30 callee.file_path AS file_path, callee.line_start AS line
31 """
32
33 # Class hierarchy
 
34 CLASS_HIERARCHY = """
35 MATCH (c:Class {name: $name})-[:INHERITS*]->(parent)
36 RETURN parent.name AS name, parent.file_path AS file_path
37 """
38
39 # All subclasses
40 SUBCLASSES = """
41 MATCH (child:Class)-[:INHERITS*]->(c:Class {name: $name})
42 RETURN child.name AS name, child.file_path AS file_path
43 """
44
45 # Context bundle: a file + everything 1-2 hops out
46 CONTEXT_BUNDLE = """
47 MATCH (root {file_path: $file_path})
48 OPTIONAL MATCH (root)-[r1:CALLS|IMPORTS|CONTAINS|INHERITS*1..2]-(neighbor)
49 RETURN root, collect(DISTINCT neighbor) AS neighbors,
50 collect(DISTINCT type(r1)) AS edge_types
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51 """
52
53 # Symbol search
 
 
54 SYMBOL_SEARCH = """
55 MATCH (n)
56 WHERE (n:Function OR n:Class OR n:Method) AND n.name CONTAINS $query
57 RETURN labels(n)[0] AS type, n.name AS name, n.file_path AS file_path,
58 n.line_start AS line, n.docstring AS docstring
59 LIMIT $limit
60 """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
--- navegador/graph/queries.py
+++ navegador/graph/queries.py
@@ -1,60 +1,202 @@
1 """
2 Cypher query templates for navegador.
3
4 Parameters passed as $name are substituted by FalkorDB at query time.
5 Optional file_path filtering: when file_path is "" the WHERE clause is omitted
6 so callers/callees/references work across the whole graph by name alone.
7 """
8
9 # ── Code: file contents ───────────────────────────────────────────────────────
10
11 FILE_CONTENTS = """
12 MATCH (f:File {path: $path})-[:CONTAINS]->(n)
13 RETURN labels(n)[0] AS type, n.name AS name, n.line_start AS line,
14 n.docstring AS docstring, n.signature AS signature
15 ORDER BY n.line_start
16 """
17
 
18 DIRECT_IMPORTS = """
19 MATCH (n {file_path: $file_path, name: $name})-[:IMPORTS]->(dep)
20 RETURN labels(dep)[0] AS type, dep.name AS name, dep.file_path AS file_path
21 """
22
23 # ── Code: call graph ──────────────────────────────────────────────────────────
24
25 # file_path is optional — if empty, match by name only across all files
26 CALLERS = """
27 MATCH (caller)-[:CALLS*1..$depth]->(fn)
28 WHERE fn.name = $name AND ($file_path = '' OR fn.file_path = $file_path)
29 RETURN DISTINCT labels(caller)[0] AS type, caller.name AS name,
30 caller.file_path AS file_path, caller.line_start AS line
31 """
32
 
33 CALLEES = """
34 MATCH (fn)-[:CALLS*1..$depth]->(callee)
35 WHERE fn.name = $name AND ($file_path = '' OR fn.file_path = $file_path)
36 RETURN DISTINCT labels(callee)[0] AS type, callee.name AS name,
37 callee.file_path AS file_path, callee.line_start AS line
38 """
39
40 # ── Code: class hierarchy ─────────────────────────────────────────────────────
41
42 CLASS_HIERARCHY = """
43 MATCH (c:Class {name: $name})-[:INHERITS*]->(parent)
44 RETURN parent.name AS name, parent.file_path AS file_path
45 """
46
 
47 SUBCLASSES = """
48 MATCH (child:Class)-[:INHERITS*]->(c:Class {name: $name})
49 RETURN child.name AS name, child.file_path AS file_path
50 """
51
52 # ── Code: decorators ─────────────────────────────────────────────────────────
53
54 # All functions/methods carrying a given decorator
55 DECORATED_BY = """
56 MATCH (d:Decorator {name: $decorator_name})-[:DECORATES]->(n)
57 RETURN labels(n)[0] AS type, n.name AS name, n.file_path AS file_path,
58 n.line_start AS line
59 """
60
61 # All decorators on a given function/method
62 DECORATORS_FOR = """
63 MATCH (d:Decorator)-[:DECORATES]->(n)
64 WHERE n.name = $name AND ($file_path = '' OR n.file_path = $file_path)
65 RETURN d.name AS decorator, d.file_path AS file_path, d.line_start AS line
66 """
67
68 # ── Code: references ─────────────────────────────────────────────────────────
69
70 REFERENCES_TO = """
71 MATCH (src)-[:REFERENCES]->(tgt)
72 WHERE tgt.name = $name AND ($file_path = '' OR tgt.file_path = $file_path)
73 RETURN DISTINCT labels(src)[0] AS type, src.name AS name,
74 src.file_path AS file_path, src.line_start AS line
75 """
76
77 # ── Universal: neighbor traversal ────────────────────────────────────────────
78
79 # All nodes reachable from a named node within N hops (any edge type)
80 NEIGHBORS = """
81 MATCH (root)
82 WHERE root.name = $name AND ($file_path = '' OR root.file_path = $file_path)
83 OPTIONAL MATCH (root)-[r*1..$depth]-(neighbor)
84 RETURN DISTINCT
85 labels(root)[0] AS root_type,
86 root.name AS root_name,
87 labels(neighbor)[0] AS neighbor_type,
88 neighbor.name AS neighbor_name,
89 neighbor.file_path AS neighbor_file_path
90 """
91
92 # ── Universal: search ─────────────────────────────────────────────────────────
93
94 # Search code symbols by name substring
95 SYMBOL_SEARCH = """
96 MATCH (n)
97 WHERE (n:Function OR n:Class OR n:Method) AND n.name CONTAINS $query
98 RETURN labels(n)[0] AS type, n.name AS name, n.file_path AS file_path,
99 n.line_start AS line, n.docstring AS docstring
100 LIMIT $limit
101 """
102
103 # Search code symbols by docstring content
104 DOCSTRING_SEARCH = """
105 MATCH (n)
106 WHERE (n:Function OR n:Class OR n:Method)
107 AND n.docstring IS NOT NULL
108 AND toLower(n.docstring) CONTAINS toLower($query)
109 RETURN labels(n)[0] AS type, n.name AS name, n.file_path AS file_path,
110 n.line_start AS line, n.docstring AS docstring
111 LIMIT $limit
112 """
113
114 # Search knowledge layer (concepts, rules, decisions, wiki) by name or description
115 KNOWLEDGE_SEARCH = """
116 MATCH (n)
117 WHERE (n:Concept OR n:Rule OR n:Decision OR n:WikiPage)
118 AND (toLower(n.name) CONTAINS toLower($query)
119 OR (n.description IS NOT NULL AND toLower(n.description) CONTAINS toLower($query)))
120 RETURN labels(n)[0] AS type, n.name AS name, n.description AS description,
121 n.domain AS domain, n.status AS status
122 LIMIT $limit
123 """
124
125 # Search everything — code + knowledge — in one query
126 GLOBAL_SEARCH = """
127 MATCH (n)
128 WHERE (n:Function OR n:Class OR n:Method OR n:Concept OR n:Rule OR n:Decision OR n:WikiPage)
129 AND (toLower(n.name) CONTAINS toLower($query)
130 OR (n.docstring IS NOT NULL AND toLower(n.docstring) CONTAINS toLower($query))
131 OR (n.description IS NOT NULL AND toLower(n.description) CONTAINS toLower($query)))
132 RETURN labels(n)[0] AS type, n.name AS name,
133 coalesce(n.file_path, '') AS file_path,
134 coalesce(n.docstring, n.description, '') AS summary,
135 n.line_start AS line
136 LIMIT $limit
137 """
138
139 # ── Knowledge: domain ─────────────────────────────────────────────────────────
140
141 DOMAIN_CONTENTS = """
142 MATCH (n)-[:BELONGS_TO]->(d:Domain {name: $domain})
143 RETURN labels(n)[0] AS type, n.name AS name,
144 coalesce(n.file_path, '') AS file_path,
145 coalesce(n.docstring, n.description, '') AS summary
146 ORDER BY labels(n)[0], n.name
147 """
148
149 # ── Knowledge: concept context ───────────────────────────────────────────────
150
151 CONCEPT_CONTEXT = """
152 MATCH (c:Concept {name: $name})
153 OPTIONAL MATCH (c)-[:RELATED_TO]-(related:Concept)
154 OPTIONAL MATCH (rule:Rule)-[:GOVERNS]->(c)
155 OPTIONAL MATCH (wiki:WikiPage)-[:DOCUMENTS]->(c)
156 OPTIONAL MATCH (impl)-[:IMPLEMENTS]->(c)
157 OPTIONAL MATCH (c)-[:BELONGS_TO]->(domain:Domain)
158 RETURN
159 c.name AS name, c.description AS description,
160 c.status AS status, c.domain AS domain,
161 collect(DISTINCT related.name) AS related_concepts,
162 collect(DISTINCT rule.name) AS governing_rules,
163 collect(DISTINCT wiki.name) AS wiki_pages,
164 collect(DISTINCT impl.name) AS implemented_by,
165 collect(DISTINCT domain.name) AS domains
166 """
167
168 # ── Explain: full picture for any named node ──────────────────────────────────
169
170 # All outbound relationships from a node
171 OUTBOUND = """
172 MATCH (n)-[r]->(neighbor)
173 WHERE n.name = $name AND ($file_path = '' OR n.file_path = $file_path)
174 RETURN type(r) AS rel, labels(neighbor)[0] AS neighbor_type,
175 neighbor.name AS neighbor_name,
176 coalesce(neighbor.file_path, '') AS neighbor_file_path
177 ORDER BY rel, neighbor_name
178 """
179
180 # All inbound relationships to a node
181 INBOUND = """
182 MATCH (neighbor)-[r]->(n)
183 WHERE n.name = $name AND ($file_path = '' OR n.file_path = $file_path)
184 RETURN type(r) AS rel, labels(neighbor)[0] AS neighbor_type,
185 neighbor.name AS neighbor_name,
186 coalesce(neighbor.file_path, '') AS neighbor_file_path
187 ORDER BY rel, neighbor_name
188 """
189
190 # ── Stats ─────────────────────────────────────────────────────────────────────
191
192 NODE_TYPE_COUNTS = """
193 MATCH (n)
194 RETURN labels(n)[0] AS type, count(n) AS count
195 ORDER BY count DESC
196 """
197
198 EDGE_TYPE_COUNTS = """
199 MATCH ()-[r]->()
200 RETURN type(r) AS type, count(r) AS count
201 ORDER BY count DESC
202 """
203
--- navegador/graph/schema.py
+++ navegador/graph/schema.py
@@ -1,15 +1,23 @@
11
"""
22
Graph schema — node labels and edge types for the navegador property graph.
33
4
-Node properties vary by label but all share: name, file_path, line_start, line_end.
4
+Navegador maintains two complementary layers in one graph:
5
+
6
+ CODE layer — AST-derived structure (files, functions, classes, calls, imports)
7
+ KNOWLEDGE layer — business context (concepts, rules, decisions, wiki, people)
8
+
9
+The two layers are connected by IMPLEMENTS, DOCUMENTS, GOVERNS, and ANNOTATES
10
+edges, so agents can traverse from a function straight to the business rule it
11
+enforces, or from a wiki page down to the exact code that implements it.
512
"""
613
714
from enum import StrEnum
815
916
1017
class NodeLabel(StrEnum):
18
+ # ── Code layer ────────────────────────────────────────────────────────────
1119
Repository = "Repository"
1220
File = "File"
1321
Module = "Module"
1422
Class = "Class"
1523
Function = "Function"
@@ -16,28 +24,45 @@
1624
Method = "Method"
1725
Variable = "Variable"
1826
Import = "Import"
1927
Decorator = "Decorator"
2028
29
+ # ── Knowledge layer ───────────────────────────────────────────────────────
30
+ Domain = "Domain" # logical grouping (auth, billing, notifications…)
31
+ Concept = "Concept" # a named business entity or idea
32
+ Rule = "Rule" # a constraint, invariant, or business rule
33
+ Decision = "Decision" # an architectural or product decision + rationale
34
+ WikiPage = "WikiPage" # a page from the project wiki (GitHub, Confluence…)
35
+ Person = "Person" # a contributor, owner, or stakeholder
36
+
2137
2238
class EdgeType(StrEnum):
23
- # structural
24
- CONTAINS = "CONTAINS" # File -CONTAINS-> Function/Class/Variable
39
+ # ── Code structural ───────────────────────────────────────────────────────
40
+ CONTAINS = "CONTAINS" # File/Class -CONTAINS-> Function/Class/Variable
2541
DEFINES = "DEFINES" # Module -DEFINES-> Class/Function
26
- # dependencies
2742
IMPORTS = "IMPORTS" # File -IMPORTS-> Module/File
28
- DEPENDS_ON = "DEPENDS_ON" # Module/Package level dependency
29
- # code relationships
43
+ DEPENDS_ON = "DEPENDS_ON" # module/package-level dependency
3044
CALLS = "CALLS" # Function -CALLS-> Function
3145
REFERENCES = "REFERENCES" # Function/Class -REFERENCES-> Variable/Class
3246
INHERITS = "INHERITS" # Class -INHERITS-> Class
33
- IMPLEMENTS = "IMPLEMENTS" # Class -IMPLEMENTS-> Class (for interfaces/ABCs)
47
+ IMPLEMENTS = "IMPLEMENTS" # Class/Function -IMPLEMENTS-> Concept/Rule
3448
DECORATES = "DECORATES" # Decorator -DECORATES-> Function/Class
3549
50
+ # ── Knowledge structural ──────────────────────────────────────────────────
51
+ BELONGS_TO = "BELONGS_TO" # any node -BELONGS_TO-> Domain
52
+ RELATED_TO = "RELATED_TO" # Concept -RELATED_TO-> Concept (bidirectional intent)
53
+ GOVERNS = "GOVERNS" # Rule -GOVERNS-> Concept/Function/Class
54
+ DOCUMENTS = "DOCUMENTS" # WikiPage/Decision -DOCUMENTS-> any node
55
+ ANNOTATES = "ANNOTATES" # Concept/Rule -ANNOTATES-> code node (lightweight link)
56
+ ASSIGNED_TO = "ASSIGNED_TO" # any node -ASSIGNED_TO-> Person (ownership)
57
+ DECIDED_BY = "DECIDED_BY" # Decision -DECIDED_BY-> Person
58
+
3659
37
-# Common property keys per node label
60
+# ── Property keys per node label ──────────────────────────────────────────────
61
+
3862
NODE_PROPS = {
63
+ # Code layer
3964
NodeLabel.Repository: ["name", "path", "language", "description"],
4065
NodeLabel.File: ["name", "path", "language", "size", "line_count"],
4166
NodeLabel.Module: ["name", "file_path", "docstring"],
4267
NodeLabel.Class: ["name", "file_path", "line_start", "line_end", "docstring", "source"],
4368
NodeLabel.Function: [
@@ -48,6 +73,28 @@
4873
"docstring", "source", "signature", "class_name",
4974
],
5075
NodeLabel.Variable: ["name", "file_path", "line_start", "type_annotation"],
5176
NodeLabel.Import: ["name", "file_path", "line_start", "module", "alias"],
5277
NodeLabel.Decorator: ["name", "file_path", "line_start"],
78
+
79
+ # Knowledge layer
80
+ NodeLabel.Domain: ["name", "description"],
81
+ NodeLabel.Concept: [
82
+ "name", "description", "domain", "status",
83
+ "rules", "examples", "wiki_refs",
84
+ ],
85
+ NodeLabel.Rule: [
86
+ "name", "description", "domain", "severity", # info|warning|critical
87
+ "rationale", "examples",
88
+ ],
89
+ NodeLabel.Decision: [
90
+ "name", "description", "domain", "status", # proposed|accepted|deprecated
91
+ "rationale", "alternatives", "date",
92
+ ],
93
+ NodeLabel.WikiPage: [
94
+ "name", "url", "source", # github|confluence|notion|local
95
+ "content", "updated_at",
96
+ ],
97
+ NodeLabel.Person: [
98
+ "name", "email", "role", "team",
99
+ ],
53100
}
54101
--- navegador/graph/schema.py
+++ navegador/graph/schema.py
@@ -1,15 +1,23 @@
1 """
2 Graph schema — node labels and edge types for the navegador property graph.
3
4 Node properties vary by label but all share: name, file_path, line_start, line_end.
 
 
 
 
 
 
 
5 """
6
7 from enum import StrEnum
8
9
10 class NodeLabel(StrEnum):
 
11 Repository = "Repository"
12 File = "File"
13 Module = "Module"
14 Class = "Class"
15 Function = "Function"
@@ -16,28 +24,45 @@
16 Method = "Method"
17 Variable = "Variable"
18 Import = "Import"
19 Decorator = "Decorator"
20
 
 
 
 
 
 
 
 
21
22 class EdgeType(StrEnum):
23 # structural
24 CONTAINS = "CONTAINS" # File -CONTAINS-> Function/Class/Variable
25 DEFINES = "DEFINES" # Module -DEFINES-> Class/Function
26 # dependencies
27 IMPORTS = "IMPORTS" # File -IMPORTS-> Module/File
28 DEPENDS_ON = "DEPENDS_ON" # Module/Package level dependency
29 # code relationships
30 CALLS = "CALLS" # Function -CALLS-> Function
31 REFERENCES = "REFERENCES" # Function/Class -REFERENCES-> Variable/Class
32 INHERITS = "INHERITS" # Class -INHERITS-> Class
33 IMPLEMENTS = "IMPLEMENTS" # Class -IMPLEMENTS-> Class (for interfaces/ABCs)
34 DECORATES = "DECORATES" # Decorator -DECORATES-> Function/Class
35
 
 
 
 
 
 
 
 
 
36
37 # Common property keys per node label
 
38 NODE_PROPS = {
 
39 NodeLabel.Repository: ["name", "path", "language", "description"],
40 NodeLabel.File: ["name", "path", "language", "size", "line_count"],
41 NodeLabel.Module: ["name", "file_path", "docstring"],
42 NodeLabel.Class: ["name", "file_path", "line_start", "line_end", "docstring", "source"],
43 NodeLabel.Function: [
@@ -48,6 +73,28 @@
48 "docstring", "source", "signature", "class_name",
49 ],
50 NodeLabel.Variable: ["name", "file_path", "line_start", "type_annotation"],
51 NodeLabel.Import: ["name", "file_path", "line_start", "module", "alias"],
52 NodeLabel.Decorator: ["name", "file_path", "line_start"],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53 }
54
--- navegador/graph/schema.py
+++ navegador/graph/schema.py
@@ -1,15 +1,23 @@
1 """
2 Graph schema — node labels and edge types for the navegador property graph.
3
4 Navegador maintains two complementary layers in one graph:
5
6 CODE layer — AST-derived structure (files, functions, classes, calls, imports)
7 KNOWLEDGE layer — business context (concepts, rules, decisions, wiki, people)
8
9 The two layers are connected by IMPLEMENTS, DOCUMENTS, GOVERNS, and ANNOTATES
10 edges, so agents can traverse from a function straight to the business rule it
11 enforces, or from a wiki page down to the exact code that implements it.
12 """
13
14 from enum import StrEnum
15
16
17 class NodeLabel(StrEnum):
18 # ── Code layer ────────────────────────────────────────────────────────────
19 Repository = "Repository"
20 File = "File"
21 Module = "Module"
22 Class = "Class"
23 Function = "Function"
@@ -16,28 +24,45 @@
24 Method = "Method"
25 Variable = "Variable"
26 Import = "Import"
27 Decorator = "Decorator"
28
29 # ── Knowledge layer ───────────────────────────────────────────────────────
30 Domain = "Domain" # logical grouping (auth, billing, notifications…)
31 Concept = "Concept" # a named business entity or idea
32 Rule = "Rule" # a constraint, invariant, or business rule
33 Decision = "Decision" # an architectural or product decision + rationale
34 WikiPage = "WikiPage" # a page from the project wiki (GitHub, Confluence…)
35 Person = "Person" # a contributor, owner, or stakeholder
36
37
38 class EdgeType(StrEnum):
39 # ── Code structural ───────────────────────────────────────────────────────
40 CONTAINS = "CONTAINS" # File/Class -CONTAINS-> Function/Class/Variable
41 DEFINES = "DEFINES" # Module -DEFINES-> Class/Function
 
42 IMPORTS = "IMPORTS" # File -IMPORTS-> Module/File
43 DEPENDS_ON = "DEPENDS_ON" # module/package-level dependency
 
44 CALLS = "CALLS" # Function -CALLS-> Function
45 REFERENCES = "REFERENCES" # Function/Class -REFERENCES-> Variable/Class
46 INHERITS = "INHERITS" # Class -INHERITS-> Class
47 IMPLEMENTS = "IMPLEMENTS" # Class/Function -IMPLEMENTS-> Concept/Rule
48 DECORATES = "DECORATES" # Decorator -DECORATES-> Function/Class
49
50 # ── Knowledge structural ──────────────────────────────────────────────────
51 BELONGS_TO = "BELONGS_TO" # any node -BELONGS_TO-> Domain
52 RELATED_TO = "RELATED_TO" # Concept -RELATED_TO-> Concept (bidirectional intent)
53 GOVERNS = "GOVERNS" # Rule -GOVERNS-> Concept/Function/Class
54 DOCUMENTS = "DOCUMENTS" # WikiPage/Decision -DOCUMENTS-> any node
55 ANNOTATES = "ANNOTATES" # Concept/Rule -ANNOTATES-> code node (lightweight link)
56 ASSIGNED_TO = "ASSIGNED_TO" # any node -ASSIGNED_TO-> Person (ownership)
57 DECIDED_BY = "DECIDED_BY" # Decision -DECIDED_BY-> Person
58
59
60 # ── Property keys per node label ──────────────────────────────────────────────
61
62 NODE_PROPS = {
63 # Code layer
64 NodeLabel.Repository: ["name", "path", "language", "description"],
65 NodeLabel.File: ["name", "path", "language", "size", "line_count"],
66 NodeLabel.Module: ["name", "file_path", "docstring"],
67 NodeLabel.Class: ["name", "file_path", "line_start", "line_end", "docstring", "source"],
68 NodeLabel.Function: [
@@ -48,6 +73,28 @@
73 "docstring", "source", "signature", "class_name",
74 ],
75 NodeLabel.Variable: ["name", "file_path", "line_start", "type_annotation"],
76 NodeLabel.Import: ["name", "file_path", "line_start", "module", "alias"],
77 NodeLabel.Decorator: ["name", "file_path", "line_start"],
78
79 # Knowledge layer
80 NodeLabel.Domain: ["name", "description"],
81 NodeLabel.Concept: [
82 "name", "description", "domain", "status",
83 "rules", "examples", "wiki_refs",
84 ],
85 NodeLabel.Rule: [
86 "name", "description", "domain", "severity", # info|warning|critical
87 "rationale", "examples",
88 ],
89 NodeLabel.Decision: [
90 "name", "description", "domain", "status", # proposed|accepted|deprecated
91 "rationale", "alternatives", "date",
92 ],
93 NodeLabel.WikiPage: [
94 "name", "url", "source", # github|confluence|notion|local
95 "content", "updated_at",
96 ],
97 NodeLabel.Person: [
98 "name", "email", "role", "team",
99 ],
100 }
101
--- navegador/ingestion/__init__.py
+++ navegador/ingestion/__init__.py
@@ -1,3 +1,5 @@
1
+from .knowledge import KnowledgeIngester
12
from .parser import RepoIngester
3
+from .wiki import WikiIngester
24
3
-__all__ = ["RepoIngester"]
5
+__all__ = ["RepoIngester", "KnowledgeIngester", "WikiIngester"]
46
57
ADDED navegador/ingestion/knowledge.py
68
ADDED navegador/ingestion/wiki.py
--- navegador/ingestion/__init__.py
+++ navegador/ingestion/__init__.py
@@ -1,3 +1,5 @@
 
1 from .parser import RepoIngester
 
2
3 __all__ = ["RepoIngester"]
4
5 DDED navegador/ingestion/knowledge.py
6 DDED navegador/ingestion/wiki.py
--- navegador/ingestion/__init__.py
+++ navegador/ingestion/__init__.py
@@ -1,3 +1,5 @@
1 from .knowledge import KnowledgeIngester
2 from .parser import RepoIngester
3 from .wiki import WikiIngester
4
5 __all__ = ["RepoIngester", "KnowledgeIngester", "WikiIngester"]
6
7 DDED navegador/ingestion/knowledge.py
8 DDED navegador/ingestion/wiki.py
--- a/navegador/ingestion/knowledge.py
+++ b/navegador/ingestion/knowledge.py
@@ -0,0 +1,206 @@
1
+"""
2
+KnowledgeIngester — manual curation of business concepts, rules, decisions,
3
+people, and domains into the navegador graph.
4
+
5
+These are the things that don't live in code but belong in the knowledge graph:
6
+business rules, architectural decisions, domain groupings, ownership, wiki refs.
7
+"""
8
+
9
+import logging
10
+from typing import Any
11
+
12
+from navegador.graph.schema import EdgeType, NodeLabel
13
+from navegador.graph.store import GraphStore
14
+
15
+logger = logging.getLogger(__name__)
16
+
17
+
18
+class KnowledgeIngester:
19
+ """
20
+ Writes business knowledge nodes and relationships into the graph.
21
+
22
+ Usage:
23
+ store = GraphStore.sqlite(".navegador/graph.db")
24
+ k = KnowledgeIngester(store)
25
+
26
+ k.add_domain("auth", description="Authentication and authorisation")
27
+ k.add_concept("JWT", description="Stateless token auth", domain="auth")
28
+ k.add_rule("tokens must expire", domain="auth", severity="critical",
29
+ rationale="Security requirement per OWASP")
30
+ k.annotate_code("validate_token", "Function", concept="JWT")
31
+ k.wiki_page("JWT Auth", url="...", content="...")
32
+ """
33
+
34
+ def __init__(self, store: GraphStore) -> None:
35
+ self.store = store
36
+
37
+ # ── Domains ───────────────────────────────────────────────────────────────
38
+
39
+ def add_domain(self, name: str, description: str = "") -> None:
40
+ self.store.create_node(e.create_node(
41
+ {
42
+ } )
43
+ if domain:Domain: %s", name)
44
+
45
+ # ── Concepts ──────────────────────────────────────────────────────────────
46
+
47
+ def add_concept(
48
+ self,
49
+ name: str,
50
+ description: str = "",
51
+ domain: str = "",
52
+ status: str = "",
53
+ rules: str = "",
54
+ examples: str = "",
55
+ wiki_refs: str = "",
56
+ ) -> None:
57
+ NodeLabel.Concept, {
58
+ me": name,
59
+ anual curation of business con"""
60
+KnowledgeIngester — manu"rules": rut(
61
+ self,
62
+────────
63
+
64
+ def description="Authentication and }e: str,
65
+ description: str = "",
66
+ domain: str = "",
67
+ Stateless token auth", domain="auth")
68
+ k.add_rule("tokens must expire", domain="auth", severity="critical",
69
+ rationale="Security requirement per OWASP")
70
+ k.annotate_code("validate_tokek.wiki_page("JWT Auth", url="...", content="...")
71
+ """
72
+
73
+ def __init__(sNodeLabel.Rule, {
74
+ me": name,
75
+ anual curation of business con"""
76
+KnowledgeIngester — manual c────────
77
+
78
+ }e: str,
79
+ description: str = "",
80
+ domain: str = "",
81
+ status: str = "",
82
+ rules: str = "",
83
+ examples: str = "",
84
+ wiki_refs: str = "",
85
+ ) -> None:
86
+ self.store.create_node(
87
+ NodeLabel.Concept,
88
+ {
89
+ {
90
+ "name": name,
91
+ "description": description,
92
+ target_label,
93
+ {"na "",
94
+ teaDecision ────────────────────────────────────────────────────�
95
+
96
+ def add_decision(
97
+ self,
98
+ name: str,
99
+ description: str = "",
100
+ domain: str = "",
101
+ status: str = "accepted",
102
+ rationale: str = "",
103
+ alternatives: str = "",
104
+ date: str = "",
105
+ ) -> None:
106
+ self.store.create_node(NodeLabel.Decision, {
107
+ me": name,
108
+ anual curation of business con"""
109
+KnowledgeIngester — manu"alternatives":"date": date,
110
+cisions,"e: str,
111
+ description: str = "",
112
+ domain: str = "",
113
+ NodeLabel.Person, {
114
+ "email": email,
115
+ l": email,
116
+ "team": team,
117
+cisions," )
118
+ if domain:Person: %s", name)
119
+
120
+ def assign(self, target_name: str, target_label: NodeLabel, person_name: str) -> None:
121
+ """Assign a person as owner of any node."""
122
+ self.store.create_edge(
123
+ target_label, target_label,
124
+ {"name": target_name},
125
+ EdgeType.ASSIGNED_TO,
126
+ {"name": person_name},
127
+ )
128
+
129
+ # ── Wiki pages ────────────────────────────────────────────────────────────
130
+
131
+ def wiki_page(
132
+ self,
133
+ name: str,
134
+ url: str = "",
135
+ source: str = "github",
136
+ content: str = "",
137
+ updated_at: str = "",
138
+ ) -> None:
139
+ self.store.create_node(NodeLabel.WikiPage, {
140
+ ": status,
141
+ "alternatives": al "alternatives": alternative,
142
+ },
143
+ )
144
+ } )
145
+ if domain:
146
+ self._link_to_domain(name, NodeLself, wiki_page def assign(selfelf, name: str, des ───────────ign(self, target_name: scription,
147
+ncept,
148
+ {
149
+ WikiPage,��───────────────
150
+
151
+ def add_person(
152
+ self,
153
+ "",
154
+ role: str = "",
155
+ team: str = "",
156
+ ) -> None:
157
+ self.store.create_node(
158
+ NodeLabel.Person,
159
+ {
160
+ "name": name,
161
+ "email": email,
162
+ "role": role,
163
+ "team": team,
164
+ },
165
+ )
166
+ logger.info("Person: %s", name)
167
+
168
+ def assign(self, target_name: str, target_label: NodeLabel, person_name: str) -> None:
169
+ """Assign a person as owner of any node."""
170
+ self.store.create_edge(
171
+ target_label,
172
+ {"name": target_name},
173
+ EdgeType.ASSIGNED_TO,
174
+ NodeLabel.Person,
175
+ {"name": per"name": person_name},
176
+ )
177
+
178
+ # ── Wiki pages ─────── {"name": code_name},
179
+ �────────────────────────────�
180
+ self,
181
+ name: str,
182
+ url: str = "",
183
+ source: st {"name": code_name},
184
+ pdated_at: str = "",
185
+ ) -> None:
186
+ self.store.create_node(
187
+ NodeLabel.WikiPage,
188
+ {
189
+ "name": name,
190
+ "url": url,
191
+ "source": source,
192
+ {"name": code_name},
193
+ EdgeType.IMPLEMENTS,
194
+ NodeLabel.Concept,eLabel.Concept,
195
+ {"name": concept_name},
196
+ )
197
+
198
+ # ── Helpers ───────────────────────────────────────────────────────────────
199
+
200
+ def _link_to_domain(self, name: str, label: NodeLabel, domain: str) -> None:
201
+ # Ensure domain node exists
202
+ self.store.create_node(NodeLabel.Domain, {"name": domain, "description": " label,
203
+ {"name": name},
204
+ EdgeType.BELONGS_TO,
205
+ {"name": domain},
206
+ )
--- a/navegador/ingestion/knowledge.py
+++ b/navegador/ingestion/knowledge.py
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/navegador/ingestion/knowledge.py
+++ b/navegador/ingestion/knowledge.py
@@ -0,0 +1,206 @@
1 """
2 KnowledgeIngester — manual curation of business concepts, rules, decisions,
3 people, and domains into the navegador graph.
4
5 These are the things that don't live in code but belong in the knowledge graph:
6 business rules, architectural decisions, domain groupings, ownership, wiki refs.
7 """
8
9 import logging
10 from typing import Any
11
12 from navegador.graph.schema import EdgeType, NodeLabel
13 from navegador.graph.store import GraphStore
14
15 logger = logging.getLogger(__name__)
16
17
18 class KnowledgeIngester:
19 """
20 Writes business knowledge nodes and relationships into the graph.
21
22 Usage:
23 store = GraphStore.sqlite(".navegador/graph.db")
24 k = KnowledgeIngester(store)
25
26 k.add_domain("auth", description="Authentication and authorisation")
27 k.add_concept("JWT", description="Stateless token auth", domain="auth")
28 k.add_rule("tokens must expire", domain="auth", severity="critical",
29 rationale="Security requirement per OWASP")
30 k.annotate_code("validate_token", "Function", concept="JWT")
31 k.wiki_page("JWT Auth", url="...", content="...")
32 """
33
34 def __init__(self, store: GraphStore) -> None:
35 self.store = store
36
37 # ── Domains ───────────────────────────────────────────────────────────────
38
39 def add_domain(self, name: str, description: str = "") -> None:
40 self.store.create_node(e.create_node(
41 {
42 } )
43 if domain:Domain: %s", name)
44
45 # ── Concepts ──────────────────────────────────────────────────────────────
46
47 def add_concept(
48 self,
49 name: str,
50 description: str = "",
51 domain: str = "",
52 status: str = "",
53 rules: str = "",
54 examples: str = "",
55 wiki_refs: str = "",
56 ) -> None:
57 NodeLabel.Concept, {
58 me": name,
59 anual curation of business con"""
60 KnowledgeIngester — manu"rules": rut(
61 self,
62 ────────
63
64 def description="Authentication and }e: str,
65 description: str = "",
66 domain: str = "",
67 Stateless token auth", domain="auth")
68 k.add_rule("tokens must expire", domain="auth", severity="critical",
69 rationale="Security requirement per OWASP")
70 k.annotate_code("validate_tokek.wiki_page("JWT Auth", url="...", content="...")
71 """
72
73 def __init__(sNodeLabel.Rule, {
74 me": name,
75 anual curation of business con"""
76 KnowledgeIngester — manual c────────
77
78 }e: str,
79 description: str = "",
80 domain: str = "",
81 status: str = "",
82 rules: str = "",
83 examples: str = "",
84 wiki_refs: str = "",
85 ) -> None:
86 self.store.create_node(
87 NodeLabel.Concept,
88 {
89 {
90 "name": name,
91 "description": description,
92 target_label,
93 {"na "",
94 teaDecision ────────────────────────────────────────────────────�
95
96 def add_decision(
97 self,
98 name: str,
99 description: str = "",
100 domain: str = "",
101 status: str = "accepted",
102 rationale: str = "",
103 alternatives: str = "",
104 date: str = "",
105 ) -> None:
106 self.store.create_node(NodeLabel.Decision, {
107 me": name,
108 anual curation of business con"""
109 KnowledgeIngester — manu"alternatives":"date": date,
110 cisions,"e: str,
111 description: str = "",
112 domain: str = "",
113 NodeLabel.Person, {
114 "email": email,
115 l": email,
116 "team": team,
117 cisions," )
118 if domain:Person: %s", name)
119
120 def assign(self, target_name: str, target_label: NodeLabel, person_name: str) -> None:
121 """Assign a person as owner of any node."""
122 self.store.create_edge(
123 target_label, target_label,
124 {"name": target_name},
125 EdgeType.ASSIGNED_TO,
126 {"name": person_name},
127 )
128
129 # ── Wiki pages ────────────────────────────────────────────────────────────
130
131 def wiki_page(
132 self,
133 name: str,
134 url: str = "",
135 source: str = "github",
136 content: str = "",
137 updated_at: str = "",
138 ) -> None:
139 self.store.create_node(NodeLabel.WikiPage, {
140 ": status,
141 "alternatives": al "alternatives": alternative,
142 },
143 )
144 } )
145 if domain:
146 self._link_to_domain(name, NodeLself, wiki_page def assign(selfelf, name: str, des ───────────ign(self, target_name: scription,
147 ncept,
148 {
149 WikiPage,��───────────────
150
151 def add_person(
152 self,
153 "",
154 role: str = "",
155 team: str = "",
156 ) -> None:
157 self.store.create_node(
158 NodeLabel.Person,
159 {
160 "name": name,
161 "email": email,
162 "role": role,
163 "team": team,
164 },
165 )
166 logger.info("Person: %s", name)
167
168 def assign(self, target_name: str, target_label: NodeLabel, person_name: str) -> None:
169 """Assign a person as owner of any node."""
170 self.store.create_edge(
171 target_label,
172 {"name": target_name},
173 EdgeType.ASSIGNED_TO,
174 NodeLabel.Person,
175 {"name": per"name": person_name},
176 )
177
178 # ── Wiki pages ─────── {"name": code_name},
179 �────────────────────────────�
180 self,
181 name: str,
182 url: str = "",
183 source: st {"name": code_name},
184 pdated_at: str = "",
185 ) -> None:
186 self.store.create_node(
187 NodeLabel.WikiPage,
188 {
189 "name": name,
190 "url": url,
191 "source": source,
192 {"name": code_name},
193 EdgeType.IMPLEMENTS,
194 NodeLabel.Concept,eLabel.Concept,
195 {"name": concept_name},
196 )
197
198 # ── Helpers ───────────────────────────────────────────────────────────────
199
200 def _link_to_domain(self, name: str, label: NodeLabel, domain: str) -> None:
201 # Ensure domain node exists
202 self.store.create_node(NodeLabel.Domain, {"name": domain, "description": " label,
203 {"name": name},
204 EdgeType.BELONGS_TO,
205 {"name": domain},
206 )
--- a/navegador/ingestion/wiki.py
+++ b/navegador/ingestion/wiki.py
@@ -0,0 +1,147 @@
1
+"""
2
+WikiIngester — pulls pages from a project wiki into the knowledge graph.
3
+
4
+Supports:
5
+ - GitHub wiki (cloned as a git repo at <repo>.wiki.git)
6
+ - Local markdown directory (any folder of .md files)
7
+
8
+Each page becomes a WikiPage node. Headings and bold terms are scanned for
9
+names that match existing Concept/Function/Class nodes — matches get a
10
+DOCUMENTS edge so agents can traverse wiki ↔ code.
11
+"""
12
+
13
+import logging
14
+import re
15
+import urllib.request
16
+from pathlib import Path
17
+
18
+from navegador.graph.schema import EdgeType, NodeLabel
19
+from navegador.graph.store import GraphStore
20
+
21
+logger = logging.getLogger(__name__)
22
+
23
+_HEADING_RE = re.compile(r"^#{1,6}\s+(.+)", re.MULTILINE)
24
+_BOLD_RE = re.compile(r"\*\*(.+?)\*\*|__(.+?)__")
25
+
26
+
27
+def _extract_terms(markdown: str) -> list[str]:
28
+ """Pull heading text and bold terms out of a markdown string."""
29
+ terms = [m.group(1).strip() for m in _HEADING_RE.finditer(markdown)]
30
+ for m in _BOLD_RE.finditer(markdown):
31
+ term = (m.group(1) or m.group(2) or "").strip()
32
+ if term:
33
+ terms.append(term)
34
+ return list(dict.fromkeys(terms)) # dedupe, preserve order
35
+
36
+
37
+class WikiIngester:
38
+ """
39
+ Ingests wiki pages into the navegador graph.
40
+
41
+ Usage:
42
+ ingester = WikiIngester(store)
43
+
44
+ # From a local directory of markdown files
45
+ ingester.ingest_local("/path/to/wiki-clone")
46
+
47
+ # From GitHub (clones the wiki repo)
48
+ ingester.ingest_github("owner/repo")
49
+ """
50
+
51
+ def __init__(self, store: GraphStore) -> None:
52
+ self.store = store
53
+
54
+ # ── Entry points ──────────────────────────────────────────────────────────
55
+
56
+ def ingest_local(self, wiki_dir: str | Path) -> dict[str, int]:
57
+ """Ingest all .md files in a local directory."""
58
+ wiki_dir = Path(wiki_dir)
59
+ if not wiki_dir.exists raise FileNotFoundError(f"Wiki directory not found: {wiki_dir}")
60
+
61
+ stats = {"pages": 0, "links": 0}
62
+ for md_file in sorted(wiki_dir.rglob("*.md")):
63
+ content = md_file.read_text(encoding="utf-8", errors="replace")
64
+ page_name = md_file.stem.replace("-", " ").replace("_", " ")
65
+ links = self._ingest_page(
66
+ name=page_name,
67
+ content=content,
68
+ source="local",
69
+ url=str(md_file),
70
+ )
71
+ stats["pages"] += 1
72
+ stats["links"] += links
73
+
74
+ logger.info("Wiki (local): %d pages, %d links", stats["pages"], stats["links"])
75
+ return stats
76
+
77
+ def ingest_github(
78
+ self,
79
+ repo: str,
80
+ token: str = "",
81
+ clone_dir: str | Path | None = None,
82
+ ) -> dict[str, int]:
83
+ """
84
+ Ingest a GitHub wiki by cloning it then processing locally.
85
+
86
+ Args:
87
+ repo: "owner/repo" — the GitHub repository.
88
+ token: GitHub personal access token (needed for private repos).
89
+ clone_dir: Where to clone. Defaults to a temp directory.
90
+ """
91
+ import subprocess
92
+ import tempfile
93
+
94
+ wiki_url = f"https://github.com/{repo}.wiki.git"
95
+ if token:
96
+ wiki_url = f"https://{token}@github.com/{repo}.wiki.git"
97
+
98
+ if clone_dir is None:
99
+ tmp = tempfile.mkdtemp(prefix="navegador-wiki-")
100
+ data = _json.loads(resp.read().decode())
101
+ content = base64.b64decode(data["content"]).decode("utf-8", errors="replace") # noqa: E501
102
+ page_name = Path(path).stem.replace("-", " ").replace("_", " ")
103
+ html_url = data.get("html_url", "")
104
+ links = self._ingest_page(page_name, content, source="github", url=html_url)
105
+ stats["pages"] += 1
106
+ stats["links"] += links
107
+ except Exception as exc:
108
+ logger.warning("Skipping %s: %s", path, exc)
109
+
110
+ logger.info("Wiki (GitHub API): %d pages, %d links", stats["pages"], stats["links"])
111
+ return stats
112
+
113
+ # ── Internal ──────────────────────────────────────────────────────────────
114
+
115
+ def _ingest_page(
116
+ self,
117
+ name: str,
118
+ content: str,
119
+ source: str,
120
+ url: str,
121
+ ) -> int:
122
+ """Store one wiki page and return the number of DOCUMENTS links created."""
123
+ NodeLabel.WikiPage, { "name": name,
124
+url": url,
125
+ "so""
126
+WikiIngester — pulls pages fr──�G.md", "ARCHITECTURE.md", "docs/index.md"]:
127
+ url = f"https://api.github.com/repos/{owner}/{name}/contents/{path}"
128
+ try:
129
+ req = urllib.request.Request(url, headers=headers)
130
+ with urllib.request.urlopen(req, timeout=10) as resp:
131
+ import base64
132
+ import json as _json
133
+
134
+ data = _json.loads(resp.read().decode())
135
+ content = base64.b64decode(data["content"]).decode("utf-8", errors="replace") # noqa: E501
136
+ page_name = Path(path).stem.replace("-", " ").replace("_", " ")
137
+ html_url = data.get("html_url", "")
138
+ links = self._ingest_page(page_name, content, source="github", url=html_url)
139
+ stats["pages"] += 1
140
+ stats["links"] += links
141
+ except Exception as exc:
142
+ logger.warning("Skipping %s: %s", path, exc)
143
+
144
+ logger.info(ats["pages"], stats["links"])
145
+ return stats
146
+
147
+ # �debuInternal ───�──────────────────────────(ValueError, Exception): md_file.read_text(e0
--- a/navegador/ingestion/wiki.py
+++ b/navegador/ingestion/wiki.py
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/navegador/ingestion/wiki.py
+++ b/navegador/ingestion/wiki.py
@@ -0,0 +1,147 @@
1 """
2 WikiIngester — pulls pages from a project wiki into the knowledge graph.
3
4 Supports:
5 - GitHub wiki (cloned as a git repo at <repo>.wiki.git)
6 - Local markdown directory (any folder of .md files)
7
8 Each page becomes a WikiPage node. Headings and bold terms are scanned for
9 names that match existing Concept/Function/Class nodes — matches get a
10 DOCUMENTS edge so agents can traverse wiki ↔ code.
11 """
12
13 import logging
14 import re
15 import urllib.request
16 from pathlib import Path
17
18 from navegador.graph.schema import EdgeType, NodeLabel
19 from navegador.graph.store import GraphStore
20
21 logger = logging.getLogger(__name__)
22
23 _HEADING_RE = re.compile(r"^#{1,6}\s+(.+)", re.MULTILINE)
24 _BOLD_RE = re.compile(r"\*\*(.+?)\*\*|__(.+?)__")
25
26
27 def _extract_terms(markdown: str) -> list[str]:
28 """Pull heading text and bold terms out of a markdown string."""
29 terms = [m.group(1).strip() for m in _HEADING_RE.finditer(markdown)]
30 for m in _BOLD_RE.finditer(markdown):
31 term = (m.group(1) or m.group(2) or "").strip()
32 if term:
33 terms.append(term)
34 return list(dict.fromkeys(terms)) # dedupe, preserve order
35
36
37 class WikiIngester:
38 """
39 Ingests wiki pages into the navegador graph.
40
41 Usage:
42 ingester = WikiIngester(store)
43
44 # From a local directory of markdown files
45 ingester.ingest_local("/path/to/wiki-clone")
46
47 # From GitHub (clones the wiki repo)
48 ingester.ingest_github("owner/repo")
49 """
50
51 def __init__(self, store: GraphStore) -> None:
52 self.store = store
53
54 # ── Entry points ──────────────────────────────────────────────────────────
55
56 def ingest_local(self, wiki_dir: str | Path) -> dict[str, int]:
57 """Ingest all .md files in a local directory."""
58 wiki_dir = Path(wiki_dir)
59 if not wiki_dir.exists raise FileNotFoundError(f"Wiki directory not found: {wiki_dir}")
60
61 stats = {"pages": 0, "links": 0}
62 for md_file in sorted(wiki_dir.rglob("*.md")):
63 content = md_file.read_text(encoding="utf-8", errors="replace")
64 page_name = md_file.stem.replace("-", " ").replace("_", " ")
65 links = self._ingest_page(
66 name=page_name,
67 content=content,
68 source="local",
69 url=str(md_file),
70 )
71 stats["pages"] += 1
72 stats["links"] += links
73
74 logger.info("Wiki (local): %d pages, %d links", stats["pages"], stats["links"])
75 return stats
76
77 def ingest_github(
78 self,
79 repo: str,
80 token: str = "",
81 clone_dir: str | Path | None = None,
82 ) -> dict[str, int]:
83 """
84 Ingest a GitHub wiki by cloning it then processing locally.
85
86 Args:
87 repo: "owner/repo" — the GitHub repository.
88 token: GitHub personal access token (needed for private repos).
89 clone_dir: Where to clone. Defaults to a temp directory.
90 """
91 import subprocess
92 import tempfile
93
94 wiki_url = f"https://github.com/{repo}.wiki.git"
95 if token:
96 wiki_url = f"https://{token}@github.com/{repo}.wiki.git"
97
98 if clone_dir is None:
99 tmp = tempfile.mkdtemp(prefix="navegador-wiki-")
100 data = _json.loads(resp.read().decode())
101 content = base64.b64decode(data["content"]).decode("utf-8", errors="replace") # noqa: E501
102 page_name = Path(path).stem.replace("-", " ").replace("_", " ")
103 html_url = data.get("html_url", "")
104 links = self._ingest_page(page_name, content, source="github", url=html_url)
105 stats["pages"] += 1
106 stats["links"] += links
107 except Exception as exc:
108 logger.warning("Skipping %s: %s", path, exc)
109
110 logger.info("Wiki (GitHub API): %d pages, %d links", stats["pages"], stats["links"])
111 return stats
112
113 # ── Internal ──────────────────────────────────────────────────────────────
114
115 def _ingest_page(
116 self,
117 name: str,
118 content: str,
119 source: str,
120 url: str,
121 ) -> int:
122 """Store one wiki page and return the number of DOCUMENTS links created."""
123 NodeLabel.WikiPage, { "name": name,
124 url": url,
125 "so""
126 WikiIngester — pulls pages fr──�G.md", "ARCHITECTURE.md", "docs/index.md"]:
127 url = f"https://api.github.com/repos/{owner}/{name}/contents/{path}"
128 try:
129 req = urllib.request.Request(url, headers=headers)
130 with urllib.request.urlopen(req, timeout=10) as resp:
131 import base64
132 import json as _json
133
134 data = _json.loads(resp.read().decode())
135 content = base64.b64decode(data["content"]).decode("utf-8", errors="replace") # noqa: E501
136 page_name = Path(path).stem.replace("-", " ").replace("_", " ")
137 html_url = data.get("html_url", "")
138 links = self._ingest_page(page_name, content, source="github", url=html_url)
139 stats["pages"] += 1
140 stats["links"] += links
141 except Exception as exc:
142 logger.warning("Skipping %s: %s", path, exc)
143
144 logger.info(ats["pages"], stats["links"])
145 return stats
146
147 # �debuInternal ───�──────────────────────────(ValueError, Exception): md_file.read_text(e0

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button