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 +199 -35
- tribalmemory/interfaces.py +44 -0
- tribalmemory/mcp/server.py +160 -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/memory.py +193 -33
- tribalmemory/services/reranker.py +267 -0
- tribalmemory/services/session_store.py +412 -0
- tribalmemory/services/vector_store.py +86 -1
- {tribalmemory-0.1.0.dist-info → tribalmemory-0.2.0.dist-info}/METADATA +61 -8
- {tribalmemory-0.1.0.dist-info → tribalmemory-0.2.0.dist-info}/RECORD +18 -15
- {tribalmemory-0.1.0.dist-info → tribalmemory-0.2.0.dist-info}/WHEEL +0 -0
- {tribalmemory-0.1.0.dist-info → tribalmemory-0.2.0.dist-info}/entry_points.txt +0 -0
- {tribalmemory-0.1.0.dist-info → tribalmemory-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {tribalmemory-0.1.0.dist-info → tribalmemory-0.2.0.dist-info}/top_level.txt +0 -0
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
|
-
#
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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 = "
|
|
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",
|
tribalmemory/interfaces.py
CHANGED
|
@@ -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."""
|
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,
|