tribalmemory 0.1.0__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,7 +27,37 @@ TRIBAL_DIR = Path.home() / ".tribal-memory"
26
27
  CONFIG_FILE = TRIBAL_DIR / "config.yaml"
27
28
  DEFAULT_INSTANCE_ID = "default"
28
29
 
29
- # MCP config for Claude Code (claude_desktop_config.json)
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
+
60
+ # MCP config for Claude Code CLI and Claude Desktop
30
61
  CLAUDE_CODE_MCP_CONFIG = {
31
62
  "mcpServers": {
32
63
  "tribal-memory": {
@@ -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,65 +162,185 @@ 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
- """Add Tribal Memory to Claude Code's MCP configuration."""
139
- # Claude Code MCP config paths by platform
140
- claude_config_paths = [
141
- Path.home() / ".claude" / "claude_desktop_config.json", # Claude Code CLI (all platforms)
142
- Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json", # Claude Desktop (macOS)
143
- Path.home() / "AppData" / "Roaming" / "Claude" / "claude_desktop_config.json", # Claude Desktop (Windows)
234
+ """Add Tribal Memory to Claude Code's MCP configuration.
235
+
236
+ Claude Code CLI reads MCP servers from ~/.claude.json (user scope).
237
+ Claude Desktop reads from platform-specific claude_desktop_config.json.
238
+ We update both if they exist, and always ensure ~/.claude.json is set.
239
+ """
240
+ # Claude Code CLI config (primary — this is what `claude` CLI reads)
241
+ claude_cli_config = Path.home() / ".claude.json"
242
+
243
+ # Claude Desktop config paths (secondary — update if they exist)
244
+ claude_desktop_paths = [
245
+ Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json", # macOS
246
+ Path.home() / "AppData" / "Roaming" / "Claude" / "claude_desktop_config.json", # Windows
247
+ Path.home() / ".claude" / "claude_desktop_config.json", # Legacy / Linux
144
248
  ]
145
249
 
146
- config_path = None
147
- for p in claude_config_paths:
148
- if p.exists():
149
- config_path = p
150
- break
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
+
255
+ mcp_entry = {
256
+ "command": mcp_command,
257
+ "env": {},
258
+ }
259
+
260
+ if is_local:
261
+ mcp_entry["env"]["TRIBAL_MEMORY_EMBEDDING_API_BASE"] = "http://localhost:11434/v1"
262
+
263
+ # Always update Claude Code CLI config (~/.claude.json)
264
+ _update_mcp_config(claude_cli_config, mcp_entry, create_if_missing=True)
265
+ print(f"✅ Claude Code CLI config updated: {claude_cli_config}")
266
+
267
+ # Also update Claude Desktop config (create platform-appropriate path)
268
+ desktop_path = _get_claude_desktop_config_path()
269
+ _update_mcp_config(desktop_path, mcp_entry, create_if_missing=True)
270
+ print(f"✅ Claude Desktop config updated: {desktop_path}")
271
+
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"
151
307
 
152
- if config_path is None:
153
- # Create default location
154
- config_path = claude_config_paths[0]
155
- config_path.parent.mkdir(parents=True, exist_ok=True)
156
308
 
157
- # Read existing config or start fresh
309
+ def _get_claude_desktop_config_path() -> Path:
310
+ """Get the platform-appropriate Claude Desktop config path."""
311
+ if sys.platform == "darwin":
312
+ return Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json"
313
+ elif sys.platform == "win32":
314
+ return Path.home() / "AppData" / "Roaming" / "Claude" / "claude_desktop_config.json"
315
+ else:
316
+ return Path.home() / ".claude" / "claude_desktop_config.json"
317
+
318
+
319
+ def _update_mcp_config(
320
+ config_path: Path, mcp_entry: dict, create_if_missing: bool = False
321
+ ) -> None:
322
+ """Update an MCP config file with the tribal-memory server entry."""
158
323
  if config_path.exists():
159
324
  try:
160
325
  existing = json.loads(config_path.read_text())
161
326
  except json.JSONDecodeError as e:
162
- print(
163
- f"⚠️ Existing config has invalid JSON: {e}"
164
- )
327
+ backup_path = config_path.with_suffix(".json.bak")
328
+ config_path.rename(backup_path)
329
+ print(f"⚠️ Existing config has invalid JSON: {e}")
330
+ print(f" Backed up to {backup_path}")
165
331
  print(f" Creating fresh config at {config_path}")
166
332
  existing = {}
167
- else:
333
+ elif create_if_missing:
334
+ config_path.parent.mkdir(parents=True, exist_ok=True)
168
335
  existing = {}
336
+ else:
337
+ return
169
338
 
170
- # Merge MCP server config
171
339
  if "mcpServers" not in existing:
172
340
  existing["mcpServers"] = {}
173
341
 
174
- mcp_entry = {
175
- "command": "tribalmemory-mcp",
176
- "env": {},
177
- }
178
-
179
- if is_local:
180
- mcp_entry["env"]["TRIBAL_MEMORY_EMBEDDING_API_BASE"] = "http://localhost:11434/v1"
181
-
182
342
  existing["mcpServers"]["tribal-memory"] = mcp_entry
183
-
184
343
  config_path.write_text(json.dumps(existing, indent=2) + "\n")
185
- print(f"✅ Claude Code MCP config updated: {config_path}")
186
344
 
187
345
 
188
346
  def _setup_codex_mcp(is_local: bool) -> None:
@@ -190,6 +348,10 @@ def _setup_codex_mcp(is_local: bool) -> None:
190
348
  codex_config_path = Path.home() / ".codex" / "config.toml"
191
349
  codex_config_path.parent.mkdir(parents=True, exist_ok=True)
192
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
+
193
355
  # Build the TOML section manually (avoid tomli_w dependency)
194
356
  # Codex uses [mcp_servers.name] sections in config.toml
195
357
  section_marker = "[mcp_servers.tribal-memory]"
@@ -198,7 +360,7 @@ def _setup_codex_mcp(is_local: bool) -> None:
198
360
  "",
199
361
  "# Tribal Memory — shared memory for AI agents",
200
362
  section_marker,
201
- 'command = "tribalmemory-mcp"',
363
+ f'command = "{mcp_command}"',
202
364
  ]
203
365
 
204
366
  if is_local:
@@ -265,6 +427,8 @@ def main() -> None:
265
427
  help="Configure Claude Code MCP integration")
266
428
  init_parser.add_argument("--codex", action="store_true",
267
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)")
268
432
  init_parser.add_argument("--instance-id", type=str, default=None,
269
433
  help="Instance identifier (default: 'default')")
270
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,