personal memory agent
0
fork

Configure Feed

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

feat(utils): support user config journal default

Extend get_journal_info() resolution order to: env -> user config (~/.config/solstone/config.toml) -> source-tree -> ~/Documents/journal default.

New module think/user_config.py exposes default_journal(), config_path(), read_user_config(), and write_user_config() for a future first-run setup tool to persist the journal location without touching the wrapper or env vars.

get_journal_info() no longer raises; SolstoneNotConfigured now only fires when get_journal()'s mkdir fails. sol config show learns sensible labels for the new config and default sources. docs/environment.md updated to reflect the four-step order.

+267 -23
+7 -4
docs/environment.md
··· 4 4 5 5 `get_journal()` / `get_journal_info()` in `think.utils` are the canonical journal resolvers. Trust them unconditionally. 6 6 7 - Resolver order: 7 + Resolver order (with the source label `get_journal_info()` returns): 8 + 9 + 1. `SOLSTONE_JOURNAL` env var, when set and non-empty → `"env"` 10 + 2. `~/.config/solstone/config.toml`, when it has a non-empty `journal = "..."` key → `"config"` 11 + 3. source-tree fallback: `<project_root>/journal` when both `<project_root>/pyproject.toml` and `<project_root>/.git` exist → `"source"` 12 + 4. built-in default: `~/Documents/journal` → `"default"` 8 13 9 - 1. `SOLSTONE_JOURNAL` if it is set 10 - 2. source-tree fallback: `<project_root>/journal` when both `<project_root>/pyproject.toml` and `<project_root>/.git` exist 11 - 3. `SolstoneNotConfigured` if neither branch resolves 14 + `get_journal_info()` no longer raises — there is always a resolved path. `get_journal()` raises `SolstoneNotConfigured` only when `os.makedirs` on the resolved path fails. 12 15 13 16 Who sets `SOLSTONE_JOURNAL`: 14 17
+79 -7
tests/test_think_utils.py
··· 992 992 assert path == str(tmp_path) 993 993 assert source == "env" 994 994 995 - def test_get_journal_info_source_tree_fallback(self, monkeypatch): 995 + def test_get_journal_info_source_tree_fallback(self, monkeypatch, tmp_path): 996 996 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 997 + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) 997 998 998 999 path, source = get_journal_info() 999 1000 1000 1001 assert path == str(Path(get_project_root()) / "journal") 1001 1002 assert source == "source" 1002 1003 1003 - def test_get_journal_info_raises_when_unconfigured(self, monkeypatch, tmp_path): 1004 + def test_get_journal_info_returns_default_when_nothing_else_resolves( 1005 + self, monkeypatch, tmp_path 1006 + ): 1004 1007 import think.utils as utils 1005 1008 1006 1009 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1007 1010 monkeypatch.setattr(utils, "get_project_root", lambda: str(tmp_path)) 1011 + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) 1008 1012 1009 - with pytest.raises(SolstoneNotConfigured) as excinfo: 1010 - get_journal_info() 1013 + path, source = get_journal_info() 1011 1014 1012 - message = str(excinfo.value) 1013 - assert "SOLSTONE_JOURNAL" in message 1014 - assert str(tmp_path) in message 1015 + assert source == "default" 1016 + assert path == str(tmp_path / "Documents" / "journal") 1015 1017 1016 1018 def test_get_journal_mkdir_failure_raises_solstone_not_configured( 1017 1019 self, monkeypatch, tmp_path ··· 1031 1033 1032 1034 assert excinfo.value.path == str(target) 1033 1035 assert isinstance(excinfo.value.error, PermissionError) 1036 + 1037 + 1038 + class TestGetJournalInfoConfigBranch: 1039 + def write_config(self, home: Path, content: str) -> Path: 1040 + cfg = home / ".config" / "solstone" / "config.toml" 1041 + cfg.parent.mkdir(parents=True) 1042 + cfg.write_text(content, encoding="utf-8") 1043 + return cfg 1044 + 1045 + def test_config_branch_used_when_env_unset_and_config_present( 1046 + self, monkeypatch, tmp_path 1047 + ): 1048 + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1049 + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) 1050 + self.write_config(tmp_path, 'journal = "/tmp/from-config"\n') 1051 + 1052 + path, source = get_journal_info() 1053 + 1054 + assert path == "/tmp/from-config" 1055 + assert source == "config" 1056 + 1057 + def test_env_wins_over_config(self, monkeypatch, tmp_path): 1058 + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) 1059 + self.write_config(tmp_path, 'journal = "/tmp/from-config"\n') 1060 + monkeypatch.setenv("SOLSTONE_JOURNAL", "/tmp/from-env") 1061 + 1062 + path, source = get_journal_info() 1063 + 1064 + assert path == "/tmp/from-env" 1065 + assert source == "env" 1066 + 1067 + def test_empty_env_treated_as_unset_and_falls_through_to_config( 1068 + self, monkeypatch, tmp_path 1069 + ): 1070 + monkeypatch.setenv("SOLSTONE_JOURNAL", "") 1071 + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) 1072 + self.write_config(tmp_path, 'journal = "/tmp/from-config"\n') 1073 + 1074 + path, source = get_journal_info() 1075 + 1076 + assert path == "/tmp/from-config" 1077 + assert source == "config" 1078 + 1079 + def test_empty_journal_key_in_config_falls_through(self, monkeypatch, tmp_path): 1080 + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1081 + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) 1082 + self.write_config(tmp_path, 'journal = ""\n') 1083 + 1084 + _path, source = get_journal_info() 1085 + 1086 + assert source == "source" 1087 + 1088 + def test_whitespace_only_journal_key_falls_through(self, monkeypatch, tmp_path): 1089 + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1090 + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) 1091 + self.write_config(tmp_path, 'journal = " "\n') 1092 + 1093 + _path, source = get_journal_info() 1094 + 1095 + assert source == "source" 1096 + 1097 + def test_config_branch_wins_over_source_branch(self, monkeypatch, tmp_path): 1098 + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1099 + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) 1100 + self.write_config(tmp_path, 'journal = "/tmp/from-config"\n') 1101 + 1102 + path, source = get_journal_info() 1103 + 1104 + assert path == "/tmp/from-config" 1105 + assert source == "config"
+98
tests/test_user_config.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + from pathlib import Path 7 + 8 + import pytest 9 + 10 + from think.user_config import ( 11 + config_path, 12 + default_journal, 13 + read_user_config, 14 + write_user_config, 15 + ) 16 + 17 + 18 + @pytest.fixture 19 + def fake_home(tmp_path, monkeypatch): 20 + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) 21 + return tmp_path 22 + 23 + 24 + def test_default_journal_returns_documents_journal(fake_home): 25 + assert default_journal() == str(fake_home / "Documents" / "journal") 26 + 27 + 28 + def test_config_path_returns_user_dot_config(fake_home): 29 + assert config_path() == fake_home / ".config" / "solstone" / "config.toml" 30 + 31 + 32 + def test_read_missing_file_returns_empty_dict(fake_home): 33 + assert read_user_config() == {} 34 + 35 + 36 + def test_read_malformed_toml_returns_empty_dict(fake_home): 37 + cfg = config_path() 38 + cfg.parent.mkdir(parents=True) 39 + cfg.write_text("this is not = toml", encoding="utf-8") 40 + 41 + assert read_user_config() == {} 42 + 43 + 44 + def test_read_missing_journal_key_returns_empty_dict_keys(fake_home): 45 + cfg = config_path() 46 + cfg.parent.mkdir(parents=True) 47 + cfg.write_text('other = "x"\n', encoding="utf-8") 48 + 49 + assert read_user_config() == {"other": "x"} 50 + 51 + 52 + def test_read_drops_non_string_values(fake_home): 53 + cfg = config_path() 54 + cfg.parent.mkdir(parents=True) 55 + cfg.write_text('journal = 123\nname = "ok"\n', encoding="utf-8") 56 + 57 + assert read_user_config() == {"name": "ok"} 58 + 59 + 60 + def test_write_then_read_roundtrip_plain_path(fake_home): 61 + write_user_config(journal="/tmp/x") 62 + 63 + assert read_user_config() == {"journal": "/tmp/x"} 64 + 65 + 66 + def test_write_then_read_roundtrip_path_with_spaces(fake_home): 67 + write_user_config(journal="/tmp/some path/journal") 68 + 69 + assert read_user_config() == {"journal": "/tmp/some path/journal"} 70 + 71 + 72 + def test_write_then_read_roundtrip_path_with_quotes_and_backslashes(fake_home): 73 + journal = '/tmp/with"quote/and\\backslash' 74 + 75 + write_user_config(journal=journal) 76 + 77 + assert read_user_config() == {"journal": journal} 78 + 79 + 80 + def test_write_creates_parent_directory(fake_home): 81 + write_user_config(journal="/x") 82 + 83 + assert config_path().parent.is_dir() 84 + 85 + 86 + def test_write_atomic_no_tmp_left_behind(fake_home): 87 + write_user_config(journal="/x") 88 + 89 + leftovers = [ 90 + path 91 + for path in config_path().parent.iterdir() 92 + if path.name.startswith(".tmp_config") 93 + ] 94 + assert leftovers == [] 95 + 96 + 97 + def test_write_returns_config_path(fake_home): 98 + assert write_user_config(journal="/x") == config_path()
+5 -1
think/config_cli.py
··· 552 552 user_source = "wrapper-embedded" 553 553 else: 554 554 user_source = "caller-override" 555 - else: 555 + elif info_source == "config": 556 + user_source = "user config (~/.config/solstone/config.toml)" 557 + elif info_source == "default": 558 + user_source = "built-in default (~/Documents/journal)" 559 + else: # "source" 556 560 user_source = "source-tree fallback" 557 561 558 562 print(f"path: {path}")
+57
think/user_config.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Read/write the per-user solstone config at ~/.config/solstone/config.toml. 5 + 6 + Single TOML key today: ``journal``. 7 + """ 8 + 9 + from __future__ import annotations 10 + 11 + import os 12 + import tempfile 13 + from pathlib import Path 14 + from typing import Any 15 + 16 + import tomllib 17 + 18 + 19 + def default_journal() -> str: 20 + return str(Path.home() / "Documents" / "journal") 21 + 22 + 23 + def config_path() -> Path: 24 + return Path.home() / ".config" / "solstone" / "config.toml" 25 + 26 + 27 + def read_user_config() -> dict[str, str]: 28 + try: 29 + data: dict[str, Any] = tomllib.loads(config_path().read_text(encoding="utf-8")) 30 + except (FileNotFoundError, OSError, tomllib.TOMLDecodeError): 31 + return {} 32 + 33 + return {k: v for k, v in data.items() if isinstance(v, str)} 34 + 35 + 36 + def write_user_config(*, journal: str) -> Path: 37 + cfg = config_path() 38 + cfg.parent.mkdir(parents=True, exist_ok=True) 39 + 40 + escaped = journal.replace("\\", "\\\\").replace('"', '\\"') 41 + content = f'journal = "{escaped}"\n' 42 + 43 + fd, tmp_name = tempfile.mkstemp( 44 + prefix=".tmp_config", 45 + suffix=".toml", 46 + dir=cfg.parent, 47 + ) 48 + tmp_path = Path(tmp_name) 49 + try: 50 + with os.fdopen(fd, "w", encoding="utf-8") as fh: 51 + fh.write(content) 52 + os.replace(tmp_path, cfg) 53 + except Exception: 54 + os.unlink(tmp_path) 55 + raise 56 + 57 + return cfg
+21 -11
think/utils.py
··· 104 104 def get_journal_info() -> tuple[str, str]: 105 105 """Resolve the journal path and its source. 106 106 107 - Returns ``(path, source)`` where source is one of ``{"env", "source"}``: 107 + Returns ``(path, source)`` where source is one of 108 + ``{"env", "config", "source", "default"}``: 108 109 109 110 - ``"env"`` — ``SOLSTONE_JOURNAL`` is set 111 + - ``"config"`` — ``~/.config/solstone/config.toml`` has a non-empty 112 + ``journal`` key 110 113 - ``"source"`` — running from a source checkout; journal is 111 114 ``<project_root>/journal`` 115 + - ``"default"`` — built-in default at ``~/Documents/journal`` 112 116 113 - Raises ``SolstoneNotConfigured`` if neither branch resolves. The wrapper 114 - at ``~/.local/bin/sol`` is responsible for setting ``SOLSTONE_JOURNAL`` 115 - on installed runs; tests set it via the autouse fixture. 117 + The wrapper at ``~/.local/bin/sol`` is responsible for setting 118 + ``SOLSTONE_JOURNAL`` on installed runs; tests set it via the autouse 119 + fixture. 116 120 """ 117 121 env_path = os.environ.get("SOLSTONE_JOURNAL") 118 122 if env_path: 119 123 return env_path, "env" 120 124 125 + from think.user_config import read_user_config 126 + 127 + user_cfg_path = read_user_config().get("journal", "").strip() 128 + if user_cfg_path: 129 + return user_cfg_path, "config" 130 + 121 131 project_root = Path(get_project_root()) 122 132 if (project_root / "pyproject.toml").exists() and (project_root / ".git").exists(): 123 133 return str(project_root / "journal"), "source" 124 134 125 - raise SolstoneNotConfigured( 126 - "solstone is not configured: set SOLSTONE_JOURNAL or run from a " 127 - f"source checkout (project_root={project_root})" 128 - ) 135 + from think.user_config import default_journal 136 + 137 + return default_journal(), "default" 129 138 130 139 131 140 def get_journal() -> str: 132 141 """Return the journal path. Auto-creates the directory. 133 142 134 - Reads ``SOLSTONE_JOURNAL`` if set, otherwise falls back to the 135 - source-tree journal at ``<project_root>/journal``. Raises 136 - ``SolstoneNotConfigured`` if neither branch resolves or if mkdir fails. 143 + Resolves the journal from ``SOLSTONE_JOURNAL``, user config, the 144 + source-tree journal at ``<project_root>/journal``, or the built-in 145 + ``~/Documents/journal`` default. Raises ``SolstoneNotConfigured`` only if 146 + mkdir fails for the resolved path. 137 147 138 148 Trust this function — never bypass it, cache its result, or set 139 149 ``SOLSTONE_JOURNAL`` from application code, agent prompts, subprocess