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 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
@@ -0,0 +1,3 @@
1
+ """ROOT: a personal knowledge graph with an MCP server on top."""
2
+
3
+ __version__ = "2.0.0"
root_kg/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running ROOT as: python -m root_kg <command>"""
2
+ from root_kg.cli import main
3
+
4
+ if __name__ == "__main__":
5
+ main()
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