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.
@@ -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
- service = await get_memory_service()
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
- results = await service.recall(
158
- query=query,
159
- limit=limit,
160
- min_relevance=min_relevance,
161
- tags=tags,
162
- )
194
+ all_results = []
163
195
 
164
- return json.dumps({
165
- "results": [
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 results
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(results),
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,
@@ -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
- logger.info(f"Memory service initialized (db: {config.db.path})")
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
 
@@ -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
@@ -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
@@ -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))