personal memory agent
0
fork

Configure Feed

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

Fix get_journal() path creation and complete normalization

- Fix get_journal() to create user-specified JOURNAL_PATH if it doesn't exist
(previously silently fell back to platform default for non-existent paths)
- Update 12 files to use get_journal() instead of os.getenv("JOURNAL_PATH")
- Remove stale docstrings about RuntimeError for missing JOURNAL_PATH
- Remove dead except RuntimeError handlers that could never be reached
- Remove redundant load_dotenv() calls and unused imports
- Update docs/INSTALL.md to document auto-default journal path behavior
- Fix test that monkeypatched removed load_dotenv

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

+144 -258
+5 -14
apps/todos/todo.py
··· 11 11 12 12 import json 13 13 import logging 14 - import os 15 14 import re 16 15 import time 17 16 from dataclasses import dataclass ··· 19 18 from pathlib import Path 20 19 from typing import Any 21 20 22 - from dotenv import load_dotenv 23 - 24 21 from think.facets import get_facets 22 + from think.utils import get_journal 25 23 26 24 __all__ = [ 27 25 "TodoChecklist", ··· 388 386 Returns: 389 387 Path to the facet-scoped todo file for the specified day. 390 388 """ 391 - load_dotenv() 392 - journal = os.getenv("JOURNAL_PATH", "journal") 389 + journal = get_journal() 393 390 return Path(journal) / "facets" / facet / "todos" / f"{day}.jsonl" 394 391 395 392 ··· 440 437 if limit <= 0: 441 438 return "No upcoming todos." 442 439 443 - journal = os.getenv("JOURNAL_PATH", "journal") 444 - root = Path(journal) 445 - if not root.is_dir(): 446 - return "No upcoming todos." 440 + root = Path(get_journal()) 447 441 448 442 today_str = today if today is not None else datetime.now().strftime("%Y%m%d") 449 443 ··· 543 537 List of facet names that have todo files for the specified day. 544 538 Returns empty list if no facets have todos or if journal path is invalid. 545 539 """ 546 - journal = os.getenv("JOURNAL_PATH", "journal") 547 - root = Path(journal) 548 - facets_dir = root / "facets" 540 + facets_dir = Path(get_journal()) / "facets" 549 541 550 542 if not facets_dir.is_dir(): 551 543 return [] ··· 578 570 Sorted list of day strings (YYYYMMDD) that have todo files within the range. 579 571 Returns empty list if no todos exist or journal path is invalid. 580 572 """ 581 - journal = os.getenv("JOURNAL_PATH", "journal") 582 - todos_dir = Path(journal) / "facets" / facet / "todos" 573 + todos_dir = Path(get_journal()) / "facets" / facet / "todos" 583 574 584 575 if not todos_dir.is_dir(): 585 576 return []
+7 -7
docs/INSTALL.md
··· 60 60 cp .env.example .env 61 61 ``` 62 62 63 - 3. Create your journal directory: 64 - 65 - ```bash 66 - mkdir -p ~/Documents/journal 67 - ``` 68 - 69 - 4. Edit `.env` and set your journal path: 63 + 3. (Optional) Set a custom journal path in `.env`: 70 64 71 65 ``` 72 66 JOURNAL_PATH=~/Documents/journal 73 67 ``` 68 + 69 + If not set, solstone automatically uses the platform-specific default: 70 + - Linux: `~/.local/share/solstone/journal` 71 + - macOS: `~/Library/Application Support/solstone/journal` 72 + 73 + The journal directory is created automatically on first use. 74 74 75 75 --- 76 76
+2 -1
muse/cortex.py
··· 30 30 from typing import Any, Dict, Optional 31 31 32 32 from think.callosum import CallosumConnection 33 + from think.utils import get_journal 33 34 34 35 35 36 class AgentProcess: ··· 72 73 """Callosum-based agent process manager.""" 73 74 74 75 def __init__(self, journal_path: Optional[str] = None): 75 - self.journal_path = Path(journal_path or os.getenv("JOURNAL_PATH", ".")) 76 + self.journal_path = Path(journal_path or get_journal()) 76 77 self.agents_dir = self.journal_path / "agents" 77 78 self.agents_dir.mkdir(parents=True, exist_ok=True) 78 79
+2 -3
muse/resources/insights.py
··· 3 3 4 4 """MCP resource handlers for insights.""" 5 5 6 - import os 7 6 from pathlib import Path 8 7 9 8 from fastmcp.resources import TextResource 10 9 11 10 from muse.mcp import mcp 11 + from think.utils import get_journal 12 12 13 13 14 14 @mcp.resource("journal://insight/{day}/{topic}") 15 15 def get_insight(day: str, topic: str) -> TextResource: 16 16 """Return the markdown insight for a topic.""" 17 - journal = os.getenv("JOURNAL_PATH", "journal") 18 - md_path = Path(journal) / day / "insights" / f"{topic}.md" 17 + md_path = Path(get_journal()) / day / "insights" / f"{topic}.md" 19 18 20 19 if not md_path.is_file(): 21 20 text = f"Topic '{topic}' not found for day {day}"
+2 -4
muse/resources/media.py
··· 3 3 4 4 """MCP resource handlers for media.""" 5 5 6 - import os 7 6 from pathlib import Path 8 7 9 8 from fastmcp.resources import FileResource 10 9 11 10 from muse.mcp import mcp 12 - from think.utils import get_raw_file 11 + from think.utils import get_journal, get_raw_file 13 12 14 13 15 14 @mcp.resource("journal://media/{day}/{name}") ··· 31 30 """ 32 31 33 32 rel_path, mime, _ = get_raw_file(day, name) 34 - journal = os.getenv("JOURNAL_PATH", "journal") 35 - abs_path = Path(journal) / day / rel_path 33 + abs_path = Path(get_journal()) / day / rel_path 36 34 return FileResource( 37 35 uri=f"journal://media/{day}/{name}", 38 36 name=f"Media: {name}",
+6 -15
muse/tools/facets.py
··· 7 7 They can also be imported and called directly for testing or internal use. 8 8 """ 9 9 10 - import os 11 10 from pathlib import Path 12 11 from typing import Any 13 12 14 13 from think.facets import facet_summary 14 + from think.utils import get_journal 15 15 16 16 17 17 def get_facet(facet: str) -> dict[str, Any]: ··· 37 37 - get_facet("work_projects") 38 38 - get_facet("research") 39 39 40 - Raises: 41 - If the facet doesn't exist or JOURNAL_PATH is not set, returns an error dictionary 42 - with an error message and suggestion for resolution. 40 + Returns: 41 + If the facet doesn't exist, returns an error dictionary with an error message 42 + and suggestion for resolution. 43 43 """ 44 44 try: 45 45 # Get the facet summary markdown ··· 48 48 except FileNotFoundError: 49 49 return { 50 50 "error": f"Facet '{facet}' not found", 51 - "suggestion": "verify the facet name exists or check JOURNAL_PATH is set correctly", 52 - } 53 - except RuntimeError as exc: 54 - return { 55 - "error": str(exc), 56 - "suggestion": "ensure JOURNAL_PATH environment variable is set", 51 + "suggestion": "verify the facet name exists in the journal", 57 52 } 58 53 except Exception as exc: 59 54 return { ··· 87 82 - facet_news("work", "20250118", "# 2025-01-18 News...") # Write news 88 83 """ 89 84 try: 90 - journal = os.getenv("JOURNAL_PATH") 91 - if not journal: 92 - raise RuntimeError("JOURNAL_PATH not set") 93 - 94 - journal_path = Path(journal) 85 + journal_path = Path(get_journal()) 95 86 facet_path = journal_path / "facets" / facet 96 87 97 88 # Check if facet exists
+2 -2
observe/describe.py
··· 31 31 from observe.aruco import detect_markers, mask_convey_region, polygon_area 32 32 from observe.utils import get_segment_key 33 33 from think.callosum import callosum_send 34 - from think.utils import setup_cli 34 + from think.utils import get_journal, setup_cli 35 35 36 36 logger = logging.getLogger(__name__) 37 37 ··· 821 821 822 822 # Emit completion event 823 823 if output_path and output_path.exists(): 824 - journal_path = Path(os.getenv("JOURNAL_PATH", "")) 824 + journal_path = Path(get_journal()) 825 825 826 826 try: 827 827 rel_input = video_path.relative_to(journal_path)
+6 -20
observe/sense.py
··· 23 23 from observe.utils import AUDIO_EXTENSIONS, VIDEO_EXTENSIONS 24 24 from think.callosum import CallosumConnection 25 25 from think.runner import ManagedProcess as RunnerManagedProcess 26 - from think.utils import day_path, setup_cli 26 + from think.utils import day_path, get_journal, setup_cli 27 27 28 28 logger = logging.getLogger(__name__) 29 29 ··· 422 422 status = {} 423 423 424 424 # Get journal path for relative paths 425 - journal_path = os.getenv("JOURNAL_PATH", "") 425 + journal_path = get_journal() 426 426 427 427 # Collect describe info 428 428 describe_running = None ··· 431 431 if self.current_describe_process is not None: 432 432 handler_proc = self.current_describe_process 433 433 try: 434 - rel_file = ( 435 - str(handler_proc.file_path.relative_to(journal_path)) 436 - if journal_path 437 - else str(handler_proc.file_path) 438 - ) 434 + rel_file = str(handler_proc.file_path.relative_to(journal_path)) 439 435 except ValueError: 440 436 rel_file = str(handler_proc.file_path) 441 437 ··· 448 444 now = time.time() 449 445 for file_path, queued_at in self.describe_queue: 450 446 try: 451 - rel_file = ( 452 - str(file_path.relative_to(journal_path)) 453 - if journal_path 454 - else str(file_path) 455 - ) 447 + rel_file = str(file_path.relative_to(journal_path)) 456 448 except ValueError: 457 449 rel_file = str(file_path) 458 450 ··· 473 465 for file_path, handler_proc in self.running.items(): 474 466 if handler_proc is not self.current_describe_process: 475 467 try: 476 - rel_file = ( 477 - str(file_path.relative_to(journal_path)) 478 - if journal_path 479 - else str(file_path) 480 - ) 468 + rel_file = str(file_path.relative_to(journal_path)) 481 469 except ValueError: 482 470 rel_file = str(file_path) 483 471 ··· 817 805 ) 818 806 args = setup_cli(parser) 819 807 820 - journal = Path(os.getenv("JOURNAL_PATH", "")) 821 - if not journal.is_dir(): 822 - parser.error("JOURNAL_PATH not set or invalid") 808 + journal = Path(get_journal()) 823 809 824 810 # Validate argument combinations 825 811 if args.reprocess and not args.day:
+3 -5
observe/transcribe.py
··· 26 26 from think.callosum import callosum_send 27 27 from think.entities import load_entity_names 28 28 from think.models import GEMINI_FLASH 29 - from think.utils import PromptNotFoundError, load_prompt, setup_cli 29 + from think.utils import PromptNotFoundError, get_journal, load_prompt, setup_cli 30 30 31 31 # Constants 32 32 MODEL = GEMINI_FLASH ··· 513 513 save_speaker_embeddings(embeddings_dir, speaker_embeddings) 514 514 515 515 # Emit completion event 516 - journal_path = Path(os.getenv("JOURNAL_PATH", "")) 516 + journal_path = Path(get_journal()) 517 517 duration_ms = int((time.time() - start_time) * 1000) 518 518 519 519 try: ··· 563 563 564 564 faulthandler.enable() 565 565 566 - journal = Path(os.getenv("JOURNAL_PATH", "")) 567 - if not journal.is_dir(): 568 - parser.error("JOURNAL_PATH not set or invalid") 566 + journal = Path(get_journal()) 569 567 570 568 audio_path = Path(args.audio_path) 571 569 if not audio_path.exists():
-3
tests/test_insight_full.py
··· 55 55 ] 56 56 57 57 monkeypatch.setattr(mod, "send_extraction", fake_send_extraction) 58 - monkeypatch.setattr(mod, "load_dotenv", lambda: True) 59 58 monkeypatch.setenv("GOOGLE_API_KEY", "x") 60 59 61 60 monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) ··· 108 107 ] 109 108 110 109 monkeypatch.setattr(mod, "send_extraction", fake_send_extraction) 111 - monkeypatch.setattr(mod, "load_dotenv", lambda: True) 112 110 monkeypatch.setenv("GOOGLE_API_KEY", "x") 113 111 114 112 monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) ··· 159 157 return [] 160 158 161 159 monkeypatch.setattr(mod, "send_extraction", fake_send_extraction) 162 - monkeypatch.setattr(mod, "load_dotenv", lambda: True) 163 160 monkeypatch.setenv("GOOGLE_API_KEY", "x") 164 161 165 162 monkeypatch.setenv("JOURNAL_PATH", str(tmp_path))
+12 -43
think/entities.py
··· 16 16 from dotenv import load_dotenv 17 17 from slugify import slugify 18 18 19 + from think.utils import get_journal 20 + 19 21 20 22 def is_valid_entity_type(etype: str) -> bool: 21 23 """Validate entity type: alphanumeric and spaces only, at least 3 characters.""" ··· 80 82 Path to facets/{facet}/entities/{normalized_name}/ 81 83 82 84 Raises: 83 - RuntimeError: If JOURNAL_PATH is not set 84 85 ValueError: If name normalizes to empty string 85 86 """ 86 - load_dotenv() 87 - journal = os.getenv("JOURNAL_PATH") 88 - if not journal: 89 - raise RuntimeError("JOURNAL_PATH not set") 90 - 91 87 normalized = normalize_entity_name(name) 92 88 if not normalized: 93 89 raise ValueError(f"Entity name '{name}' normalizes to empty string") 94 90 95 - return Path(journal) / "facets" / facet / "entities" / normalized 91 + return Path(get_journal()) / "facets" / facet / "entities" / normalized 96 92 97 93 98 94 def ensure_entity_folder(facet: str, name: str) -> Path: ··· 106 102 Path to the created/existing folder 107 103 108 104 Raises: 109 - RuntimeError: If JOURNAL_PATH is not set 110 105 ValueError: If name normalizes to empty string 111 106 """ 112 107 folder = entity_folder_path(facet, name) ··· 129 124 or names normalize to the same value 130 125 131 126 Raises: 132 - RuntimeError: If JOURNAL_PATH is not set 133 127 ValueError: If either name normalizes to empty string 134 128 OSError: If rename fails (e.g., target exists) 135 129 """ 136 - # Get paths using entity_folder_path (handles validation and JOURNAL_PATH) 137 130 old_folder = entity_folder_path(facet, old_name) 138 131 new_folder = entity_folder_path(facet, new_name) 139 132 ··· 212 205 213 206 Returns: 214 207 Path to entities.jsonl (attached) or entities/YYYYMMDD.jsonl (detected) 215 - 216 - Raises: 217 - RuntimeError: If JOURNAL_PATH is not set 218 208 """ 219 - load_dotenv() 220 - journal = os.getenv("JOURNAL_PATH") 221 - if not journal: 222 - raise RuntimeError("JOURNAL_PATH not set") 223 - 224 - facet_path = Path(journal) / "facets" / facet 209 + facet_path = Path(get_journal()) / "facets" / facet 225 210 226 211 if day is None: 227 212 # Attached entities ··· 270 255 entities: List of entity dictionaries (must have type, name, description keys; 271 256 attached entities may also have attached_at, updated_at timestamps) 272 257 day: Optional day in YYYYMMDD format for detected entities 273 - 274 - Raises: 275 - RuntimeError: If JOURNAL_PATH is not set 276 258 """ 277 259 path = entity_file_path(facet, day) 278 260 ··· 328 310 329 311 Raises: 330 312 ValueError: If entity not found or guard mismatch 331 - RuntimeError: If JOURNAL_PATH is not set 332 313 """ 333 314 # Load ALL entities including detached to avoid data loss on save 334 315 # For attached entities (day=None), we need include_detached=True ··· 374 355 Used for agent context loading. Provides deterministic behavior 375 356 despite allowing independent entity descriptions across facets. 376 357 """ 377 - load_dotenv() 378 - journal = os.getenv("JOURNAL_PATH") 379 - if not journal: 380 - raise RuntimeError("JOURNAL_PATH not set") 381 - 382 - facets_dir = Path(journal) / "facets" 358 + facets_dir = Path(get_journal()) / "facets" 383 359 if not facets_dir.exists(): 384 360 return [] 385 361 ··· 448 424 When spoken=True: List of shortened entity names for speech, or None if no entities found. 449 425 """ 450 426 # Load entities using existing utilities 451 - try: 452 - if facet is None: 453 - # Load from ALL facets with deduplication 454 - entities = load_all_attached_entities() 455 - else: 456 - # Load from specific facet 457 - entities = load_entities(facet) 458 - except RuntimeError: 459 - # JOURNAL_PATH not set 460 - return None 427 + if facet is None: 428 + # Load from ALL facets with deduplication 429 + entities = load_all_attached_entities() 430 + else: 431 + # Load from specific facet 432 + entities = load_entities(facet) 461 433 462 434 if not entities: 463 435 return None ··· 545 517 """ 546 518 from datetime import datetime, timedelta 547 519 548 - load_dotenv() 549 - journal = os.getenv("JOURNAL_PATH") 550 - if not journal: 551 - raise RuntimeError("JOURNAL_PATH not set") 520 + journal = get_journal() 552 521 553 522 # Load attached entities (excluding detached) and build exclusion set 554 523 # Detached entities should appear in detected list again
+8 -51
think/facets.py
··· 15 15 from fastmcp import Context 16 16 from fastmcp.server.dependencies import get_http_headers 17 17 18 + from think.utils import get_journal 19 + 18 20 19 21 def _get_actor_info(context: Context | None = None) -> tuple[str, str | None]: 20 22 """Extract actor (persona) and agent_id from meta or HTTP headers. ··· 86 88 actor: For tools: persona name. For apps: app name 87 89 day: Day in YYYYMMDD format (defaults to today) 88 90 agent_id: Optional agent ID (only for tool actions) 89 - 90 - Raises: 91 - RuntimeError: If JOURNAL_PATH is not set 92 91 """ 93 - load_dotenv() 94 - journal = os.getenv("JOURNAL_PATH") 95 - if not journal: 96 - raise RuntimeError("JOURNAL_PATH not set") 92 + journal = get_journal() 97 93 98 94 if day is None: 99 95 day = datetime.now().strftime("%Y%m%d") ··· 152 148 params: Dictionary of action-specific parameters 153 149 context: Optional FastMCP context for extracting persona/agent_id 154 150 day: Day in YYYYMMDD format (defaults to today) 155 - 156 - Raises: 157 - RuntimeError: If JOURNAL_PATH is not set 158 151 """ 159 152 actor, agent_id = _get_actor_info(context) 160 153 _write_action_log( ··· 174 167 Each key is the facet name. The value contains the facet metadata 175 168 from facet.json including title, description, and the facet path. 176 169 """ 177 - load_dotenv() 178 - journal = os.getenv("JOURNAL_PATH") 179 - if not journal: 180 - raise RuntimeError("JOURNAL_PATH not set") 181 - 182 - facets_dir = Path(journal) / "facets" 170 + facets_dir = Path(get_journal()) / "facets" 183 171 facets: dict[str, dict[str, object]] = {} 184 172 185 173 if not facets_dir.exists(): ··· 227 215 228 216 Raises: 229 217 FileNotFoundError: If the facet doesn't exist 230 - RuntimeError: If JOURNAL_PATH is not set 231 218 """ 232 - load_dotenv() 233 - journal = os.getenv("JOURNAL_PATH") 234 - if not journal or journal == "": 235 - raise RuntimeError("JOURNAL_PATH not set") 236 - 237 - facet_path = Path(journal) / "facets" / facet 219 + facet_path = Path(get_journal()) / "facets" / facet 238 220 if not facet_path.exists(): 239 221 raise FileNotFoundError(f"Facet '{facet}' not found at {facet_path}") 240 222 ··· 324 306 Dictionary with ``days`` (list of news day payloads), ``next_cursor`` 325 307 (date string for subsequent requests) and ``has_more`` boolean flag. 326 308 """ 327 - 328 - load_dotenv() 329 - journal = os.getenv("JOURNAL_PATH") 330 - if not journal: 331 - raise RuntimeError("JOURNAL_PATH not set") 332 - 333 - news_dir = Path(journal) / "facets" / facet / "news" 309 + news_dir = Path(get_journal()) / "facets" / facet / "news" 334 310 if not news_dir.exists(): 335 311 return {"days": [], "next_cursor": None, "has_more": False} 336 312 ··· 415 391 416 392 Returns: 417 393 Set of facet names that had at least one occurrence event on that day 418 - 419 - Raises: 420 - RuntimeError: If JOURNAL_PATH is not set 421 394 """ 422 - load_dotenv() 423 - journal = os.getenv("JOURNAL_PATH") 424 - if not journal: 425 - raise RuntimeError("JOURNAL_PATH not set") 426 - 427 - facets_dir = Path(journal) / "facets" 395 + facets_dir = Path(get_journal()) / "facets" 428 396 active: set[str] = set() 429 397 430 398 if not facets_dir.exists(): ··· 472 440 473 441 Raises: 474 442 FileNotFoundError: If facet doesn't exist 475 - RuntimeError: If JOURNAL_PATH not set 476 443 """ 477 - load_dotenv() 478 - journal = os.getenv("JOURNAL_PATH") 479 - if not journal: 480 - raise RuntimeError("JOURNAL_PATH not set") 481 - 482 - facet_path = Path(journal) / "facets" / facet 444 + facet_path = Path(get_journal()) / "facets" / facet 483 445 if not facet_path.exists(): 484 446 raise FileNotFoundError(f"Facet '{facet}' not found at {facet_path}") 485 447 ··· 550 512 ------- 551 513 str 552 514 Formatted markdown string with all facets and their entities 553 - 554 - Raises 555 - ------ 556 - RuntimeError 557 - If JOURNAL_PATH is not set 558 515 """ 559 516 from think.entities import load_entities, load_entity_names 560 517
+3 -7
think/formatters.py
··· 43 43 from pathlib import Path 44 44 from typing import Any, Callable 45 45 46 - from dotenv import load_dotenv 47 - 48 - from think.utils import day_dirs, segment_key 46 + from think.utils import day_dirs, get_journal, segment_key 49 47 50 48 # Date pattern for path parsing 51 49 _DATE_RE = re.compile(r"^\d{8}$") ··· 361 359 raise FileNotFoundError(f"File not found: {file_path}") 362 360 363 361 # Get journal-relative path for pattern matching 364 - load_dotenv() 365 - journal = os.getenv("JOURNAL_PATH", "") 366 - journal_path = Path(journal).resolve() if journal else None 362 + journal_path = Path(get_journal()).resolve() 367 363 368 - if journal_path and file_path.is_relative_to(journal_path): 364 + if file_path.is_relative_to(journal_path): 369 365 rel_path = str(file_path.relative_to(journal_path)) 370 366 else: 371 367 # Fall back to just the filename parts for matching
+6 -14
think/importer.py
··· 26 26 from think.utils import ( 27 27 PromptNotFoundError, 28 28 day_path, 29 + get_journal, 29 30 load_prompt, 30 31 segment_key, 31 32 setup_cli, ··· 53 54 54 55 55 56 def _get_relative_path(path: str) -> str: 56 - """Get path relative to JOURNAL_PATH, or return as-is if not under JOURNAL_PATH.""" 57 - journal_path = os.getenv("JOURNAL_PATH", "") 58 - if not journal_path: 59 - return path 57 + """Get path relative to journal, or return as-is if not under journal.""" 58 + journal_path = get_journal() 60 59 try: 61 60 return os.path.relpath(path, journal_path) 62 61 except ValueError: ··· 675 674 676 675 677 676 def _is_in_imports(media_path: str) -> bool: 678 - """Check if file path is already under {JOURNAL_PATH}/imports/.""" 679 - journal = os.getenv("JOURNAL_PATH", "") 680 - if not journal: 681 - return False 682 - imports_dir = os.path.join(journal, "imports") 677 + """Check if file path is already under journal/imports/.""" 678 + imports_dir = os.path.join(get_journal(), "imports") 683 679 abs_media = os.path.abspath(media_path) 684 680 abs_imports = os.path.abspath(imports_dir) 685 681 return abs_media.startswith(abs_imports + os.sep) ··· 693 689 detection_result: dict | None, 694 690 ) -> str: 695 691 """Copy file to imports/ and write metadata. Returns new file path.""" 696 - journal = os.getenv("JOURNAL_PATH") 697 - if not journal: 698 - raise RuntimeError("JOURNAL_PATH not set") 699 - 700 - journal_root = Path(journal) 692 + journal_root = Path(get_journal()) 701 693 import_dir = journal_root / "imports" / timestamp 702 694 703 695 # Check for conflict
+5 -10
think/insight.py
··· 6 6 import os 7 7 from pathlib import Path 8 8 9 - from dotenv import load_dotenv 10 9 from google import genai 11 10 from google.genai import types 12 11 ··· 18 17 day_path, 19 18 get_insight_topic, 20 19 get_insights, 20 + get_journal, 21 21 load_prompt, 22 22 setup_cli, 23 23 ) ··· 47 47 Returns: 48 48 List of paths to written JSONL files. 49 49 """ 50 - load_dotenv() 51 - journal = os.getenv("JOURNAL_PATH") 52 - if not journal: 53 - raise RuntimeError("JOURNAL_PATH not set") 50 + journal = get_journal() 54 51 55 52 # Group events by (facet, event_day) 56 53 grouped: dict[tuple[str, str], list[dict]] = {} ··· 378 375 markdown = input_note + markdown 379 376 380 377 try: 381 - 382 - load_dotenv() 383 378 if args.verbose: 384 379 print("Verbose mode enabled") 385 380 api_key = os.getenv("GOOGLE_API_KEY") ··· 500 495 # Compute the relative source insight path 501 496 # md_path is absolute, day_dir is the YYYYMMDD directory path 502 497 # source_insight should be like "20240101/insights/meetings.md" 503 - journal = os.getenv("JOURNAL_PATH", "") 504 - if journal and str(md_path).startswith(journal): 498 + journal = get_journal() 499 + try: 505 500 source_insight = os.path.relpath(str(md_path), journal) 506 - else: 501 + except ValueError: 507 502 # Fallback: construct from day and topic 508 503 source_insight = os.path.join( 509 504 day,
+75 -59
think/utils.py
··· 14 14 from string import Template 15 15 from typing import Any, NamedTuple, Optional 16 16 17 + import platformdirs 17 18 from dotenv import load_dotenv 18 19 from timefhuman import timefhuman 19 20 20 21 DATE_RE = re.compile(r"\d{8}") 22 + _journal_path_cache: str | None = None 21 23 22 24 # Insight colors are now stored in each insight's JSON metadata file 23 25 ··· 139 141 return PromptContent(text=text, path=prompt_path) 140 142 141 143 144 + def get_journal() -> str: 145 + """Return the journal path, auto-creating it if it doesn't exist. 146 + 147 + Resolution order: 148 + 1. JOURNAL_PATH environment variable (from .env or shell) - created if missing 149 + 2. Cached platform default from previous call 150 + 3. Platform-specific default: <user_data_dir>/solstone/journal 151 + 152 + When using the platform default, the path is cached and set in os.environ. 153 + Environment variable changes are always respected (no caching for explicit config). 154 + An INFO log message is emitted when auto-creating the default path. 155 + 156 + Returns 157 + ------- 158 + str 159 + Absolute path to the journal directory. 160 + """ 161 + global _journal_path_cache 162 + 163 + # Always check environment first (allows tests to override) 164 + load_dotenv() 165 + journal = os.getenv("JOURNAL_PATH") 166 + 167 + if journal: 168 + # User explicitly configured a path - create it if needed and use it 169 + os.makedirs(journal, exist_ok=True) 170 + return journal 171 + 172 + # Use cached platform default if available 173 + if _journal_path_cache: 174 + return _journal_path_cache 175 + 176 + # Create platform-specific default 177 + data_dir = platformdirs.user_data_dir("solstone") 178 + default_journal = os.path.join(data_dir, "journal") 179 + 180 + # Create directory if needed 181 + os.makedirs(default_journal, exist_ok=True) 182 + 183 + # Set environment for this process and children 184 + os.environ["JOURNAL_PATH"] = default_journal 185 + _journal_path_cache = default_journal 186 + 187 + logging.info("Using default journal path: %s", default_journal) 188 + return default_journal 189 + 190 + 142 191 def day_path(day: Optional[str] = None) -> Path: 143 - """Return absolute path for a day from ``JOURNAL_PATH`` environment variable. 192 + """Return absolute path for a day directory within the journal. 144 193 145 194 Parameters 146 195 ---------- ··· 154 203 155 204 Raises 156 205 ------ 157 - RuntimeError 158 - If JOURNAL_PATH is not set. 159 206 ValueError 160 207 If day format is invalid. 161 208 """ 162 - load_dotenv() 163 - journal = os.getenv("JOURNAL_PATH") 164 - if not journal: 165 - raise RuntimeError("JOURNAL_PATH not set") 209 + journal = get_journal() 166 210 167 211 # Handle "today" case 168 212 if day is None: ··· 178 222 def day_dirs() -> dict[str, str]: 179 223 """Return mapping of YYYYMMDD day names to absolute paths. 180 224 181 - Uses JOURNAL_PATH from environment (must be set via load_dotenv() or setup_cli()). 182 - 183 225 Returns 184 226 ------- 185 227 dict[str, str] 186 228 Mapping of day folder names to their full paths. 187 229 Example: {"20250101": "/path/to/journal/20250101", ...} 188 - 189 - Raises 190 - ------ 191 - RuntimeError 192 - If JOURNAL_PATH environment variable is not set. 193 230 """ 194 - load_dotenv() 195 - journal = os.getenv("JOURNAL_PATH") 196 - if not journal: 197 - raise RuntimeError("JOURNAL_PATH not set") 198 - if not os.path.isdir(journal): 199 - return {} 231 + journal = get_journal() 200 232 201 233 days: dict[str, str] = {} 202 234 for name in os.listdir(journal): ··· 334 366 Journal configuration with at least an 'identity' key containing 335 367 name, preferred, bio, pronouns, aliases, email_addresses, and 336 368 timezone fields. Returns default empty structure if config file doesn't exist. 337 - 338 - Raises 339 - ------ 340 - RuntimeError 341 - If JOURNAL_PATH is not set. 342 369 """ 343 370 # Default identity structure - defined once 344 371 default_identity = { ··· 356 383 "timezone": "", 357 384 } 358 385 359 - load_dotenv() 360 - journal = os.getenv("JOURNAL_PATH") 361 - if not journal: 362 - raise RuntimeError("JOURNAL_PATH not set") 363 - 386 + journal = get_journal() 364 387 config_path = Path(journal) / "config" / "journal.json" 365 388 366 389 # Return default structure if file doesn't exist ··· 407 430 408 431 def journal_log(message: str) -> None: 409 432 """Append ``message`` to the journal's ``task_log.txt``.""" 410 - load_dotenv() 411 - journal = os.getenv("JOURNAL_PATH") 412 - if journal: 413 - _append_task_log(journal, message) 433 + _append_task_log(get_journal(), message) 414 434 415 435 416 436 def day_input_summary(day: str) -> str: ··· 468 488 def setup_cli(parser: argparse.ArgumentParser, *, parse_known: bool = False): 469 489 """Parse command line arguments and configure logging. 470 490 471 - The parser will be extended with ``-v``/``--verbose`` and ``-d``/``--debug`` flags. Environment 472 - variables from ``.env`` are loaded and ``JOURNAL_PATH`` is validated. The 473 - parsed arguments are returned. If ``parse_known`` is ``True`` a tuple of 474 - ``(args, extra)`` is returned using :func:`argparse.ArgumentParser.parse_known_args`. 491 + The parser will be extended with ``-v``/``--verbose`` and ``-d``/``--debug`` flags. 492 + The journal path is resolved via get_journal() which loads .env and auto-creates 493 + a default path if needed. The parsed arguments are returned. If ``parse_known`` 494 + is ``True`` a tuple of ``(args, extra)`` is returned using 495 + :func:`argparse.ArgumentParser.parse_known_args`. 475 496 """ 476 - 477 - load_dotenv() 478 497 parser.add_argument( 479 498 "-v", "--verbose", action="store_true", help="Enable verbose output" 480 499 ) ··· 496 515 497 516 logging.basicConfig(level=log_level) 498 517 499 - journal = os.getenv("JOURNAL_PATH") 500 - if not journal or not os.path.isdir(journal): 501 - parser.error("JOURNAL_PATH not set or invalid") 518 + # Initialize journal path (may auto-create default) 519 + get_journal() 502 520 503 521 return (args, extra) if parse_known else args 504 522 ··· 675 693 extra_parts = [] 676 694 677 695 # Add facet context - either focused single facet or all facets summary 678 - journal = os.getenv("JOURNAL_PATH") 679 - if journal: 680 - try: 681 - if facet: 682 - # Focused mode: detailed view of single facet with full entities 683 - from think.facets import facet_summary 696 + try: 697 + if facet: 698 + # Focused mode: detailed view of single facet with full entities 699 + from think.facets import facet_summary 684 700 685 - detailed = facet_summary(facet) 686 - extra_parts.append(f"## Facet Focus\n{detailed}") 687 - else: 688 - # General mode: summary of all facets 689 - from think.facets import facet_summaries 701 + detailed = facet_summary(facet) 702 + extra_parts.append(f"## Facet Focus\n{detailed}") 703 + else: 704 + # General mode: summary of all facets 705 + from think.facets import facet_summaries 690 706 691 - facets_summary = facet_summaries() 692 - if facets_summary and facets_summary != "No facets found.": 693 - extra_parts.append(facets_summary) 694 - except Exception: 695 - pass # Ignore if facets can't be loaded 707 + facets_summary = facet_summaries() 708 + if facets_summary and facets_summary != "No facets found.": 709 + extra_parts.append(facets_summary) 710 + except Exception: 711 + pass # Ignore if facets can't be loaded 696 712 697 713 # Add insights to agent instructions 698 714 insights = get_insights()