personal memory agent
0
fork

Configure Feed

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

feat(cli): require supervisor-up for writer commands (phase 2 of migration-at-startup)

Add `is_solstone_up()` and `require_solstone()` in `think/utils.py`. Each
writer/stack-dependent CLI calls `require_solstone()` after arg parsing to
exit(1) with a clear message when convey isn't reachable — preventing
races with Phase 1's supervisor-startup migrations and half-written state.

Guarded 20 argparse entry points and 13 Typer sub-apps (one callback per
app). Carve-outs (supervisor, convey, maint, service, top, health, stats,
talent, export, health/transcripts/support sub-apps) are untouched.

Bypass with SOL_SKIP_SUPERVISOR_CHECK=1. tests/conftest.py sets this for
all unit tests so the suite runs without a live stack.

Ambiguous sub-app verdicts (from prep audit):
- transcripts → SKIP (all read)
- support → SKIP (external-only, no journal/stack dependency)
- speakers → GATE (dominant writes)
- awareness → GATE (imports, log write)
- photos → GATE (entity_signals writes)
- sol → GATE (identity writes)

+265 -17
+7
apps/awareness/call.py
··· 10 10 11 11 import typer 12 12 13 + from think.utils import require_solstone 14 + 13 15 app = typer.Typer(help="Awareness system — solstone's self-knowledge.") 16 + 17 + 18 + @app.callback() 19 + def _require_up() -> None: 20 + require_solstone() 14 21 15 22 16 23 @app.command("status")
+6
apps/calendar/call.py
··· 15 15 16 16 from apps.calendar import event 17 17 from think.facets import log_call_action 18 + from think.utils import require_solstone 18 19 19 20 app = typer.Typer(help="Calendar event management.") 21 + 22 + 23 + @app.callback() 24 + def _require_up() -> None: 25 + require_solstone() 20 26 21 27 22 28 def _print_day_facet(day: str, facet: str) -> bool:
+12 -1
apps/entities/call.py
··· 42 42 get_entity_strength, 43 43 search_entities, 44 44 ) 45 - from think.utils import get_journal, now_ms, resolve_sol_day, resolve_sol_facet 45 + from think.utils import ( 46 + get_journal, 47 + now_ms, 48 + require_solstone, 49 + resolve_sol_day, 50 + resolve_sol_facet, 51 + ) 46 52 47 53 app = typer.Typer(help="Entity management.") 54 + 55 + 56 + @app.callback() 57 + def _require_up() -> None: 58 + require_solstone() 48 59 49 60 50 61 def _clear_all_caches():
+7 -1
apps/import/call.py
··· 29 29 load_facet_relationship, 30 30 save_facet_relationship, 31 31 ) 32 - from think.utils import get_journal 32 + from think.utils import get_journal, require_solstone 33 33 34 34 app = typer.Typer(help="Import review and resolution.") 35 + 36 + 37 + @app.callback() 38 + def _require_up() -> None: 39 + require_solstone() 40 + 35 41 36 42 ingest = import_module("apps.import.ingest") 37 43 journal_sources = import_module("apps.import.journal_sources")
+7
apps/photos/call.py
··· 5 5 6 6 import typer 7 7 8 + from think.utils import require_solstone 9 + 8 10 app = typer.Typer( 9 11 name="photos", 10 12 help="Photo intelligence from macOS Photos library.", 11 13 no_args_is_help=True, 12 14 ) 15 + 16 + 17 + @app.callback() 18 + def _require_up() -> None: 19 + require_solstone() 13 20 14 21 15 22 @app.command("sync")
+9
apps/settings/call.py
··· 14 14 15 15 import typer 16 16 17 + from think.utils import require_solstone 18 + 17 19 app = typer.Typer( 18 20 help="Journal settings — keys, providers, transcription, identity, and observer." 19 21 ) 22 + 23 + 24 + @app.callback() 25 + def _require_up() -> None: 26 + require_solstone() 27 + 28 + 20 29 keys_app = typer.Typer(help="API key management.") 21 30 app.add_typer(keys_app, name="keys") 22 31 providers_app = typer.Typer(help="AI provider configuration.")
+7
apps/sol/call.py
··· 14 14 15 15 import typer 16 16 17 + from think.utils import require_solstone 18 + 17 19 app = typer.Typer(help="Agent identity — name and status.") 20 + 21 + 22 + @app.callback() 23 + def _require_up() -> None: 24 + require_solstone() 18 25 19 26 20 27 def _get_agent_config() -> dict:
+7
apps/speakers/call.py
··· 27 27 28 28 import typer 29 29 30 + from think.utils import require_solstone 31 + 30 32 app = typer.Typer( 31 33 name="speakers", 32 34 help="Speaker voiceprint management.", 33 35 no_args_is_help=True, 34 36 ) 37 + 38 + 39 + @app.callback() 40 + def _require_up() -> None: 41 + require_solstone() 35 42 36 43 37 44 @app.command("status")
+6 -1
apps/todos/call.py
··· 12 12 13 13 from apps.todos import todo 14 14 from think.facets import log_call_action 15 - from think.utils import get_journal 15 + from think.utils import get_journal, require_solstone 16 16 17 17 app = typer.Typer(help="Todo checklist management.") 18 + 19 + 20 + @app.callback() 21 + def _require_up() -> None: 22 + require_solstone() 18 23 19 24 20 25 def _print_day_facet(day: str, facet: str) -> bool:
+2 -1
convey/restart.py
··· 12 12 import time 13 13 14 14 from think.callosum import CallosumConnection 15 - from think.utils import read_service_port, setup_cli 15 + from think.utils import read_service_port, require_solstone, setup_cli 16 16 17 17 18 18 def _format_log(timestamp: float, stream: str, line: str) -> str: ··· 178 178 ) 179 179 180 180 args = setup_cli(parser) 181 + require_solstone() 181 182 182 183 from think.utils import get_journal 183 184
+2 -1
convey/screenshot.py
··· 12 12 13 13 from playwright.sync_api import sync_playwright 14 14 15 - from think.utils import read_service_port, setup_cli 15 + from think.utils import read_service_port, require_solstone, setup_cli 16 16 17 17 18 18 class _HelpOnErrorParser(argparse.ArgumentParser): ··· 169 169 ) 170 170 171 171 args = setup_cli(parser) 172 + require_solstone() 172 173 173 174 # Determine port: CLI arg takes precedence, then port file, then error 174 175 if args.port is not None:
+2
observe/describe.py
··· 41 41 get_config, 42 42 get_journal, 43 43 journal_relative_path, 44 + require_solstone, 44 45 setup_cli, 45 46 ) 46 47 ··· 902 903 help="Reprocess file, overwriting existing outputs", 903 904 ) 904 905 args = setup_cli(parser) 906 + require_solstone() 905 907 906 908 video_path = Path(args.video_path) 907 909 if not video_path.exists():
+2 -1
observe/observer_cli.py
··· 33 33 save_observer, 34 34 ) 35 35 from apps.utils import log_app_action 36 - from think.utils import now_ms, setup_cli 36 + from think.utils import now_ms, require_solstone, setup_cli 37 37 38 38 logger = logging.getLogger(__name__) 39 39 ··· 458 458 ) 459 459 460 460 args = setup_cli(parser) 461 + require_solstone() 461 462 462 463 # Bridge journal path to convey.state so apps.utils resolves correctly 463 464 # (setup_cli initializes the journal, but convey.state needs it too)
+2
observe/sense.py
··· 33 33 iter_segments, 34 34 journal_relative_path, 35 35 now_ms, 36 + require_solstone, 36 37 resolve_journal_path, 37 38 setup_cli, 38 39 ) ··· 1069 1070 help="Show what would be processed (or deleted with --reprocess) without making changes", 1070 1071 ) 1071 1072 args = setup_cli(parser) 1073 + require_solstone() 1072 1074 1073 1075 journal = Path(get_journal()) 1074 1076
+2
observe/transcribe/main.py
··· 78 78 get_journal, 79 79 iter_segments, 80 80 journal_relative_path, 81 + require_solstone, 81 82 resolve_journal_path, 82 83 setup_cli, 83 84 ) ··· 806 807 help=f"STT backend to use (overrides config, default: {DEFAULT_BACKEND})", 807 808 ) 808 809 args = setup_cli(parser) 810 + require_solstone() 809 811 810 812 if args.all and args.audio_path: 811 813 parser.error("--all and audio_path are mutually exclusive")
+2
observe/transfer.py
··· 35 35 get_journal, 36 36 iter_segments, 37 37 now_ms, 38 + require_solstone, 38 39 setup_cli, 39 40 ) 40 41 ··· 692 693 ) 693 694 694 695 args = setup_cli(parser) 696 + require_solstone() 695 697 696 698 if args.command == "export": 697 699 try:
+1
tests/conftest.py
··· 34 34 "_SOLSTONE_JOURNAL_OVERRIDE", 35 35 str(Path("tests/fixtures/journal").resolve()), 36 36 ) 37 + monkeypatch.setenv("SOL_SKIP_SUPERVISOR_CHECK", "1") 37 38 38 39 39 40 @pytest.fixture(autouse=True)
+96
tests/test_think_utils.py
··· 6 6 import argparse 7 7 import json 8 8 import os 9 + import socket 9 10 import sys 10 11 import tempfile 11 12 from datetime import time ··· 825 826 # Now it should exist 826 827 assert health_dir.exists() 827 828 assert (health_dir / "new_service.port").read_text() == "9999" 829 + 830 + 831 + class TestSolstoneGuard: 832 + """Tests for solstone availability guard helpers.""" 833 + 834 + def test_is_solstone_up_false_without_port_file(self, monkeypatch, tmp_path): 835 + """Missing convey port file reports stack down.""" 836 + from think.utils import is_solstone_up 837 + 838 + monkeypatch.delenv("SOL_SKIP_SUPERVISOR_CHECK", raising=False) 839 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 840 + 841 + assert is_solstone_up() is False 842 + 843 + def test_is_solstone_up_false_with_closed_port(self, monkeypatch, tmp_path): 844 + """Stale convey port file reports stack down.""" 845 + from think.utils import is_solstone_up, write_service_port 846 + 847 + monkeypatch.delenv("SOL_SKIP_SUPERVISOR_CHECK", raising=False) 848 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 849 + 850 + with socket.socket() as sock: 851 + sock.bind(("127.0.0.1", 0)) 852 + stale_port = sock.getsockname()[1] 853 + 854 + write_service_port("convey", stale_port) 855 + assert is_solstone_up() is False 856 + 857 + def test_is_solstone_up_true_with_listening_server(self, monkeypatch, tmp_path): 858 + """Listening convey port reports stack up.""" 859 + from think.utils import is_solstone_up, write_service_port 860 + 861 + monkeypatch.delenv("SOL_SKIP_SUPERVISOR_CHECK", raising=False) 862 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 863 + 864 + with socket.socket() as server: 865 + server.bind(("127.0.0.1", 0)) 866 + server.listen(1) 867 + write_service_port("convey", server.getsockname()[1]) 868 + assert is_solstone_up() is True 869 + 870 + def test_require_solstone_exits_with_message_when_down( 871 + self, monkeypatch, tmp_path, capsys 872 + ): 873 + """Guard exits with the expected message when convey is unavailable.""" 874 + from think.utils import require_solstone 875 + 876 + monkeypatch.delenv("SOL_SKIP_SUPERVISOR_CHECK", raising=False) 877 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 878 + 879 + with pytest.raises(SystemExit) as excinfo: 880 + require_solstone() 881 + 882 + captured = capsys.readouterr() 883 + assert excinfo.value.code == 1 884 + assert captured.out == "" 885 + assert ( 886 + captured.err 887 + == "sol: solstone isn't running. Start it with 'sol up' and retry.\n" 888 + ) 889 + 890 + def test_require_solstone_returns_silently_when_up( 891 + self, monkeypatch, tmp_path, capsys 892 + ): 893 + """Guard returns None without output when convey is reachable.""" 894 + from think.utils import require_solstone, write_service_port 895 + 896 + monkeypatch.delenv("SOL_SKIP_SUPERVISOR_CHECK", raising=False) 897 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 898 + 899 + with socket.socket() as server: 900 + server.bind(("127.0.0.1", 0)) 901 + server.listen(1) 902 + write_service_port("convey", server.getsockname()[1]) 903 + assert require_solstone() is None 904 + 905 + captured = capsys.readouterr() 906 + assert captured.out == "" 907 + assert captured.err == "" 908 + 909 + def test_require_solstone_skips_check_with_env_override( 910 + self, monkeypatch, tmp_path 911 + ): 912 + """SOL_SKIP_SUPERVISOR_CHECK bypasses availability probing.""" 913 + import think.utils as utils 914 + 915 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 916 + monkeypatch.setenv("SOL_SKIP_SUPERVISOR_CHECK", "1") 917 + monkeypatch.setattr( 918 + utils, 919 + "is_solstone_up", 920 + lambda timeout=0.2: (_ for _ in ()).throw(AssertionError("should not run")), 921 + ) 922 + 923 + assert utils.require_solstone() is None 828 924 829 925 830 926 class TestIterSegments:
+2
think/agents.py
··· 46 46 format_segment_times, 47 47 get_journal, 48 48 now_ms, 49 + require_solstone, 49 50 segment_parse, 50 51 setup_cli, 51 52 ) ··· 1500 1501 ) 1501 1502 1502 1503 args = setup_cli(parser) 1504 + require_solstone() 1503 1505 if args.subcommand == "check": 1504 1506 await _run_check(args) 1505 1507 return
+2 -1
think/chat_cli.py
··· 11 11 12 12 from think.callosum import CallosumConnection 13 13 from think.cortex_client import cortex_request, read_agent_events 14 - from think.utils import setup_cli 14 + from think.utils import require_solstone, setup_cli 15 15 16 16 17 17 def main() -> None: ··· 27 27 "--talent", default="unified", help="Talent agent name (default: unified)" 28 28 ) 29 29 args = setup_cli(parser) 30 + require_solstone() 30 31 31 32 from think.awareness import ensure_sol_directory 32 33
+2 -1
think/cortex.py
··· 771 771 """CLI entry point for the Cortex service.""" 772 772 import argparse 773 773 774 - from think.utils import setup_cli 774 + from think.utils import require_solstone, setup_cli 775 775 776 776 parser = argparse.ArgumentParser(description="solstone Cortex Agent Manager") 777 777 args = setup_cli(parser) 778 + require_solstone() 778 779 779 780 # Set up logging 780 781 logging.basicConfig(
+2
think/dream.py
··· 46 46 iso_date, 47 47 iter_segments, 48 48 now_ms, 49 + require_solstone, 49 50 setup_cli, 50 51 updated_days, 51 52 ) ··· 2764 2765 2765 2766 parser = parse_args() 2766 2767 args = setup_cli(parser) 2768 + require_solstone() 2767 2769 2768 2770 from think.awareness import ensure_sol_directory 2769 2771
+6
think/engage.py
··· 10 10 11 11 import typer 12 12 13 + from think.utils import require_solstone 14 + 13 15 engage_app = typer.Typer(name="engage") 14 16 15 17 ··· 96 98 97 99 def main() -> None: 98 100 """Entry point for ``sol engage``.""" 101 + if any(arg in {"-h", "--help"} for arg in sys.argv[1:]): 102 + engage_app() 103 + return 104 + require_solstone() 99 105 engage_app()
+2 -1
think/heartbeat.py
··· 15 15 16 16 from think.awareness import ensure_sol_directory 17 17 from think.cortex_client import cortex_request, wait_for_agents 18 - from think.utils import get_journal, setup_cli 18 + from think.utils import get_journal, require_solstone, setup_cli 19 19 20 20 logger = logging.getLogger(__name__) 21 21 ··· 54 54 help="Run full check regardless of recency", 55 55 ) 56 56 args = setup_cli(parser) 57 + require_solstone() 57 58 58 59 journal = Path(get_journal()) 59 60 ensure_sol_directory()
+9 -1
think/importers/cli.py
··· 25 25 from think.importers.text import _read_transcript, process_transcript 26 26 from think.importers.utils import save_import_segments 27 27 from think.streams import stream_name, update_stream, write_segment_stream 28 - from think.utils import day_path, get_journal, get_rev, segment_key, setup_cli 28 + from think.utils import ( 29 + day_path, 30 + get_journal, 31 + get_rev, 32 + require_solstone, 33 + segment_key, 34 + setup_cli, 35 + ) 29 36 30 37 logger = logging.getLogger(__name__) 31 38 ··· 323 330 help="Output results as JSON (file importers only)", 324 331 ) 325 332 args, extra = setup_cli(parser, parse_known=True) 333 + require_solstone() 326 334 if extra and not args.timestamp: 327 335 args.timestamp = extra[0] 328 336
+2 -1
think/indexer/cli.py
··· 6 6 import argparse 7 7 from typing import Any 8 8 9 - from think.utils import get_journal, journal_log, setup_cli 9 + from think.utils import get_journal, journal_log, require_solstone, setup_cli 10 10 11 11 from .journal import ( 12 12 index_file, ··· 155 155 ) 156 156 157 157 args = setup_cli(parser) 158 + require_solstone() 158 159 journal = get_journal() 159 160 160 161 if (
+2 -1
think/notify_cli.py
··· 5 5 import sys 6 6 7 7 from think.callosum import callosum_send 8 - from think.utils import setup_cli 8 + from think.utils import require_solstone, setup_cli 9 9 10 10 11 11 def main() -> None: ··· 32 32 ) 33 33 34 34 args = setup_cli(parser) 35 + require_solstone() 35 36 36 37 message = " ".join(args.message) 37 38 kwargs = {"message": message}
+2 -1
think/password_cli.py
··· 19 19 20 20 from werkzeug.security import generate_password_hash 21 21 22 - from think.utils import get_config, get_journal, setup_cli 22 + from think.utils import get_config, get_journal, require_solstone, setup_cli 23 23 24 24 25 25 def _set_password() -> None: ··· 54 54 subparsers.add_parser("reset", help="Reset the convey password") 55 55 56 56 args = setup_cli(parser) 57 + require_solstone() 57 58 58 59 if args.subcommand in ("set", "reset"): 59 60 _set_password()
+2 -1
think/scheduler.py
··· 22 22 from pathlib import Path 23 23 from typing import Any 24 24 25 - from think.utils import get_journal, now_ms, setup_cli 25 + from think.utils import get_journal, now_ms, require_solstone, setup_cli 26 26 27 27 logger = logging.getLogger(__name__) 28 28 ··· 546 546 """CLI entry point for sol schedule.""" 547 547 parser = argparse.ArgumentParser(description="Show scheduled tasks") 548 548 setup_cli(parser) 549 + require_solstone() 549 550 550 551 journal = Path(get_journal()) 551 552 config_path = journal / "config" / "schedules.json"
+2
think/segment.py
··· 26 26 day_path, 27 27 get_journal, 28 28 iter_segments, 29 + require_solstone, 29 30 segment_parse, 30 31 setup_cli, 31 32 ) ··· 861 862 ) 862 863 863 864 args = setup_cli(parser) 865 + require_solstone() 864 866 865 867 if args.subcommand is None: 866 868 parser.print_help()
+2 -1
think/streams.py
··· 382 382 """CLI entry point for sol streams.""" 383 383 import argparse 384 384 385 - from think.utils import setup_cli 385 + from think.utils import require_solstone, setup_cli 386 386 387 387 parser = argparse.ArgumentParser(description="Inspect and manage stream identity") 388 388 parser.add_argument( ··· 397 397 ) 398 398 399 399 args = setup_cli(parser) 400 + require_solstone() 400 401 401 402 if args.rebuild: 402 403 summary = rebuild_stream_state(name=args.name)
+8
think/tools/call.py
··· 44 44 day_path, 45 45 get_journal, 46 46 iter_segments, 47 + require_solstone, 47 48 resolve_sol_day, 48 49 resolve_sol_facet, 49 50 resolve_sol_segment, ··· 52 53 53 54 app = typer.Typer(help="Journal search and browsing.") 54 55 facet_app = typer.Typer(help="Facet management.") 56 + 57 + 58 + @app.callback() 59 + def _require_up() -> None: 60 + require_solstone() 61 + 62 + 55 63 app.add_typer(facet_app, name="facet") 56 64 retention_app = typer.Typer(help="Media retention management.") 57 65 app.add_typer(retention_app, name="retention")
+3
think/tools/navigate.py
··· 8 8 9 9 import typer 10 10 11 + from think.utils import require_solstone 12 + 11 13 app = typer.Typer() 12 14 13 15 ··· 17 19 facet: str = typer.Option(None, "--facet", "-f", help="Facet to switch to."), 18 20 ) -> None: 19 21 """Navigate the browser to a path and/or switch facet.""" 22 + require_solstone() 20 23 if not path and not facet: 21 24 typer.echo("Error: provide a path and/or --facet", err=True) 22 25 raise typer.Exit(1)
+6 -1
think/tools/routines.py
··· 18 18 import typer 19 19 20 20 from think.routines import _run_routine, cron_matches, get_config, save_config 21 - from think.utils import get_journal 21 + from think.utils import get_journal, require_solstone 22 22 23 23 app = typer.Typer(help="Manage custom routines.") 24 + 25 + 26 + @app.callback() 27 + def _require_up() -> None: 28 + require_solstone() 24 29 25 30 26 31 def _resolve_id(config: dict[str, dict], prefix: str) -> str:
+6 -1
think/tools/sol.py
··· 24 24 update_self_md_section, 25 25 ) 26 26 from think.entities.core import atomic_write 27 - from think.utils import day_dirs, day_path 27 + from think.utils import day_dirs, day_path, require_solstone 28 28 29 29 app = typer.Typer( 30 30 help="Sol identity directory — self.md, partner.md, agency.md, pulse.md, awareness.md, and morning briefing." 31 31 ) 32 + 33 + 34 + @app.callback() 35 + def _require_up() -> None: 36 + require_solstone() 32 37 33 38 34 39 def _sol_dir():
+26
think/utils.py
··· 16 16 import logging 17 17 import os 18 18 import re 19 + import socket 19 20 import sys 20 21 import time 21 22 from datetime import datetime ··· 931 932 return int(port_file.read_text().strip()) 932 933 except (FileNotFoundError, ValueError): 933 934 return None 935 + 936 + 937 + def is_solstone_up(timeout: float = 0.2) -> bool: 938 + """Return True if convey is accepting TCP connections on its recorded port.""" 939 + port = read_service_port("convey") 940 + if port is None: 941 + return False 942 + try: 943 + with socket.create_connection(("127.0.0.1", port), timeout=timeout): 944 + return True 945 + except OSError: 946 + return False 947 + 948 + 949 + def require_solstone() -> None: 950 + """Exit(1) with a clear message if solstone's stack isn't running.""" 951 + if os.environ.get("SOL_SKIP_SUPERVISOR_CHECK") == "1": 952 + return 953 + if is_solstone_up(): 954 + return 955 + print( 956 + "sol: solstone isn't running. Start it with 'sol up' and retry.", 957 + file=sys.stderr, 958 + ) 959 + sys.exit(1)