tribalmemory 0.1.1__py3-none-any.whl → 0.3.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/cli.py +147 -4
- tribalmemory/interfaces.py +66 -3
- tribalmemory/mcp/server.py +272 -14
- tribalmemory/server/app.py +53 -2
- tribalmemory/server/config.py +41 -0
- tribalmemory/server/models.py +65 -0
- tribalmemory/server/routes.py +68 -0
- tribalmemory/services/fts_store.py +255 -0
- tribalmemory/services/graph_store.py +627 -0
- tribalmemory/services/memory.py +471 -37
- tribalmemory/services/reranker.py +267 -0
- tribalmemory/services/session_store.py +412 -0
- tribalmemory/services/vector_store.py +86 -1
- {tribalmemory-0.1.1.dist-info → tribalmemory-0.3.0.dist-info}/METADATA +1 -1
- {tribalmemory-0.1.1.dist-info → tribalmemory-0.3.0.dist-info}/RECORD +19 -15
- {tribalmemory-0.1.1.dist-info → tribalmemory-0.3.0.dist-info}/WHEEL +0 -0
- {tribalmemory-0.1.1.dist-info → tribalmemory-0.3.0.dist-info}/entry_points.txt +0 -0
- {tribalmemory-0.1.1.dist-info → tribalmemory-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {tribalmemory-0.1.1.dist-info → tribalmemory-0.3.0.dist-info}/top_level.txt +0 -0
tribalmemory/mcp/server.py
CHANGED
|
@@ -16,11 +16,13 @@ from mcp.server.fastmcp import FastMCP
|
|
|
16
16
|
from ..interfaces import MemorySource
|
|
17
17
|
from ..server.config import TribalMemoryConfig
|
|
18
18
|
from ..services import create_memory_service, TribalMemoryService
|
|
19
|
+
from ..services.session_store import SessionStore, SessionMessage
|
|
19
20
|
|
|
20
21
|
logger = logging.getLogger(__name__)
|
|
21
22
|
|
|
22
23
|
# Global service instance (initialized on first use)
|
|
23
24
|
_memory_service: Optional[TribalMemoryService] = None
|
|
25
|
+
_session_store: Optional[SessionStore] = None
|
|
24
26
|
_service_lock = asyncio.Lock()
|
|
25
27
|
|
|
26
28
|
|
|
@@ -60,6 +62,32 @@ async def get_memory_service() -> TribalMemoryService:
|
|
|
60
62
|
return _memory_service
|
|
61
63
|
|
|
62
64
|
|
|
65
|
+
async def get_session_store() -> SessionStore:
|
|
66
|
+
"""Get or create the session store singleton (thread-safe)."""
|
|
67
|
+
global _session_store
|
|
68
|
+
|
|
69
|
+
if _session_store is not None:
|
|
70
|
+
return _session_store
|
|
71
|
+
|
|
72
|
+
memory_service = await get_memory_service()
|
|
73
|
+
|
|
74
|
+
async with _service_lock:
|
|
75
|
+
if _session_store is not None:
|
|
76
|
+
return _session_store
|
|
77
|
+
|
|
78
|
+
config = TribalMemoryConfig.from_env()
|
|
79
|
+
instance_id = os.environ.get("TRIBAL_MEMORY_INSTANCE_ID", "mcp-claude-code")
|
|
80
|
+
|
|
81
|
+
_session_store = SessionStore(
|
|
82
|
+
instance_id=instance_id,
|
|
83
|
+
embedding_service=memory_service.embedding_service,
|
|
84
|
+
vector_store=memory_service.vector_store,
|
|
85
|
+
)
|
|
86
|
+
logger.info("Session store initialized")
|
|
87
|
+
|
|
88
|
+
return _session_store
|
|
89
|
+
|
|
90
|
+
|
|
63
91
|
def create_server() -> FastMCP:
|
|
64
92
|
"""Create and configure the MCP server with all tools."""
|
|
65
93
|
mcp = FastMCP("tribal-memory")
|
|
@@ -127,17 +155,19 @@ def create_server() -> FastMCP:
|
|
|
127
155
|
limit: int = 5,
|
|
128
156
|
min_relevance: float = 0.3,
|
|
129
157
|
tags: Optional[list[str]] = None,
|
|
158
|
+
sources: str = "memories",
|
|
130
159
|
) -> str:
|
|
131
|
-
"""Search memories by semantic similarity.
|
|
160
|
+
"""Search memories and/or session transcripts by semantic similarity.
|
|
132
161
|
|
|
133
162
|
Args:
|
|
134
163
|
query: Natural language search query (required)
|
|
135
164
|
limit: Maximum number of results (1-50, default 5)
|
|
136
165
|
min_relevance: Minimum similarity score (0.0-1.0, default 0.3)
|
|
137
166
|
tags: Filter results to only memories with these tags
|
|
167
|
+
sources: What to search - "memories" (default), "sessions", or "all"
|
|
138
168
|
|
|
139
169
|
Returns:
|
|
140
|
-
JSON with: results (list of memories with similarity scores), query, count
|
|
170
|
+
JSON with: results (list of memories/chunks with similarity scores), query, count
|
|
141
171
|
"""
|
|
142
172
|
# Input validation
|
|
143
173
|
if not query or not query.strip():
|
|
@@ -148,22 +178,33 @@ def create_server() -> FastMCP:
|
|
|
148
178
|
"error": "Query cannot be empty",
|
|
149
179
|
})
|
|
150
180
|
|
|
151
|
-
|
|
181
|
+
valid_sources = {"memories", "sessions", "all"}
|
|
182
|
+
if sources not in valid_sources:
|
|
183
|
+
return json.dumps({
|
|
184
|
+
"results": [],
|
|
185
|
+
"query": query,
|
|
186
|
+
"count": 0,
|
|
187
|
+
"error": f"Invalid sources: {sources}. Valid options: {', '.join(sorted(valid_sources))}",
|
|
188
|
+
})
|
|
152
189
|
|
|
153
190
|
# Clamp limit to valid range
|
|
154
191
|
limit = max(1, min(50, limit))
|
|
155
192
|
min_relevance = max(0.0, min(1.0, min_relevance))
|
|
156
193
|
|
|
157
|
-
|
|
158
|
-
query=query,
|
|
159
|
-
limit=limit,
|
|
160
|
-
min_relevance=min_relevance,
|
|
161
|
-
tags=tags,
|
|
162
|
-
)
|
|
194
|
+
all_results = []
|
|
163
195
|
|
|
164
|
-
|
|
165
|
-
|
|
196
|
+
# Search memories
|
|
197
|
+
if sources in ("memories", "all"):
|
|
198
|
+
service = await get_memory_service()
|
|
199
|
+
memory_results = await service.recall(
|
|
200
|
+
query=query,
|
|
201
|
+
limit=limit,
|
|
202
|
+
min_relevance=min_relevance,
|
|
203
|
+
tags=tags,
|
|
204
|
+
)
|
|
205
|
+
all_results.extend([
|
|
166
206
|
{
|
|
207
|
+
"type": "memory",
|
|
167
208
|
"memory_id": r.memory.id,
|
|
168
209
|
"content": r.memory.content,
|
|
169
210
|
"similarity_score": round(r.similarity_score, 4),
|
|
@@ -173,12 +214,117 @@ def create_server() -> FastMCP:
|
|
|
173
214
|
"created_at": r.memory.created_at.isoformat(),
|
|
174
215
|
"context": r.memory.context,
|
|
175
216
|
}
|
|
176
|
-
for r in
|
|
177
|
-
]
|
|
217
|
+
for r in memory_results
|
|
218
|
+
])
|
|
219
|
+
|
|
220
|
+
# Search sessions
|
|
221
|
+
if sources in ("sessions", "all"):
|
|
222
|
+
session_store = await get_session_store()
|
|
223
|
+
session_results = await session_store.search(
|
|
224
|
+
query=query,
|
|
225
|
+
limit=limit,
|
|
226
|
+
min_relevance=min_relevance,
|
|
227
|
+
)
|
|
228
|
+
all_results.extend([
|
|
229
|
+
{
|
|
230
|
+
"type": "session",
|
|
231
|
+
"chunk_id": r["chunk_id"],
|
|
232
|
+
"session_id": r["session_id"],
|
|
233
|
+
"instance_id": r["instance_id"],
|
|
234
|
+
"content": r["content"],
|
|
235
|
+
"similarity_score": round(r["similarity_score"], 4),
|
|
236
|
+
"start_time": r["start_time"].isoformat() if hasattr(r["start_time"], "isoformat") else str(r["start_time"]),
|
|
237
|
+
"end_time": r["end_time"].isoformat() if hasattr(r["end_time"], "isoformat") else str(r["end_time"]),
|
|
238
|
+
"chunk_index": r["chunk_index"],
|
|
239
|
+
}
|
|
240
|
+
for r in session_results
|
|
241
|
+
])
|
|
242
|
+
|
|
243
|
+
# Sort combined results by score, take top limit
|
|
244
|
+
all_results.sort(key=lambda x: x["similarity_score"], reverse=True)
|
|
245
|
+
all_results = all_results[:limit]
|
|
246
|
+
|
|
247
|
+
return json.dumps({
|
|
248
|
+
"results": all_results,
|
|
178
249
|
"query": query,
|
|
179
|
-
"count": len(
|
|
250
|
+
"count": len(all_results),
|
|
251
|
+
"sources": sources,
|
|
180
252
|
})
|
|
181
253
|
|
|
254
|
+
@mcp.tool()
|
|
255
|
+
async def tribal_sessions_ingest(
|
|
256
|
+
session_id: str,
|
|
257
|
+
messages: str,
|
|
258
|
+
instance_id: Optional[str] = None,
|
|
259
|
+
) -> str:
|
|
260
|
+
"""Ingest a session transcript for indexing.
|
|
261
|
+
|
|
262
|
+
Chunks conversation messages into ~400 token windows and indexes them
|
|
263
|
+
for semantic search. Supports delta ingestion — only new messages
|
|
264
|
+
since last ingest are processed.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
session_id: Unique identifier for the session (required)
|
|
268
|
+
messages: JSON array of messages, each with "role", "content",
|
|
269
|
+
and optional "timestamp" (ISO 8601). Example:
|
|
270
|
+
[{"role": "user", "content": "What is Docker?"},
|
|
271
|
+
{"role": "assistant", "content": "Docker is a container platform"}]
|
|
272
|
+
instance_id: Override the agent instance ID (optional)
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
JSON with: success, chunks_created, messages_processed
|
|
276
|
+
"""
|
|
277
|
+
if not session_id or not session_id.strip():
|
|
278
|
+
return json.dumps({
|
|
279
|
+
"success": False,
|
|
280
|
+
"error": "session_id cannot be empty",
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
raw_messages = json.loads(messages)
|
|
285
|
+
except (json.JSONDecodeError, TypeError) as e:
|
|
286
|
+
return json.dumps({
|
|
287
|
+
"success": False,
|
|
288
|
+
"error": f"Invalid messages JSON: {e}",
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
if not isinstance(raw_messages, list):
|
|
292
|
+
return json.dumps({
|
|
293
|
+
"success": False,
|
|
294
|
+
"error": "messages must be a JSON array",
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
from datetime import datetime, timezone
|
|
298
|
+
parsed_messages = []
|
|
299
|
+
for i, msg in enumerate(raw_messages):
|
|
300
|
+
if not isinstance(msg, dict) or "role" not in msg or "content" not in msg:
|
|
301
|
+
return json.dumps({
|
|
302
|
+
"success": False,
|
|
303
|
+
"error": f"Message {i} must have 'role' and 'content' fields",
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
ts = datetime.now(timezone.utc)
|
|
307
|
+
if "timestamp" in msg:
|
|
308
|
+
try:
|
|
309
|
+
ts = datetime.fromisoformat(msg["timestamp"])
|
|
310
|
+
except (ValueError, TypeError):
|
|
311
|
+
pass # Use current time if timestamp is invalid
|
|
312
|
+
|
|
313
|
+
parsed_messages.append(SessionMessage(
|
|
314
|
+
role=msg["role"],
|
|
315
|
+
content=msg["content"],
|
|
316
|
+
timestamp=ts,
|
|
317
|
+
))
|
|
318
|
+
|
|
319
|
+
session_store = await get_session_store()
|
|
320
|
+
result = await session_store.ingest(
|
|
321
|
+
session_id=session_id,
|
|
322
|
+
messages=parsed_messages,
|
|
323
|
+
instance_id=instance_id,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
return json.dumps(result)
|
|
327
|
+
|
|
182
328
|
@mcp.tool()
|
|
183
329
|
async def tribal_correct(
|
|
184
330
|
original_id: str,
|
|
@@ -266,6 +412,118 @@ def create_server() -> FastMCP:
|
|
|
266
412
|
|
|
267
413
|
return json.dumps(stats)
|
|
268
414
|
|
|
415
|
+
@mcp.tool()
|
|
416
|
+
async def tribal_recall_entity(
|
|
417
|
+
entity_name: str,
|
|
418
|
+
hops: int = 1,
|
|
419
|
+
limit: int = 10,
|
|
420
|
+
) -> str:
|
|
421
|
+
"""Recall memories associated with an entity and its connections.
|
|
422
|
+
|
|
423
|
+
Enables entity-centric queries like:
|
|
424
|
+
- "Tell me everything about auth-service"
|
|
425
|
+
- "What do we know about PostgreSQL?"
|
|
426
|
+
- "What services connect to the user database?"
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
entity_name: Name of the entity to query (required).
|
|
430
|
+
Examples: "auth-service", "PostgreSQL", "user-db"
|
|
431
|
+
hops: Number of relationship hops to traverse (default 1).
|
|
432
|
+
1 = direct connections only
|
|
433
|
+
2 = connections of connections
|
|
434
|
+
limit: Maximum number of results (1-50, default 10)
|
|
435
|
+
|
|
436
|
+
Returns:
|
|
437
|
+
JSON with: results (list of memories), entity, hops, count
|
|
438
|
+
"""
|
|
439
|
+
if not entity_name or not entity_name.strip():
|
|
440
|
+
return json.dumps({
|
|
441
|
+
"results": [],
|
|
442
|
+
"entity": entity_name,
|
|
443
|
+
"hops": hops,
|
|
444
|
+
"count": 0,
|
|
445
|
+
"error": "Entity name cannot be empty",
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
hops = max(1, min(10, hops)) # Clamp to reasonable range
|
|
449
|
+
limit = max(1, min(50, limit))
|
|
450
|
+
|
|
451
|
+
service = await get_memory_service()
|
|
452
|
+
|
|
453
|
+
if not service.graph_enabled:
|
|
454
|
+
return json.dumps({
|
|
455
|
+
"results": [],
|
|
456
|
+
"entity": entity_name,
|
|
457
|
+
"hops": hops,
|
|
458
|
+
"count": 0,
|
|
459
|
+
"error": "Graph search not enabled. Requires db_path for persistent storage.",
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
results = await service.recall_entity(
|
|
463
|
+
entity_name=entity_name,
|
|
464
|
+
hops=hops,
|
|
465
|
+
limit=limit,
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
return json.dumps({
|
|
469
|
+
"results": [
|
|
470
|
+
{
|
|
471
|
+
"memory_id": r.memory.id,
|
|
472
|
+
"content": r.memory.content,
|
|
473
|
+
"source_type": r.memory.source_type.value,
|
|
474
|
+
"source_instance": r.memory.source_instance,
|
|
475
|
+
"tags": r.memory.tags,
|
|
476
|
+
"created_at": r.memory.created_at.isoformat(),
|
|
477
|
+
}
|
|
478
|
+
for r in results
|
|
479
|
+
],
|
|
480
|
+
"entity": entity_name,
|
|
481
|
+
"hops": hops,
|
|
482
|
+
"count": len(results),
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
@mcp.tool()
|
|
486
|
+
async def tribal_entity_graph(
|
|
487
|
+
entity_name: str,
|
|
488
|
+
hops: int = 2,
|
|
489
|
+
) -> str:
|
|
490
|
+
"""Get the relationship graph around an entity.
|
|
491
|
+
|
|
492
|
+
Useful for understanding how concepts/services/technologies
|
|
493
|
+
are connected in your project knowledge base.
|
|
494
|
+
|
|
495
|
+
Args:
|
|
496
|
+
entity_name: Name of the entity to explore (required)
|
|
497
|
+
hops: How many relationship hops to include (default 2)
|
|
498
|
+
|
|
499
|
+
Returns:
|
|
500
|
+
JSON with: entities (list with name/type), relationships (list with source/target/type)
|
|
501
|
+
"""
|
|
502
|
+
if not entity_name or not entity_name.strip():
|
|
503
|
+
return json.dumps({
|
|
504
|
+
"entities": [],
|
|
505
|
+
"relationships": [],
|
|
506
|
+
"error": "Entity name cannot be empty",
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
hops = max(1, min(5, hops)) # Clamp to reasonable range
|
|
510
|
+
|
|
511
|
+
service = await get_memory_service()
|
|
512
|
+
|
|
513
|
+
if not service.graph_enabled:
|
|
514
|
+
return json.dumps({
|
|
515
|
+
"entities": [],
|
|
516
|
+
"relationships": [],
|
|
517
|
+
"error": "Graph search not enabled. Requires db_path for persistent storage.",
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
graph = service.get_entity_graph(
|
|
521
|
+
entity_name=entity_name,
|
|
522
|
+
hops=hops,
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
return json.dumps(graph)
|
|
526
|
+
|
|
269
527
|
@mcp.tool()
|
|
270
528
|
async def tribal_export(
|
|
271
529
|
tags: Optional[list[str]] = None,
|
tribalmemory/server/app.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""FastAPI application for tribal-memory service."""
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import logging
|
|
4
5
|
from contextlib import asynccontextmanager
|
|
5
6
|
from pathlib import Path
|
|
@@ -10,11 +11,13 @@ from fastapi import FastAPI
|
|
|
10
11
|
from fastapi.middleware.cors import CORSMiddleware
|
|
11
12
|
|
|
12
13
|
from ..services import create_memory_service, TribalMemoryService
|
|
14
|
+
from ..services.session_store import SessionStore
|
|
13
15
|
from .config import TribalMemoryConfig
|
|
14
16
|
from .routes import router
|
|
15
17
|
|
|
16
18
|
# Global service instance (set during lifespan)
|
|
17
19
|
_memory_service: Optional[TribalMemoryService] = None
|
|
20
|
+
_session_store: Optional[SessionStore] = None
|
|
18
21
|
_instance_id: Optional[str] = None
|
|
19
22
|
|
|
20
23
|
logger = logging.getLogger("tribalmemory.server")
|
|
@@ -23,7 +26,7 @@ logger = logging.getLogger("tribalmemory.server")
|
|
|
23
26
|
@asynccontextmanager
|
|
24
27
|
async def lifespan(app: FastAPI):
|
|
25
28
|
"""Application lifespan manager."""
|
|
26
|
-
global _memory_service, _instance_id
|
|
29
|
+
global _memory_service, _session_store, _instance_id
|
|
27
30
|
|
|
28
31
|
config: TribalMemoryConfig = app.state.config
|
|
29
32
|
|
|
@@ -43,18 +46,66 @@ async def lifespan(app: FastAPI):
|
|
|
43
46
|
api_base=config.embedding.api_base,
|
|
44
47
|
embedding_model=config.embedding.model,
|
|
45
48
|
embedding_dimensions=config.embedding.dimensions,
|
|
49
|
+
hybrid_search=config.search.hybrid_enabled,
|
|
50
|
+
hybrid_vector_weight=config.search.vector_weight,
|
|
51
|
+
hybrid_text_weight=config.search.text_weight,
|
|
52
|
+
hybrid_candidate_multiplier=config.search.candidate_multiplier,
|
|
46
53
|
)
|
|
47
54
|
|
|
48
|
-
|
|
55
|
+
# Create session store (shares embedding service and vector store)
|
|
56
|
+
_session_store = SessionStore(
|
|
57
|
+
instance_id=config.instance_id,
|
|
58
|
+
embedding_service=_memory_service.embedding_service,
|
|
59
|
+
vector_store=_memory_service.vector_store,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
search_mode = "hybrid (vector + BM25)" if config.search.hybrid_enabled else "vector-only"
|
|
63
|
+
logger.info(f"Memory service initialized (db: {config.db.path}, search: {search_mode})")
|
|
64
|
+
logger.info(f"Session store initialized (retention: {config.server.session_retention_days} days)")
|
|
65
|
+
|
|
66
|
+
# Start background session cleanup task
|
|
67
|
+
cleanup_task = asyncio.create_task(
|
|
68
|
+
_session_cleanup_loop(
|
|
69
|
+
_session_store,
|
|
70
|
+
config.server.session_retention_days,
|
|
71
|
+
)
|
|
72
|
+
)
|
|
49
73
|
|
|
50
74
|
yield
|
|
51
75
|
|
|
52
76
|
# Cleanup
|
|
77
|
+
cleanup_task.cancel()
|
|
78
|
+
try:
|
|
79
|
+
await cleanup_task
|
|
80
|
+
except asyncio.CancelledError:
|
|
81
|
+
pass
|
|
53
82
|
logger.info("Shutting down tribal-memory service")
|
|
54
83
|
_memory_service = None
|
|
84
|
+
_session_store = None
|
|
55
85
|
_instance_id = None
|
|
56
86
|
|
|
57
87
|
|
|
88
|
+
async def _session_cleanup_loop(
|
|
89
|
+
session_store: SessionStore,
|
|
90
|
+
retention_days: int,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Background task that periodically cleans up expired session chunks.
|
|
93
|
+
|
|
94
|
+
Runs every 6 hours. Deletes session chunks older than retention_days.
|
|
95
|
+
"""
|
|
96
|
+
cleanup_interval = 6 * 60 * 60 # 6 hours in seconds
|
|
97
|
+
while True:
|
|
98
|
+
try:
|
|
99
|
+
await asyncio.sleep(cleanup_interval)
|
|
100
|
+
deleted = await session_store.cleanup(retention_days=retention_days)
|
|
101
|
+
if deleted > 0:
|
|
102
|
+
logger.info(f"Session cleanup: deleted {deleted} expired chunks (retention: {retention_days} days)")
|
|
103
|
+
except asyncio.CancelledError:
|
|
104
|
+
raise
|
|
105
|
+
except Exception:
|
|
106
|
+
logger.exception("Session cleanup failed")
|
|
107
|
+
|
|
108
|
+
|
|
58
109
|
def create_app(config: Optional[TribalMemoryConfig] = None) -> FastAPI:
|
|
59
110
|
"""Create FastAPI application.
|
|
60
111
|
|
tribalmemory/server/config.py
CHANGED
|
@@ -51,6 +51,44 @@ class ServerConfig:
|
|
|
51
51
|
"""HTTP server configuration."""
|
|
52
52
|
host: str = "127.0.0.1"
|
|
53
53
|
port: int = 18790
|
|
54
|
+
session_retention_days: int = 30 # Days to retain session chunks
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class SearchConfig:
|
|
59
|
+
"""Search configuration for hybrid BM25 + vector search."""
|
|
60
|
+
hybrid_enabled: bool = True
|
|
61
|
+
vector_weight: float = 0.7
|
|
62
|
+
text_weight: float = 0.3
|
|
63
|
+
candidate_multiplier: int = 4
|
|
64
|
+
# Reranking configuration
|
|
65
|
+
reranking: str = "heuristic" # "auto" | "cross-encoder" | "heuristic" | "none"
|
|
66
|
+
recency_decay_days: float = 30.0 # Half-life for recency boost
|
|
67
|
+
tag_boost_weight: float = 0.1 # Weight for tag match boost
|
|
68
|
+
rerank_pool_multiplier: int = 2 # How many candidates to give reranker (N * limit)
|
|
69
|
+
|
|
70
|
+
def __post_init__(self):
|
|
71
|
+
if self.vector_weight < 0:
|
|
72
|
+
raise ValueError("vector_weight must be non-negative")
|
|
73
|
+
if self.text_weight < 0:
|
|
74
|
+
raise ValueError("text_weight must be non-negative")
|
|
75
|
+
if self.vector_weight == 0 and self.text_weight == 0:
|
|
76
|
+
raise ValueError(
|
|
77
|
+
"At least one of vector_weight or text_weight must be > 0"
|
|
78
|
+
)
|
|
79
|
+
if self.candidate_multiplier < 1:
|
|
80
|
+
raise ValueError("candidate_multiplier must be >= 1")
|
|
81
|
+
if self.reranking not in ("auto", "cross-encoder", "heuristic", "none"):
|
|
82
|
+
raise ValueError(
|
|
83
|
+
f"Invalid reranking mode: {self.reranking}. "
|
|
84
|
+
f"Valid options: 'auto', 'cross-encoder', 'heuristic', 'none'"
|
|
85
|
+
)
|
|
86
|
+
if self.recency_decay_days <= 0:
|
|
87
|
+
raise ValueError("recency_decay_days must be positive")
|
|
88
|
+
if self.tag_boost_weight < 0:
|
|
89
|
+
raise ValueError("tag_boost_weight must be non-negative")
|
|
90
|
+
if self.rerank_pool_multiplier < 1:
|
|
91
|
+
raise ValueError("rerank_pool_multiplier must be >= 1")
|
|
54
92
|
|
|
55
93
|
|
|
56
94
|
@dataclass
|
|
@@ -60,6 +98,7 @@ class TribalMemoryConfig:
|
|
|
60
98
|
db: DatabaseConfig = field(default_factory=DatabaseConfig)
|
|
61
99
|
embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig)
|
|
62
100
|
server: ServerConfig = field(default_factory=ServerConfig)
|
|
101
|
+
search: SearchConfig = field(default_factory=SearchConfig)
|
|
63
102
|
|
|
64
103
|
@classmethod
|
|
65
104
|
def from_file(cls, path: str | Path) -> "TribalMemoryConfig":
|
|
@@ -79,12 +118,14 @@ class TribalMemoryConfig:
|
|
|
79
118
|
db_data = data.get("db", {})
|
|
80
119
|
embedding_data = data.get("embedding", {})
|
|
81
120
|
server_data = data.get("server", {})
|
|
121
|
+
search_data = data.get("search", {})
|
|
82
122
|
|
|
83
123
|
return cls(
|
|
84
124
|
instance_id=data.get("instance_id", "default"),
|
|
85
125
|
db=DatabaseConfig(**db_data) if db_data else DatabaseConfig(),
|
|
86
126
|
embedding=EmbeddingConfig(**embedding_data) if embedding_data else EmbeddingConfig(),
|
|
87
127
|
server=ServerConfig(**server_data) if server_data else ServerConfig(),
|
|
128
|
+
search=SearchConfig(**search_data) if search_data else SearchConfig(),
|
|
88
129
|
)
|
|
89
130
|
|
|
90
131
|
@classmethod
|
tribalmemory/server/models.py
CHANGED
|
@@ -204,3 +204,68 @@ class ImportResponse(BaseModel):
|
|
|
204
204
|
duration_ms: float = 0.0
|
|
205
205
|
error_details: list[str] = Field(default_factory=list)
|
|
206
206
|
error: Optional[str] = None
|
|
207
|
+
|
|
208
|
+
# =============================================================================
|
|
209
|
+
# Session Indexing Models (Issue #38)
|
|
210
|
+
# =============================================================================
|
|
211
|
+
|
|
212
|
+
class SessionMessageRequest(BaseModel):
|
|
213
|
+
"""A single message in a session transcript."""
|
|
214
|
+
role: str = Field(..., description="Message role (user, assistant, system)")
|
|
215
|
+
content: str = Field(..., description="Message content")
|
|
216
|
+
timestamp: datetime = Field(..., description="When the message was sent")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class SessionIngestRequest(BaseModel):
|
|
220
|
+
"""Request to ingest session transcript."""
|
|
221
|
+
session_id: str = Field(..., description="Unique session identifier")
|
|
222
|
+
messages: list[SessionMessageRequest] = Field(
|
|
223
|
+
..., description="Conversation messages to index"
|
|
224
|
+
)
|
|
225
|
+
instance_id: Optional[str] = Field(
|
|
226
|
+
default=None,
|
|
227
|
+
description="Override instance ID (defaults to server's instance_id)"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class SessionIngestResponse(BaseModel):
|
|
232
|
+
"""Response from session ingestion."""
|
|
233
|
+
success: bool
|
|
234
|
+
chunks_created: int = 0
|
|
235
|
+
messages_processed: int = 0
|
|
236
|
+
error: Optional[str] = None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class SessionSearchRequest(BaseModel):
|
|
240
|
+
"""Request to search session transcripts."""
|
|
241
|
+
query: str = Field(..., description="Natural language search query")
|
|
242
|
+
session_id: Optional[str] = Field(
|
|
243
|
+
default=None,
|
|
244
|
+
description="Filter to specific session (optional)"
|
|
245
|
+
)
|
|
246
|
+
limit: int = Field(default=5, ge=1, le=50, description="Maximum results")
|
|
247
|
+
min_relevance: float = Field(
|
|
248
|
+
default=0.0,
|
|
249
|
+
ge=0.0,
|
|
250
|
+
le=1.0,
|
|
251
|
+
description="Minimum similarity score"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class SessionChunkResponse(BaseModel):
|
|
256
|
+
"""A session transcript chunk result."""
|
|
257
|
+
chunk_id: str
|
|
258
|
+
session_id: str
|
|
259
|
+
instance_id: str
|
|
260
|
+
content: str
|
|
261
|
+
similarity_score: float
|
|
262
|
+
start_time: datetime
|
|
263
|
+
end_time: datetime
|
|
264
|
+
chunk_index: int
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class SessionSearchResponse(BaseModel):
|
|
268
|
+
"""Response from session search."""
|
|
269
|
+
results: list[SessionChunkResponse]
|
|
270
|
+
query: str
|
|
271
|
+
error: Optional[str] = None
|
tribalmemory/server/routes.py
CHANGED
|
@@ -7,6 +7,7 @@ from fastapi import APIRouter, HTTPException, Depends
|
|
|
7
7
|
|
|
8
8
|
from ..interfaces import MemorySource, MemoryEntry
|
|
9
9
|
from ..services import TribalMemoryService
|
|
10
|
+
from ..services.session_store import SessionStore, SessionMessage
|
|
10
11
|
from .models import (
|
|
11
12
|
RememberRequest,
|
|
12
13
|
RecallRequest,
|
|
@@ -24,6 +25,11 @@ from .models import (
|
|
|
24
25
|
ExportResponse,
|
|
25
26
|
ImportRequest,
|
|
26
27
|
ImportResponse,
|
|
28
|
+
SessionIngestRequest,
|
|
29
|
+
SessionIngestResponse,
|
|
30
|
+
SessionSearchRequest,
|
|
31
|
+
SessionSearchResponse,
|
|
32
|
+
SessionChunkResponse,
|
|
27
33
|
)
|
|
28
34
|
|
|
29
35
|
router = APIRouter(prefix="/v1", tags=["memory"])
|
|
@@ -40,6 +46,17 @@ def get_memory_service() -> TribalMemoryService:
|
|
|
40
46
|
return _memory_service
|
|
41
47
|
|
|
42
48
|
|
|
49
|
+
def get_session_store() -> SessionStore:
|
|
50
|
+
"""Dependency injection for session store.
|
|
51
|
+
|
|
52
|
+
This is set by the app during startup.
|
|
53
|
+
"""
|
|
54
|
+
from .app import _session_store
|
|
55
|
+
if _session_store is None:
|
|
56
|
+
raise HTTPException(status_code=503, detail="Session store not initialized")
|
|
57
|
+
return _session_store
|
|
58
|
+
|
|
59
|
+
|
|
43
60
|
def get_instance_id() -> str:
|
|
44
61
|
"""Get the current instance ID."""
|
|
45
62
|
from .app import _instance_id
|
|
@@ -376,3 +393,54 @@ async def shutdown() -> ShutdownResponse:
|
|
|
376
393
|
0.5, lambda: os.kill(os.getpid(), signal.SIGTERM)
|
|
377
394
|
)
|
|
378
395
|
return ShutdownResponse(status="shutting_down")
|
|
396
|
+
|
|
397
|
+
# =============================================================================
|
|
398
|
+
# Session Indexing Routes (Issue #38)
|
|
399
|
+
# =============================================================================
|
|
400
|
+
|
|
401
|
+
@router.post("/sessions/ingest", response_model=SessionIngestResponse)
|
|
402
|
+
async def ingest_session(
|
|
403
|
+
request: SessionIngestRequest,
|
|
404
|
+
store: SessionStore = Depends(get_session_store),
|
|
405
|
+
instance_id: str = Depends(get_instance_id),
|
|
406
|
+
) -> SessionIngestResponse:
|
|
407
|
+
"""Ingest a session transcript for indexing."""
|
|
408
|
+
try:
|
|
409
|
+
messages = [
|
|
410
|
+
SessionMessage(role=msg.role, content=msg.content, timestamp=msg.timestamp)
|
|
411
|
+
for msg in request.messages
|
|
412
|
+
]
|
|
413
|
+
|
|
414
|
+
result = await store.ingest(
|
|
415
|
+
session_id=request.session_id,
|
|
416
|
+
messages=messages,
|
|
417
|
+
instance_id=request.instance_id or instance_id,
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
return SessionIngestResponse(
|
|
421
|
+
success=result.get("success", False),
|
|
422
|
+
chunks_created=result.get("chunks_created", 0),
|
|
423
|
+
messages_processed=result.get("messages_processed", 0),
|
|
424
|
+
error=result.get("error"),
|
|
425
|
+
)
|
|
426
|
+
except Exception as e:
|
|
427
|
+
return SessionIngestResponse(success=False, error=str(e))
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
@router.get("/sessions/search", response_model=SessionSearchResponse)
|
|
431
|
+
async def search_sessions(
|
|
432
|
+
query: str,
|
|
433
|
+
session_id: Optional[str] = None,
|
|
434
|
+
limit: int = 5,
|
|
435
|
+
min_relevance: float = 0.0,
|
|
436
|
+
store: SessionStore = Depends(get_session_store),
|
|
437
|
+
) -> SessionSearchResponse:
|
|
438
|
+
"""Search session transcripts by semantic similarity."""
|
|
439
|
+
try:
|
|
440
|
+
results = await store.search(query, session_id, limit, min_relevance)
|
|
441
|
+
return SessionSearchResponse(
|
|
442
|
+
results=[SessionChunkResponse(**r) for r in results],
|
|
443
|
+
query=query,
|
|
444
|
+
)
|
|
445
|
+
except Exception as e:
|
|
446
|
+
return SessionSearchResponse(results=[], query=query, error=str(e))
|