tribalmemory 0.1.1__py3-none-any.whl → 0.2.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 CHANGED
@@ -9,6 +9,7 @@ Usage:
9
9
  import argparse
10
10
  import json
11
11
  import os
12
+ import shutil
12
13
  import sys
13
14
  from pathlib import Path
14
15
 
@@ -26,6 +27,36 @@ TRIBAL_DIR = Path.home() / ".tribal-memory"
26
27
  CONFIG_FILE = TRIBAL_DIR / "config.yaml"
27
28
  DEFAULT_INSTANCE_ID = "default"
28
29
 
30
+ # Paths to global instructions files (relative to home)
31
+ CLAUDE_INSTRUCTIONS_FILE = Path(".claude") / "CLAUDE.md"
32
+ CODEX_INSTRUCTIONS_FILE = Path(".codex") / "AGENTS.md"
33
+
34
+ # Section marker used to detect if auto-capture instructions already exist
35
+ _AUTO_CAPTURE_MARKER = "## Tribal Memory — Auto-Capture"
36
+
37
+ AUTO_CAPTURE_INSTRUCTIONS = f"""\
38
+
39
+ {_AUTO_CAPTURE_MARKER}
40
+
41
+ You have access to Tribal Memory — a shared memory system for AI agents.
42
+ Use it proactively:
43
+
44
+ **After completing tasks, making decisions, or learning something:**
45
+ - Call `tribal_remember` to store the insight with relevant tags
46
+ - Good memories: architectural decisions, bug fixes, "gotchas", user preferences,
47
+ project conventions, API quirks, things that took a while to figure out
48
+ - Bad memories: trivial actions ("I ran the tests"), obvious facts, temporary state
49
+
50
+ **Before answering questions about prior work:**
51
+ - Call `tribal_recall` to search for relevant context first
52
+ - Search by topic, not exact phrases — it's semantic search
53
+
54
+ **Tips:**
55
+ - Use tags to organize: `["python", "debugging"]`, `["api", "auth"]`
56
+ - One clear insight per memory is better than a wall of text
57
+ - If you're unsure whether to remember something, remember it — recall is cheap
58
+ """
59
+
29
60
  # MCP config for Claude Code CLI and Claude Desktop
30
61
  CLAUDE_CODE_MCP_CONFIG = {
31
62
  "mcpServers": {
@@ -55,7 +86,7 @@ db:
55
86
  server:
56
87
  host: 127.0.0.1
57
88
  port: 18790
58
- """
89
+ {auto_capture_line}"""
59
90
 
60
91
  LOCAL_CONFIG_TEMPLATE = """\
61
92
  # Tribal Memory Configuration — Local Mode (Zero Cloud)
@@ -78,7 +109,7 @@ db:
78
109
  server:
79
110
  host: 127.0.0.1
80
111
  port: 18790
81
- """
112
+ {auto_capture_line}"""
82
113
 
83
114
 
84
115
  def cmd_init(args: argparse.Namespace) -> int:
@@ -89,16 +120,23 @@ def cmd_init(args: argparse.Namespace) -> int:
89
120
  # Create config directory
90
121
  TRIBAL_DIR.mkdir(parents=True, exist_ok=True)
91
122
 
123
+ # Auto-capture config line (only included when flag is set)
124
+ auto_capture_line = ""
125
+ if args.auto_capture:
126
+ auto_capture_line = "\nauto_capture: true\n"
127
+
92
128
  # Choose template
93
129
  if args.local:
94
130
  config_content = LOCAL_CONFIG_TEMPLATE.format(
95
131
  instance_id=instance_id,
96
132
  db_path=db_path,
133
+ auto_capture_line=auto_capture_line,
97
134
  )
98
135
  else:
99
136
  config_content = OPENAI_CONFIG_TEMPLATE.format(
100
137
  instance_id=instance_id,
101
138
  db_path=db_path,
139
+ auto_capture_line=auto_capture_line,
102
140
  )
103
141
 
104
142
  # Write config
@@ -124,16 +162,74 @@ def cmd_init(args: argparse.Namespace) -> int:
124
162
  if args.codex:
125
163
  _setup_codex_mcp(args.local)
126
164
 
165
+ # Set up auto-capture instructions
166
+ if args.auto_capture:
167
+ _setup_auto_capture(
168
+ claude_code=args.claude_code,
169
+ codex=args.codex,
170
+ )
171
+
127
172
  print()
128
173
  print("🚀 Start the server:")
129
174
  print(" tribalmemory serve")
130
175
  print()
131
176
  print("🧠 Or use with Claude Code (MCP):")
132
177
  print(" tribalmemory-mcp")
178
+
179
+ if not args.auto_capture:
180
+ print()
181
+ print("💡 Want your agents to remember things automatically?")
182
+ print(" tribalmemory init --auto-capture --force")
133
183
 
134
184
  return 0
135
185
 
136
186
 
187
+ def _setup_auto_capture(claude_code: bool = False, codex: bool = False) -> None:
188
+ """Write auto-capture instructions to agent instruction files.
189
+
190
+ Appends memory usage instructions so agents proactively use
191
+ tribal_remember and tribal_recall without being explicitly asked.
192
+
193
+ Writes to:
194
+ - ~/.claude/CLAUDE.md (Claude Code) — when --claude-code is set
195
+ - ~/.codex/AGENTS.md (Codex CLI) — when --codex is set
196
+ - Both files if neither flag is set (covers the common case)
197
+
198
+ Skips if instructions are already present (idempotent).
199
+ """
200
+ # If no specific flag, write to both (default behavior)
201
+ if not claude_code and not codex:
202
+ claude_code = codex = True
203
+
204
+ targets = []
205
+ if claude_code:
206
+ targets.append(("Claude Code", Path.home() / CLAUDE_INSTRUCTIONS_FILE))
207
+ if codex:
208
+ targets.append(("Codex CLI", Path.home() / CODEX_INSTRUCTIONS_FILE))
209
+
210
+ for label, instructions_path in targets:
211
+ _write_instructions_file(instructions_path, label)
212
+
213
+
214
+ def _write_instructions_file(instructions_path: Path, label: str) -> None:
215
+ """Write auto-capture instructions to a single instructions file."""
216
+ instructions_path.parent.mkdir(parents=True, exist_ok=True)
217
+
218
+ if instructions_path.exists():
219
+ existing = instructions_path.read_text()
220
+ if _AUTO_CAPTURE_MARKER in existing:
221
+ print(f"✅ Auto-capture already present in {label}: {instructions_path}")
222
+ return
223
+ # Append to existing file
224
+ if not existing.endswith("\n"):
225
+ existing += "\n"
226
+ instructions_path.write_text(existing + AUTO_CAPTURE_INSTRUCTIONS)
227
+ else:
228
+ instructions_path.write_text(AUTO_CAPTURE_INSTRUCTIONS.lstrip("\n"))
229
+
230
+ print(f"✅ Auto-capture instructions written for {label}: {instructions_path}")
231
+
232
+
137
233
  def _setup_claude_code_mcp(is_local: bool) -> None:
138
234
  """Add Tribal Memory to Claude Code's MCP configuration.
139
235
 
@@ -151,8 +247,13 @@ def _setup_claude_code_mcp(is_local: bool) -> None:
151
247
  Path.home() / ".claude" / "claude_desktop_config.json", # Legacy / Linux
152
248
  ]
153
249
 
250
+ # Resolve full path to tribalmemory-mcp binary.
251
+ # Claude Desktop doesn't inherit the user's shell PATH (e.g. ~/.local/bin),
252
+ # so we need the absolute path for it to find the command.
253
+ mcp_command = _resolve_mcp_command()
254
+
154
255
  mcp_entry = {
155
- "command": "tribalmemory-mcp",
256
+ "command": mcp_command,
156
257
  "env": {},
157
258
  }
158
259
 
@@ -169,6 +270,42 @@ def _setup_claude_code_mcp(is_local: bool) -> None:
169
270
  print(f"✅ Claude Desktop config updated: {desktop_path}")
170
271
 
171
272
 
273
+ def _resolve_mcp_command() -> str:
274
+ """Resolve the full path to the tribalmemory-mcp binary.
275
+
276
+ Claude Desktop doesn't inherit the user's shell PATH (e.g. ~/.local/bin
277
+ from uv/pipx installs), so bare command names like "tribalmemory-mcp"
278
+ fail with "No such file or directory". We resolve the absolute path at
279
+ init time so the config works regardless of the app's PATH.
280
+
281
+ Falls back to the bare command name if not found on PATH (e.g. user
282
+ hasn't installed yet and will do so later).
283
+ """
284
+ resolved = shutil.which("tribalmemory-mcp")
285
+ if resolved:
286
+ return resolved
287
+
288
+ # Check common tool install locations that might not be on PATH
289
+ base_name = "tribalmemory-mcp"
290
+ search_dirs = [
291
+ Path.home() / ".local" / "bin", # uv/pipx (Linux/macOS)
292
+ Path.home() / ".cargo" / "bin", # unlikely but possible
293
+ ]
294
+ # On Windows, executables may have .exe/.cmd extensions
295
+ suffixes = [""]
296
+ if sys.platform == "win32":
297
+ suffixes = [".exe", ".cmd", ""]
298
+
299
+ for search_dir in search_dirs:
300
+ for suffix in suffixes:
301
+ candidate = search_dir / (base_name + suffix)
302
+ if candidate.exists() and os.access(candidate, os.X_OK):
303
+ return str(candidate)
304
+
305
+ # Fall back to bare command — will work if PATH is set correctly
306
+ return "tribalmemory-mcp"
307
+
308
+
172
309
  def _get_claude_desktop_config_path() -> Path:
173
310
  """Get the platform-appropriate Claude Desktop config path."""
174
311
  if sys.platform == "darwin":
@@ -211,6 +348,10 @@ def _setup_codex_mcp(is_local: bool) -> None:
211
348
  codex_config_path = Path.home() / ".codex" / "config.toml"
212
349
  codex_config_path.parent.mkdir(parents=True, exist_ok=True)
213
350
 
351
+ # Resolve full path (same reason as Claude Desktop — Codex may not
352
+ # inherit the user's full shell PATH)
353
+ mcp_command = _resolve_mcp_command()
354
+
214
355
  # Build the TOML section manually (avoid tomli_w dependency)
215
356
  # Codex uses [mcp_servers.name] sections in config.toml
216
357
  section_marker = "[mcp_servers.tribal-memory]"
@@ -219,7 +360,7 @@ def _setup_codex_mcp(is_local: bool) -> None:
219
360
  "",
220
361
  "# Tribal Memory — shared memory for AI agents",
221
362
  section_marker,
222
- 'command = "tribalmemory-mcp"',
363
+ f'command = "{mcp_command}"',
223
364
  ]
224
365
 
225
366
  if is_local:
@@ -286,6 +427,8 @@ def main() -> None:
286
427
  help="Configure Claude Code MCP integration")
287
428
  init_parser.add_argument("--codex", action="store_true",
288
429
  help="Configure Codex CLI MCP integration")
430
+ init_parser.add_argument("--auto-capture", action="store_true",
431
+ help="Enable auto-capture (writes instructions to agent config files)")
289
432
  init_parser.add_argument("--instance-id", type=str, default=None,
290
433
  help="Instance identifier (default: 'default')")
291
434
  init_parser.add_argument("--force", action="store_true",
@@ -174,6 +174,50 @@ class IVectorStore(ABC):
174
174
  """Count memories matching filters."""
175
175
  pass
176
176
 
177
+ async def get_stats(self) -> dict:
178
+ """Compute aggregate statistics over all memories.
179
+
180
+ Returns dict with keys:
181
+ total_memories, by_source_type, by_tag, by_instance, corrections
182
+
183
+ Default implementation iterates in pages of 500. Subclasses
184
+ should override with native queries (SQL GROUP BY, etc.) for
185
+ stores with >10k entries.
186
+ """
187
+ page_size = 500
188
+ total = 0
189
+ corrections = 0
190
+ by_source: dict[str, int] = {}
191
+ by_instance: dict[str, int] = {}
192
+ by_tag: dict[str, int] = {}
193
+
194
+ offset = 0
195
+ while True:
196
+ page = await self.list(limit=page_size, offset=offset)
197
+ if not page:
198
+ break
199
+ total += len(page)
200
+ for m in page:
201
+ src = m.source_type.value
202
+ by_source[src] = by_source.get(src, 0) + 1
203
+ inst = m.source_instance
204
+ by_instance[inst] = by_instance.get(inst, 0) + 1
205
+ for tag in m.tags:
206
+ by_tag[tag] = by_tag.get(tag, 0) + 1
207
+ if m.supersedes:
208
+ corrections += 1
209
+ if len(page) < page_size:
210
+ break
211
+ offset += page_size
212
+
213
+ return {
214
+ "total_memories": total,
215
+ "by_source_type": by_source,
216
+ "by_tag": by_tag,
217
+ "by_instance": by_instance,
218
+ "corrections": corrections,
219
+ }
220
+
177
221
 
178
222
  class IDeduplicationService(ABC):
179
223
  """Interface for detecting duplicate memories."""
@@ -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,
@@ -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