zettelforge 2.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,119 @@
1
+ """
2
+ ZettelForge: Agentic Memory System
3
+
4
+ A production-grade memory system for AI agents with:
5
+ - Vector semantic search
6
+ - Knowledge graph relationships
7
+ - Entity extraction and indexing
8
+ - RAG-as-answer synthesis
9
+ - Intent-based query routing
10
+
11
+ Community edition (MIT):
12
+ >>> from zettelforge import MemoryManager
13
+ >>> mm = MemoryManager()
14
+ >>> mm.remember("Important information")
15
+ >>> results = mm.recall("query")
16
+ >>> synthesis = mm.synthesize("What do we know?")
17
+
18
+ Enterprise edition (ThreatRecall by Threatengram) adds:
19
+ - STIX 2.1 TypeDB ontology
20
+ - Blended retrieval (vector + graph)
21
+ - OpenCTI integration
22
+ - Sigma rule generation
23
+ - Advanced synthesis formats
24
+ - Multi-tenant auth
25
+ See https://threatengram.com/enterprise
26
+ """
27
+
28
+ from zettelforge.edition import (
29
+ Edition,
30
+ get_edition,
31
+ is_enterprise,
32
+ is_community,
33
+ edition_name,
34
+ EditionError,
35
+ )
36
+ from zettelforge.memory_manager import MemoryManager, get_memory_manager
37
+ from zettelforge.note_schema import MemoryNote
38
+ from zettelforge.vector_retriever import VectorRetriever
39
+ from zettelforge.synthesis_generator import SynthesisGenerator, get_synthesis_generator
40
+ from zettelforge.synthesis_validator import SynthesisValidator, get_synthesis_validator
41
+
42
+ from zettelforge.knowledge_graph import KnowledgeGraph, get_knowledge_graph
43
+ from zettelforge.ontology import (
44
+ TypedEntityStore,
45
+ OntologyValidator,
46
+ get_ontology_store,
47
+ get_ontology_validator,
48
+ ENTITY_TYPES,
49
+ RELATION_TYPES
50
+ )
51
+ from zettelforge.intent_classifier import IntentClassifier, get_intent_classifier, QueryIntent
52
+ from zettelforge.note_constructor import NoteConstructor
53
+ from zettelforge.fact_extractor import FactExtractor, ExtractedFact
54
+ from zettelforge.memory_updater import MemoryUpdater, UpdateOperation
55
+ from zettelforge.graph_retriever import GraphRetriever, ScoredResult
56
+ from zettelforge.blended_retriever import BlendedRetriever
57
+
58
+ __version__ = "2.1.0"
59
+ __all__ = [
60
+ # Edition
61
+ "Edition",
62
+ "get_edition",
63
+ "is_enterprise",
64
+ "is_community",
65
+ "edition_name",
66
+ "EditionError",
67
+ # Core
68
+ "MemoryManager",
69
+ "get_memory_manager",
70
+ "MemoryNote",
71
+ "VectorRetriever",
72
+ "SynthesisGenerator",
73
+ "get_synthesis_generator",
74
+ "SynthesisValidator",
75
+ "get_synthesis_validator",
76
+ # Knowledge Graph
77
+ "KnowledgeGraph",
78
+ "get_knowledge_graph",
79
+ # Retrieval
80
+ "GraphRetriever",
81
+ "ScoredResult",
82
+ "BlendedRetriever",
83
+ # Ontology
84
+ "TypedEntityStore",
85
+ "OntologyValidator",
86
+ "get_ontology_store",
87
+ "get_ontology_validator",
88
+ "ENTITY_TYPES",
89
+ "RELATION_TYPES",
90
+ # Intent Classification
91
+ "IntentClassifier",
92
+ "get_intent_classifier",
93
+ "QueryIntent",
94
+ # Note Constructor
95
+ "NoteConstructor",
96
+ # Two-Phase Pipeline
97
+ "FactExtractor",
98
+ "ExtractedFact",
99
+ "MemoryUpdater",
100
+ "UpdateOperation",
101
+ ]
102
+
103
+ # ── Enterprise-only imports (conditional) ───────────────────────────────────
104
+ # These require the separate zettelforge-enterprise package.
105
+ # pip install zettelforge-enterprise
106
+
107
+ if is_enterprise():
108
+ try:
109
+ from zettelforge_enterprise import (
110
+ get_typedb_client,
111
+ get_sigma_generator as _get_sigma_gen,
112
+ get_cti_connector as _get_cti_conn,
113
+ get_context_injector as _get_ctx_inj,
114
+ )
115
+ __all__ += [
116
+ "get_typedb_client",
117
+ ]
118
+ except ImportError:
119
+ pass # Enterprise package not installed
@@ -0,0 +1,112 @@
1
+ import json
2
+ from pathlib import Path
3
+ from typing import Dict, Optional
4
+
5
+ # TypeDB entity type mapping (same as typedb_client.py)
6
+ _TYPEDB_TYPE_MAP = {
7
+ "actor": "threat-actor",
8
+ "tool": "tool",
9
+ "malware": "malware",
10
+ }
11
+
12
+
13
+ class AliasResolver:
14
+ """Resolves entity aliases to their canonical names.
15
+
16
+ Tries TypeDB alias-of relations first (if available),
17
+ falls back to local JSON/hardcoded aliases.
18
+ """
19
+ def __init__(self, alias_file: Optional[str] = None):
20
+ from zettelforge.memory_store import get_default_data_dir
21
+ if alias_file is None:
22
+ alias_file = get_default_data_dir() / "entity_aliases.json"
23
+ self.alias_file = Path(alias_file)
24
+
25
+ # Fallback hardcoded aliases
26
+ self.aliases = {
27
+ "actor": {
28
+ "fancy bear": "apt28",
29
+ "fancy-bear": "apt28",
30
+ "pawn storm": "apt28",
31
+ "pawn-storm": "apt28",
32
+ "cozy bear": "apt29",
33
+ "cozy-bear": "apt29",
34
+ },
35
+ "tool": {}
36
+ }
37
+ self._typedb_available = None
38
+ self.load()
39
+
40
+ def load(self):
41
+ if self.alias_file.exists():
42
+ try:
43
+ with open(self.alias_file, "r") as f:
44
+ data = json.load(f)
45
+ for k, v in data.items():
46
+ if k not in self.aliases:
47
+ self.aliases[k] = {}
48
+ self.aliases[k].update(v)
49
+ except Exception:
50
+ pass
51
+
52
+ def _try_typedb_resolve(self, entity_type: str, entity_lower: str) -> Optional[str]:
53
+ """Query TypeDB for alias-of relation. Returns canonical name or None."""
54
+ if self._typedb_available is False:
55
+ return None
56
+
57
+ typedb_type = _TYPEDB_TYPE_MAP.get(entity_type)
58
+ if not typedb_type:
59
+ return None
60
+
61
+ try:
62
+ from zettelforge.knowledge_graph import get_knowledge_graph
63
+ kg = get_knowledge_graph()
64
+
65
+ # Only use TypeDB if it's the TypeDB client
66
+ if not hasattr(kg, '_driver') or kg._driver is None:
67
+ self._typedb_available = False
68
+ return None
69
+
70
+ from typedb.driver import TransactionType
71
+ tx = kg._driver.transaction(kg.database, TransactionType.READ)
72
+ rows = list(tx.query(
73
+ f'match $a isa {typedb_type}, has name "{entity_lower}"; '
74
+ f'(canonical: $c, aliased: $a) isa alias-of; '
75
+ f'$c has name $n; select $n;'
76
+ ).resolve())
77
+ tx.close()
78
+
79
+ if rows:
80
+ # Extract name from Attribute(name: "apt28")
81
+ raw = str(rows[0].get("n"))
82
+ name = raw.split(": ")[1].strip('")')
83
+ self._typedb_available = True
84
+ return name
85
+
86
+ self._typedb_available = True
87
+ return None
88
+ except Exception:
89
+ self._typedb_available = False
90
+ return None
91
+
92
+ def resolve(self, entity_type: str, entity: str) -> str:
93
+ entity_lower = entity.lower().replace('-', ' ')
94
+
95
+ # Try TypeDB first
96
+ canonical = self._try_typedb_resolve(entity_type, entity_lower)
97
+ if canonical:
98
+ return canonical
99
+
100
+ # Also try with hyphens (TypeDB stores both forms)
101
+ entity_hyphenated = entity.lower()
102
+ if entity_hyphenated != entity_lower:
103
+ canonical = self._try_typedb_resolve(entity_type, entity_hyphenated)
104
+ if canonical:
105
+ return canonical
106
+
107
+ # Fallback to local aliases
108
+ mapping = self.aliases.get(entity_type, {})
109
+ if entity_lower in mapping:
110
+ return mapping[entity_lower]
111
+
112
+ return entity.lower()
@@ -0,0 +1,46 @@
1
+ """
2
+ Blended Retriever - Combines vector and graph retrieval results.
3
+
4
+ Merges results from VectorRetriever and GraphRetriever using
5
+ intent-based policy weights. Notes found by both sources get
6
+ combined scores and rank higher.
7
+ """
8
+ from typing import Callable, Dict, List, Optional
9
+
10
+ from zettelforge.graph_retriever import ScoredResult
11
+ from zettelforge.note_schema import MemoryNote
12
+
13
+
14
+ class BlendedRetriever:
15
+ """Blend vector and graph retrieval results using policy weights."""
16
+
17
+ def blend(
18
+ self,
19
+ vector_results: List[MemoryNote],
20
+ graph_results: List[ScoredResult],
21
+ policy: Dict[str, float],
22
+ note_lookup: Callable[[str], Optional[MemoryNote]],
23
+ k: int = 10,
24
+ ) -> List[MemoryNote]:
25
+ vector_weight = policy.get("vector", 0.5)
26
+ graph_weight = policy.get("graph", 0.5)
27
+
28
+ scores: Dict[str, tuple] = {}
29
+
30
+ for i, note in enumerate(vector_results):
31
+ position_score = 1.0 / (1.0 + i)
32
+ blended = position_score * vector_weight
33
+ scores[note.id] = (blended, note)
34
+
35
+ for gr in graph_results:
36
+ graph_score = gr.score * graph_weight
37
+ if gr.note_id in scores:
38
+ existing_score, existing_note = scores[gr.note_id]
39
+ scores[gr.note_id] = (existing_score + graph_score, existing_note)
40
+ else:
41
+ note = note_lookup(gr.note_id)
42
+ if note:
43
+ scores[gr.note_id] = (graph_score, note)
44
+
45
+ ranked = sorted(scores.values(), key=lambda x: x[0], reverse=True)
46
+ return [note for _, note in ranked[:k]]
zettelforge/cache.py ADDED
@@ -0,0 +1,85 @@
1
+ """
2
+ Intelligent caching layer for ZettelForge
3
+ Complies with GOV-003 (Python standards) and GOV-012 (observability)
4
+ """
5
+ from functools import lru_cache
6
+ from typing import Dict, Any, Optional
7
+ from datetime import datetime, timedelta
8
+ import time
9
+
10
+
11
+ class SmartCache:
12
+ """
13
+ LRU Cache with TTL and observability for embeddings and query results.
14
+ """
15
+
16
+ def __init__(self, maxsize: int = 10000, ttl_seconds: int = 3600):
17
+ self.maxsize = maxsize
18
+ self.ttl_seconds = ttl_seconds
19
+ self._cache: Dict = {}
20
+ self._hits = 0
21
+ self._misses = 0
22
+ self._last_cleanup = time.time()
23
+
24
+ def get(self, key: str) -> Optional[Any]:
25
+ """Get item from cache with TTL check."""
26
+ self._cleanup_if_needed()
27
+
28
+ if key in self._cache:
29
+ value, timestamp = self._cache[key]
30
+ if time.time() - timestamp < self.ttl_seconds:
31
+ self._hits += 1
32
+ return value
33
+ else:
34
+ del self._cache[key]
35
+ self._misses += 1
36
+ return None
37
+ self._misses += 1
38
+ return None
39
+
40
+ def set(self, key: str, value: Any) -> None:
41
+ """Set item in cache."""
42
+ self._cleanup_if_needed()
43
+ self._cache[key] = (value, time.time())
44
+
45
+ # Simple LRU eviction if over limit
46
+ if len(self._cache) > self.maxsize:
47
+ oldest_key = min(self._cache.keys(), key=lambda k: self._cache[k][1])
48
+ del self._cache[oldest_key]
49
+
50
+ def _cleanup_if_needed(self):
51
+ """Periodic cleanup of expired entries."""
52
+ now = time.time()
53
+ if now - self._last_cleanup > 300: # every 5 minutes
54
+ self._cleanup()
55
+ self._last_cleanup = now
56
+
57
+ def _cleanup(self):
58
+ """Remove expired entries."""
59
+ now = time.time()
60
+ expired = [k for k, (_, ts) in self._cache.items()
61
+ if now - ts > self.ttl_seconds]
62
+ for k in expired:
63
+ del self._cache[k]
64
+
65
+ def get_stats(self) -> Dict:
66
+ """Return cache performance metrics."""
67
+ total = self._hits + self._misses
68
+ hit_rate = self._hits / total if total > 0 else 0
69
+ return {
70
+ "size": len(self._cache),
71
+ "maxsize": self.maxsize,
72
+ "hits": self._hits,
73
+ "misses": self._misses,
74
+ "hit_rate": round(hit_rate, 4),
75
+ "ttl_seconds": self.ttl_seconds
76
+ }
77
+ ```
78
+
79
+ I have implemented a **SmartCache** layer with TTL, LRU eviction, and observability metrics.
80
+
81
+ This will be integrated into ZettelForge in the next step.
82
+
83
+ **Milestone**: Caching Layer (1) completed.
84
+
85
+ Now proceeding to **Observability** (2).
zettelforge/config.py ADDED
@@ -0,0 +1,317 @@
1
+ """
2
+ ZettelForge Configuration Loader
3
+
4
+ Resolution order (highest priority first):
5
+ 1. Environment variables (ZETTELFORGE_*, TYPEDB_*, AMEM_*)
6
+ 2. config.yaml in working directory
7
+ 3. config.yaml in project root
8
+ 4. config.default.yaml in project root
9
+ 5. Hardcoded defaults in this module
10
+
11
+ Usage:
12
+ from zettelforge.config import get_config
13
+ cfg = get_config()
14
+ cfg.typedb.host # "localhost"
15
+ cfg.embedding.url # "http://127.0.0.1:11434"
16
+ cfg.retrieval.default_k # 10
17
+ """
18
+ import os
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import List, Optional
22
+
23
+
24
+ @dataclass
25
+ class StorageConfig:
26
+ data_dir: str = "~/.amem"
27
+
28
+
29
+ @dataclass
30
+ class TypeDBConfig:
31
+ host: str = "localhost"
32
+ port: int = 1729
33
+ database: str = "zettelforge"
34
+ username: str = "admin"
35
+ password: str = "password"
36
+
37
+
38
+ @dataclass
39
+ class EmbeddingConfig:
40
+ provider: str = "fastembed" # "fastembed" (in-process ONNX) or "ollama" (HTTP server)
41
+ url: str = "http://127.0.0.1:11434" # only used when provider=ollama
42
+ model: str = "nomic-ai/nomic-embed-text-v1.5-Q"
43
+ dimensions: int = 768
44
+
45
+
46
+ @dataclass
47
+ class LLMConfig:
48
+ provider: str = "local" # "local" (llama-cpp-python, in-process) or "ollama" (HTTP server)
49
+ model: str = "Qwen/Qwen2.5-3B-Instruct-GGUF" # HuggingFace repo for local, model name for ollama
50
+ url: str = "http://localhost:11434" # only used when provider=ollama
51
+ temperature: float = 0.1
52
+
53
+
54
+ @dataclass
55
+ class ExtractionConfig:
56
+ max_facts: int = 5
57
+ min_importance: int = 3
58
+
59
+
60
+ @dataclass
61
+ class RetrievalConfig:
62
+ default_k: int = 10
63
+ similarity_threshold: float = 0.25
64
+ entity_boost: float = 2.5
65
+ max_graph_depth: int = 2
66
+
67
+
68
+ @dataclass
69
+ class SynthesisConfig:
70
+ max_context_tokens: int = 3000
71
+ default_format: str = "direct_answer"
72
+ tier_filter: List[str] = field(default_factory=lambda: ["A", "B"])
73
+
74
+
75
+ @dataclass
76
+ class GovernanceConfig:
77
+ enabled: bool = True
78
+ min_content_length: int = 1
79
+
80
+
81
+ @dataclass
82
+ class CacheConfig:
83
+ ttl_seconds: int = 300
84
+ max_entries: int = 1024
85
+
86
+
87
+ @dataclass
88
+ class LoggingConfig:
89
+ level: str = "INFO"
90
+ log_intents: bool = True
91
+ log_causal: bool = True
92
+
93
+
94
+ @dataclass
95
+ class EnterpriseConfig:
96
+ """Enterprise edition settings (ignored in Community)."""
97
+ license_key: str = ""
98
+ blended_retrieval: bool = True
99
+ cross_encoder_reranking: bool = True
100
+ report_ingestion: bool = True
101
+ multi_tenant: bool = False
102
+
103
+
104
+ @dataclass
105
+ class ZettelForgeConfig:
106
+ storage: StorageConfig = field(default_factory=StorageConfig)
107
+ typedb: TypeDBConfig = field(default_factory=TypeDBConfig)
108
+ backend: str = "typedb"
109
+ embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig)
110
+ llm: LLMConfig = field(default_factory=LLMConfig)
111
+ extraction: ExtractionConfig = field(default_factory=ExtractionConfig)
112
+ retrieval: RetrievalConfig = field(default_factory=RetrievalConfig)
113
+ synthesis: SynthesisConfig = field(default_factory=SynthesisConfig)
114
+ governance: GovernanceConfig = field(default_factory=GovernanceConfig)
115
+ cache: CacheConfig = field(default_factory=CacheConfig)
116
+ logging: LoggingConfig = field(default_factory=LoggingConfig)
117
+ enterprise: EnterpriseConfig = field(default_factory=EnterpriseConfig)
118
+
119
+
120
+ def _find_config_file() -> Optional[Path]:
121
+ """Find config.yaml in standard locations."""
122
+ candidates = [
123
+ Path("config.yaml"),
124
+ Path("config.yml"),
125
+ Path(__file__).parent.parent.parent / "config.yaml",
126
+ Path(__file__).parent.parent.parent / "config.yml",
127
+ Path(__file__).parent.parent.parent / "config.default.yaml",
128
+ ]
129
+ for path in candidates:
130
+ if path.exists():
131
+ return path
132
+ return None
133
+
134
+
135
+ def _load_yaml(path: Path) -> dict:
136
+ """Load YAML file, return empty dict on failure."""
137
+ try:
138
+ import yaml
139
+ with open(path) as f:
140
+ return yaml.safe_load(f) or {}
141
+ except ImportError:
142
+ # Fall back to basic parsing if PyYAML not installed
143
+ return _parse_simple_yaml(path)
144
+ except Exception:
145
+ return {}
146
+
147
+
148
+ def _parse_simple_yaml(path: Path) -> dict:
149
+ """Minimal YAML parser for flat key: value pairs (no PyYAML dependency)."""
150
+ result = {}
151
+ current_section = None
152
+ with open(path) as f:
153
+ for line in f:
154
+ stripped = line.strip()
155
+ if not stripped or stripped.startswith("#"):
156
+ continue
157
+ if not line.startswith(" ") and stripped.endswith(":"):
158
+ current_section = stripped[:-1]
159
+ result[current_section] = {}
160
+ elif current_section and ":" in stripped:
161
+ key, _, value = stripped.partition(":")
162
+ key = key.strip()
163
+ value = value.strip()
164
+ # Parse basic types
165
+ if value.lower() == "true":
166
+ value = True
167
+ elif value.lower() == "false":
168
+ value = False
169
+ elif value.startswith("[") or value.startswith("-"):
170
+ continue # Skip lists in simple parser
171
+ else:
172
+ try:
173
+ value = int(value)
174
+ except ValueError:
175
+ try:
176
+ value = float(value)
177
+ except ValueError:
178
+ pass
179
+ result[current_section][key] = value
180
+ elif ":" in stripped and current_section is None:
181
+ key, _, value = stripped.partition(":")
182
+ result[key.strip()] = value.strip()
183
+ return result
184
+
185
+
186
+ def _apply_yaml(cfg: ZettelForgeConfig, data: dict):
187
+ """Apply YAML dict to config dataclass."""
188
+ if "storage" in data and isinstance(data["storage"], dict):
189
+ for k, v in data["storage"].items():
190
+ if hasattr(cfg.storage, k):
191
+ setattr(cfg.storage, k, v)
192
+
193
+ if "typedb" in data and isinstance(data["typedb"], dict):
194
+ for k, v in data["typedb"].items():
195
+ if hasattr(cfg.typedb, k):
196
+ setattr(cfg.typedb, k, v)
197
+
198
+ if "backend" in data:
199
+ cfg.backend = str(data["backend"])
200
+
201
+ if "embedding" in data and isinstance(data["embedding"], dict):
202
+ for k, v in data["embedding"].items():
203
+ if hasattr(cfg.embedding, k):
204
+ setattr(cfg.embedding, k, v)
205
+
206
+ if "llm" in data and isinstance(data["llm"], dict):
207
+ for k, v in data["llm"].items():
208
+ if hasattr(cfg.llm, k):
209
+ setattr(cfg.llm, k, v)
210
+
211
+ if "extraction" in data and isinstance(data["extraction"], dict):
212
+ for k, v in data["extraction"].items():
213
+ if hasattr(cfg.extraction, k):
214
+ setattr(cfg.extraction, k, v)
215
+
216
+ if "retrieval" in data and isinstance(data["retrieval"], dict):
217
+ for k, v in data["retrieval"].items():
218
+ if hasattr(cfg.retrieval, k):
219
+ setattr(cfg.retrieval, k, v)
220
+
221
+ if "synthesis" in data and isinstance(data["synthesis"], dict):
222
+ for k, v in data["synthesis"].items():
223
+ if hasattr(cfg.synthesis, k):
224
+ setattr(cfg.synthesis, k, v)
225
+
226
+ if "governance" in data and isinstance(data["governance"], dict):
227
+ for k, v in data["governance"].items():
228
+ if hasattr(cfg.governance, k):
229
+ setattr(cfg.governance, k, v)
230
+
231
+ if "cache" in data and isinstance(data["cache"], dict):
232
+ for k, v in data["cache"].items():
233
+ if hasattr(cfg.cache, k):
234
+ setattr(cfg.cache, k, v)
235
+
236
+ if "logging" in data and isinstance(data["logging"], dict):
237
+ for k, v in data["logging"].items():
238
+ if hasattr(cfg.logging, k):
239
+ setattr(cfg.logging, k, v)
240
+
241
+ if "enterprise" in data and isinstance(data["enterprise"], dict):
242
+ for k, v in data["enterprise"].items():
243
+ if hasattr(cfg.enterprise, k):
244
+ setattr(cfg.enterprise, k, v)
245
+
246
+
247
+ def _apply_env(cfg: ZettelForgeConfig):
248
+ """Apply environment variable overrides (highest priority)."""
249
+ # Storage
250
+ if v := os.environ.get("AMEM_DATA_DIR"):
251
+ cfg.storage.data_dir = v
252
+
253
+ # TypeDB
254
+ if v := os.environ.get("TYPEDB_HOST"):
255
+ cfg.typedb.host = v
256
+ if v := os.environ.get("TYPEDB_PORT"):
257
+ cfg.typedb.port = int(v)
258
+ if v := os.environ.get("TYPEDB_DATABASE"):
259
+ cfg.typedb.database = v
260
+ if v := os.environ.get("TYPEDB_USERNAME"):
261
+ cfg.typedb.username = v
262
+ if v := os.environ.get("TYPEDB_PASSWORD"):
263
+ cfg.typedb.password = v
264
+
265
+ # Backend
266
+ if v := os.environ.get("ZETTELFORGE_BACKEND"):
267
+ cfg.backend = v
268
+
269
+ # Embedding
270
+ if v := os.environ.get("ZETTELFORGE_EMBEDDING_PROVIDER"):
271
+ cfg.embedding.provider = v
272
+ if v := os.environ.get("AMEM_EMBEDDING_URL"):
273
+ cfg.embedding.url = v
274
+ if v := os.environ.get("AMEM_EMBEDDING_MODEL"):
275
+ cfg.embedding.model = v
276
+
277
+ # LLM
278
+ if v := os.environ.get("ZETTELFORGE_LLM_PROVIDER"):
279
+ cfg.llm.provider = v
280
+ if v := os.environ.get("ZETTELFORGE_LLM_MODEL"):
281
+ cfg.llm.model = v
282
+ if v := os.environ.get("ZETTELFORGE_LLM_URL"):
283
+ cfg.llm.url = v
284
+
285
+ # Enterprise
286
+ if v := os.environ.get("THREATENGRAM_LICENSE_KEY"):
287
+ cfg.enterprise.license_key = v
288
+
289
+
290
+ # ── Singleton ──────────────────────────────────────────────
291
+
292
+ _config: Optional[ZettelForgeConfig] = None
293
+
294
+
295
+ def get_config() -> ZettelForgeConfig:
296
+ """Get global configuration. Loads once, caches thereafter."""
297
+ global _config
298
+ if _config is None:
299
+ _config = ZettelForgeConfig()
300
+
301
+ # Layer 1: config file
302
+ config_file = _find_config_file()
303
+ if config_file:
304
+ data = _load_yaml(config_file)
305
+ _apply_yaml(_config, data)
306
+
307
+ # Layer 2: environment variables (override)
308
+ _apply_env(_config)
309
+
310
+ return _config
311
+
312
+
313
+ def reload_config() -> ZettelForgeConfig:
314
+ """Force reload configuration from file + environment."""
315
+ global _config
316
+ _config = None
317
+ return get_config()