root-kg 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- root_kg/.env.example +13 -0
- root_kg/__init__.py +3 -0
- root_kg/__main__.py +5 -0
- root_kg/adapters/__init__.py +0 -0
- root_kg/adapters/vault.py +122 -0
- root_kg/chunker.py +53 -0
- root_kg/cli.py +380 -0
- root_kg/config.example.yaml +66 -0
- root_kg/db.py +833 -0
- root_kg/embeddings.py +39 -0
- root_kg/extractor.py +251 -0
- root_kg/indexer.py +450 -0
- root_kg/llm.py +525 -0
- root_kg/merge_cli.py +135 -0
- root_kg/paths.py +25 -0
- root_kg/query.py +127 -0
- root_kg/rootd.py +78 -0
- root_kg/server.py +619 -0
- root_kg/tools/__init__.py +0 -0
- root_kg/tools/correlations.py +278 -0
- root_kg/tools/graph.py +285 -0
- root_kg/tools/intelligence.py +206 -0
- root_kg/tools/patterns.py +204 -0
- root_kg/tools/search.py +110 -0
- root_kg-2.0.0.dist-info/METADATA +384 -0
- root_kg-2.0.0.dist-info/RECORD +30 -0
- root_kg-2.0.0.dist-info/WHEEL +5 -0
- root_kg-2.0.0.dist-info/entry_points.txt +4 -0
- root_kg-2.0.0.dist-info/licenses/LICENSE +21 -0
- root_kg-2.0.0.dist-info/top_level.txt +1 -0
root_kg/.env.example
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# ROOT environment variables
|
|
2
|
+
# Copy to .env and fill in your keys
|
|
3
|
+
|
|
4
|
+
# Option A: Anthropic API (recommended, ~$3 for full extraction)
|
|
5
|
+
# Get key at: https://console.anthropic.com/settings/keys
|
|
6
|
+
ANTHROPIC_API_KEY=sk-ant-your-key-here
|
|
7
|
+
|
|
8
|
+
# Option B: OpenRouter (free $1 credit)
|
|
9
|
+
# Get key at: https://openrouter.ai/keys
|
|
10
|
+
# OPENROUTER_API_KEY=sk-or-your-key-here
|
|
11
|
+
|
|
12
|
+
# Option C: Ollama (free, local, no key needed)
|
|
13
|
+
# Just install Ollama and pull a model: ollama pull llama3.1
|
root_kg/__init__.py
ADDED
root_kg/__main__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ROOT Obsidian vault adapter.
|
|
3
|
+
|
|
4
|
+
Reads markdown notes from the vault, extracts metadata,
|
|
5
|
+
and yields them for indexing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import re
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Iterator
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _extract_title(content: str, filename: str) -> str:
|
|
16
|
+
"""Extract title from frontmatter, first heading, or filename."""
|
|
17
|
+
# Try YAML frontmatter
|
|
18
|
+
fm_match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
|
|
19
|
+
if fm_match:
|
|
20
|
+
for line in fm_match.group(1).split("\n"):
|
|
21
|
+
if line.startswith("title:"):
|
|
22
|
+
return line.split(":", 1)[1].strip().strip("\"'")
|
|
23
|
+
|
|
24
|
+
# Try first heading
|
|
25
|
+
heading_match = re.match(r"^#\s+(.+)", content.lstrip(), re.MULTILINE)
|
|
26
|
+
if heading_match:
|
|
27
|
+
return heading_match.group(1).strip()
|
|
28
|
+
|
|
29
|
+
# Fall back to filename
|
|
30
|
+
return filename.replace(".md", "").replace("-", " ").replace("_", " ")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _extract_date(content: str, file_path: Path) -> str | None:
|
|
34
|
+
"""Extract date from frontmatter, filename, or title.
|
|
35
|
+
|
|
36
|
+
Supports multiple date formats:
|
|
37
|
+
- YAML frontmatter: date: or created: field
|
|
38
|
+
- Filename with dashes: 2026-03-19
|
|
39
|
+
- Filename/title with spaces: 2026 03 19 (common in Obsidian/Granola)
|
|
40
|
+
"""
|
|
41
|
+
# Try frontmatter
|
|
42
|
+
fm_match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
|
|
43
|
+
if fm_match:
|
|
44
|
+
for line in fm_match.group(1).split("\n"):
|
|
45
|
+
if line.startswith("date:") or line.startswith("created:"):
|
|
46
|
+
date_str = line.split(":", 1)[1].strip().strip("\"'")
|
|
47
|
+
return date_str
|
|
48
|
+
|
|
49
|
+
# Try date in filename (YYYY-MM-DD)
|
|
50
|
+
date_match = re.search(r"(\d{4}-\d{2}-\d{2})", file_path.name)
|
|
51
|
+
if date_match:
|
|
52
|
+
return date_match.group(1)
|
|
53
|
+
|
|
54
|
+
# Try date in filename with spaces (YYYY MM DD)
|
|
55
|
+
space_match = re.search(r"(\d{4})\s+(\d{2})\s+(\d{2})", file_path.stem)
|
|
56
|
+
if space_match:
|
|
57
|
+
return f"{space_match.group(1)}-{space_match.group(2)}-{space_match.group(3)}"
|
|
58
|
+
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _content_hash(content: str) -> str:
|
|
63
|
+
"""SHA-256 hash of content for change detection."""
|
|
64
|
+
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _get_folder(path: Path, vault_root: Path) -> str:
|
|
68
|
+
"""Get the top-level folder relative to vault root."""
|
|
69
|
+
try:
|
|
70
|
+
relative = path.relative_to(vault_root)
|
|
71
|
+
parts = relative.parts
|
|
72
|
+
return parts[0] if len(parts) > 1 else "(root)"
|
|
73
|
+
except ValueError:
|
|
74
|
+
return "(unknown)"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def scan_vault(
|
|
78
|
+
vault_path: str | Path,
|
|
79
|
+
exclude_folders: list[str] | None = None,
|
|
80
|
+
exclude_patterns: list[str] | None = None,
|
|
81
|
+
) -> Iterator[dict]:
|
|
82
|
+
"""Walk the vault and yield note dicts for indexing.
|
|
83
|
+
|
|
84
|
+
Yields: {"path": str, "title": str, "content": str,
|
|
85
|
+
"content_hash": str, "folder": str, "created_at": str|None}
|
|
86
|
+
"""
|
|
87
|
+
vault_root = Path(vault_path)
|
|
88
|
+
exclude_folders = set(exclude_folders or [])
|
|
89
|
+
exclude_patterns = exclude_patterns or []
|
|
90
|
+
|
|
91
|
+
if not vault_root.exists():
|
|
92
|
+
raise FileNotFoundError(f"Vault not found: {vault_root}")
|
|
93
|
+
|
|
94
|
+
for md_file in sorted(vault_root.rglob("*.md")):
|
|
95
|
+
# Skip excluded folders
|
|
96
|
+
relative_parts = md_file.relative_to(vault_root).parts
|
|
97
|
+
if any(part in exclude_folders for part in relative_parts):
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
# Skip excluded patterns
|
|
101
|
+
if any(md_file.match(pat) for pat in exclude_patterns):
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
content = md_file.read_text(encoding="utf-8", errors="replace")
|
|
106
|
+
except (OSError, PermissionError):
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
if not content.strip():
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
title = _extract_title(content, md_file.name)
|
|
113
|
+
folder = _get_folder(md_file, vault_root)
|
|
114
|
+
|
|
115
|
+
yield {
|
|
116
|
+
"path": str(md_file.relative_to(vault_root)),
|
|
117
|
+
"title": title,
|
|
118
|
+
"content": content,
|
|
119
|
+
"content_hash": _content_hash(content),
|
|
120
|
+
"folder": folder,
|
|
121
|
+
"created_at": _extract_date(content, md_file),
|
|
122
|
+
}
|
root_kg/chunker.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ROOT text chunking.
|
|
3
|
+
|
|
4
|
+
Strategy: most Obsidian notes are short enough to embed whole.
|
|
5
|
+
Split longer notes at heading boundaries.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
MAX_CHUNK_CHARS = 4000 # ~1000 tokens, well within model limits
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def chunk_size(config: dict) -> int:
|
|
15
|
+
"""Chunk size in characters from config (embeddings.max_chunk_chars), else the default."""
|
|
16
|
+
return int((config.get("embeddings") or {}).get("max_chunk_chars", MAX_CHUNK_CHARS))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def chunk_note(content: str, title: str = "", max_chars: int = MAX_CHUNK_CHARS) -> list[dict]:
|
|
20
|
+
"""Split a note into chunks suitable for embedding.
|
|
21
|
+
|
|
22
|
+
Returns list of {"idx": int, "text": str}.
|
|
23
|
+
Short notes (<max_chars) stay as one chunk.
|
|
24
|
+
Longer notes split at ## headings.
|
|
25
|
+
"""
|
|
26
|
+
# Prepend title for context
|
|
27
|
+
full_text = f"# {title}\n\n{content}" if title else content
|
|
28
|
+
full_text = full_text.strip()
|
|
29
|
+
|
|
30
|
+
if not full_text:
|
|
31
|
+
return []
|
|
32
|
+
|
|
33
|
+
if len(full_text) <= max_chars:
|
|
34
|
+
return [{"idx": 0, "text": full_text}]
|
|
35
|
+
|
|
36
|
+
# Split at headings (## or ###)
|
|
37
|
+
sections = re.split(r"\n(?=#{1,3}\s)", full_text)
|
|
38
|
+
chunks = []
|
|
39
|
+
current_chunk = ""
|
|
40
|
+
idx = 0
|
|
41
|
+
|
|
42
|
+
for section in sections:
|
|
43
|
+
if len(current_chunk) + len(section) > max_chars and current_chunk:
|
|
44
|
+
chunks.append({"idx": idx, "text": current_chunk.strip()})
|
|
45
|
+
idx += 1
|
|
46
|
+
current_chunk = f"# {title}\n\n" if title else ""
|
|
47
|
+
|
|
48
|
+
current_chunk += section + "\n"
|
|
49
|
+
|
|
50
|
+
if current_chunk.strip():
|
|
51
|
+
chunks.append({"idx": idx, "text": current_chunk.strip()})
|
|
52
|
+
|
|
53
|
+
return chunks if chunks else [{"idx": 0, "text": full_text[:max_chars]}]
|
root_kg/cli.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ROOT CLI.
|
|
3
|
+
|
|
4
|
+
Setup wizard, management commands, and cron-callable search/ingest
|
|
5
|
+
for the personal knowledge graph.
|
|
6
|
+
|
|
7
|
+
Usage (cron-safe):
|
|
8
|
+
root-kg search --query "morning digest signals" [--limit 5]
|
|
9
|
+
root-kg note --content "Musa pulse: ..." [--tags "musa,signal"]
|
|
10
|
+
|
|
11
|
+
Exit codes: 0 = success, 1 = error. Errors go to stderr, results to stdout.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import os
|
|
16
|
+
import shutil
|
|
17
|
+
import sys
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import yaml
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
from root_kg.paths import PROJECT_ROOT
|
|
25
|
+
CONFIG_PATH = PROJECT_ROOT / "config.yaml"
|
|
26
|
+
ENV_PATH = PROJECT_ROOT / ".env"
|
|
27
|
+
|
|
28
|
+
# Templates ship inside the package so a PyPI install can run init too.
|
|
29
|
+
PACKAGE_DIR = Path(__file__).resolve().parent
|
|
30
|
+
CONFIG_EXAMPLE = PACKAGE_DIR / "config.example.yaml"
|
|
31
|
+
ENV_EXAMPLE = PACKAGE_DIR / ".env.example"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def init():
|
|
35
|
+
"""Interactive setup wizard for ROOT."""
|
|
36
|
+
print("=" * 50)
|
|
37
|
+
print(" ROOT: Personal Knowledge Graph Setup")
|
|
38
|
+
print("=" * 50)
|
|
39
|
+
print(f" ROOT home: {PROJECT_ROOT} (override with ROOT_KG_HOME)")
|
|
40
|
+
print()
|
|
41
|
+
|
|
42
|
+
PROJECT_ROOT.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
|
|
44
|
+
# Step 1: Vault path
|
|
45
|
+
vault_path = _ask_vault_path()
|
|
46
|
+
|
|
47
|
+
# Step 2: LLM backend
|
|
48
|
+
backend, api_key = _ask_llm_backend()
|
|
49
|
+
|
|
50
|
+
# Step 3: Create config.yaml
|
|
51
|
+
_create_config(vault_path, backend)
|
|
52
|
+
|
|
53
|
+
# Step 4: Create .env
|
|
54
|
+
_create_env(backend, api_key)
|
|
55
|
+
|
|
56
|
+
# Step 5: Create directories
|
|
57
|
+
(PROJECT_ROOT / "data").mkdir(exist_ok=True)
|
|
58
|
+
(PROJECT_ROOT / "logs").mkdir(exist_ok=True)
|
|
59
|
+
|
|
60
|
+
print()
|
|
61
|
+
print("=" * 50)
|
|
62
|
+
print(" Setup complete!")
|
|
63
|
+
print("=" * 50)
|
|
64
|
+
print()
|
|
65
|
+
print("Next steps:")
|
|
66
|
+
print(" 1. Index your vault:")
|
|
67
|
+
print(" root-index")
|
|
68
|
+
print()
|
|
69
|
+
print(" 2. Extract entities (requires LLM):")
|
|
70
|
+
print(" root-index --extract")
|
|
71
|
+
print()
|
|
72
|
+
print(" 3. Register as MCP server in Claude Code:")
|
|
73
|
+
server_bin = shutil.which("root-server") or str(Path(sys.executable).with_name("root-server"))
|
|
74
|
+
print(f" claude mcp add root {server_bin}")
|
|
75
|
+
print()
|
|
76
|
+
print(" 4. Try it:")
|
|
77
|
+
print(' root_search("your topic")')
|
|
78
|
+
print(' root_ask("your question")')
|
|
79
|
+
print()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _ask_vault_path() -> str:
|
|
83
|
+
"""Ask for the vault/notes directory."""
|
|
84
|
+
print("Where are your notes?")
|
|
85
|
+
print()
|
|
86
|
+
|
|
87
|
+
# Try to detect common vault locations
|
|
88
|
+
home = Path.home()
|
|
89
|
+
candidates = [
|
|
90
|
+
home / "Library/Mobile Documents/iCloud~md~obsidian/Documents",
|
|
91
|
+
home / "Documents",
|
|
92
|
+
home / "Obsidian",
|
|
93
|
+
home / "Notes",
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
detected = []
|
|
97
|
+
for candidate in candidates:
|
|
98
|
+
if candidate.exists():
|
|
99
|
+
# Look for folders with .md files
|
|
100
|
+
for folder in candidate.iterdir():
|
|
101
|
+
if folder.is_dir() and not folder.name.startswith("."):
|
|
102
|
+
md_count = len(list(folder.glob("**/*.md")))
|
|
103
|
+
if md_count > 10:
|
|
104
|
+
detected.append((folder, md_count))
|
|
105
|
+
|
|
106
|
+
if detected:
|
|
107
|
+
print("Detected vaults:")
|
|
108
|
+
for i, (path, count) in enumerate(detected[:5], 1):
|
|
109
|
+
print(f" {i}. {path} ({count} notes)")
|
|
110
|
+
print(f" {len(detected[:5]) + 1}. Enter custom path")
|
|
111
|
+
print()
|
|
112
|
+
|
|
113
|
+
choice = input("Choose [1]: ").strip() or "1"
|
|
114
|
+
try:
|
|
115
|
+
idx = int(choice) - 1
|
|
116
|
+
if 0 <= idx < len(detected[:5]):
|
|
117
|
+
return str(detected[idx][0])
|
|
118
|
+
except ValueError:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
# Custom path
|
|
122
|
+
while True:
|
|
123
|
+
path = input("Enter path to your notes folder: ").strip()
|
|
124
|
+
path = os.path.expanduser(path)
|
|
125
|
+
if Path(path).exists():
|
|
126
|
+
md_count = len(list(Path(path).glob("**/*.md")))
|
|
127
|
+
print(f" Found {md_count} markdown files.")
|
|
128
|
+
if md_count > 0:
|
|
129
|
+
return path
|
|
130
|
+
print(" No markdown files found. Try a different path.")
|
|
131
|
+
else:
|
|
132
|
+
print(" Path doesn't exist. Try again.")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _ask_llm_backend() -> tuple[str, str]:
|
|
136
|
+
"""Ask which LLM backend to use."""
|
|
137
|
+
print()
|
|
138
|
+
print("Which LLM backend for entity extraction?")
|
|
139
|
+
print(" 1. Anthropic API (~$3 for full extraction, best quality)")
|
|
140
|
+
print(" 2. OpenRouter (free $1 credit)")
|
|
141
|
+
print(" 3. Ollama (free, local, lower quality)")
|
|
142
|
+
print(" 4. Skip (no extraction, search-only mode)")
|
|
143
|
+
print()
|
|
144
|
+
|
|
145
|
+
choice = input("Choose [1]: ").strip() or "1"
|
|
146
|
+
|
|
147
|
+
if choice == "1":
|
|
148
|
+
key = input("Anthropic API key (or press Enter to add later): ").strip()
|
|
149
|
+
return "anthropic", key
|
|
150
|
+
elif choice == "2":
|
|
151
|
+
key = input("OpenRouter API key (or press Enter to add later): ").strip()
|
|
152
|
+
return "openrouter", key
|
|
153
|
+
elif choice == "3":
|
|
154
|
+
print(" Make sure Ollama is running: ollama serve")
|
|
155
|
+
print(" And pull a model: ollama pull llama3.1")
|
|
156
|
+
return "ollama", ""
|
|
157
|
+
else:
|
|
158
|
+
return "anthropic", ""
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _create_config(vault_path: str, backend: str) -> None:
|
|
162
|
+
"""Create config.yaml from template."""
|
|
163
|
+
if CONFIG_PATH.exists():
|
|
164
|
+
overwrite = input(f"\nconfig.yaml already exists. Overwrite? [y/N]: ").strip().lower()
|
|
165
|
+
if overwrite != "y":
|
|
166
|
+
print(" Keeping existing config.yaml")
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
with open(CONFIG_EXAMPLE) as f:
|
|
170
|
+
config = yaml.safe_load(f)
|
|
171
|
+
|
|
172
|
+
config["vault"]["path"] = vault_path
|
|
173
|
+
config["llm"]["backend"] = backend
|
|
174
|
+
|
|
175
|
+
# Set model names based on backend
|
|
176
|
+
if backend == "openrouter":
|
|
177
|
+
config["llm"]["extraction_model"] = "anthropic/claude-haiku-4-5-20251001"
|
|
178
|
+
config["llm"]["synthesis_model"] = "anthropic/claude-sonnet-4-20250514"
|
|
179
|
+
elif backend == "ollama":
|
|
180
|
+
config["llm"]["extraction_model"] = "llama3.1"
|
|
181
|
+
config["llm"]["synthesis_model"] = "llama3.1"
|
|
182
|
+
|
|
183
|
+
with open(CONFIG_PATH, "w") as f:
|
|
184
|
+
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
|
|
185
|
+
|
|
186
|
+
print(f" Created config.yaml")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _create_env(backend: str, api_key: str) -> None:
|
|
190
|
+
"""Create .env file with API key."""
|
|
191
|
+
if not api_key:
|
|
192
|
+
if not ENV_PATH.exists():
|
|
193
|
+
shutil.copy(ENV_EXAMPLE, ENV_PATH)
|
|
194
|
+
print(f" Created .env from template (add your API key later)")
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
key_name = {
|
|
198
|
+
"anthropic": "ANTHROPIC_API_KEY",
|
|
199
|
+
"openrouter": "OPENROUTER_API_KEY",
|
|
200
|
+
}.get(backend)
|
|
201
|
+
|
|
202
|
+
if key_name:
|
|
203
|
+
with open(ENV_PATH, "w") as f:
|
|
204
|
+
f.write(f"{key_name}={api_key}\n")
|
|
205
|
+
print(f" Created .env with {key_name}")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def stats():
|
|
209
|
+
"""Show ROOT index and graph statistics."""
|
|
210
|
+
from root_kg.db import RootDB
|
|
211
|
+
db = RootDB(PROJECT_ROOT / "data/root.db")
|
|
212
|
+
|
|
213
|
+
index_stats = db.get_stats()
|
|
214
|
+
entity_stats = db.get_entity_stats()
|
|
215
|
+
|
|
216
|
+
print("ROOT Stats")
|
|
217
|
+
print(f" Notes: {index_stats['total_notes']}")
|
|
218
|
+
print(f" Chunks: {index_stats['total_chunks']}")
|
|
219
|
+
print(f" Entities: {entity_stats['total_entities']}")
|
|
220
|
+
print(f" Relations: {entity_stats['total_relations']}")
|
|
221
|
+
print(f" Extracted: {entity_stats['notes_extracted']}/{index_stats['total_notes']}")
|
|
222
|
+
print(f" Last index: {index_stats['last_indexed']}")
|
|
223
|
+
|
|
224
|
+
if entity_stats["by_entity_type"]:
|
|
225
|
+
print("\n Entity breakdown:")
|
|
226
|
+
for etype, count in entity_stats["by_entity_type"].items():
|
|
227
|
+
print(f" {etype}: {count}")
|
|
228
|
+
|
|
229
|
+
db.close()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _load_config() -> dict:
|
|
233
|
+
"""Load config.yaml. Exits with error if missing."""
|
|
234
|
+
if not CONFIG_PATH.exists():
|
|
235
|
+
print("Error: config.yaml not found. Run: root-kg init", file=sys.stderr)
|
|
236
|
+
sys.exit(1)
|
|
237
|
+
with open(CONFIG_PATH) as f:
|
|
238
|
+
return yaml.safe_load(f)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def search(query: str, limit: int = 5) -> None:
|
|
242
|
+
"""Semantic search across ROOT's knowledge graph.
|
|
243
|
+
|
|
244
|
+
Prints one result per line: "<rank>. [<folder>] <title> -- <snippet>"
|
|
245
|
+
Suitable for capturing into a bash variable.
|
|
246
|
+
"""
|
|
247
|
+
config = _load_config()
|
|
248
|
+
db_path = PROJECT_ROOT / config["database"]["path"]
|
|
249
|
+
|
|
250
|
+
from root_kg.db import RootDB
|
|
251
|
+
from root_kg.embeddings import Embedder
|
|
252
|
+
from root_kg.tools.search import semantic_search
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
db = RootDB(db_path)
|
|
256
|
+
embedder = Embedder(config["embeddings"]["model"])
|
|
257
|
+
results = semantic_search(query=query, db=db, embedder=embedder, limit=limit)
|
|
258
|
+
db.close()
|
|
259
|
+
except Exception as exc:
|
|
260
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
261
|
+
sys.exit(1)
|
|
262
|
+
|
|
263
|
+
if not results:
|
|
264
|
+
# Empty stdout, exit 0 -- caller treats empty output as "no results"
|
|
265
|
+
return
|
|
266
|
+
|
|
267
|
+
for i, r in enumerate(results, 1):
|
|
268
|
+
snippet = r["snippet"].replace("\n", " ").strip()
|
|
269
|
+
if len(snippet) > 200:
|
|
270
|
+
snippet = snippet[:200] + "..."
|
|
271
|
+
print(f"{i}. [{r['folder']}] {r['title']} -- {snippet}")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def note(content: str, tags: list[str] | None = None) -> None:
|
|
275
|
+
"""Ingest a plain-text note into ROOT's index.
|
|
276
|
+
|
|
277
|
+
Uses the same chunking + embedding pipeline as root_ingest (server.py).
|
|
278
|
+
source_type is 'cli' so notes are queryable via root_search without
|
|
279
|
+
polluting the vault source bucket.
|
|
280
|
+
"""
|
|
281
|
+
config = _load_config()
|
|
282
|
+
db_path = PROJECT_ROOT / config["database"]["path"]
|
|
283
|
+
|
|
284
|
+
from root_kg.chunker import chunk_note, chunk_size
|
|
285
|
+
from root_kg.db import RootDB
|
|
286
|
+
from root_kg.embeddings import Embedder
|
|
287
|
+
|
|
288
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
289
|
+
|
|
290
|
+
# Derive a short title from the first non-empty line
|
|
291
|
+
first_line = next((ln.strip() for ln in content.splitlines() if ln.strip()), "CLI Note")
|
|
292
|
+
title = first_line[:120]
|
|
293
|
+
|
|
294
|
+
# Stable unique path: hash of content + ingest timestamp to avoid collisions
|
|
295
|
+
# on identical content ingested at different times
|
|
296
|
+
path_hash = hashlib.sha256((content + now).encode()).hexdigest()[:16]
|
|
297
|
+
path = f"cli-notes/{path_hash}"
|
|
298
|
+
|
|
299
|
+
tag_str = (",".join(tags) + " ") if tags else ""
|
|
300
|
+
# Prepend tags into body so they're searchable
|
|
301
|
+
indexed_content = f"{tag_str}{content}" if tag_str else content
|
|
302
|
+
content_hash = hashlib.sha256(indexed_content.encode("utf-8")).hexdigest()
|
|
303
|
+
folder = "CLI Notes"
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
db = RootDB(db_path)
|
|
307
|
+
embedder = Embedder(config["embeddings"]["model"])
|
|
308
|
+
|
|
309
|
+
note_id = db.upsert_note(
|
|
310
|
+
path=path,
|
|
311
|
+
title=title,
|
|
312
|
+
content=indexed_content,
|
|
313
|
+
content_hash=content_hash,
|
|
314
|
+
folder=folder,
|
|
315
|
+
source_type="cli",
|
|
316
|
+
created_at=now,
|
|
317
|
+
indexed_at=now,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
chunks = chunk_note(indexed_content, title, max_chars=chunk_size(config))
|
|
321
|
+
if chunks:
|
|
322
|
+
texts = [c["text"] for c in chunks]
|
|
323
|
+
embeddings = embedder.embed_batch(texts)
|
|
324
|
+
indexed_chunks = [
|
|
325
|
+
{"idx": c["idx"], "text": c["text"], "embedding": emb}
|
|
326
|
+
for c, emb in zip(chunks, embeddings)
|
|
327
|
+
]
|
|
328
|
+
db.store_chunks(note_id, indexed_chunks)
|
|
329
|
+
|
|
330
|
+
db.close()
|
|
331
|
+
except Exception as exc:
|
|
332
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
333
|
+
sys.exit(1)
|
|
334
|
+
|
|
335
|
+
# Success: silent stdout, exit 0
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def main():
|
|
339
|
+
"""CLI entry point."""
|
|
340
|
+
if len(sys.argv) < 2:
|
|
341
|
+
print("Usage: root-kg <command>")
|
|
342
|
+
print("Commands: init, stats, index, extract, search, note")
|
|
343
|
+
return
|
|
344
|
+
|
|
345
|
+
command = sys.argv[1]
|
|
346
|
+
|
|
347
|
+
if command == "init":
|
|
348
|
+
init()
|
|
349
|
+
elif command == "stats":
|
|
350
|
+
stats()
|
|
351
|
+
elif command == "index":
|
|
352
|
+
from root_kg.indexer import main as index_main
|
|
353
|
+
sys.argv = sys.argv[1:] # Shift args for indexer's argparse
|
|
354
|
+
index_main()
|
|
355
|
+
elif command == "extract":
|
|
356
|
+
sys.argv = ["indexer", "--extract-only"] + sys.argv[2:]
|
|
357
|
+
from root_kg.indexer import main as index_main
|
|
358
|
+
index_main()
|
|
359
|
+
elif command == "search":
|
|
360
|
+
import argparse
|
|
361
|
+
parser = argparse.ArgumentParser(prog="root-kg search")
|
|
362
|
+
parser.add_argument("--query", required=True, help="Natural language search query")
|
|
363
|
+
parser.add_argument("--limit", type=int, default=5, help="Max results (default 5)")
|
|
364
|
+
args = parser.parse_args(sys.argv[2:])
|
|
365
|
+
search(query=args.query, limit=args.limit)
|
|
366
|
+
elif command == "note":
|
|
367
|
+
import argparse
|
|
368
|
+
parser = argparse.ArgumentParser(prog="root-kg note")
|
|
369
|
+
parser.add_argument("--content", required=True, help="Plain text note content")
|
|
370
|
+
parser.add_argument("--tags", default="", help="Comma-separated tags (optional)")
|
|
371
|
+
args = parser.parse_args(sys.argv[2:])
|
|
372
|
+
tags = [t.strip() for t in args.tags.split(",") if t.strip()] if args.tags else []
|
|
373
|
+
note(content=args.content, tags=tags)
|
|
374
|
+
else:
|
|
375
|
+
print(f"Unknown command: {command}")
|
|
376
|
+
print("Commands: init, stats, index, extract, search, note")
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
if __name__ == "__main__":
|
|
380
|
+
main()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# ROOT Configuration
|
|
2
|
+
# Copy to config.yaml and edit, or run: root-kg init
|
|
3
|
+
|
|
4
|
+
vault:
|
|
5
|
+
# Path to your Obsidian vault (or any folder of markdown files)
|
|
6
|
+
path: "~/Documents/My Vault"
|
|
7
|
+
exclude_folders:
|
|
8
|
+
- ".obsidian"
|
|
9
|
+
- ".trash"
|
|
10
|
+
- "templates"
|
|
11
|
+
exclude_patterns:
|
|
12
|
+
- "*.excalidraw.md"
|
|
13
|
+
|
|
14
|
+
# Extra roots beyond the main vault. Optional; omit for single-vault setups.
|
|
15
|
+
#
|
|
16
|
+
# Each root gets its own source_type, so stale sweeps, index stats and the
|
|
17
|
+
# root_search source filter stay independent per root. An unreachable root is
|
|
18
|
+
# skipped, never swept.
|
|
19
|
+
#
|
|
20
|
+
# `extract` is the cost gate and defaults to false. Indexing is free (local
|
|
21
|
+
# MiniLM embeddings on CPU), while entity extraction calls an LLM per note.
|
|
22
|
+
# So a root is semantically searchable the moment it is indexed, and only
|
|
23
|
+
# earns extraction if you want its entities in the graph. Point a large,
|
|
24
|
+
# low-entity corpus here with extract: false and it costs you nothing.
|
|
25
|
+
#
|
|
26
|
+
# `prefix` namespaces the root's paths, defaulting to its name, because note
|
|
27
|
+
# paths are unique and two roots can both contain `index.md`. The main vault
|
|
28
|
+
# above keeps no prefix, so adding roots never re-indexes an existing vault.
|
|
29
|
+
#
|
|
30
|
+
# roots:
|
|
31
|
+
# - name: project-docs
|
|
32
|
+
# path: "~/code/myproject/docs"
|
|
33
|
+
# extract: true
|
|
34
|
+
# - name: agent-memory
|
|
35
|
+
# path: "~/.config/agent/memory"
|
|
36
|
+
# extract: false
|
|
37
|
+
|
|
38
|
+
embeddings:
|
|
39
|
+
# Local model, free, runs on CPU. No API key needed.
|
|
40
|
+
model: "all-MiniLM-L6-v2"
|
|
41
|
+
# Long notes split at headings into chunks of at most this many characters
|
|
42
|
+
max_chunk_chars: 4000
|
|
43
|
+
|
|
44
|
+
database:
|
|
45
|
+
path: "data/root.db"
|
|
46
|
+
|
|
47
|
+
indexer:
|
|
48
|
+
log_dir: "logs"
|
|
49
|
+
batch_size: 64
|
|
50
|
+
# Skip notes with less prose than this, measured after stripping frontmatter,
|
|
51
|
+
# headings, embeds and links. Navigation-only notes (a title plus a file embed
|
|
52
|
+
# plus prev/next links) embed as near-identical tag soup and crowd out real
|
|
53
|
+
# answers for any query in the same vocabulary. Set 0 to index everything.
|
|
54
|
+
# Preview before changing: root-index --report-thin --min-prose N
|
|
55
|
+
min_prose_chars: 30
|
|
56
|
+
|
|
57
|
+
llm:
|
|
58
|
+
# Backend options:
|
|
59
|
+
# "anthropic" - Direct Anthropic API (needs ANTHROPIC_API_KEY in .env)
|
|
60
|
+
# "openrouter" - OpenRouter API (needs OPENROUTER_API_KEY, free $1 credit)
|
|
61
|
+
# "ollama" - Local LLM via Ollama (free, no API key needed)
|
|
62
|
+
backend: "anthropic"
|
|
63
|
+
extraction_model: "claude-haiku-4-5-20251001"
|
|
64
|
+
synthesis_model: "claude-sonnet-5"
|
|
65
|
+
max_content_chars: 6000
|
|
66
|
+
batch_delay_ms: 100
|