personal memory agent
0
fork

Configure Feed

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

Add sol config facet rename command

Adds `sol config facet rename <old> <new>` to rename a facet ID.
Handles the four operations that matter: directory rename, convey
config references (selected/order), chat metadata facet fields,
and search index rebuild.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+285 -2
+161
tests/test_facet_rename.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Tests for facet rename functionality.""" 5 + 6 + import json 7 + 8 + import pytest 9 + 10 + from think.facets import rename_facet 11 + 12 + 13 + @pytest.fixture 14 + def journal(tmp_path, monkeypatch): 15 + """Create a minimal journal with a facet for rename tests.""" 16 + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 17 + 18 + # Create facet directory with facet.json 19 + facet_dir = tmp_path / "facets" / "old-name" 20 + facet_dir.mkdir(parents=True) 21 + (facet_dir / "facet.json").write_text( 22 + json.dumps({"title": "Old Facet", "description": "Test facet"}) 23 + ) 24 + 25 + # Create some facet content to verify it moves 26 + (facet_dir / "todos").mkdir() 27 + (facet_dir / "todos" / "20260101.jsonl").write_text('{"text": "Buy groceries"}\n') 28 + 29 + return tmp_path 30 + 31 + 32 + def test_rename_moves_directory(journal): 33 + """Rename moves the facet directory.""" 34 + rename_facet("old-name", "new-name") 35 + 36 + assert not (journal / "facets" / "old-name").exists() 37 + assert (journal / "facets" / "new-name").is_dir() 38 + assert (journal / "facets" / "new-name" / "facet.json").exists() 39 + 40 + # Content preserved 41 + todos = journal / "facets" / "new-name" / "todos" / "20260101.jsonl" 42 + assert todos.read_text().strip() == '{"text": "Buy groceries"}' 43 + 44 + 45 + def test_rename_updates_convey_config_selected(journal): 46 + """Rename updates facets.selected in convey config.""" 47 + config_dir = journal / "config" 48 + config_dir.mkdir(parents=True) 49 + (config_dir / "convey.json").write_text( 50 + json.dumps({"facets": {"selected": "old-name", "order": ["other"]}}) 51 + ) 52 + 53 + rename_facet("old-name", "new-name") 54 + 55 + config = json.loads((config_dir / "convey.json").read_text()) 56 + assert config["facets"]["selected"] == "new-name" 57 + assert config["facets"]["order"] == ["other"] # unchanged 58 + 59 + 60 + def test_rename_updates_convey_config_order(journal): 61 + """Rename replaces old name in facets.order.""" 62 + config_dir = journal / "config" 63 + config_dir.mkdir(parents=True) 64 + (config_dir / "convey.json").write_text( 65 + json.dumps( 66 + {"facets": {"selected": "other", "order": ["work", "old-name", "personal"]}} 67 + ) 68 + ) 69 + 70 + rename_facet("old-name", "new-name") 71 + 72 + config = json.loads((config_dir / "convey.json").read_text()) 73 + assert config["facets"]["selected"] == "other" # unchanged 74 + assert config["facets"]["order"] == ["work", "new-name", "personal"] 75 + 76 + 77 + def test_rename_updates_chat_metadata(journal): 78 + """Rename updates facet field in matching chat metadata files.""" 79 + chats_dir = journal / "apps" / "chat" / "chats" 80 + chats_dir.mkdir(parents=True) 81 + 82 + # Chat with matching facet 83 + (chats_dir / "111.json").write_text( 84 + json.dumps({"ts": 111, "title": "Chat 1", "facet": "old-name"}) 85 + ) 86 + # Chat with different facet 87 + (chats_dir / "222.json").write_text( 88 + json.dumps({"ts": 222, "title": "Chat 2", "facet": "work"}) 89 + ) 90 + # Chat with no facet 91 + (chats_dir / "333.json").write_text(json.dumps({"ts": 333, "title": "Chat 3"})) 92 + 93 + rename_facet("old-name", "new-name") 94 + 95 + chat1 = json.loads((chats_dir / "111.json").read_text()) 96 + assert chat1["facet"] == "new-name" 97 + 98 + chat2 = json.loads((chats_dir / "222.json").read_text()) 99 + assert chat2["facet"] == "work" # unchanged 100 + 101 + chat3 = json.loads((chats_dir / "333.json").read_text()) 102 + assert "facet" not in chat3 # unchanged 103 + 104 + 105 + def test_rename_old_not_found(journal): 106 + """Rename fails if old facet doesn't exist.""" 107 + with pytest.raises(ValueError, match="does not exist"): 108 + rename_facet("nonexistent", "new-name") 109 + 110 + 111 + def test_rename_new_already_exists(journal): 112 + """Rename fails if new facet name already exists.""" 113 + (journal / "facets" / "new-name").mkdir(parents=True) 114 + 115 + with pytest.raises(ValueError, match="already exists"): 116 + rename_facet("old-name", "new-name") 117 + 118 + 119 + def test_rename_invalid_new_name(journal): 120 + """Rename fails with invalid new name.""" 121 + with pytest.raises(ValueError, match="Invalid facet name"): 122 + rename_facet("old-name", "Bad Name!") 123 + 124 + with pytest.raises(ValueError, match="Invalid facet name"): 125 + rename_facet("old-name", "123start") 126 + 127 + with pytest.raises(ValueError, match="Invalid facet name"): 128 + rename_facet("old-name", "UPPER") 129 + 130 + 131 + def test_rename_no_convey_config(journal): 132 + """Rename succeeds when convey config doesn't exist.""" 133 + rename_facet("old-name", "new-name") 134 + 135 + assert (journal / "facets" / "new-name").is_dir() 136 + 137 + 138 + def test_rename_no_chat_dir(journal): 139 + """Rename succeeds when chat directory doesn't exist.""" 140 + rename_facet("old-name", "new-name") 141 + 142 + assert (journal / "facets" / "new-name").is_dir() 143 + 144 + 145 + def test_rename_rebuilds_index(journal): 146 + """Rename creates a fresh search index.""" 147 + # Create an old index file 148 + index_dir = journal / "indexer" 149 + index_dir.mkdir() 150 + (index_dir / "journal.sqlite").write_text("old data") 151 + 152 + rename_facet("old-name", "new-name") 153 + 154 + # Old index should be gone (reset removes it) 155 + # New index may or may not exist depending on content, 156 + # but the old one should not remain as-is 157 + old_content = None 158 + index_file = index_dir / "journal.sqlite" 159 + if index_file.exists(): 160 + old_content = index_file.read_text(errors="replace") 161 + assert old_content != "old data"
+22 -2
think/config_cli.py
··· 7 7 its source for shell integration. 8 8 9 9 Usage: 10 - sol config Show full config JSON 11 - sol config env Show JOURNAL_PATH and source 10 + sol config Show full config JSON 11 + sol config env Show JOURNAL_PATH and source 12 + sol config facet rename OLD NEW Rename a facet 12 13 """ 13 14 14 15 from __future__ import annotations 15 16 16 17 import argparse 17 18 import json 19 + import sys 18 20 19 21 from think.utils import get_config, get_journal_info, setup_cli 20 22 ··· 24 26 subparsers = parser.add_subparsers(dest="subcommand") 25 27 subparsers.add_parser("env", help="Show journal path and source") 26 28 29 + # facet subcommand with its own sub-subcommands 30 + facet_parser = subparsers.add_parser("facet", help="Facet management") 31 + facet_sub = facet_parser.add_subparsers(dest="facet_action") 32 + rename_parser = facet_sub.add_parser("rename", help="Rename a facet") 33 + rename_parser.add_argument("old_name", help="Current facet name") 34 + rename_parser.add_argument("new_name", help="New facet name") 35 + 27 36 # Capture journal info BEFORE setup_cli() loads .env 28 37 journal_info = get_journal_info() 29 38 ··· 32 41 if args.subcommand == "env": 33 42 path, source = journal_info 34 43 print(f"JOURNAL_PATH={path} (from {source})") 44 + elif args.subcommand == "facet": 45 + if args.facet_action == "rename": 46 + from think.facets import rename_facet 47 + 48 + try: 49 + rename_facet(args.old_name, args.new_name) 50 + except ValueError as exc: 51 + print(f"Error: {exc}", file=sys.stderr) 52 + sys.exit(1) 53 + else: 54 + facet_parser.print_help() 35 55 else: 36 56 config = get_config() 37 57 print(json.dumps(config, indent=2))
+102
think/facets.py
··· 888 888 meta["indexer"] = {"topic": "action"} 889 889 890 890 return chunks, meta 891 + 892 + 893 + def rename_facet(old_name: str, new_name: str) -> None: 894 + """Rename a facet by updating its directory, config references, and chat metadata. 895 + 896 + Performs the following steps: 897 + 1. Rename facets/{old}/ directory to facets/{new}/ 898 + 2. Update config/convey.json (facets.selected, facets.order) 899 + 3. Update apps/chat/chats/*.json metadata files 900 + 4. Reset and rebuild the search index 901 + 902 + Args: 903 + old_name: Current facet name (must exist) 904 + new_name: New facet name (must not already exist) 905 + 906 + Raises: 907 + ValueError: If names are invalid or preconditions fail 908 + """ 909 + from think.indexer.journal import reset_journal_index, scan_journal 910 + 911 + journal = get_journal() 912 + facets_dir = Path(journal) / "facets" 913 + 914 + # Validate new name format (lowercase alphanumeric + hyphens/underscores) 915 + if not re.fullmatch(r"[a-z][a-z0-9_-]*", new_name): 916 + raise ValueError( 917 + f"Invalid facet name '{new_name}': must be lowercase, start with a letter, " 918 + "and contain only letters, digits, hyphens, or underscores" 919 + ) 920 + 921 + old_path = facets_dir / old_name 922 + new_path = facets_dir / new_name 923 + 924 + if not old_path.is_dir(): 925 + raise ValueError(f"Facet '{old_name}' does not exist") 926 + if new_path.exists(): 927 + raise ValueError(f"Facet '{new_name}' already exists") 928 + 929 + # Step 1: Rename the directory 930 + print(f"Renaming facets/{old_name}/ → facets/{new_name}/") 931 + os.rename(old_path, new_path) 932 + 933 + # Step 2: Update config/convey.json 934 + convey_config_path = Path(journal) / "config" / "convey.json" 935 + if convey_config_path.exists(): 936 + try: 937 + with open(convey_config_path, "r", encoding="utf-8") as f: 938 + config = json.load(f) 939 + 940 + changed = False 941 + facets_config = config.get("facets", {}) 942 + 943 + if facets_config.get("selected") == old_name: 944 + facets_config["selected"] = new_name 945 + changed = True 946 + 947 + order = facets_config.get("order", []) 948 + if old_name in order: 949 + facets_config["order"] = [ 950 + new_name if name == old_name else name for name in order 951 + ] 952 + changed = True 953 + 954 + if changed: 955 + config["facets"] = facets_config 956 + with open(convey_config_path, "w", encoding="utf-8") as f: 957 + json.dump(config, f, indent=2, ensure_ascii=False) 958 + f.write("\n") 959 + print("Updated config/convey.json") 960 + else: 961 + print("No changes needed in config/convey.json") 962 + except (json.JSONDecodeError, OSError) as exc: 963 + logging.warning("Failed to update convey config: %s", exc) 964 + 965 + # Step 3: Update chat metadata 966 + chats_dir = Path(journal) / "apps" / "chat" / "chats" 967 + updated_chats = 0 968 + if chats_dir.is_dir(): 969 + for chat_file in chats_dir.glob("*.json"): 970 + try: 971 + with open(chat_file, "r", encoding="utf-8") as f: 972 + chat_data = json.load(f) 973 + 974 + if chat_data.get("facet") == old_name: 975 + chat_data["facet"] = new_name 976 + with open(chat_file, "w", encoding="utf-8") as f: 977 + json.dump(chat_data, f, indent=2, ensure_ascii=False) 978 + f.write("\n") 979 + updated_chats += 1 980 + except (json.JSONDecodeError, OSError): 981 + continue 982 + 983 + if updated_chats: 984 + print(f"Updated {updated_chats} chat metadata file(s)") 985 + else: 986 + print("No chat metadata needed updating") 987 + 988 + # Step 4: Rebuild search index 989 + print("Rebuilding search index...") 990 + reset_journal_index(journal) 991 + scan_journal(journal, full=True) 992 + print("Done")