personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add facet merge journal command

Add `sol call journal facet merge SOURCE --into DEST [--consent]` to merge entities, open todos, calendar events, and news into a destination facet before deleting the source facet. Reuse canonical observation helpers for atomic writes, and cover the entity-conflict merge branch plus the rest of the merge flow in tests. Document the facet merge command and the related entity, calendar, and todo move commands in SKILL.md and unified.md.

+609
+22
muse/journal/SKILL.md
··· 192 192 sol call journal facet delete old-facet --yes 193 193 ``` 194 194 195 + ## facet merge 196 + 197 + Merge all data from a source facet into a destination facet, then permanently delete the source. 198 + 199 + ```bash 200 + sol call journal facet merge SOURCE --into DEST [--consent] 201 + ``` 202 + 203 + - `SOURCE` — facet to merge from; will be permanently deleted after merge 204 + - `--into DEST` — destination facet to receive all data 205 + - `--consent` — required for agent audit trail when called by an agent 206 + 207 + Moves all entities, open todos (not completed or cancelled), non-cancelled calendar events, and news files from SOURCE into DEST. For entity conflicts (entity exists in both): DEST relationship wins, observations are appended without duplicates. For news conflicts (same date file in both): DEST file is preserved, SOURCE file is skipped. Completed/cancelled todos and cancelled events are not moved (they are deleted with the source facet). After all data is moved, SOURCE is permanently deleted and the index is rebuilt. 208 + 209 + Prints a summary of what will be moved before performing any mutations. 210 + 211 + Example: 212 + 213 + ```bash 214 + sol call journal facet merge old-project --into archive --consent 215 + ``` 216 + 195 217 ## facets 196 218 197 219 ```bash
+4
muse/unified.md
··· 63 63 - `sol call entities intelligence ENTITY [--facet NAME]` — Full intelligence briefing (returns JSON — synthesize into natural language). 64 64 - `sol call entities detect <TYPE> <entity> <description> [-f facet] [-d day]` — Detect/record an entity. 65 65 - `sol call entities attach <TYPE> <entity> <description> [-f facet]` — Attach entity to facet. 66 + - `sol call entities move ENTITY --from SOURCE --to DEST [--merge] [--consent]` — move an entity from one facet to another; `--merge` appends observations if entity exists in dest 66 67 67 68 ### Calendar 68 69 - `sol call calendar list [DAY] --facet FACET` — List events for a day. 69 70 - `sol call calendar create TITLE --start HH:MM --day DAY --facet FACET [--end HH:MM] [--summary TEXT] [--participants NAMES]` — Create an event. 70 71 - `sol call calendar update LINE --day DAY --facet FACET [--title TEXT] [--start HH:MM] [--end HH:MM] [--summary TEXT] [--participants NAMES]` — Update an event. 71 72 - `sol call calendar cancel LINE --day DAY --facet FACET` — Cancel an event. 73 + - `sol call calendar move LINE --day YYYYMMDD --from SOURCE --to DEST [--consent]` — move a non-cancelled calendar event to another facet 72 74 73 75 ### Todos 74 76 - `sol call todos list [DAY] [-f facet] [--to end_day]` — Show todos for a day. ··· 76 78 - `sol call todos done LINE [-d DAY] [-f facet]` — Mark a todo as done. 77 79 - `sol call todos cancel LINE [-d DAY] [-f facet]` — Cancel a todo. 78 80 - `sol call todos upcoming [-l limit] [-f facet]` — Show upcoming todos. 81 + - `sol call todos move LINE --day YYYYMMDD --from SOURCE --to DEST [--consent]` — move an open todo to another facet 79 82 80 83 ### Navigation 81 84 - `sol call navigate [PATH] --facet FACET` — Navigate the browser to a path and/or switch facet. ··· 89 92 - `sol call journal facet mute <name>` — Hide a facet from default listings. 90 93 - `sol call journal facet unmute <name>` — Show a previously muted facet in default listings. 91 94 - `sol call journal facet delete <name> --yes [--consent]` — Delete a facet and all its data. Requires `--consent` when called by a proactive agent (must have explicit user approval before calling). 95 + - `sol call journal facet merge SOURCE --into DEST [--consent]` — merge all entities, open todos, calendar events, and news from SOURCE into DEST, then delete SOURCE 92 96 - `sol call journal facets [--all]` — List facets. 93 97 94 98 ### Awareness
+393
tests/test_call.py
··· 48 48 think.utils._journal_path_cache = None 49 49 50 50 51 + @pytest.fixture 52 + def merge_journal(tmp_path, monkeypatch): 53 + """Create a journal with source and destination facets for merge testing.""" 54 + journal = tmp_path / "journal" 55 + src_dir = journal / "facets" / "src-facet" 56 + dst_dir = journal / "facets" / "dst-facet" 57 + src_dir.mkdir(parents=True) 58 + dst_dir.mkdir(parents=True) 59 + 60 + (src_dir / "facet.json").write_text( 61 + json.dumps({"title": "Source Facet"}, indent=2) + "\n", 62 + encoding="utf-8", 63 + ) 64 + (dst_dir / "facet.json").write_text( 65 + json.dumps({"title": "Destination Facet"}, indent=2) + "\n", 66 + encoding="utf-8", 67 + ) 68 + 69 + config_dir = journal / "config" 70 + config_dir.mkdir(parents=True) 71 + (config_dir / "facets.json").write_text( 72 + json.dumps({"facets": ["src-facet", "dst-facet"]}, indent=2) + "\n", 73 + encoding="utf-8", 74 + ) 75 + 76 + src_entity_dir = src_dir / "entities" / "test_entity" 77 + src_entity_dir.mkdir(parents=True) 78 + (src_entity_dir / "entity.json").write_text( 79 + json.dumps( 80 + { 81 + "entity_id": "test_entity", 82 + "description": "Source relationship", 83 + "created_at": 1, 84 + "updated_at": 1, 85 + }, 86 + indent=2, 87 + ) 88 + + "\n", 89 + encoding="utf-8", 90 + ) 91 + (src_entity_dir / "observations.jsonl").write_text( 92 + json.dumps({"content": "Knows the migration plan", "observed_at": 101}) + "\n", 93 + encoding="utf-8", 94 + ) 95 + 96 + src_todos_dir = src_dir / "todos" 97 + src_todos_dir.mkdir(parents=True) 98 + (src_todos_dir / "20260101.jsonl").write_text( 99 + json.dumps({"text": "Move the roadmap", "created_at": 1000}) + "\n", 100 + encoding="utf-8", 101 + ) 102 + 103 + src_calendar_dir = src_dir / "calendar" 104 + src_calendar_dir.mkdir(parents=True) 105 + (src_calendar_dir / "20260101.jsonl").write_text( 106 + json.dumps( 107 + { 108 + "title": "Merge planning", 109 + "start": "09:00", 110 + "end": "10:00", 111 + "summary": "Review the merge sequence", 112 + "participants": ["Alex", "Blair"], 113 + "created_at": 2000, 114 + } 115 + ) 116 + + "\n", 117 + encoding="utf-8", 118 + ) 119 + 120 + src_news_dir = src_dir / "news" 121 + dst_news_dir = dst_dir / "news" 122 + src_news_dir.mkdir(parents=True) 123 + dst_news_dir.mkdir(parents=True) 124 + (src_news_dir / "20260101.md").write_text( 125 + "# Source News\nThis should be skipped.\n", 126 + encoding="utf-8", 127 + ) 128 + (src_news_dir / "20260102.md").write_text( 129 + "# Unique Source News\nThis should be copied.\n", 130 + encoding="utf-8", 131 + ) 132 + (dst_news_dir / "20260101.md").write_text( 133 + "# Destination News\nKeep this version.\n", 134 + encoding="utf-8", 135 + ) 136 + 137 + monkeypatch.setenv("JOURNAL_PATH", str(journal)) 138 + import think.utils 139 + 140 + think.utils._journal_path_cache = None 141 + yield journal 142 + think.utils._journal_path_cache = None 143 + 144 + 51 145 class TestDiscovery: 52 146 """Tests for app CLI discovery.""" 53 147 ··· 515 609 assert result.exit_code == 0 516 610 assert "muted-one" in result.output 517 611 assert "[muted]" in result.output 612 + 613 + 614 + class TestFacetMerge: 615 + """Tests for journal facet merge.""" 616 + 617 + @staticmethod 618 + def _mock_indexer(monkeypatch): 619 + import think.tools.call as call_module 620 + 621 + calls = [] 622 + 623 + def _run(*args, **kwargs): 624 + calls.append((args, kwargs)) 625 + return None 626 + 627 + monkeypatch.setattr(call_module.subprocess, "run", _run) 628 + return calls 629 + 630 + def test_merge_moves_entities(self, merge_journal, monkeypatch): 631 + """Merge moves entity directories into the destination facet.""" 632 + self._mock_indexer(monkeypatch) 633 + 634 + result = runner.invoke( 635 + call_app, 636 + ["journal", "facet", "merge", "src-facet", "--into", "dst-facet"], 637 + ) 638 + 639 + assert result.exit_code == 0 640 + assert not (merge_journal / "facets" / "src-facet" / "entities").exists() 641 + assert ( 642 + merge_journal 643 + / "facets" 644 + / "dst-facet" 645 + / "entities" 646 + / "test_entity" 647 + / "entity.json" 648 + ).exists() 649 + observations_path = ( 650 + merge_journal 651 + / "facets" 652 + / "dst-facet" 653 + / "entities" 654 + / "test_entity" 655 + / "observations.jsonl" 656 + ) 657 + assert observations_path.exists() 658 + 659 + def test_merge_entity_conflict(self, merge_journal, monkeypatch): 660 + """Merge preserves destination relationship fields and appends observations.""" 661 + self._mock_indexer(monkeypatch) 662 + 663 + src_entity_dir = ( 664 + merge_journal / "facets" / "src-facet" / "entities" / "test_entity" 665 + ) 666 + dst_entity_dir = ( 667 + merge_journal / "facets" / "dst-facet" / "entities" / "test_entity" 668 + ) 669 + dst_entity_dir.mkdir(parents=True) 670 + (src_entity_dir / "entity.json").write_text( 671 + json.dumps( 672 + { 673 + "entity_id": "test_entity", 674 + "description": "Source desc", 675 + "source_only": "keep-if-missing", 676 + }, 677 + indent=2, 678 + ) 679 + + "\n", 680 + encoding="utf-8", 681 + ) 682 + (dst_entity_dir / "entity.json").write_text( 683 + json.dumps( 684 + { 685 + "entity_id": "test_entity", 686 + "description": "Dest desc", 687 + "dest_only": "wins", 688 + }, 689 + indent=2, 690 + ) 691 + + "\n", 692 + encoding="utf-8", 693 + ) 694 + (src_entity_dir / "observations.jsonl").write_text( 695 + json.dumps({"content": "Source fact", "observed_at": 101}) + "\n", 696 + encoding="utf-8", 697 + ) 698 + (dst_entity_dir / "observations.jsonl").write_text( 699 + json.dumps({"content": "Dest fact", "observed_at": 202}) + "\n", 700 + encoding="utf-8", 701 + ) 702 + 703 + result = runner.invoke( 704 + call_app, 705 + ["journal", "facet", "merge", "src-facet", "--into", "dst-facet"], 706 + ) 707 + 708 + assert result.exit_code == 0 709 + assert dst_entity_dir.exists() 710 + assert not (merge_journal / "facets" / "src-facet" / "entities").exists() 711 + merged_relationship = json.loads( 712 + (dst_entity_dir / "entity.json").read_text(encoding="utf-8") 713 + ) 714 + assert merged_relationship["description"] == "Dest desc" 715 + assert merged_relationship["dest_only"] == "wins" 716 + assert merged_relationship["source_only"] == "keep-if-missing" 717 + merged_observations = [ 718 + json.loads(line) 719 + for line in (dst_entity_dir / "observations.jsonl") 720 + .read_text(encoding="utf-8") 721 + .splitlines() 722 + ] 723 + assert len(merged_observations) == 2 724 + assert {obs["content"] for obs in merged_observations} == { 725 + "Source fact", 726 + "Dest fact", 727 + } 728 + 729 + def test_merge_moves_open_todos(self, merge_journal, monkeypatch): 730 + """Merge appends open todos to destination and cancels them in source.""" 731 + self._mock_indexer(monkeypatch) 732 + import think.tools.call as call_module 733 + 734 + monkeypatch.setattr(call_module, "delete_facet", lambda *args, **kwargs: None) 735 + 736 + result = runner.invoke( 737 + call_app, 738 + ["journal", "facet", "merge", "src-facet", "--into", "dst-facet"], 739 + ) 740 + 741 + assert result.exit_code == 0 742 + dst_todos = ( 743 + (merge_journal / "facets" / "dst-facet" / "todos" / "20260101.jsonl") 744 + .read_text(encoding="utf-8") 745 + .splitlines() 746 + ) 747 + assert any(json.loads(line)["text"] == "Move the roadmap" for line in dst_todos) 748 + src_payloads = [ 749 + json.loads(line) 750 + for line in ( 751 + merge_journal / "facets" / "src-facet" / "todos" / "20260101.jsonl" 752 + ) 753 + .read_text(encoding="utf-8") 754 + .splitlines() 755 + ] 756 + assert src_payloads[0]["cancelled"] is True 757 + assert src_payloads[0]["cancelled_reason"] == "moved_to_facet" 758 + assert src_payloads[0]["moved_to"] == "dst-facet" 759 + 760 + def test_merge_moves_open_calendar_events(self, merge_journal, monkeypatch): 761 + """Merge appends open events to destination and cancels them in source.""" 762 + self._mock_indexer(monkeypatch) 763 + import think.tools.call as call_module 764 + 765 + monkeypatch.setattr(call_module, "delete_facet", lambda *args, **kwargs: None) 766 + 767 + result = runner.invoke( 768 + call_app, 769 + ["journal", "facet", "merge", "src-facet", "--into", "dst-facet"], 770 + ) 771 + 772 + assert result.exit_code == 0 773 + dst_events = ( 774 + (merge_journal / "facets" / "dst-facet" / "calendar" / "20260101.jsonl") 775 + .read_text(encoding="utf-8") 776 + .splitlines() 777 + ) 778 + payloads = [json.loads(line) for line in dst_events] 779 + assert any(item["title"] == "Merge planning" for item in payloads) 780 + src_payloads = [ 781 + json.loads(line) 782 + for line in ( 783 + merge_journal / "facets" / "src-facet" / "calendar" / "20260101.jsonl" 784 + ) 785 + .read_text(encoding="utf-8") 786 + .splitlines() 787 + ] 788 + assert src_payloads[0]["cancelled"] is True 789 + assert src_payloads[0]["cancelled_reason"] == "moved_to_facet" 790 + assert src_payloads[0]["moved_to"] == "dst-facet" 791 + 792 + def test_merge_copies_news_skips_conflicts(self, merge_journal, monkeypatch): 793 + """Merge copies unique news files and preserves destination conflicts.""" 794 + self._mock_indexer(monkeypatch) 795 + 796 + result = runner.invoke( 797 + call_app, 798 + ["journal", "facet", "merge", "src-facet", "--into", "dst-facet"], 799 + ) 800 + 801 + assert result.exit_code == 0 802 + assert ( 803 + merge_journal / "facets" / "dst-facet" / "news" / "20260102.md" 804 + ).read_text( 805 + encoding="utf-8" 806 + ) == "# Unique Source News\nThis should be copied.\n" 807 + assert ( 808 + merge_journal / "facets" / "dst-facet" / "news" / "20260101.md" 809 + ).read_text(encoding="utf-8") == "# Destination News\nKeep this version.\n" 810 + 811 + def test_merge_deletes_source_facet(self, merge_journal, monkeypatch): 812 + """Merge deletes the source facet after moving data.""" 813 + self._mock_indexer(monkeypatch) 814 + 815 + result = runner.invoke( 816 + call_app, 817 + ["journal", "facet", "merge", "src-facet", "--into", "dst-facet"], 818 + ) 819 + 820 + assert result.exit_code == 0 821 + assert not (merge_journal / "facets" / "src-facet").exists() 822 + 823 + def test_merge_logs_action(self, merge_journal, monkeypatch): 824 + """Merge records a journal-level facet_merge action with counts.""" 825 + self._mock_indexer(monkeypatch) 826 + from datetime import datetime 827 + 828 + result = runner.invoke( 829 + call_app, 830 + ["journal", "facet", "merge", "src-facet", "--into", "dst-facet"], 831 + ) 832 + 833 + assert result.exit_code == 0 834 + today = datetime.now().strftime("%Y%m%d") 835 + log_path = merge_journal / "config" / "actions" / f"{today}.jsonl" 836 + entries = [ 837 + json.loads(line) 838 + for line in log_path.read_text(encoding="utf-8").splitlines() 839 + if line.strip() 840 + ] 841 + merge_entry = next( 842 + entry for entry in entries if entry["action"] == "facet_merge" 843 + ) 844 + assert merge_entry["params"]["source"] == "src-facet" 845 + assert merge_entry["params"]["dest"] == "dst-facet" 846 + assert merge_entry["params"]["entity_count"] == 1 847 + assert merge_entry["params"]["todo_count"] == 1 848 + assert merge_entry["params"]["calendar_count"] == 1 849 + assert merge_entry["params"]["news_count"] == 1 850 + 851 + def test_merge_same_facet_error(self, merge_journal): 852 + """Merge rejects using the same facet as source and destination.""" 853 + result = runner.invoke( 854 + call_app, 855 + ["journal", "facet", "merge", "src-facet", "--into", "src-facet"], 856 + ) 857 + 858 + assert result.exit_code == 1 859 + assert "Source and destination facets must be different" in result.output 860 + 861 + def test_merge_missing_source_error(self, merge_journal): 862 + """Merge rejects a missing source facet.""" 863 + result = runner.invoke( 864 + call_app, 865 + ["journal", "facet", "merge", "missing-facet", "--into", "dst-facet"], 866 + ) 867 + 868 + assert result.exit_code == 1 869 + assert "Error: Facet 'missing-facet' not found." in result.output 870 + 871 + def test_merge_missing_dest_error(self, merge_journal): 872 + """Merge rejects a missing destination facet.""" 873 + result = runner.invoke( 874 + call_app, 875 + ["journal", "facet", "merge", "src-facet", "--into", "missing-facet"], 876 + ) 877 + 878 + assert result.exit_code == 1 879 + assert "Error: Facet 'missing-facet' not found." in result.output 880 + 881 + def test_merge_consent_flag_logged(self, merge_journal, monkeypatch): 882 + """Merge records consent=True when requested.""" 883 + self._mock_indexer(monkeypatch) 884 + from datetime import datetime 885 + 886 + result = runner.invoke( 887 + call_app, 888 + [ 889 + "journal", 890 + "facet", 891 + "merge", 892 + "src-facet", 893 + "--into", 894 + "dst-facet", 895 + "--consent", 896 + ], 897 + ) 898 + 899 + assert result.exit_code == 0 900 + today = datetime.now().strftime("%Y%m%d") 901 + log_path = merge_journal / "config" / "actions" / f"{today}.jsonl" 902 + entries = [ 903 + json.loads(line) 904 + for line in log_path.read_text(encoding="utf-8").splitlines() 905 + if line.strip() 906 + ] 907 + merge_entry = next( 908 + entry for entry in entries if entry["action"] == "facet_merge" 909 + ) 910 + assert merge_entry["params"]["consent"] is True 518 911 519 912 520 913 class TestResolveHelpers:
+190
think/tools/call.py
··· 11 11 """ 12 12 13 13 import json 14 + import shutil 15 + import subprocess 14 16 import sys 15 17 from pathlib import Path 16 18 17 19 import typer 18 20 21 + from think.entities import scan_facet_relationships 19 22 from think.facets import ( 20 23 create_facet, 21 24 delete_facet, ··· 334 337 typer.echo(f"Error: Facet '{name}' not found.", err=True) 335 338 raise typer.Exit(1) 336 339 typer.echo(f"Deleted facet '{name}'.") 340 + 341 + 342 + @facet_app.command("merge") 343 + def merge( 344 + source: str = typer.Argument(help="Source facet to merge from (will be deleted)."), 345 + dest: str = typer.Option(..., "--into", help="Destination facet to merge into."), 346 + consent: bool = typer.Option( 347 + False, 348 + "--consent", 349 + help="Assert that explicit user approval was obtained before calling this command (agent audit trail).", 350 + ), 351 + ) -> None: 352 + """Merge all data from SOURCE facet into DEST facet, then delete SOURCE.""" 353 + from apps.calendar import event as event_module 354 + from apps.todos import todo as todo_module 355 + from think.entities.observations import load_observations, save_observations 356 + from think.entities.relationships import ( 357 + load_facet_relationship, 358 + save_facet_relationship, 359 + ) 360 + 361 + if source == dest: 362 + typer.echo("Error: Source and destination facets must be different.", err=True) 363 + raise typer.Exit(1) 364 + 365 + journal = Path(get_journal()) 366 + src_path = journal / "facets" / source 367 + dst_path = journal / "facets" / dest 368 + 369 + if not src_path.is_dir(): 370 + typer.echo(f"Error: Facet '{source}' not found.", err=True) 371 + raise typer.Exit(1) 372 + if not dst_path.is_dir(): 373 + typer.echo(f"Error: Facet '{dest}' not found.", err=True) 374 + raise typer.Exit(1) 375 + 376 + entity_slugs = scan_facet_relationships(source) 377 + 378 + open_todos: list[tuple[str, int, todo_module.TodoItem]] = [] 379 + todos_dir = src_path / "todos" 380 + if todos_dir.is_dir(): 381 + for todo_file in sorted(todos_dir.glob("*.jsonl")): 382 + checklist = todo_module.TodoChecklist.load(todo_file.stem, source) 383 + for item in checklist.items: 384 + if not item.completed and not item.cancelled: 385 + open_todos.append((todo_file.stem, item.index, item)) 386 + 387 + open_events: list[tuple[str, int, event_module.CalendarEvent]] = [] 388 + calendar_dir = src_path / "calendar" 389 + if calendar_dir.is_dir(): 390 + for calendar_file in sorted(calendar_dir.glob("*.jsonl")): 391 + event_day = event_module.EventDay.load(calendar_file.stem, source) 392 + for item in event_day.items: 393 + if not item.cancelled: 394 + open_events.append((calendar_file.stem, item.index, item)) 395 + 396 + news_to_copy: list[tuple[Path, Path]] = [] 397 + src_news_dir = src_path / "news" 398 + dst_news_dir = dst_path / "news" 399 + if src_news_dir.is_dir(): 400 + for news_file in sorted(src_news_dir.glob("*.md")): 401 + dest_file = dst_news_dir / news_file.name 402 + if not dest_file.exists(): 403 + news_to_copy.append((news_file, dest_file)) 404 + 405 + typer.echo( 406 + f"Merging '{source}' into '{dest}': " 407 + f"{len(entity_slugs)} entities, {len(open_todos)} open todos, " 408 + f"{len(open_events)} calendar events, {len(news_to_copy)} news files. " 409 + f"This cannot be undone. Proceeding..." 410 + ) 411 + 412 + for entity_id in entity_slugs: 413 + src_dir = src_path / "entities" / entity_id 414 + dst_dir = dst_path / "entities" / entity_id 415 + if dst_dir.exists(): 416 + src_rel = load_facet_relationship(source, entity_id) 417 + dst_rel = load_facet_relationship(dest, entity_id) 418 + if src_rel is not None or dst_rel is not None: 419 + merged_rel = {**(src_rel or {}), **(dst_rel or {})} 420 + save_facet_relationship(dest, entity_id, merged_rel) 421 + 422 + src_obs = load_observations(source, entity_id) 423 + dst_obs = load_observations(dest, entity_id) 424 + seen = {(o.get("content", ""), o.get("observed_at")) for o in dst_obs} 425 + merged_obs = list(dst_obs) 426 + for observation in src_obs: 427 + key = ( 428 + observation.get("content", ""), 429 + observation.get("observed_at"), 430 + ) 431 + if key not in seen: 432 + merged_obs.append(observation) 433 + seen.add(key) 434 + save_observations(dest, entity_id, merged_obs) 435 + shutil.rmtree(str(src_dir)) 436 + else: 437 + dst_dir.parent.mkdir(parents=True, exist_ok=True) 438 + shutil.move(str(src_dir), str(dst_dir)) 439 + 440 + for day, line_number, item in open_todos: 441 + captured_item = item 442 + 443 + def _append_todo( 444 + checklist: todo_module.TodoChecklist, 445 + ) -> tuple[todo_module.TodoChecklist, todo_module.TodoItem]: 446 + new_item = checklist.append_entry( 447 + captured_item.text, 448 + captured_item.nudge, 449 + created_at=captured_item.created_at, 450 + ) 451 + return checklist, new_item 452 + 453 + captured_line_number = line_number 454 + captured_dest = dest 455 + 456 + def _cancel_todo( 457 + checklist: todo_module.TodoChecklist, 458 + ) -> tuple[todo_module.TodoChecklist, todo_module.TodoItem]: 459 + cancelled_item = checklist.cancel_entry( 460 + captured_line_number, 461 + cancelled_reason="moved_to_facet", 462 + moved_to=captured_dest, 463 + ) 464 + return checklist, cancelled_item 465 + 466 + todo_module.TodoChecklist.locked_modify(day, dest, _append_todo) 467 + todo_module.TodoChecklist.locked_modify(day, source, _cancel_todo) 468 + 469 + for day, line_number, item in open_events: 470 + captured_item = item 471 + 472 + def _append_event( 473 + event_day: event_module.EventDay, 474 + ) -> tuple[event_module.EventDay, event_module.CalendarEvent]: 475 + new_item = event_day.append_event( 476 + captured_item.title, 477 + captured_item.start, 478 + captured_item.end, 479 + captured_item.summary, 480 + captured_item.participants, 481 + created_at=captured_item.created_at, 482 + ) 483 + return event_day, new_item 484 + 485 + captured_line_number = line_number 486 + captured_dest = dest 487 + 488 + def _cancel_event( 489 + event_day: event_module.EventDay, 490 + ) -> tuple[event_module.EventDay, event_module.CalendarEvent]: 491 + cancelled_item = event_day.cancel_event( 492 + captured_line_number, 493 + cancelled_reason="moved_to_facet", 494 + moved_to=captured_dest, 495 + ) 496 + return event_day, cancelled_item 497 + 498 + event_module.EventDay.locked_modify(day, dest, _append_event) 499 + event_module.EventDay.locked_modify(day, source, _cancel_event) 500 + 501 + if news_to_copy: 502 + dst_news_dir.mkdir(parents=True, exist_ok=True) 503 + for src_file, dest_file in news_to_copy: 504 + shutil.copy2(src_file, dest_file) 505 + 506 + params: dict[str, object] = { 507 + "source": source, 508 + "dest": dest, 509 + "entity_count": len(entity_slugs), 510 + "todo_count": len(open_todos), 511 + "calendar_count": len(open_events), 512 + "news_count": len(news_to_copy), 513 + } 514 + if consent: 515 + params["consent"] = True 516 + log_call_action(facet=None, action="facet_merge", params=params) 517 + 518 + delete_facet(source) 519 + 520 + subprocess.run( 521 + ["sol", "indexer", "--rescan-full"], 522 + check=False, 523 + capture_output=True, 524 + ) 525 + 526 + typer.echo(f"Merged '{source}' into '{dest}'. Index rebuild started.") 337 527 338 528 339 529 @app.command()