diff --git a/flexus_client_kit/integrations/fi_mongo_store.py b/flexus_client_kit/integrations/fi_mongo_store.py index caa65ee0..40363e0b 100644 --- a/flexus_client_kit/integrations/fi_mongo_store.py +++ b/flexus_client_kit/integrations/fi_mongo_store.py @@ -2,6 +2,7 @@ import os import logging import re +import urllib.parse from typing import Dict, Any, Optional from flexus_client_kit.integrations.fi_localfile import _validate_file_security @@ -19,8 +20,8 @@ "properties": { "op": { "type": "string", - "enum": ["help", "list", "ls", "cat", "grep", "delete", "upload", "save"], - "description": "Operation: list/ls (list files), cat (read file), grep (search), delete, upload (from disk), save (content directly)", + "enum": ["help", "list", "ls", "cat", "grep", "delete", "upload", "save", "patch", "render_download_link"], + "description": "Operation: list/ls (list files), cat (read file), grep (search), delete, upload (from disk), save (content directly), patch (find-and-replace in a file), render_download_link (show download card to user)", }, "args": { "type": "object", @@ -33,8 +34,10 @@ "pattern": {"type": ["string", "null"], "description": "Python regex pattern for grep"}, "context": {"type": ["integer", "null"], "description": "Context lines around grep matches"}, "content": {"type": ["string", "null"], "description": "Content for save op (JSON string or text)"}, + "old_text": {"type": ["string", "null"], "description": "For patch: exact text to find"}, + "new_text": {"type": ["string", "null"], "description": "For patch: replacement text"}, }, - "required": ["path", "lines_range", "safety_valve", "pattern", "context", "content"], + "required": ["path", "lines_range", "safety_valve", "pattern", "context", "content", "old_text", "new_text"], }, }, "required": ["op", "args"], @@ -65,17 +68,42 @@ Sometimes you need to grep .json files on disk, remember that all the strings inside are escaped in that case, making it a bit harder to match. +patch - Find and replace an exact string in a stored file. + args: path (required), old_text (required), new_text (required) + Fails if old_text is not found exactly once. + +render_download_link - Show a download card to the user for an already-stored file. + The user sees a styled card with file icon, name, and download/preview button. + args: path (required) + Examples: mongo_store(op="list", args={"path": "folder1/"}) mongo_store(op="cat", args={"path": "folder1/something_20250803.json", "lines_range": "0:40", "safety_valve": "10k"}) mongo_store(op="save", args={"path": "investigations/abc123.json", "content": "{...json...}"}) mongo_store(op="delete", args={"path": "folder1/something_20250803.json"}) mongo_store(op="grep", args={"path": "tasks.txt", "pattern": "TODO", "context": 2}) + mongo_store(op="patch", args={"path": "report.html", "old_text": "

Old

", "new_text": "

New

"}) + mongo_store(op="render_download_link", args={"path": "reports/monthly.pdf"}) """ # There's also a secret op="undelete" command that can bring deleted files +_MIME_TYPES = { + ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", + ".webp": "image/webp", ".gif": "image/gif", ".svg": "image/svg+xml", + ".pdf": "application/pdf", ".txt": "text/plain", ".csv": "text/csv", + ".json": "application/json", ".html": "text/html", ".htm": "text/html", + ".xml": "application/xml", ".md": "text/markdown", + ".yaml": "application/yaml", ".yml": "application/yaml", +} + + +def _guess_mime_type(path: str) -> str: + ext = os.path.splitext(path)[1].lower() + return _MIME_TYPES.get(ext, "application/octet-stream") + + async def handle_mongo_store( rcx, toolcall: ckit_cloudtool.FCloudtoolCall, @@ -212,6 +240,58 @@ async def handle_mongo_store( else: return f"Error: File {path} not found in MongoDB" + elif op == "patch": + if not path: + return f"Error: path parameter required for `patch` operation\n\n{HELP}" + old_text = args.get("old_text") + new_text = args.get("new_text") + if old_text is None: + return "Error: old_text is required for patch" + if new_text is None: + return "Error: new_text is required for patch" + path_error = validate_path(path) + if path_error: + return f"Error: {path_error}" + doc = await rcx.personal_mongo.find_one({"path": path}) + if not doc: + return f"Error: file not found: {path}" + if path.endswith(".json"): + json_data = doc.get("json") + if json_data is None: + return f"Error: file has no data: {path}" + content = json.dumps(json_data, indent=4) + else: + raw = doc.get("data") + if raw is None: + return f"Error: file has no data: {path}" + try: + content = bytes(raw).decode("utf-8") + except UnicodeDecodeError: + return "Error: file is not valid UTF-8 text" + count = content.count(old_text) + if count == 0: + return "Error: old_text not found in file" + if count > 1: + return f"Error: old_text found {count} times — make it more specific so it matches exactly once" + new_content = content.replace(old_text, new_text, 1) + await ckit_mongo.mongo_overwrite(rcx.personal_mongo, path, new_content.encode("utf-8")) + return f"✅ Patch applied to {path}" + + elif op == "render_download_link": + if not path: + return f"Error: path parameter required for `render_download_link` operation\n\n{HELP}" + persona_id = toolcall.connected_persona_id + if not persona_id: + return "Error: `render_download_link` operation requires persona_id (connected_persona_id missing from toolcall)" + path_error = validate_path(path) + if path_error: + return f"Error: {path_error}" + display_name = os.path.basename(path) + mime = _guess_mime_type(path) + enc_path = urllib.parse.quote(path, safe="/") + enc_name = urllib.parse.quote(display_name, safe="") + return f"📎DOWNLOAD:{persona_id}:{enc_path}:{enc_name}:{mime}" + else: return HELP