diff --git a/CHANGELOG.md b/CHANGELOG.md index 19952bc..7d3aaf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to RecallForge will be documented in this file. ## [Unreleased] - Replaced the tiny UAT video clips with compact episodic-memory fixtures, richer transcript sidecars, related artifact metadata, and regression coverage for the video corpus. +- Added `memory_add_conversation` so conversation threads ingest as canonical parent memories with turn-level child memories and standard memory rollups. ## [0.2.1] — 2026-05-17 diff --git a/README.md b/README.md index 4fc40f6..82821ff 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ One query. Any modality. All local. | Audio transcript ingest | ✅ | ❌ | ❌ | ❌ | ❌ | | Document ingest (PDF/DOCX/PPTX) | ✅ | ❌ | ❌ | ❌ | ❌ | | Built-in reranking | ✅ Multimodal | ❌ | ❌ | ✅ ColBERT | ✅ Modules | -| MCP-native | ✅ 21 tools | ❌ | ❌ | ❌ | ❌ | +| MCP-native | ✅ 24 tools | ❌ | ❌ | ❌ | ❌ | | 100% local | ✅ | ✅ | ⚠️ Cloud default | ✅ | ✅ Docker | | Apple Silicon optimized | ✅ MLX 4-bit | ❌ | ❌ | ❌ | ❌ | | Cloud option | ❌ | ✅ | ✅ | ✅ | ✅ | @@ -146,7 +146,7 @@ Run over HTTP/SSE: recallforge serve --http --host 127.0.0.1 --port 7433 --mode embed ``` -RecallForge now exposes **21 MCP tools** across search, ingest, memory, collection admin, and runtime config. HTTP/SSE mode also exposes `/health`, `/sse`, and `/messages/`. +RecallForge now exposes **24 MCP tools** across search, ingest, memory, collection admin, and runtime config. HTTP/SSE mode also exposes `/health`, `/sse`, and `/messages/`. See [docs/mcp-tools.md](docs/mcp-tools.md) for the full tool reference. @@ -161,7 +161,7 @@ See [docs/mcp-tools.md](docs/mcp-tools.md) for the full tool reference. ## How it works -RecallForge encodes text, images, video frames, documents, and audio transcripts into the same 2048-dimensional vector space using Qwen3-VL. This means "find notes about this diagram" works whether the diagram is text, an image, or a frame from a video. A 3-stage pipeline handles the rest: +RecallForge encodes text, images, video frames, documents, conversation turns, and audio transcripts into the same 2048-dimensional vector space using Qwen3-VL. This means "find notes about this diagram" works whether the diagram is text, an image, a conversation thread, or a frame from a video. A 3-stage pipeline handles the rest: ```mermaid graph TD @@ -170,6 +170,7 @@ graph TD Imgs[🖼️ Images] Vids[🎬 Video] Aud[🎙️ Audio + Transcript] + Conv[Conversation Turns] end subgraph RecallForge Ingest @@ -177,6 +178,7 @@ graph TD Imgs --> VLM[Qwen3-VL Encoder] Vids --> Frame[Frame & Audio Extractor] Aud --> TxtExt + Conv --> TxtExt Frame --> VLM TxtExt --> VLM end @@ -274,7 +276,7 @@ src/recallforge/ │ └── lancedb_backend.py # LanceDB + Tantivy FTS ├── cache.py # LRU embedding cache ├── search.py # Hybrid search pipeline (BM25 + vector + RRF) -├── server.py # MCP server (21 tools, stdio + HTTP/SSE) +├── server.py # MCP server (24 tools, stdio + HTTP/SSE) ├── documents.py # PDF/DOCX/PPTX extraction ├── video.py # Frame/transcript extraction ├── audio.py # Transcript-first audio ingest diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6507ff8..d7cfa50 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -114,6 +114,16 @@ id | collection | file_path | title | content_hash | content_type | active created_at | updated_at ``` +Conversation memories use the same parent/child layout as media-derived memories: + +``` +conversation root path + ├──> root text memory with summary, participants, and excerpts + └──> turn child memories (`path::turn:0001`, `path::turn:0002`, ...) +``` + +All turn children share the root `memory_id` and `memory_root_path`, so matching turns strengthen the parent conversation result through the standard memory rollup path. + **content** (bodies, content-addressed) ``` hash | doc | content_type | created_at @@ -183,7 +193,7 @@ backend = recallforge.get_backend() ### 5. MCP Server (`src/recallforge/server.py`) ``` -Tools: 21 MCP tools across search, ingest, memory, collection admin, batch, and runtime config +Tools: 24 MCP tools across search, ingest, memory, collection admin, batch, and runtime config Transport: stdio (default) or HTTP/SSE (`/health`, `/sse`, `/messages/`) Startup: backend.warm_up() for predictable latency Signals: SIGTERM/SIGINT graceful shutdown diff --git a/docs/MEMORY_POLICY.md b/docs/MEMORY_POLICY.md index cf6513a..58b690a 100644 --- a/docs/MEMORY_POLICY.md +++ b/docs/MEMORY_POLICY.md @@ -23,6 +23,7 @@ RecallForge stores complex files as a root memory plus child assets: - Video memory: one root video memory, with frame children and transcript children when available. - Document memory: one root document memory, with section/page children and OCR siblings when available. - Audio memory: one root audio memory, with timestamped transcript children from sidecar transcripts. +- Conversation memory: one root text memory with a summary/participant overview, plus one child memory per turn. The root memory uses `memory_role="root"` and child assets use `memory_role="child"` with `memory_root_path` pointing back to the root. @@ -42,3 +43,7 @@ The boost is intentionally modest: it can break ties and surface important recen Audio support is transcript-first. Put a `.srt`, `.vtt`, `.txt`, or `.transcript.json` sidecar next to the audio file. RecallForge indexes the audio as a root `content_type="audio"` memory and indexes transcript segments as text child memories. Raw audio transcription and dedicated audio encoders are not part of the shipped v0.3.0 policy. + +## Conversations + +Use `memory_add_conversation` when an agent or app wants to persist a thread. RecallForge stores the parent at the supplied `path` and stores each turn at `path::turn:0001`, `path::turn:0002`, and so on. All turns share the parent `memory_id`, so if several turns match a query, search and explanation output roll them up into the parent conversation with evidence paths. diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 4d7d83d..64de4a6 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -51,8 +51,11 @@ Example MCP client config (Claude Desktop): ### Memory - `memory_add` +- `memory_add_conversation` - `memory_update` - `memory_delete` +- `memory_get` +- `list_memories` ### Admin / Introspection - `status` @@ -117,7 +120,13 @@ Example MCP client config (Claude Desktop): "user_id": null, "session_id": null, "project_id": null, - "profile": null + "profile": null, + "memory_id": "b4f7...", + "memory_role": "root", + "memory_root_path": "notes/meeting.md", + "memory_hit_count": 2, + "memory_primary_evidence_path": "recallforge://default/notes/meeting.md::turn:0002", + "memory_supporting_paths": [] } ] } @@ -632,6 +641,77 @@ Example MCP client config (Claude Desktop): --- +## memory_add_conversation + +**Description:** Add or replace a conversation as a canonical parent memory with turn-level child memories. Matching turns roll up into the parent conversation in `search` and `explain_results`. + +**Parameters:** +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| path | string | Yes | — | Conversation root path | +| turns | array[object] | Yes | — | Chronological turns or message groups | +| title | string | No | derived from `path` | Conversation title | +| summary | string | No | — | Optional parent summary | +| collection | string | No | server default collection | Collection name | +| user_id | string | No | — | User namespace | +| session_id | string | No | — | Session/thread namespace | +| project_id | string | No | — | Project namespace | +| profile | string | No | — | Profile namespace | +| importance | number | No | — | 0.0 to 1.0 importance score | +| ttl_seconds | integer | No | — | TTL in seconds; `0`/`null` means no expiration | +| tags | array[string] | No | — | Extra tags | + +Turn objects accept: + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| role | string | No | Role such as `user`, `assistant`, `agent`, `tool`, or `system` | +| speaker | string | No | Persona/person label | +| content | string | Conditionally* | Turn content | +| text | string | Conditionally* | Alias for `content` | +| timestamp | string | No | Timestamp string, ideally ISO 8601 | + +\* Each turn must include non-empty `content` or `text`. + +**Example Request:** +```json +{ + "path": "threads/customer-renewal", + "title": "Customer Renewal Thread", + "summary": "Pricing approval and legal review are the open blockers.", + "turns": [ + { "role": "user", "content": "Can we renew the customer contract before Q3?" }, + { "role": "assistant", "content": "Yes. The renewal plan depends on pricing approval." }, + { "role": "user", "content": "Please remember the pricing risk and legal review." } + ], + "tags": ["sales"] +} +``` + +**Example Response:** +```json +{ + "success": true, + "path": "threads/customer-renewal", + "collection": "default", + "hash": "abc123...", + "memory_id": "b4f7...", + "title": "Customer Renewal Thread", + "indexed_turns": 3, + "tags": ["conversation", "turns:3", "role:user", "role:assistant", "sales"], + "operation": "add_conversation" +} +``` + +**Errors:** +- `INVALID_INPUT`: when `path` is missing, `turns` is empty, or a turn lacks text. +- `BACKEND_ERROR`: when the storage backend does not support conversation memories. +- `INTERNAL_ERROR`: uncaught exceptions. + +**Notes:** The root path becomes the stable `memory_id` identity seed. Child turns are stored at `path::turn:0001`, `path::turn:0002`, and so on with the same `memory_id` and `memory_root_path`. + +--- + ## memory_update **Description:** Update an existing memory entry (same upsert backend path as add). @@ -723,6 +803,100 @@ Example MCP client config (Claude Desktop): --- +## memory_get + +**Description:** Fetch one canonical memory object by stable `memory_id` or by root `path`. + +**Parameters:** +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| memory_id | string | Conditionally* | — | Stable memory identifier | +| path | string | Conditionally* | — | Root memory path | +| collection | string | No | server default collection | Collection name | +| user_id | string | No | — | User namespace | +| session_id | string | No | — | Session namespace | +| project_id | string | No | — | Project namespace | +| profile | string | No | — | Profile namespace | + +\* Provide either `memory_id` or `path`. + +**Example Request:** +```json +{ + "path": "threads/customer-renewal", + "collection": "default" +} +``` + +**Example Response:** +```json +{ + "memory_id": "b4f7...", + "collection": "default", + "title": "Customer Renewal Thread", + "path": "threads/customer-renewal", + "content_type": "text", + "summary": "Discussion about renewal timing...", + "children": [ + { "path": "threads/customer-renewal::turn:0001", "content_type": "text", "memory_role": "child" } + ], + "snippets": [ + { "path": "threads/customer-renewal::turn:0001", "text": "..." } + ] +} +``` + +**Errors:** +- `INVALID_INPUT`: when neither `memory_id` nor `path` is provided. +- `NOT_FOUND`: when the memory cannot be found. +- `INTERNAL_ERROR`: uncaught exceptions. + +--- + +## list_memories + +**Description:** List canonical root memories for a collection or namespace. + +**Parameters:** +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| collection | string | No | all collections | Collection filter | +| limit | integer | No | 50 | Max memories | +| user_id | string | No | — | User namespace | +| session_id | string | No | — | Session namespace | +| project_id | string | No | — | Project namespace | +| profile | string | No | — | Profile namespace | + +**Example Request:** +```json +{ + "collection": "default", + "limit": 20 +} +``` + +**Example Response:** +```json +{ + "success": true, + "collection": "default", + "count": 1, + "memories": [ + { + "memory_id": "b4f7...", + "path": "threads/customer-renewal", + "title": "Customer Renewal Thread", + "summary": "Discussion about renewal timing..." + } + ] +} +``` + +**Errors:** +- `INTERNAL_ERROR`: uncaught exceptions. + +--- + ## status **Description:** Return server/model/database status. @@ -1006,7 +1180,12 @@ Operation object schema: 2. Update with `memory_update` as facts evolve. 3. Retrieve later with `search` + matching namespace filters. -### 3) Configure mode, then ingest +### 3) Persist a conversation thread +1. Call `memory_add_conversation` with a stable thread `path`, optional `summary`, and chronological `turns`. +2. Query with `search` or `explain_results`; matching turns roll up to the parent `memory_id`. +3. Inspect the full memory with `memory_get` when the agent needs turn evidence. + +### 4) Configure mode, then ingest 1. Inspect config using `get_config`. 2. Set desired runtime defaults with `set_config` (for mode, default collection, max file size). 3. Run `ingest` without repeating shared defaults. @@ -1019,7 +1198,7 @@ Structured errors returned via `_error_response(code, message, details)`: - `NOT_FOUND` - Resource is missing (for example, image path does not exist) or capability is unavailable (e.g., unsupported raw video query backend). - `BACKEND_ERROR` - - Backend/storage operation failed in handler-managed exception path (currently used in `rebuild_fts`). + - Backend/storage capability is unavailable or failed in a handler-managed path. - `INTERNAL_ERROR` - Unhandled exception at top-level tool dispatch. diff --git a/src/recallforge/conversations.py b/src/recallforge/conversations.py new file mode 100644 index 0000000..11fdad0 --- /dev/null +++ b/src/recallforge/conversations.py @@ -0,0 +1,157 @@ +"""Helpers for turning conversations into canonical RecallForge memories.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any, Iterable, Optional + + +_DEFAULT_ROLE = "speaker" +_MAX_SUMMARY_CHARS = 4000 +_MAX_TURN_CHARS = 6000 + + +@dataclass(frozen=True) +class ConversationTurn: + """Normalized representation of one conversation turn or message group.""" + + role: str + content: str + speaker: Optional[str] = None + timestamp: Optional[str] = None + + +def _compact_text(value: Any, *, max_chars: Optional[int] = None) -> str: + text = re.sub(r"\s+", " ", str(value or "").strip()) + if max_chars is not None and len(text) > max_chars: + truncated = text[: max_chars - 3].rsplit(" ", 1)[0].strip() + return (truncated or text[: max_chars - 3]).rstrip() + "..." + return text + + +def normalize_conversation_turns(raw_turns: Any) -> list[ConversationTurn]: + """Validate and normalize MCP conversation turn payloads.""" + if not isinstance(raw_turns, list) or not raw_turns: + raise ValueError("turns must be a non-empty array") + + turns: list[ConversationTurn] = [] + for index, raw_turn in enumerate(raw_turns): + if not isinstance(raw_turn, dict): + raise ValueError(f"turns[{index}] must be an object") + + raw_content = raw_turn.get("content", raw_turn.get("text")) + content = _compact_text(raw_content, max_chars=_MAX_TURN_CHARS) + if not content: + raise ValueError(f"turns[{index}].content must be a non-empty string") + + speaker = _compact_text(raw_turn.get("speaker")) or None + role = _compact_text(raw_turn.get("role") or speaker or _DEFAULT_ROLE).lower() + if len(role) > 64: + role = role[:64].strip() + + timestamp = _compact_text(raw_turn.get("timestamp")) or None + turns.append( + ConversationTurn( + role=role or _DEFAULT_ROLE, + speaker=speaker, + timestamp=timestamp, + content=content, + ) + ) + + return turns + + +def normalize_conversation_tags( + turns: Iterable[ConversationTurn], + extra_tags: Optional[list[str]] = None, +) -> list[str]: + """Build compact retrieval tags for a conversation memory.""" + tags: list[str] = [] + seen: set[str] = set() + + def add(raw: str) -> None: + tag = _compact_text(raw).lower() + if not tag or tag in seen: + return + seen.add(tag) + tags.append(tag) + + add("conversation") + turns_list = list(turns) + add(f"turns:{len(turns_list)}") + for turn in turns_list: + add(f"role:{turn.role}") + if turn.speaker: + add(f"participant:{turn.speaker.lower()}") + + for tag in extra_tags or []: + add(str(tag)) + + return tags[:16] + + +def conversation_turn_path(root_path: str, index: int) -> str: + """Return the logical child path for a one-based turn index.""" + normalized_root = _compact_text(root_path) + if not normalized_root: + raise ValueError("path is required") + return f"{normalized_root}::turn:{index:04d}" + + +def build_conversation_summary( + *, + title: str, + turns: list[ConversationTurn], + summary: Optional[str] = None, +) -> str: + """Build deterministic parent text for a conversation root memory.""" + title_text = _compact_text(title) or "Conversation" + lines = [f"# {title_text}", "", f"Conversation with {len(turns)} turns."] + + participants = sorted( + { + (turn.speaker or turn.role).strip() + for turn in turns + if (turn.speaker or turn.role).strip() + }, + key=str.lower, + ) + if participants: + lines.append(f"Participants: {', '.join(participants[:12])}.") + + summary_text = _compact_text(summary, max_chars=1200) if summary else "" + if summary_text: + lines.extend(["", "Summary:", summary_text]) + + excerpt_turns = turns[: min(len(turns), 8)] + if excerpt_turns: + lines.extend(["", "Turn excerpts:"]) + for index, turn in enumerate(excerpt_turns, start=1): + label = turn.speaker or turn.role + timestamp = f" [{turn.timestamp}]" if turn.timestamp else "" + excerpt = _compact_text(turn.content, max_chars=360) + lines.append(f"- Turn {index} {label}{timestamp}: {excerpt}") + + text = "\n".join(lines).strip() + return text[:_MAX_SUMMARY_CHARS].strip() + + +def build_conversation_turn_text( + *, + title: str, + turn: ConversationTurn, + index: int, + total: int, +) -> str: + """Build searchable text for one child turn memory.""" + title_text = _compact_text(title) or "Conversation" + label = turn.speaker or turn.role + timestamp = f"\nTimestamp: {turn.timestamp}" if turn.timestamp else "" + return ( + f"# {title_text} - turn {index} of {total}\n" + f"Role: {turn.role}\n" + f"Speaker: {label}{timestamp}\n\n" + f"{turn.content}" + ).strip() diff --git a/src/recallforge/server.py b/src/recallforge/server.py index 7280845..649b1e0 100644 --- a/src/recallforge/server.py +++ b/src/recallforge/server.py @@ -4,7 +4,7 @@ MCP protocol server with stdio or HTTP/SSE transport. Tools: search, search_fts, search_vec, explain_results, search_batch, ingest, index_document, index_image, index_audio, memory_add, memory_update, memory_delete, -memory_get, list_memories, status, rebuild_fts, list_collections, +memory_add_conversation, memory_get, list_memories, status, rebuild_fts, list_collections, list_namespaces, rename_collection, delete_collection, batch, get_config, set_config. Resources expose canonical memories via memory:// URIs. @@ -29,6 +29,7 @@ from . import __version__, get_backend, get_storage, warmup_backend from .audio import is_audio_file, load_audio_transcript_segments +from .conversations import normalize_conversation_turns from .documents import extract_document_artifacts, is_document_file from .search import HybridSearcher from .video import is_video_file @@ -469,6 +470,42 @@ async def list_tools() -> list[Tool]: "required": ["path", "text"], }, ), + Tool( + name="memory_add_conversation", + description="Add or replace a conversation memory with a canonical parent and turn-level child memories", + inputSchema={ + "type": "object", + "properties": { + "path": {"type": "string", "description": "Conversation memory root path within collection"}, + "turns": { + "type": "array", + "description": "Conversation turns or message groups in chronological order", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "role": {"type": "string", "description": "Speaker role such as user, assistant, agent, tool, or system"}, + "speaker": {"type": "string", "description": "Optional speaker/persona label"}, + "content": {"type": "string", "description": "Turn text content"}, + "text": {"type": "string", "description": "Alias for content"}, + "timestamp": {"type": "string", "description": "Optional timestamp string, ideally ISO 8601"}, + }, + }, + }, + "title": {"type": "string", "description": "Optional conversation title"}, + "summary": {"type": "string", "description": "Optional caller-provided summary for the parent memory"}, + "collection": {"type": "string", "description": "Collection name", "default": "default"}, + "user_id": {"type": "string", "description": "Optional user namespace for multi-tenant isolation"}, + "session_id": {"type": "string", "description": "Optional session/thread namespace"}, + "project_id": {"type": "string", "description": "Optional project namespace"}, + "profile": {"type": "string", "description": "Optional profile namespace"}, + "importance": {"type": "number", "description": "Importance score 0.0-1.0 (optional)", "minimum": 0, "maximum": 1}, + "ttl_seconds": {"type": "integer", "description": "Time-to-live in seconds, 0 or null = no expiration (optional)"}, + "tags": {"type": "array", "items": {"type": "string"}, "description": "Extra string tags (optional)"}, + }, + "required": ["path", "turns"], + }, + ), Tool( name="memory_update", description="Update a text memory entry at path, replacing old vectors", @@ -788,6 +825,8 @@ async def _dispatch_tool( return await _handle_index_audio(arguments, backend, storage) elif name == "memory_add": return await _handle_memory_add(arguments, backend, storage) + elif name == "memory_add_conversation": + return await _handle_memory_add_conversation(arguments, backend, storage) elif name == "memory_update": return await _handle_memory_update(arguments, backend, storage) elif name == "memory_delete": @@ -966,6 +1005,8 @@ async def _handle_search(arguments: dict, backend, storage) -> list[TextContent] "memory_role": getattr(r, "memory_role", "root"), "memory_root_path": getattr(r, "memory_root_path", None), "memory_hit_count": getattr(r, "memory_hit_count", 1), + "memory_primary_evidence_path": getattr(r, "memory_primary_evidence_path", None), + "memory_supporting_paths": getattr(r, "memory_supporting_paths", None), "tags": getattr(r, "tags", None), } for r in results @@ -1290,6 +1331,12 @@ async def _handle_search_batch(arguments: dict, backend, storage) -> list[TextCo "session_id": getattr(r, "session_id", None), "project_id": getattr(r, "project_id", None), "profile": getattr(r, "profile", None), + "memory_id": getattr(r, "memory_id", None), + "memory_role": getattr(r, "memory_role", "root"), + "memory_root_path": getattr(r, "memory_root_path", None), + "memory_hit_count": getattr(r, "memory_hit_count", 1), + "memory_primary_evidence_path": getattr(r, "memory_primary_evidence_path", None), + "memory_supporting_paths": getattr(r, "memory_supporting_paths", None), "tags": getattr(r, "tags", None), } for r in results @@ -1490,6 +1537,72 @@ async def _handle_memory_add(arguments: dict, backend, storage) -> list[TextCont return [TextContent(type="text", text=json.dumps(output, indent=2))] +async def _handle_memory_add_conversation(arguments: dict, backend, storage) -> list[TextContent]: + """Handle conversation memory ingest.""" + path = arguments.get("path", "") + turns = arguments.get("turns") + collection = arguments.get("collection", "default") + title = arguments.get("title") + summary = arguments.get("summary") + user_id = arguments.get("user_id") + session_id = arguments.get("session_id") + project_id = arguments.get("project_id") + profile = arguments.get("profile") + importance = arguments.get("importance") + ttl_seconds = arguments.get("ttl_seconds") + tags = arguments.get("tags") + + trace_log("memory_add_conversation_start", path=path, collection=collection, + user_id=user_id, session_id=session_id, project_id=project_id, profile=profile, + importance=importance, ttl_seconds=ttl_seconds, tags=tags) + + if not isinstance(path, str) or not path.strip(): + return _error_response("INVALID_INPUT", "path is required") + if tags is not None and not isinstance(tags, list): + return _error_response("INVALID_INPUT", "tags must be an array of strings") + + try: + normalize_conversation_turns(turns) + except ValueError as exc: + return _error_response("INVALID_INPUT", str(exc)) + + index_conversation = getattr(storage, "index_conversation", None) + if not callable(index_conversation): + return _error_response("BACKEND_ERROR", "Storage backend does not support conversation memories") + + try: + output = await _run_blocking( + index_conversation, + path=path, + turns=turns, + collection=collection, + model="Qwen3-VL-Embedding-2B", + embed_func=backend.embed_text, + title=title, + summary=summary, + user_id=user_id, + session_id=session_id, + project_id=project_id, + profile=profile, + importance=importance, + ttl_seconds=ttl_seconds, + tags=tags, + ) + except ValueError as exc: + return _error_response("INVALID_INPUT", str(exc)) + + trace_log( + "memory_add_conversation_done", + path=path, + hash=str(output.get("hash", ""))[:8], + indexed_turns=output.get("indexed_turns", 0), + ) + + output = dict(output) + output["operation"] = "add_conversation" + return [TextContent(type="text", text=json.dumps(output, indent=2))] + + async def _handle_memory_update(arguments: dict, backend, storage) -> list[TextContent]: """Handle memory update.""" path = arguments.get("path", "") diff --git a/src/recallforge/storage/base.py b/src/recallforge/storage/base.py index 564bdf9..d1cc29c 100644 --- a/src/recallforge/storage/base.py +++ b/src/recallforge/storage/base.py @@ -172,6 +172,9 @@ def insert_embedding( memory_id: Optional[str] = None, memory_role: str = "root", memory_root_path: Optional[str] = None, + importance: Optional[float] = None, + ttl_seconds: Optional[int] = None, + tags: Optional[List[str]] = None, ) -> None: """Insert an embedding for a document chunk.""" pass @@ -264,10 +267,37 @@ def upsert_memory( session_id: Optional[str] = None, project_id: Optional[str] = None, profile: Optional[str] = None, + importance: Optional[float] = None, + ttl_seconds: Optional[int] = None, + tags: Optional[List[str]] = None, + _skip_delete: bool = False, + memory_role: str = "root", + memory_root_path: Optional[str] = None, ) -> str: """Create or update a memory entry and its embeddings.""" pass + @abstractmethod + def index_conversation( + self, + path: str, + turns: List[Dict[str, Any]], + collection: str, + embed_func: Callable[[str], List[float]], + model: str, + title: Optional[str] = None, + summary: Optional[str] = None, + user_id: Optional[str] = None, + session_id: Optional[str] = None, + project_id: Optional[str] = None, + profile: Optional[str] = None, + importance: Optional[float] = None, + ttl_seconds: Optional[int] = None, + tags: Optional[List[str]] = None, + ) -> Dict[str, Any]: + """Index a conversation root plus turn-level child memories.""" + pass + @abstractmethod def delete_memory( self, diff --git a/src/recallforge/storage/indexing_ops.py b/src/recallforge/storage/indexing_ops.py index 05ee70a..8cd244c 100644 --- a/src/recallforge/storage/indexing_ops.py +++ b/src/recallforge/storage/indexing_ops.py @@ -13,6 +13,13 @@ from typing import Any, Dict, List, Optional, TYPE_CHECKING from ..audio import is_audio_file, load_audio_transcript_segments +from ..conversations import ( + build_conversation_summary, + build_conversation_turn_text, + conversation_turn_path, + normalize_conversation_tags, + normalize_conversation_turns, +) from ..documents import extract_document_artifacts, is_document_file from ..video import extract_video_artifacts, is_video_file from .chunking import chunk_document @@ -21,6 +28,7 @@ _safe_filter, _validate_identifier, hash_file_bytes, + build_memory_id, extract_title, hash_content, trace_log, @@ -398,6 +406,156 @@ def upsert_memory( trace_log("upsert_memory_done", path=normalized_path, hash=content_hash[:8], chunks=len(chunks)) return content_hash + def index_conversation( + self, + path: str, + turns: List[Dict[str, Any]], + collection: str, + embed_func, + model: str, + title: Optional[str] = None, + summary: Optional[str] = None, + user_id: Optional[str] = None, + session_id: Optional[str] = None, + project_id: Optional[str] = None, + profile: Optional[str] = None, + importance: Optional[float] = None, + ttl_seconds: Optional[int] = None, + tags: Optional[List[str]] = None, + ) -> Dict[str, Any]: + """Index a conversation as one root memory plus turn-level children.""" + normalized_path = path.strip() if isinstance(path, str) else "" + if not normalized_path: + raise ValueError("path is required") + + normalized_turns = normalize_conversation_turns(turns) + resolved_title = ( + (title or "").strip() + or os.path.splitext(os.path.basename(normalized_path))[0] + or normalized_path + ) + root_text = build_conversation_summary( + title=resolved_title, + turns=normalized_turns, + summary=summary, + ) + root_tags = normalize_conversation_tags(normalized_turns, tags) + memory_id = build_memory_id( + collection, + normalized_path, + user_id=user_id, + session_id=session_id, + project_id=project_id, + profile=profile, + ) + + trace_log( + "index_conversation_start", + path=normalized_path, + collection=collection, + turns=len(normalized_turns), + user_id=user_id, + session_id=session_id, + project_id=project_id, + profile=profile, + ) + + self._delete_path_entries( + collection=collection, + logical_path=normalized_path, + user_id=user_id, + session_id=session_id, + project_id=project_id, + profile=profile, + include_children=True, + ) + + root_hash = "" + child_hashes: List[str] = [] + with self._backend.bulk_mode(): + root_hash = self.upsert_memory( + path=normalized_path, + text=root_text, + collection=collection, + embed_func=embed_func, + model=model, + user_id=user_id, + session_id=session_id, + project_id=project_id, + profile=profile, + importance=importance, + ttl_seconds=ttl_seconds, + tags=root_tags, + _skip_delete=True, + memory_role="root", + memory_root_path=normalized_path, + ) + + total_turns = len(normalized_turns) + for index, turn in enumerate(normalized_turns, start=1): + turn_tags: List[str] = [] + for raw_tag in root_tags + [ + "conversation_turn", + f"turn:{index:04d}", + f"role:{turn.role}", + ]: + tag = str(raw_tag or "").strip().lower() + if tag and tag not in turn_tags: + turn_tags.append(tag) + if turn.speaker: + speaker_tag = f"participant:{turn.speaker.lower()}" + if speaker_tag not in turn_tags: + turn_tags.append(speaker_tag) + turn_text = build_conversation_turn_text( + title=resolved_title, + turn=turn, + index=index, + total=total_turns, + ) + child_hashes.append( + self.upsert_memory( + path=conversation_turn_path(normalized_path, index), + text=turn_text, + collection=collection, + embed_func=embed_func, + model=model, + user_id=user_id, + session_id=session_id, + project_id=project_id, + profile=profile, + importance=importance, + ttl_seconds=ttl_seconds, + tags=turn_tags, + _skip_delete=True, + memory_role="child", + memory_root_path=normalized_path, + ) + ) + + trace_log( + "index_conversation_done", + path=normalized_path, + hash=root_hash[:8], + indexed_turns=len(child_hashes), + ) + + return { + "success": True, + "path": normalized_path, + "collection": collection, + "hash": root_hash, + "memory_id": memory_id, + "title": resolved_title, + "indexed_turns": len(child_hashes), + "user_id": user_id, + "session_id": session_id, + "project_id": project_id, + "profile": profile, + "importance": importance, + "ttl_seconds": ttl_seconds, + "tags": root_tags, + } + def delete_memory( self, path: str, diff --git a/src/recallforge/storage/lancedb_backend.py b/src/recallforge/storage/lancedb_backend.py index 9a945cf..2db6f82 100644 --- a/src/recallforge/storage/lancedb_backend.py +++ b/src/recallforge/storage/lancedb_backend.py @@ -1608,6 +1608,41 @@ def delete_memory( project_id=project_id, profile=profile, ) + + def index_conversation( + self, + path: str, + turns: List[Dict[str, Any]], + collection: str, + embed_func, + model: str, + title: Optional[str] = None, + summary: Optional[str] = None, + user_id: Optional[str] = None, + session_id: Optional[str] = None, + project_id: Optional[str] = None, + profile: Optional[str] = None, + importance: Optional[float] = None, + ttl_seconds: Optional[int] = None, + tags: Optional[List[str]] = None, + ) -> Dict[str, Any]: + """Index a conversation root plus turn-level child memories.""" + return self._indexer.index_conversation( + path=path, + turns=turns, + collection=collection, + embed_func=embed_func, + model=model, + title=title, + summary=summary, + user_id=user_id, + session_id=session_id, + project_id=project_id, + profile=profile, + importance=importance, + ttl_seconds=ttl_seconds, + tags=tags, + ) def delete_path( self, diff --git a/tests/test_batch_tool.py b/tests/test_batch_tool.py index 8601511..ee4124f 100644 --- a/tests/test_batch_tool.py +++ b/tests/test_batch_tool.py @@ -278,7 +278,7 @@ async def test_all_existing_tools_still_present(self): expected = { "search", "search_fts", "search_vec", "ingest", "index_document", "index_image", - "memory_add", "memory_update", "memory_delete", + "memory_add", "memory_add_conversation", "memory_update", "memory_delete", "status", "rebuild_fts", "batch", } self.assertTrue(expected.issubset(set(names)), f"Missing tools: {expected - set(names)}") diff --git a/tests/test_config_tools.py b/tests/test_config_tools.py index 1e9c332..ccf7016 100644 --- a/tests/test_config_tools.py +++ b/tests/test_config_tools.py @@ -26,6 +26,7 @@ _handle_list_memories, _handle_memory_get, _handle_search, + _handle_memory_add_conversation, _resolve_file_query_input, _handle_set_config, create_server, @@ -96,6 +97,16 @@ def _make_storage(store_path="/tmp/test-store"): "children": [], "snippets": [], } + s.index_conversation.return_value = { + "success": True, + "path": "threads/demo", + "collection": "default", + "hash": "hash-conversation", + "memory_id": "mem-conversation", + "title": "Demo Conversation", + "indexed_turns": 2, + "tags": ["conversation"], + } return s @@ -412,6 +423,26 @@ async def test_get_config_no_mutable_config_uses_defaults(self): self.assertIn("version", data) self.assertEqual(data["collection"], "default") + async def test_memory_add_conversation_dispatched(self): + cfg = _mutable_config() + result = await _dispatch_tool( + "memory_add_conversation", + { + "path": "threads/demo", + "turns": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi"}, + ], + }, + self.backend, + self.storage, + cfg, + ) + data = json.loads(result[0].text) + self.assertTrue(data["success"]) + self.assertEqual(data["operation"], "add_conversation") + self.storage.index_conversation.assert_called_once() + # --------------------------------------------------------------------------- # create_server: new tools appear in list_tools @@ -441,6 +472,7 @@ async def test_explain_results_registered(self): async def test_memory_tools_registered(self): names = await self._get_tool_names() + self.assertIn("memory_add_conversation", names) self.assertIn("memory_get", names) self.assertIn("list_memories", names) @@ -449,7 +481,7 @@ async def test_all_original_tools_still_present(self): expected = { "search", "search_fts", "search_vec", "ingest", "index_document", "index_image", "index_audio", - "memory_add", "memory_update", "memory_delete", + "memory_add", "memory_add_conversation", "memory_update", "memory_delete", "status", "rebuild_fts", "batch", "list_collections", "list_namespaces", "rename_collection", "delete_collection", @@ -461,6 +493,37 @@ async def test_all_original_tools_still_present(self): class TestMemoryTools(unittest.IsolatedAsyncioTestCase): + async def test_memory_add_conversation_returns_json(self): + backend = _make_backend() + storage = _make_storage() + result = await _handle_memory_add_conversation( + { + "path": "threads/demo", + "title": "Demo Conversation", + "turns": [ + {"role": "user", "content": "remember the pricing risk"}, + {"role": "assistant", "content": "pricing risk recorded"}, + ], + "tags": ["sales"], + }, + backend, + storage, + ) + data = json.loads(result[0].text) + self.assertTrue(data["success"]) + self.assertEqual(data["operation"], "add_conversation") + storage.index_conversation.assert_called_once() + + async def test_memory_add_conversation_invalid_turns_returns_error(self): + result = await _handle_memory_add_conversation( + {"path": "threads/demo", "turns": []}, + _make_backend(), + _make_storage(), + ) + data = json.loads(result[0].text) + self.assertTrue(data["error"]) + self.assertEqual(data["code"], "INVALID_INPUT") + async def test_list_memories_returns_json(self): result = await _handle_list_memories({}, _make_storage()) data = json.loads(result[0].text) @@ -572,6 +635,8 @@ async def test_search_file_path_routes_through_text_query(self): result_item.memory_role = "root" result_item.memory_root_path = None result_item.memory_hit_count = 1 + result_item.memory_primary_evidence_path = None + result_item.memory_supporting_paths = [] result_item.tags = ["memory query", "markdown"] with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False) as tmp: @@ -697,6 +762,21 @@ async def test_explain_results_schema(self): self.assertIn("file_path", schema["properties"]) self.assertIn("rerank_top_k", schema["properties"]) + async def test_memory_add_conversation_schema(self): + backend = _make_backend() + storage = _make_storage() + server = await create_server(backend=backend, storage=storage) + handler = server.request_handlers[ListToolsRequest] + result = await handler(ListToolsRequest(method="tools/list", params=None)) + tool = next(t for t in result.root.tools if t.name == "memory_add_conversation") + schema = tool.inputSchema + self.assertEqual(schema["type"], "object") + self.assertEqual(schema["required"], ["path", "turns"]) + self.assertIn("turns", schema["properties"]) + self.assertIn("summary", schema["properties"]) + self.assertIn("role", schema["properties"]["turns"]["items"]["properties"]) + self.assertIn("content", schema["properties"]["turns"]["items"]["properties"]) + async def test_search_schema_accepts_file_path(self): backend = _make_backend() storage = _make_storage() diff --git a/tests/test_error_responses.py b/tests/test_error_responses.py index 005d8d3..cff7146 100644 --- a/tests/test_error_responses.py +++ b/tests/test_error_responses.py @@ -293,6 +293,14 @@ async def test_memory_add_missing_fields(self, mocks): assert result["error"] is True assert result["code"] == "INVALID_INPUT" + @pytest.mark.asyncio + async def test_memory_add_conversation_missing_turns(self, mocks): + from recallforge.server import _handle_memory_add_conversation + backend, storage = mocks + result = _parse(await _handle_memory_add_conversation({"path": "threads/demo"}, backend, storage)) + assert result["error"] is True + assert result["code"] == "INVALID_INPUT" + @pytest.mark.asyncio async def test_memory_update_missing_fields(self, mocks): from recallforge.server import _handle_memory_update diff --git a/tests/test_json_compliance.py b/tests/test_json_compliance.py index 33d9f25..1f69dfb 100644 --- a/tests/test_json_compliance.py +++ b/tests/test_json_compliance.py @@ -20,6 +20,7 @@ _handle_list_memories, _handle_list_namespaces, _handle_memory_add, + _handle_memory_add_conversation, _handle_memory_delete, _handle_memory_get, _handle_memory_update, @@ -88,6 +89,17 @@ def index_audio(self, **_kwargs): def upsert_memory(self, **_kwargs): return "hash-mem" + def index_conversation(self, **_kwargs): + return { + "success": True, + "path": "threads/demo", + "collection": "default", + "hash": "hash-conversation", + "memory_id": "mem-conversation", + "indexed_turns": 2, + "tags": ["conversation"], + } + def delete_memory(self, **_kwargs): return {"success": True, "removed_vectors": 1} @@ -203,6 +215,24 @@ async def test_all_tool_handlers_valid_and_invalid_calls_return_json(self): lambda: _handle_memory_add({"path": "mem/1", "text": "x"}, self.backend, self.storage), lambda: _handle_memory_add({"path": "", "text": ""}, self.backend, self.storage), ), + ( + lambda: _handle_memory_add_conversation( + { + "path": "threads/demo", + "turns": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi"}, + ], + }, + self.backend, + self.storage, + ), + lambda: _handle_memory_add_conversation( + {"path": "threads/demo", "turns": []}, + self.backend, + self.storage, + ), + ), ( lambda: _handle_memory_update({"path": "mem/1", "text": "x"}, self.backend, self.storage), lambda: _handle_memory_update({"path": "", "text": ""}, self.backend, self.storage), diff --git a/tests/test_storage.py b/tests/test_storage.py index 67ba31f..93ec706 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -26,6 +26,7 @@ from recallforge.storage.base import StorageBackend, SearchResult, Document from recallforge.storage.lancedb_backend import LanceDBBackend from recallforge.storage.lancedb_shared import build_memory_id +from recallforge.search import HybridSearcher # --------------------------------------------------------------------------- @@ -48,6 +49,19 @@ def mock_embed_array(text: str) -> np.ndarray: return np.array(mock_embed(text), dtype=np.float32) +class EmbedOnlyBackend: + """Minimal backend for storage-backed hybrid search tests.""" + + def get_mode(self): + return "embed" + + def needs_reranker(self): + return False + + def embed_text(self, text: str): + return mock_embed(text) + + # --------------------------------------------------------------------------- # Tests for StorageBackend ABC # --------------------------------------------------------------------------- @@ -802,6 +816,107 @@ def test_memory_lookup_surfaces_derived_summary(self): self.assertEqual(memory["path"], path) +class TestConversationMemoryIndexing(unittest.TestCase): + """Tests for first-class conversation memories with turn rollups.""" + + def setUp(self): + self.temp_dir = tempfile.mkdtemp(prefix="recallforge-test-conversation-") + self.backend = LanceDBBackend(self.temp_dir) + self.backend.initialize(self.temp_dir) + + def tearDown(self): + self.backend.close() + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def test_index_conversation_creates_root_and_turn_children(self): + turns = [ + {"role": "user", "content": "Can we renew the customer contract before Q3?"}, + {"role": "assistant", "content": "Yes. The renewal plan depends on pricing approval."}, + {"role": "user", "content": "Please remember the pricing risk and legal review."}, + ] + + result = self.backend.index_conversation( + path="threads/customer-renewal", + title="Customer Renewal Thread", + summary="Discussion about renewal timing, pricing risk, and legal review.", + turns=turns, + collection="test", + embed_func=mock_embed, + model="mock-embedder", + user_id="alice", + session_id="thread-123", + tags=["sales"], + ) + + self.assertTrue(result["success"]) + self.assertEqual(result["indexed_turns"], 3) + self.assertEqual(result["path"], "threads/customer-renewal") + self.assertEqual( + result["memory_id"], + build_memory_id( + "test", + "threads/customer-renewal", + user_id="alice", + session_id="thread-123", + ), + ) + self.assertIn("conversation", result["tags"]) + self.assertIn("sales", result["tags"]) + + rows = self.backend._documents_table.search().where( + "collection = 'test' AND active = 1" + ).to_list() + self.assertEqual(len(rows), 4) + roles = {row["file_path"]: row["memory_role"] for row in rows} + self.assertEqual(roles["threads/customer-renewal"], "root") + self.assertEqual(roles["threads/customer-renewal::turn:0001"], "child") + self.assertEqual(roles["threads/customer-renewal::turn:0002"], "child") + self.assertEqual(roles["threads/customer-renewal::turn:0003"], "child") + self.assertEqual({row["memory_id"] for row in rows}, {result["memory_id"]}) + self.assertEqual({row["memory_root_path"] for row in rows}, {"threads/customer-renewal"}) + + memory = self.backend.get_memory(path="threads/customer-renewal", collection="test", user_id="alice") + self.assertIsNotNone(memory) + self.assertEqual(memory["memory_id"], result["memory_id"]) + self.assertEqual(len(memory["children"]), 3) + self.assertTrue(any("pricing risk" in snippet["text"] for snippet in memory["snippets"])) + + def test_matching_turns_roll_up_to_parent_conversation(self): + turns = [ + {"role": "user", "content": "The launch needs pricing approval from finance."}, + {"role": "assistant", "content": "I noted that pricing approval blocks the renewal email."}, + {"role": "user", "content": "Legal review is separate from the pricing approval path."}, + ] + self.backend.index_conversation( + path="threads/pricing-approval", + title="Pricing Approval Thread", + turns=turns, + collection="test", + embed_func=mock_embed, + model="mock-embedder", + ) + self.backend.rebuild_fts_index() + + searcher = HybridSearcher( + backend=EmbedOnlyBackend(), + storage=self.backend, + collection="test", + limit=5, + fts_probe_limit=10, + ) + results = searcher.search("pricing approval renewal") + + self.assertEqual(len(results), 1) + result = results[0] + self.assertEqual(result.memory_role, "root") + self.assertEqual(result.memory_root_path, "threads/pricing-approval") + self.assertEqual(result.filepath, "recallforge://test/threads/pricing-approval") + self.assertEqual(result.display_path, "test/threads/pricing-approval") + self.assertGreaterEqual(result.memory_hit_count, 2) + evidence_paths = [result.memory_primary_evidence_path] + (result.memory_supporting_paths or []) + self.assertIn("recallforge://test/threads/pricing-approval::turn:0002", evidence_paths) + + class TestFTSMissFallbackBehavior(unittest.TestCase): """Tests for P0: FTS miss fallback behavior - no BM25 fallback on empty results.""" diff --git a/tests/test_watch_folder.py b/tests/test_watch_folder.py index ba2ce94..2fae861 100644 --- a/tests/test_watch_folder.py +++ b/tests/test_watch_folder.py @@ -43,7 +43,7 @@ def embed_image(self, image): return [0.0] * 8 -def _wait_until(predicate, timeout: float = 3.0, interval: float = 0.05) -> bool: +def _wait_until(predicate, timeout: float = 8.0, interval: float = 0.05) -> bool: deadline = time.time() + timeout while time.time() < deadline: if predicate(): diff --git a/tests/uat/test_mcp_server.sh b/tests/uat/test_mcp_server.sh index 4271b58..602b536 100755 --- a/tests/uat/test_mcp_server.sh +++ b/tests/uat/test_mcp_server.sh @@ -236,6 +236,13 @@ def _as_json_payload(content): return {"error": text} +def _result_paths(result): + """Return canonical and evidence paths from a rolled memory search result.""" + paths = [result.get("filepath"), result.get("memory_primary_evidence_path")] + paths.extend(result.get("memory_supporting_paths") or []) + return [path for path in paths if path] + + async def test_server(): global pass_count, fail_count @@ -251,7 +258,7 @@ async def test_server(): required_tools = [ "search", "search_fts", "search_vec", "ingest", "index_document", "index_image", - "memory_add", "memory_update", "memory_delete", + "memory_add", "memory_add_conversation", "memory_update", "memory_delete", "rename_collection", "delete_collection", "list_collections", "status", "rebuild_fts", "get_config", "set_config" ] @@ -400,7 +407,10 @@ async def test_server(): }) document_search = json.loads(result[0].text) report( - any("deployment_review.pptx::slide:" in r.get("filepath", "") for r in document_search.get("results", [])), + any( + any("deployment_review.pptx::slide:" in path for path in _result_paths(r)) + for r in document_search.get("results", []) + ), "document-derived sections are searchable after ingest", ) @@ -455,6 +465,25 @@ async def test_server(): initial_count = len(initial_rows) report(initial_count > 0, f"memory_add created {initial_count} embedding rows") + result = await _call_tool(server, "memory_add_conversation", { + "path": "threads/customer-renewal", + "title": "Customer Renewal Thread", + "summary": "Pricing approval and legal review are the open blockers.", + "turns": [ + {"role": "user", "content": "Can we renew the customer contract before Q3?"}, + {"role": "assistant", "content": "The renewal depends on pricing approval."}, + {"role": "user", "content": "Please remember the pricing risk and legal review."}, + ], + "collection": "mcp_test", + }) + conversation_data = json.loads(result[0].text) + report(conversation_data.get("success") == True, "memory_add_conversation succeeded") + report(conversation_data.get("indexed_turns") == 3, "conversation memory indexed turn children") + + conversation_memory = storage.get_memory(path="threads/customer-renewal", collection="mcp_test") + report(conversation_memory is not None, "conversation memory can be fetched by root path") + report(len((conversation_memory or {}).get("children", [])) == 3, "conversation memory exposes three child turns") + result = await _call_tool(server, "memory_update", { "path": "memories/agent-notes.md", "text": "Updated memory content with changed wording to force hash replacement.", @@ -633,7 +662,10 @@ async def test_server(): "content_type": "text", }) video_search = json.loads(result[0].text) - has_transcripts = any("whiteboard_session.mp4::transcript:" in r.get("filepath", "") for r in video_search.get("results", [])) + has_transcripts = any( + any("whiteboard_session.mp4::transcript:" in path for path in _result_paths(r)) + for r in video_search.get("results", []) + ) if transcripts >= 1: report(has_transcripts, "video transcript assets are searchable after ingest") elif has_transcripts: diff --git a/tests/uat/test_uat_comprehensive.py b/tests/uat/test_uat_comprehensive.py index bd73103..8ecb099 100644 --- a/tests/uat/test_uat_comprehensive.py +++ b/tests/uat/test_uat_comprehensive.py @@ -459,7 +459,7 @@ async def test_server_exposes_required_tools(self, mock_backend, mock_storage): required_tools = { "search", "search_fts", "search_vec", "ingest", "index_document", "index_image", - "memory_add", "memory_update", "memory_delete", + "memory_add", "memory_add_conversation", "memory_update", "memory_delete", "status", "rebuild_fts", "get_config", "set_config", "list_collections", "list_namespaces", "rename_collection", "delete_collection",