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.
- tribalmemory/__init__.py +3 -0
- tribalmemory/a21/__init__.py +38 -0
- tribalmemory/a21/config/__init__.py +20 -0
- tribalmemory/a21/config/providers.py +104 -0
- tribalmemory/a21/config/system.py +184 -0
- tribalmemory/a21/container/__init__.py +8 -0
- tribalmemory/a21/container/container.py +212 -0
- tribalmemory/a21/providers/__init__.py +32 -0
- tribalmemory/a21/providers/base.py +241 -0
- tribalmemory/a21/providers/deduplication.py +99 -0
- tribalmemory/a21/providers/lancedb.py +232 -0
- tribalmemory/a21/providers/memory.py +128 -0
- tribalmemory/a21/providers/mock.py +54 -0
- tribalmemory/a21/providers/openai.py +151 -0
- tribalmemory/a21/providers/timestamp.py +88 -0
- tribalmemory/a21/system.py +293 -0
- tribalmemory/cli.py +298 -0
- tribalmemory/interfaces.py +306 -0
- tribalmemory/mcp/__init__.py +9 -0
- tribalmemory/mcp/__main__.py +6 -0
- tribalmemory/mcp/server.py +484 -0
- tribalmemory/performance/__init__.py +1 -0
- tribalmemory/performance/benchmarks.py +285 -0
- tribalmemory/performance/corpus_generator.py +171 -0
- tribalmemory/portability/__init__.py +1 -0
- tribalmemory/portability/embedding_metadata.py +320 -0
- tribalmemory/server/__init__.py +9 -0
- tribalmemory/server/__main__.py +6 -0
- tribalmemory/server/app.py +187 -0
- tribalmemory/server/config.py +115 -0
- tribalmemory/server/models.py +206 -0
- tribalmemory/server/routes.py +378 -0
- tribalmemory/services/__init__.py +15 -0
- tribalmemory/services/deduplication.py +115 -0
- tribalmemory/services/embeddings.py +273 -0
- tribalmemory/services/import_export.py +506 -0
- tribalmemory/services/memory.py +275 -0
- tribalmemory/services/vector_store.py +360 -0
- tribalmemory/testing/__init__.py +22 -0
- tribalmemory/testing/embedding_utils.py +110 -0
- tribalmemory/testing/fixtures.py +123 -0
- tribalmemory/testing/metrics.py +256 -0
- tribalmemory/testing/mocks.py +560 -0
- tribalmemory/testing/semantic_expansions.py +91 -0
- tribalmemory/utils.py +23 -0
- tribalmemory-0.1.0.dist-info/METADATA +275 -0
- tribalmemory-0.1.0.dist-info/RECORD +51 -0
- tribalmemory-0.1.0.dist-info/WHEEL +5 -0
- tribalmemory-0.1.0.dist-info/entry_points.txt +3 -0
- tribalmemory-0.1.0.dist-info/licenses/LICENSE +190 -0
- 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,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
|