tribalmemory 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. tribalmemory/__init__.py +3 -0
  2. tribalmemory/a21/__init__.py +38 -0
  3. tribalmemory/a21/config/__init__.py +20 -0
  4. tribalmemory/a21/config/providers.py +104 -0
  5. tribalmemory/a21/config/system.py +184 -0
  6. tribalmemory/a21/container/__init__.py +8 -0
  7. tribalmemory/a21/container/container.py +212 -0
  8. tribalmemory/a21/providers/__init__.py +32 -0
  9. tribalmemory/a21/providers/base.py +241 -0
  10. tribalmemory/a21/providers/deduplication.py +99 -0
  11. tribalmemory/a21/providers/lancedb.py +232 -0
  12. tribalmemory/a21/providers/memory.py +128 -0
  13. tribalmemory/a21/providers/mock.py +54 -0
  14. tribalmemory/a21/providers/openai.py +151 -0
  15. tribalmemory/a21/providers/timestamp.py +88 -0
  16. tribalmemory/a21/system.py +293 -0
  17. tribalmemory/cli.py +298 -0
  18. tribalmemory/interfaces.py +306 -0
  19. tribalmemory/mcp/__init__.py +9 -0
  20. tribalmemory/mcp/__main__.py +6 -0
  21. tribalmemory/mcp/server.py +484 -0
  22. tribalmemory/performance/__init__.py +1 -0
  23. tribalmemory/performance/benchmarks.py +285 -0
  24. tribalmemory/performance/corpus_generator.py +171 -0
  25. tribalmemory/portability/__init__.py +1 -0
  26. tribalmemory/portability/embedding_metadata.py +320 -0
  27. tribalmemory/server/__init__.py +9 -0
  28. tribalmemory/server/__main__.py +6 -0
  29. tribalmemory/server/app.py +187 -0
  30. tribalmemory/server/config.py +115 -0
  31. tribalmemory/server/models.py +206 -0
  32. tribalmemory/server/routes.py +378 -0
  33. tribalmemory/services/__init__.py +15 -0
  34. tribalmemory/services/deduplication.py +115 -0
  35. tribalmemory/services/embeddings.py +273 -0
  36. tribalmemory/services/import_export.py +506 -0
  37. tribalmemory/services/memory.py +275 -0
  38. tribalmemory/services/vector_store.py +360 -0
  39. tribalmemory/testing/__init__.py +22 -0
  40. tribalmemory/testing/embedding_utils.py +110 -0
  41. tribalmemory/testing/fixtures.py +123 -0
  42. tribalmemory/testing/metrics.py +256 -0
  43. tribalmemory/testing/mocks.py +560 -0
  44. tribalmemory/testing/semantic_expansions.py +91 -0
  45. tribalmemory/utils.py +23 -0
  46. tribalmemory-0.1.0.dist-info/METADATA +275 -0
  47. tribalmemory-0.1.0.dist-info/RECORD +51 -0
  48. tribalmemory-0.1.0.dist-info/WHEEL +5 -0
  49. tribalmemory-0.1.0.dist-info/entry_points.txt +3 -0
  50. tribalmemory-0.1.0.dist-info/licenses/LICENSE +190 -0
  51. tribalmemory-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,320 @@
1
+ """Embedding metadata and portability utilities.
2
+
3
+ Tracks embedding model info in export bundles so that imports can detect
4
+ model mismatches and handle re-embedding when needed.
5
+
6
+ Strategy chosen for Issue #5: **Metadata + optional re-embedding**.
7
+ - Every export bundle includes an EmbeddingMetadata block documenting
8
+ which model/dimensions produced the embeddings.
9
+ - On import, the system compares source and target metadata.
10
+ - Three strategies: KEEP (use as-is), DROP (clear for re-generation),
11
+ AUTO (keep if compatible, drop if not).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import copy
17
+ from dataclasses import dataclass, field
18
+ from datetime import datetime, timezone
19
+ from enum import Enum
20
+ from typing import Optional
21
+
22
+ from ..interfaces import MemoryEntry
23
+
24
+
25
+ class ReembeddingStrategy(Enum):
26
+ """Strategy for handling embeddings on import."""
27
+ KEEP = "keep" # Keep original embeddings regardless of model mismatch
28
+ DROP = "drop" # Drop embeddings (caller must re-embed later)
29
+ AUTO = "auto" # Keep if compatible, drop if not
30
+
31
+
32
+ @dataclass
33
+ class EmbeddingMetadata:
34
+ """Metadata about the embedding model used to generate vectors.
35
+
36
+ Attributes:
37
+ model_name: Model identifier (e.g. "text-embedding-3-small").
38
+ dimensions: Number of dimensions in the embedding vector.
39
+ provider: Optional provider name (e.g. "openai", "sentence-transformers").
40
+ created_at: When this metadata was created.
41
+ """
42
+ model_name: str
43
+ dimensions: int
44
+ provider: Optional[str] = None
45
+ created_at: Optional[str] = None
46
+
47
+ def is_compatible_with(self, other: EmbeddingMetadata) -> bool:
48
+ """Check if two embedding configurations are compatible.
49
+
50
+ Compatible means same model name AND same dimensions,
51
+ so vectors can be compared directly without re-embedding.
52
+ """
53
+ return (
54
+ self.model_name == other.model_name
55
+ and self.dimensions == other.dimensions
56
+ )
57
+
58
+ def to_dict(self) -> dict:
59
+ """Serialize to dict for JSON export."""
60
+ d: dict = {
61
+ "model_name": self.model_name,
62
+ "dimensions": self.dimensions,
63
+ }
64
+ if self.provider is not None:
65
+ d["provider"] = self.provider
66
+ if self.created_at is not None:
67
+ d["created_at"] = self.created_at
68
+ return d
69
+
70
+ @classmethod
71
+ def from_dict(cls, d: dict) -> EmbeddingMetadata:
72
+ """Deserialize from dict."""
73
+ return cls(
74
+ model_name=d["model_name"],
75
+ dimensions=d["dimensions"],
76
+ provider=d.get("provider"),
77
+ created_at=d.get("created_at"),
78
+ )
79
+
80
+
81
+ @dataclass
82
+ class EmbeddingManifest:
83
+ """Manifest included in portable bundles.
84
+
85
+ Extends the basic manifest with embedding metadata so importers
86
+ can determine compatibility.
87
+ """
88
+ schema_version: str
89
+ embedding_metadata: EmbeddingMetadata
90
+ memory_count: int
91
+ exported_at: Optional[str] = None
92
+
93
+ def to_dict(self) -> dict:
94
+ """Serialize to dict for JSON export."""
95
+ d: dict = {
96
+ "schema_version": self.schema_version,
97
+ "embedding": self.embedding_metadata.to_dict(),
98
+ "memory_count": self.memory_count,
99
+ }
100
+ if self.exported_at:
101
+ d["exported_at"] = self.exported_at
102
+ return d
103
+
104
+ @classmethod
105
+ def from_dict(cls, d: dict) -> EmbeddingManifest:
106
+ """Deserialize from dict."""
107
+ return cls(
108
+ schema_version=d["schema_version"],
109
+ embedding_metadata=EmbeddingMetadata.from_dict(d["embedding"]),
110
+ memory_count=d["memory_count"],
111
+ exported_at=d.get("exported_at"),
112
+ )
113
+
114
+
115
+ @dataclass
116
+ class PortableBundle:
117
+ """A portable memory bundle with embedding metadata.
118
+
119
+ Contains the manifest (with embedding info) and the memory entries.
120
+ Designed for JSON serialization to enable cross-system portability.
121
+ """
122
+ manifest: EmbeddingManifest
123
+ entries: list[MemoryEntry] = field(default_factory=list)
124
+
125
+ def to_dict(self) -> dict:
126
+ """Serialize the entire bundle to a dict."""
127
+ return {
128
+ "manifest": self.manifest.to_dict(),
129
+ "entries": [_entry_to_dict(e) for e in self.entries],
130
+ }
131
+
132
+ @classmethod
133
+ def from_dict(cls, d: dict) -> PortableBundle:
134
+ """Deserialize from dict."""
135
+ manifest = EmbeddingManifest.from_dict(d["manifest"])
136
+ entries = [_entry_from_dict(e) for e in d.get("entries", [])]
137
+ return cls(manifest=manifest, entries=entries)
138
+
139
+
140
+ @dataclass
141
+ class ImportResult:
142
+ """Result of importing a portable bundle.
143
+
144
+ Attributes:
145
+ entries: Imported memory entries (embeddings may be None if dropped).
146
+ needs_embedding: True if entries had embeddings cleared and need
147
+ re-embedding by the caller before they can be searched.
148
+ source_metadata: Embedding metadata from the source bundle.
149
+ target_metadata: Embedding metadata of the target system.
150
+ strategy_used: Which re-embedding strategy was applied.
151
+ """
152
+ entries: list[MemoryEntry]
153
+ needs_embedding: bool
154
+ source_metadata: EmbeddingMetadata
155
+ target_metadata: EmbeddingMetadata
156
+ strategy_used: ReembeddingStrategy
157
+
158
+
159
+ def create_embedding_metadata(
160
+ model_name: str,
161
+ dimensions: int,
162
+ provider: Optional[str] = None,
163
+ ) -> EmbeddingMetadata:
164
+ """Create embedding metadata with current timestamp."""
165
+ return EmbeddingMetadata(
166
+ model_name=model_name,
167
+ dimensions=dimensions,
168
+ provider=provider,
169
+ created_at=datetime.now(timezone.utc).isoformat(),
170
+ )
171
+
172
+
173
+ def needs_reembedding(
174
+ source: EmbeddingMetadata,
175
+ target: EmbeddingMetadata,
176
+ ) -> bool:
177
+ """Check if embeddings need to be regenerated.
178
+
179
+ Returns True if the source and target models are incompatible.
180
+ """
181
+ return not source.is_compatible_with(target)
182
+
183
+
184
+ def create_portable_bundle(
185
+ entries: list[MemoryEntry],
186
+ embedding_metadata: EmbeddingMetadata,
187
+ schema_version: str = "1.0",
188
+ ) -> PortableBundle:
189
+ """Create a portable bundle from memory entries and embedding metadata.
190
+
191
+ Validates that any entry with an embedding has dimensions matching
192
+ the declared metadata.
193
+
194
+ Raises:
195
+ ValueError: If an entry's embedding dimensions don't match metadata.
196
+ """
197
+ for entry in entries:
198
+ if entry.embedding and len(entry.embedding) != embedding_metadata.dimensions:
199
+ raise ValueError(
200
+ f"Entry {entry.id} has {len(entry.embedding)} dimensions, "
201
+ f"expected {embedding_metadata.dimensions}"
202
+ )
203
+ manifest = EmbeddingManifest(
204
+ schema_version=schema_version,
205
+ embedding_metadata=embedding_metadata,
206
+ memory_count=len(entries),
207
+ exported_at=datetime.now(timezone.utc).isoformat(),
208
+ )
209
+ return PortableBundle(manifest=manifest, entries=list(entries))
210
+
211
+
212
+ SUPPORTED_SCHEMA_VERSIONS = {"1.0"}
213
+
214
+
215
+ def import_bundle(
216
+ bundle: PortableBundle,
217
+ target_metadata: EmbeddingMetadata,
218
+ strategy: ReembeddingStrategy = ReembeddingStrategy.AUTO,
219
+ ) -> ImportResult:
220
+ """Import a portable bundle with the given re-embedding strategy.
221
+
222
+ Args:
223
+ bundle: The bundle to import.
224
+ target_metadata: Embedding metadata of the target system.
225
+ strategy: How to handle embedding model mismatches.
226
+
227
+ Returns:
228
+ ImportResult with entries (possibly with cleared embeddings).
229
+
230
+ Raises:
231
+ ValueError: If bundle schema version is unsupported.
232
+ """
233
+ version = bundle.manifest.schema_version
234
+ if version not in SUPPORTED_SCHEMA_VERSIONS:
235
+ raise ValueError(
236
+ f"Unsupported schema version '{version}'. "
237
+ f"Supported: {SUPPORTED_SCHEMA_VERSIONS}"
238
+ )
239
+ source_meta = bundle.manifest.embedding_metadata
240
+ compatible = source_meta.is_compatible_with(target_metadata)
241
+
242
+ # Deep copy entries to avoid mutating the bundle
243
+ imported_entries = [_copy_entry(e) for e in bundle.entries]
244
+
245
+ should_drop = False
246
+ if strategy == ReembeddingStrategy.DROP:
247
+ should_drop = True
248
+ elif strategy == ReembeddingStrategy.AUTO and not compatible:
249
+ should_drop = True
250
+ # KEEP and AUTO-compatible: keep embeddings as-is
251
+
252
+ if should_drop:
253
+ for entry in imported_entries:
254
+ entry.embedding = None
255
+
256
+ return ImportResult(
257
+ entries=imported_entries,
258
+ needs_embedding=should_drop,
259
+ source_metadata=source_meta,
260
+ target_metadata=target_metadata,
261
+ strategy_used=strategy,
262
+ )
263
+
264
+
265
+ # --- Serialization helpers for MemoryEntry ---
266
+
267
+ def _entry_to_dict(entry: MemoryEntry) -> dict:
268
+ """Serialize a MemoryEntry to a dict."""
269
+ return {
270
+ "id": entry.id,
271
+ "content": entry.content,
272
+ "embedding": entry.embedding,
273
+ "source_instance": entry.source_instance,
274
+ "source_type": entry.source_type.value,
275
+ "created_at": entry.created_at.isoformat(),
276
+ "updated_at": entry.updated_at.isoformat(),
277
+ "tags": entry.tags,
278
+ "context": entry.context,
279
+ "confidence": entry.confidence,
280
+ "supersedes": entry.supersedes,
281
+ "related_to": entry.related_to,
282
+ }
283
+
284
+
285
+ def _entry_from_dict(d: dict) -> MemoryEntry:
286
+ """Deserialize a MemoryEntry from a dict."""
287
+ from ..interfaces import MemorySource
288
+
289
+ return MemoryEntry(
290
+ id=d["id"],
291
+ content=d["content"],
292
+ embedding=d.get("embedding"),
293
+ source_instance=d.get("source_instance", "unknown"),
294
+ source_type=MemorySource(d.get("source_type", "unknown")),
295
+ created_at=datetime.fromisoformat(d["created_at"]),
296
+ updated_at=datetime.fromisoformat(d["updated_at"]),
297
+ tags=d.get("tags", []),
298
+ context=d.get("context"),
299
+ confidence=d.get("confidence", 1.0),
300
+ supersedes=d.get("supersedes"),
301
+ related_to=d.get("related_to", []),
302
+ )
303
+
304
+
305
+ def _copy_entry(entry: MemoryEntry) -> MemoryEntry:
306
+ """Deep copy a MemoryEntry."""
307
+ return MemoryEntry(
308
+ id=entry.id,
309
+ content=entry.content,
310
+ embedding=list(entry.embedding) if entry.embedding else None,
311
+ source_instance=entry.source_instance,
312
+ source_type=entry.source_type,
313
+ created_at=entry.created_at,
314
+ updated_at=entry.updated_at,
315
+ tags=list(entry.tags),
316
+ context=entry.context,
317
+ confidence=entry.confidence,
318
+ supersedes=entry.supersedes,
319
+ related_to=list(entry.related_to),
320
+ )
@@ -0,0 +1,9 @@
1
+ """Tribal Memory HTTP Server.
2
+
3
+ FastAPI-based HTTP interface for tribal-memory service.
4
+ Designed for integration with OpenClaw's memory-tribal extension.
5
+ """
6
+
7
+ from .app import create_app, run_server
8
+
9
+ __all__ = ["create_app", "run_server"]
@@ -0,0 +1,6 @@
1
+ """Allow running as: python -m tribalmemory.server"""
2
+
3
+ from .app import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,187 @@
1
+ """FastAPI application for tribal-memory service."""
2
+
3
+ import logging
4
+ from contextlib import asynccontextmanager
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import uvicorn
9
+ from fastapi import FastAPI
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+
12
+ from ..services import create_memory_service, TribalMemoryService
13
+ from .config import TribalMemoryConfig
14
+ from .routes import router
15
+
16
+ # Global service instance (set during lifespan)
17
+ _memory_service: Optional[TribalMemoryService] = None
18
+ _instance_id: Optional[str] = None
19
+
20
+ logger = logging.getLogger("tribalmemory.server")
21
+
22
+
23
+ @asynccontextmanager
24
+ async def lifespan(app: FastAPI):
25
+ """Application lifespan manager."""
26
+ global _memory_service, _instance_id
27
+
28
+ config: TribalMemoryConfig = app.state.config
29
+
30
+ # Validate config
31
+ errors = config.validate()
32
+ if errors:
33
+ raise ValueError(f"Configuration errors: {errors}")
34
+
35
+ logger.info(f"Starting tribal-memory service (instance: {config.instance_id})")
36
+
37
+ # Create memory service
38
+ _instance_id = config.instance_id
39
+ _memory_service = create_memory_service(
40
+ instance_id=config.instance_id,
41
+ db_path=config.db.path,
42
+ openai_api_key=config.embedding.api_key,
43
+ api_base=config.embedding.api_base,
44
+ embedding_model=config.embedding.model,
45
+ embedding_dimensions=config.embedding.dimensions,
46
+ )
47
+
48
+ logger.info(f"Memory service initialized (db: {config.db.path})")
49
+
50
+ yield
51
+
52
+ # Cleanup
53
+ logger.info("Shutting down tribal-memory service")
54
+ _memory_service = None
55
+ _instance_id = None
56
+
57
+
58
+ def create_app(config: Optional[TribalMemoryConfig] = None) -> FastAPI:
59
+ """Create FastAPI application.
60
+
61
+ Args:
62
+ config: Service configuration. If None, loads from environment.
63
+
64
+ Returns:
65
+ Configured FastAPI application.
66
+ """
67
+ if config is None:
68
+ config = TribalMemoryConfig.from_env()
69
+
70
+ app = FastAPI(
71
+ title="Tribal Memory",
72
+ description="Long-term memory service for AI agents with provenance tracking",
73
+ version="0.1.0",
74
+ lifespan=lifespan,
75
+ )
76
+
77
+ # Store config for lifespan access
78
+ app.state.config = config
79
+
80
+ # CORS middleware (localhost only)
81
+ # Uses regex to match any port on localhost - OpenClaw Gateway runs on
82
+ # user-configurable ports (default 18789). Server is bound to 127.0.0.1
83
+ # so only local processes can reach it regardless of CORS settings.
84
+ app.add_middleware(
85
+ CORSMiddleware,
86
+ allow_origin_regex=r"http://(localhost|127\.0\.0\.1)(:\d+)?",
87
+ allow_credentials=True,
88
+ allow_methods=["*"],
89
+ allow_headers=["*"],
90
+ )
91
+
92
+ # Include routes
93
+ app.include_router(router)
94
+
95
+ # Root endpoint
96
+ @app.get("/")
97
+ async def root():
98
+ return {
99
+ "service": "tribal-memory",
100
+ "version": "0.1.0",
101
+ "docs": "/docs",
102
+ }
103
+
104
+ return app
105
+
106
+
107
+ def run_server(
108
+ config: Optional[TribalMemoryConfig] = None,
109
+ host: Optional[str] = None,
110
+ port: Optional[int] = None,
111
+ log_level: str = "info",
112
+ ):
113
+ """Run the HTTP server.
114
+
115
+ Args:
116
+ config: Service configuration. If None, loads from environment.
117
+ host: Override host from config.
118
+ port: Override port from config.
119
+ log_level: Logging level.
120
+ """
121
+ if config is None:
122
+ config = TribalMemoryConfig.from_env()
123
+
124
+ # Ensure db directory exists
125
+ db_path = Path(config.db.path)
126
+ db_path.parent.mkdir(parents=True, exist_ok=True)
127
+
128
+ app = create_app(config)
129
+
130
+ uvicorn.run(
131
+ app,
132
+ host=host or config.server.host,
133
+ port=port or config.server.port,
134
+ log_level=log_level,
135
+ )
136
+
137
+
138
+ # CLI entry point
139
+ def main():
140
+ """CLI entry point."""
141
+ import argparse
142
+
143
+ parser = argparse.ArgumentParser(description="Tribal Memory HTTP Server")
144
+ parser.add_argument(
145
+ "--config", "-c",
146
+ type=str,
147
+ default=None,
148
+ help="Path to config file (default: ~/.tribal-memory/config.yaml)",
149
+ )
150
+ parser.add_argument(
151
+ "--host",
152
+ type=str,
153
+ default=None,
154
+ help="Host to bind to (default: 127.0.0.1)",
155
+ )
156
+ parser.add_argument(
157
+ "--port", "-p",
158
+ type=int,
159
+ default=None,
160
+ help="Port to bind to (default: 18790)",
161
+ )
162
+ parser.add_argument(
163
+ "--log-level",
164
+ type=str,
165
+ default="info",
166
+ choices=["debug", "info", "warning", "error"],
167
+ help="Logging level",
168
+ )
169
+
170
+ args = parser.parse_args()
171
+
172
+ # Load config
173
+ if args.config:
174
+ config = TribalMemoryConfig.from_file(args.config)
175
+ else:
176
+ config = TribalMemoryConfig.from_env()
177
+
178
+ run_server(
179
+ config=config,
180
+ host=args.host,
181
+ port=args.port,
182
+ log_level=args.log_level,
183
+ )
184
+
185
+
186
+ if __name__ == "__main__":
187
+ main()
@@ -0,0 +1,115 @@
1
+ """Server configuration."""
2
+
3
+ import os
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import yaml
9
+
10
+
11
+ @dataclass
12
+ class EmbeddingConfig:
13
+ """Embedding service configuration.
14
+
15
+ Supports OpenAI, Ollama, and any OpenAI-compatible embedding API.
16
+
17
+ For local Ollama embeddings (zero cloud, zero cost):
18
+ api_base: http://localhost:11434/v1
19
+ model: nomic-embed-text
20
+ dimensions: 768
21
+ # api_key not needed for local models
22
+ """
23
+ provider: str = "openai"
24
+ model: str = "text-embedding-3-small"
25
+ api_key: Optional[str] = None
26
+ api_base: Optional[str] = None
27
+ dimensions: int = 1536
28
+
29
+ def __post_init__(self):
30
+ # Resolve from environment if not set
31
+ if self.api_key is None:
32
+ self.api_key = os.environ.get("OPENAI_API_KEY")
33
+ if self.api_base is None:
34
+ self.api_base = os.environ.get("TRIBAL_MEMORY_EMBEDDING_API_BASE")
35
+
36
+
37
+ @dataclass
38
+ class DatabaseConfig:
39
+ """Database configuration."""
40
+ provider: str = "lancedb"
41
+ path: str = "~/.tribal-memory/lancedb"
42
+ uri: Optional[str] = None # For cloud
43
+
44
+ def __post_init__(self):
45
+ # Expand home directory
46
+ self.path = str(Path(self.path).expanduser())
47
+
48
+
49
+ @dataclass
50
+ class ServerConfig:
51
+ """HTTP server configuration."""
52
+ host: str = "127.0.0.1"
53
+ port: int = 18790
54
+
55
+
56
+ @dataclass
57
+ class TribalMemoryConfig:
58
+ """Full service configuration."""
59
+ instance_id: str = "default"
60
+ db: DatabaseConfig = field(default_factory=DatabaseConfig)
61
+ embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig)
62
+ server: ServerConfig = field(default_factory=ServerConfig)
63
+
64
+ @classmethod
65
+ def from_file(cls, path: str | Path) -> "TribalMemoryConfig":
66
+ """Load configuration from YAML file."""
67
+ path = Path(path).expanduser()
68
+ if not path.exists():
69
+ return cls()
70
+
71
+ with open(path) as f:
72
+ data = yaml.safe_load(f) or {}
73
+
74
+ return cls.from_dict(data)
75
+
76
+ @classmethod
77
+ def from_dict(cls, data: dict) -> "TribalMemoryConfig":
78
+ """Create configuration from dictionary."""
79
+ db_data = data.get("db", {})
80
+ embedding_data = data.get("embedding", {})
81
+ server_data = data.get("server", {})
82
+
83
+ return cls(
84
+ instance_id=data.get("instance_id", "default"),
85
+ db=DatabaseConfig(**db_data) if db_data else DatabaseConfig(),
86
+ embedding=EmbeddingConfig(**embedding_data) if embedding_data else EmbeddingConfig(),
87
+ server=ServerConfig(**server_data) if server_data else ServerConfig(),
88
+ )
89
+
90
+ @classmethod
91
+ def from_env(cls) -> "TribalMemoryConfig":
92
+ """Create configuration from environment variables."""
93
+ config_path = os.environ.get(
94
+ "TRIBAL_MEMORY_CONFIG",
95
+ "~/.tribal-memory/config.yaml"
96
+ )
97
+ return cls.from_file(config_path)
98
+
99
+ def validate(self) -> list[str]:
100
+ """Validate configuration, return list of errors."""
101
+ errors = []
102
+
103
+ # api_key is only required when using OpenAI (no custom api_base)
104
+ api_base = (self.embedding.api_base or "").strip()
105
+ is_local = (
106
+ api_base != ""
107
+ and "api.openai.com" not in api_base.lower()
108
+ )
109
+ if not self.embedding.api_key and not is_local:
110
+ errors.append("embedding.api_key is required (or set OPENAI_API_KEY)")
111
+
112
+ if not self.instance_id:
113
+ errors.append("instance_id is required")
114
+
115
+ return errors