pomera-ai-commander 1.2.1 → 1.2.3

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.
@@ -196,7 +196,14 @@ class ToolRegistry:
196
196
  self._register_html_tool()
197
197
  self._register_list_comparator_tool()
198
198
 
199
+ # Safe Update Tool (Phase 5) - for AI-initiated updates
200
+ self._register_safe_update_tool()
201
+
202
+ # Find & Replace Diff Tool (Phase 6) - regex find/replace with diff preview and Notes backup
203
+ self._register_find_replace_diff_tool()
204
+
199
205
  self._logger.info(f"Registered {len(self._tools)} built-in MCP tools")
206
+
200
207
 
201
208
  def _register_case_tool(self) -> None:
202
209
  """Register the Case Tool."""
@@ -1844,9 +1851,14 @@ class ToolRegistry:
1844
1851
 
1845
1852
  def _get_notes_db_path(self) -> str:
1846
1853
  """Get the path to the notes database."""
1847
- import os
1848
- project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
1849
- return os.path.join(project_root, 'notes.db')
1854
+ try:
1855
+ from core.data_directory import get_database_path
1856
+ return get_database_path('notes.db')
1857
+ except ImportError:
1858
+ # Fallback to legacy behavior
1859
+ import os
1860
+ project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
1861
+ return os.path.join(project_root, 'notes.db')
1850
1862
 
1851
1863
  def _get_notes_connection(self):
1852
1864
  """Get a connection to the notes database."""
@@ -1856,13 +1868,41 @@ class ToolRegistry:
1856
1868
  conn.row_factory = sqlite3.Row
1857
1869
  return conn
1858
1870
 
1871
+ def _sanitize_text(self, text: str) -> str:
1872
+ """
1873
+ Sanitize text by removing invalid UTF-8 surrogate characters.
1874
+
1875
+ Lone surrogates (U+D800 to U+DFFF) are invalid in UTF-8 and cause
1876
+ encoding errors. This function removes them while preserving valid content.
1877
+
1878
+ Args:
1879
+ text: Input text that may contain invalid surrogates
1880
+
1881
+ Returns:
1882
+ Sanitized text safe for UTF-8 encoding and database storage
1883
+ """
1884
+ if not text:
1885
+ return text
1886
+
1887
+ # Encode to UTF-8 with 'surrogatepass' to handle surrogates,
1888
+ # then decode with 'replace' to replace invalid sequences
1889
+ try:
1890
+ # This two-step process handles lone surrogates:
1891
+ # 1. surrogatepass allows encoding surrogates (normally forbidden in UTF-8)
1892
+ # 2. errors='replace' replaces invalid sequences with replacement char
1893
+ sanitized = text.encode('utf-8', errors='surrogatepass').decode('utf-8', errors='replace')
1894
+ return sanitized
1895
+ except Exception:
1896
+ # Fallback: manually filter out surrogate characters
1897
+ return ''.join(c for c in text if not (0xD800 <= ord(c) <= 0xDFFF))
1898
+
1859
1899
  def _handle_notes_save(self, args: Dict[str, Any]) -> str:
1860
1900
  """Handle saving a new note."""
1861
1901
  from datetime import datetime
1862
1902
 
1863
- title = args.get("title", "")
1864
- input_content = args.get("input_content", "")
1865
- output_content = args.get("output_content", "")
1903
+ title = self._sanitize_text(args.get("title", ""))
1904
+ input_content = self._sanitize_text(args.get("input_content", ""))
1905
+ output_content = self._sanitize_text(args.get("output_content", ""))
1866
1906
 
1867
1907
  if not title:
1868
1908
  return "Error: Title is required"
@@ -2030,15 +2070,15 @@ class ToolRegistry:
2030
2070
 
2031
2071
  if "title" in args:
2032
2072
  updates.append("Title = ?")
2033
- values.append(args["title"])
2073
+ values.append(self._sanitize_text(args["title"]))
2034
2074
 
2035
2075
  if "input_content" in args:
2036
2076
  updates.append("Input = ?")
2037
- values.append(args["input_content"])
2077
+ values.append(self._sanitize_text(args["input_content"]))
2038
2078
 
2039
2079
  if "output_content" in args:
2040
2080
  updates.append("Output = ?")
2041
- values.append(args["output_content"])
2081
+ values.append(self._sanitize_text(args["output_content"]))
2042
2082
 
2043
2083
  if not updates:
2044
2084
  conn.close()
@@ -2352,9 +2392,329 @@ class ToolRegistry:
2352
2392
  result_lines.extend(in_both if in_both else ["(none)"])
2353
2393
 
2354
2394
  return "\n".join(result_lines)
2395
+
2396
+ def _register_safe_update_tool(self) -> None:
2397
+ """Register the Safe Update Tool for AI-initiated updates."""
2398
+ self.register(MCPToolAdapter(
2399
+ name="pomera_safe_update",
2400
+ description="Check update safety and get backup instructions before updating Pomera. "
2401
+ "IMPORTANT: In portable mode, npm/pip updates WILL DELETE user data. "
2402
+ "Always call this with action='check' before initiating any update.",
2403
+ input_schema={
2404
+ "type": "object",
2405
+ "properties": {
2406
+ "action": {
2407
+ "type": "string",
2408
+ "enum": ["check", "backup", "get_update_command"],
2409
+ "description": "check=analyze risks, backup=create backup to safe location, "
2410
+ "get_update_command=get recommended update command"
2411
+ },
2412
+ "backup_path": {
2413
+ "type": "string",
2414
+ "description": "For backup action: directory to save backup (default: user's Documents folder)"
2415
+ }
2416
+ },
2417
+ "required": ["action"]
2418
+ },
2419
+ handler=self._handle_safe_update
2420
+ ))
2421
+
2422
+ def _handle_safe_update(self, args: Dict[str, Any]) -> str:
2423
+ """Handle safe update tool execution."""
2424
+ import json
2425
+ import os
2426
+ import platform
2427
+ from pathlib import Path
2428
+
2429
+ action = args.get("action", "check")
2430
+
2431
+ # Determine installation mode
2432
+ try:
2433
+ from core.data_directory import is_portable_mode, get_user_data_dir, get_data_directory_info
2434
+ portable = is_portable_mode()
2435
+ data_info = get_data_directory_info()
2436
+ except ImportError:
2437
+ portable = False
2438
+ data_info = {"error": "data_directory module not available"}
2439
+
2440
+ # Get current version
2441
+ try:
2442
+ import importlib.metadata
2443
+ version = importlib.metadata.version("pomera-ai-commander")
2444
+ except Exception:
2445
+ version = "unknown"
2446
+
2447
+ # Get installation directory
2448
+ install_dir = Path(__file__).parent.parent.parent
2449
+
2450
+ # Find existing databases
2451
+ databases_found = []
2452
+ for db_name in ["settings.db", "notes.db", "settings.json"]:
2453
+ db_path = install_dir / db_name
2454
+ if db_path.exists():
2455
+ databases_found.append({
2456
+ "name": db_name,
2457
+ "path": str(db_path),
2458
+ "size_bytes": db_path.stat().st_size
2459
+ })
2460
+
2461
+ if action == "check":
2462
+ # Return risk assessment
2463
+ result = {
2464
+ "current_version": version,
2465
+ "installation_mode": "portable" if portable else "platform_dirs",
2466
+ "installation_dir": str(install_dir),
2467
+ "data_at_risk": portable and len(databases_found) > 0,
2468
+ "databases_in_install_dir": databases_found,
2469
+ "backup_required": portable and len(databases_found) > 0,
2470
+ "platform": platform.system(),
2471
+ }
2472
+
2473
+ if portable and databases_found:
2474
+ result["warning"] = (
2475
+ "CRITICAL: Portable mode detected with databases in installation directory. "
2476
+ "Running 'npm update' or 'pip install --upgrade' WILL DELETE these files! "
2477
+ "You MUST run pomera_safe_update with action='backup' before updating."
2478
+ )
2479
+ result["recommended_action"] = "backup"
2480
+ else:
2481
+ result["info"] = (
2482
+ "Safe to update. Data is stored in user data directory and will survive package updates."
2483
+ )
2484
+ result["recommended_action"] = "get_update_command"
2485
+
2486
+ return json.dumps(result, indent=2)
2487
+
2488
+ elif action == "backup":
2489
+ # Create backup to safe location
2490
+ backup_dir = args.get("backup_path")
2491
+
2492
+ if not backup_dir:
2493
+ # Default to user's Documents folder
2494
+ if platform.system() == "Windows":
2495
+ backup_dir = os.path.join(os.environ.get("USERPROFILE", ""), "Documents", "pomera-backup")
2496
+ else:
2497
+ backup_dir = os.path.join(os.path.expanduser("~"), "Documents", "pomera-backup")
2498
+
2499
+ backup_path = Path(backup_dir)
2500
+ backup_path.mkdir(parents=True, exist_ok=True)
2501
+
2502
+ import shutil
2503
+ from datetime import datetime
2504
+
2505
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
2506
+ backed_up = []
2507
+
2508
+ for db in databases_found:
2509
+ src = Path(db["path"])
2510
+ dst = backup_path / f"{src.stem}_{timestamp}{src.suffix}"
2511
+ try:
2512
+ shutil.copy2(src, dst)
2513
+ backed_up.append({"name": db["name"], "backup_path": str(dst)})
2514
+ except Exception as e:
2515
+ backed_up.append({"name": db["name"], "error": str(e)})
2516
+
2517
+ result = {
2518
+ "backup_location": str(backup_path),
2519
+ "files_backed_up": backed_up,
2520
+ "success": all("error" not in b for b in backed_up),
2521
+ "next_step": "You can now safely update Pomera. Use action='get_update_command' for the command."
2522
+ }
2523
+
2524
+ return json.dumps(result, indent=2)
2525
+
2526
+ elif action == "get_update_command":
2527
+ # Return appropriate update command
2528
+ result = {
2529
+ "npm_update": "npm update pomera-ai-commander",
2530
+ "pip_update": "pip install --upgrade pomera-ai-commander",
2531
+ "github_releases": "https://github.com/matbanik/Pomera-AI-Commander/releases",
2532
+ "note": "After updating, run the application to trigger automatic data migration."
2533
+ }
2534
+
2535
+ if portable and databases_found:
2536
+ result["warning"] = (
2537
+ "Data in installation directory detected! "
2538
+ "Ensure you have backed up (action='backup') before running update."
2539
+ )
2540
+
2541
+ return json.dumps(result, indent=2)
2542
+
2543
+ else:
2544
+ return json.dumps({"error": f"Unknown action: {action}"})
2545
+
2546
+ def _register_find_replace_diff_tool(self) -> None:
2547
+ """Register the Find & Replace Diff Tool."""
2548
+ self.register(MCPToolAdapter(
2549
+ name="pomera_find_replace_diff",
2550
+ description="Regex find/replace with diff preview and automatic backup to Notes. "
2551
+ "Designed for AI agent workflows requiring verification and rollback capability. "
2552
+ "Operations: validate (check regex), preview (show diff), execute (replace+backup), recall (retrieve previous).",
2553
+ input_schema={
2554
+ "type": "object",
2555
+ "properties": {
2556
+ "operation": {
2557
+ "type": "string",
2558
+ "enum": ["validate", "preview", "execute", "recall"],
2559
+ "description": "validate=check regex syntax, preview=show compact diff, execute=replace+backup to Notes, recall=retrieve by note_id"
2560
+ },
2561
+ "text": {
2562
+ "type": "string",
2563
+ "description": "Input text to process (for validate/preview/execute)"
2564
+ },
2565
+ "find_pattern": {
2566
+ "type": "string",
2567
+ "description": "Regex pattern to find"
2568
+ },
2569
+ "replace_pattern": {
2570
+ "type": "string",
2571
+ "description": "Replacement string (supports backreferences \\1, \\2, etc.)",
2572
+ "default": ""
2573
+ },
2574
+ "flags": {
2575
+ "type": "array",
2576
+ "items": {"type": "string", "enum": ["i", "m", "s", "x"]},
2577
+ "default": [],
2578
+ "description": "Regex flags: i=ignore case, m=multiline, s=dotall, x=verbose"
2579
+ },
2580
+ "context_lines": {
2581
+ "type": "integer",
2582
+ "default": 2,
2583
+ "minimum": 0,
2584
+ "maximum": 10,
2585
+ "description": "Lines of context in diff output (for preview)"
2586
+ },
2587
+ "save_to_notes": {
2588
+ "type": "boolean",
2589
+ "default": True,
2590
+ "description": "Save operation to Notes for rollback (for execute)"
2591
+ },
2592
+ "note_id": {
2593
+ "type": "integer",
2594
+ "description": "Note ID to recall (for recall operation)"
2595
+ }
2596
+ },
2597
+ "required": ["operation"]
2598
+ },
2599
+ handler=self._handle_find_replace_diff
2600
+ ))
2601
+
2602
+ def _handle_find_replace_diff(self, args: Dict[str, Any]) -> str:
2603
+ """Handle find/replace diff tool execution."""
2604
+ import json
2605
+ from core.mcp.find_replace_diff import validate_regex, preview_replace, execute_replace, recall_operation
2606
+
2607
+ operation = args.get("operation", "")
2608
+
2609
+ if operation == "validate":
2610
+ pattern = args.get("find_pattern", "")
2611
+ flags = args.get("flags", [])
2612
+ result = validate_regex(pattern, flags)
2613
+ return json.dumps(result, ensure_ascii=False)
2614
+
2615
+ elif operation == "preview":
2616
+ text = args.get("text", "")
2617
+ find_pattern = args.get("find_pattern", "")
2618
+ replace_pattern = args.get("replace_pattern", "")
2619
+ flags = args.get("flags", [])
2620
+ context_lines = args.get("context_lines", 2)
2621
+
2622
+ if not text:
2623
+ return json.dumps({"success": False, "error": "text is required for preview"})
2624
+ if not find_pattern:
2625
+ return json.dumps({"success": False, "error": "find_pattern is required for preview"})
2626
+
2627
+ result = preview_replace(text, find_pattern, replace_pattern, flags, context_lines)
2628
+ return json.dumps(result, ensure_ascii=False)
2629
+
2630
+ elif operation == "execute":
2631
+ text = args.get("text", "")
2632
+ find_pattern = args.get("find_pattern", "")
2633
+ replace_pattern = args.get("replace_pattern", "")
2634
+ flags = args.get("flags", [])
2635
+ save_to_notes = args.get("save_to_notes", True)
2636
+
2637
+ if not text:
2638
+ return json.dumps({"success": False, "error": "text is required for execute"})
2639
+ if not find_pattern:
2640
+ return json.dumps({"success": False, "error": "find_pattern is required for execute"})
2641
+
2642
+ # Create notes handler if saving is requested
2643
+ notes_handler = None
2644
+ if save_to_notes:
2645
+ notes_handler = self._create_notes_handler()
2646
+
2647
+ result = execute_replace(text, find_pattern, replace_pattern, flags, save_to_notes, notes_handler)
2648
+ return json.dumps(result, ensure_ascii=False)
2649
+
2650
+ elif operation == "recall":
2651
+ note_id = args.get("note_id")
2652
+ if note_id is None:
2653
+ return json.dumps({"success": False, "error": "note_id is required for recall"})
2654
+
2655
+ notes_getter = self._create_notes_getter()
2656
+ result = recall_operation(note_id, notes_getter)
2657
+ return json.dumps(result, ensure_ascii=False)
2658
+
2659
+ else:
2660
+ return json.dumps({"success": False, "error": f"Unknown operation: {operation}"})
2661
+
2662
+ def _create_notes_handler(self):
2663
+ """Create a handler function for saving to notes."""
2664
+ registry = self # Capture reference
2665
+
2666
+ def save_to_notes(title: str, input_content: str, output_content: str) -> int:
2667
+ """Save operation to notes and return note_id."""
2668
+ try:
2669
+ from datetime import datetime
2670
+ # Sanitize text to prevent UTF-8 surrogate errors
2671
+ sanitized_title = registry._sanitize_text(title)
2672
+ sanitized_input = registry._sanitize_text(input_content)
2673
+ sanitized_output = registry._sanitize_text(output_content)
2674
+
2675
+ conn = registry._get_notes_connection()
2676
+ now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
2677
+ cursor = conn.execute('''
2678
+ INSERT INTO notes (Created, Modified, Title, Input, Output)
2679
+ VALUES (?, ?, ?, ?, ?)
2680
+ ''', (now, now, sanitized_title, sanitized_input, sanitized_output))
2681
+ note_id = cursor.lastrowid
2682
+ conn.commit()
2683
+ conn.close()
2684
+ return note_id
2685
+ except Exception as e:
2686
+ registry._logger.warning(f"Failed to save to notes: {e}")
2687
+ return -1
2688
+ return save_to_notes
2689
+
2690
+ def _create_notes_getter(self):
2691
+ """Create a getter function for retrieving notes."""
2692
+ registry = self # Capture reference
2693
+
2694
+ def get_note(note_id: int) -> Dict[str, Any]:
2695
+ """Get note by ID."""
2696
+ try:
2697
+ conn = registry._get_notes_connection()
2698
+ row = conn.execute('SELECT * FROM notes WHERE id = ?', (note_id,)).fetchone()
2699
+ conn.close()
2700
+ if row:
2701
+ return {
2702
+ 'id': row[0],
2703
+ 'created': row[1],
2704
+ 'modified': row[2],
2705
+ 'title': row[3],
2706
+ 'input_content': row[4],
2707
+ 'output_content': row[5]
2708
+ }
2709
+ return None
2710
+ except Exception as e:
2711
+ registry._logger.warning(f"Failed to get note: {e}")
2712
+ return None
2713
+ return get_note
2355
2714
 
2356
2715
 
2357
2716
  # Singleton instance for convenience
2717
+
2358
2718
  _default_registry: Optional[ToolRegistry] = None
2359
2719
 
2360
2720