personal memory agent
0
fork

Configure Feed

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

think: add weekly reflection scheduling and indexing

+444 -5
+99
talent/weekly_reflection.md
··· 1 + { 2 + "type": "cogitate", 3 + "title": "Weekly Reflection", 4 + "description": "Sunday-start weekly reflection synthesized from the journal", 5 + "schedule": "weekly", 6 + "priority": 90, 7 + "output": "md" 8 + } 9 + 10 + $facets 11 + 12 + You are generating the weekly reflection for $agent_name. 13 + 14 + This is not a conversation. Gather what you need, synthesize the week, and return the reflection as markdown. The system saves your response automatically. 15 + 16 + `$day_YYYYMMDD` is the canonical Sunday that starts the week under review. Cover that Sunday through the following Saturday. 17 + 18 + Follow the provenance pattern from `talent/patterns/provenance.md`, including: 19 + - a coverage preamble with source counts and gaps 20 + - `sol://` attribution for consequential claims 21 + - confidence-graded language that distinguishes observation from inference 22 + - safe handling of tool errors and missing data 23 + 24 + ## Gather 25 + 26 + Collect enough evidence to describe the week clearly. Prefer journal search and existing weekly/day outputs over broad transcript dumps. 27 + 28 + Suggested sources: 29 + 1. `sol call journal facets` 30 + 2. For each active facet and relevant day in the week: facet newsletters and notable day-level outputs 31 + 3. `sol call journal search "" --day-from $day_YYYYMMDD --day-to <+6> -a pulse -n 12` 32 + 4. `sol call journal search "" --day-from $day_YYYYMMDD --day-to <+6> -a decisions -n 12` 33 + 5. `sol call journal search "" --day-from $day_YYYYMMDD --day-to <+6> -a followups -n 12` 34 + 6. `sol call activities list --source anticipated --from $day_YYYYMMDD --to <+6>` 35 + 7. `sol call todos list` 36 + 8. Entity or relationship lookups only when they materially improve the reflection 37 + 38 + Before writing, audit your coverage: 39 + - `newsletters` 40 + - `activities` 41 + - `decisions` 42 + - `followups` 43 + - `todos` 44 + - `relationship_signals` 45 + - `gaps` 46 + 47 + ## Writing Rules 48 + 49 + - Hard ceiling: 800 words total, including the coverage preamble. 50 + - Every consequential claim must cite a `sol://` link. 51 + - Omit empty sections cleanly. Do not emit placeholders. 52 + - Do not emit a Cadence section in v1. Skip the `## Cadence` heading entirely. 53 + - Favor synthesis over recap. The owner should come away with a view of the week, not a dump of notes. 54 + 55 + ## Output 56 + 57 + Return only markdown in this structure: 58 + 59 + ```markdown 60 + --- 61 + type: weekly_reflection 62 + week: $day_YYYYMMDD 63 + generated: [current ISO 8601 datetime] 64 + model: [model identifier] 65 + sources: 66 + newsletters: [count] 67 + activities: [count] 68 + decisions: [count] 69 + followups: [count] 70 + todos: [count] 71 + relationship_signals: [count] 72 + gaps: [list of gap descriptions, or []] 73 + --- 74 + 75 + > [coverage preamble summarizing source counts and gaps] 76 + 77 + ## This week 78 + [content] 79 + 80 + ## Cadence 81 + [omit entirely in v1] 82 + 83 + ## Follow-ups 84 + [content] 85 + 86 + ## Decisions 87 + [content] 88 + 89 + ## Relationships 90 + [content] 91 + 92 + ## Wins 93 + [content] 94 + 95 + ## Forward look 96 + [content] 97 + ``` 98 + 99 + Use the section headers exactly as written above when a section has content. Keep them in that order. If a section has nothing meaningful to say, omit that heading entirely.
+8
tests/baselines/api/settings/providers.json
··· 400 400 "tier": 2, 401 401 "type": "generate" 402 402 }, 403 + "talent.system.weekly_reflection": { 404 + "disabled": false, 405 + "group": "Think", 406 + "label": "Weekly Reflection", 407 + "schedule": "weekly", 408 + "tier": 2, 409 + "type": "cogitate" 410 + }, 403 411 "talent.todos.daily": { 404 412 "disabled": false, 405 413 "group": "Think",
+11
tests/baselines/api/sol/talents-day.json
··· 499 499 "source": "system", 500 500 "title": "Work Story", 501 501 "type": "generate" 502 + }, 503 + "weekly_reflection": { 504 + "app": null, 505 + "color": "#6c757d", 506 + "description": "Sunday-start weekly reflection synthesized from the journal", 507 + "multi_facet": false, 508 + "output_format": "md", 509 + "schedule": "weekly", 510 + "source": "system", 511 + "title": "Weekly Reflection", 512 + "type": "cogitate" 502 513 } 503 514 }, 504 515 "uses": []
+38
tests/fixtures/journal/reflections/weekly/20260308.md
··· 1 + --- 2 + type: weekly_reflection 3 + week: 20260308 4 + generated: 2026-03-10T19:00:00Z 5 + model: openai/gpt-5 6 + sources: 7 + newsletters: 3 8 + activities: 4 9 + decisions: 1 10 + followups: 2 11 + todos: 2 12 + relationship_signals: 2 13 + gaps: [] 14 + --- 15 + 16 + > Built from 3 facet newsletters, 4 activity signals, 1 decision thread, 2 follow-up signals, 2 todos, and 2 relationship signals. No gaps. 17 + 18 + ## This week 19 + - The week had a boardroom balcony inflection: the secret collaboration moved from furtive sprint energy to a board-approved joint venture, which changed the emotional center of the work from urgency to stewardship (sol://20260308/talents/flow; sol://facets/montague/news/20260310; sol://facets/verona/news/20260310). 20 + - Sunday still sounded like a narrow demo push with two days to prove the idea; by Tuesday the same thread had become a public platform launch with both boards aligned behind it (sol://20260308/talents/flow; sol://facets/verona/events/20260310). 21 + 22 + ## Follow-ups 23 + - Approval did not clear the deck as much as it reframed it. The remaining work shifted from "can this happen?" to "can this survive contact with a real organization?" through presentation follow-through and team formation (sol://facets/verona/todos/20260308; sol://facets/verona/todos/20260310). 24 + - The open loop is less about winning the room now and more about building enough operating structure to deserve the win you just got (sol://facets/verona/todos/20260310). 25 + 26 + ## Decisions 27 + - The decisive move was to bet on technical proof and coalition-building instead of trying to outmaneuver the political noise around Paris Duke's alternative. That choice shows up both in the Sunday plan and in the approval outcome (sol://20260308/talents/flow; sol://facets/montague/news/20260310). 28 + 29 + ## Relationships 30 + - Juliet shifted from co-conspirator to explicit co-lead. The relationship signal is no longer hidden alignment; it is shared ownership in public, which raises both trust and scrutiny (sol://20260308/talents/flow; sol://facets/verona/news/20260310). 31 + - Friar Lawrence remained the strategic adult in the room: part sponsor, part translator, part stabilizer. His presence on the Sunday strategy call suggests the work needed legitimacy as much as speed (sol://facets/verona/events/20260308). 32 + 33 + ## Wins 34 + - The obvious win is the joint venture approval itself, but the deeper win is that the demo established the Verona Platform as the credible path, not just the romantic one (sol://facets/montague/news/20260310; sol://facets/verona/news/20260310). 35 + - The week also ended with a cleaner story about the work: a platform with real performance numbers, named leaders, and a next chapter that can be staffed instead of improvised (sol://facets/verona/news/20260310; sol://facets/verona/todos/20260310). 36 + 37 + ## Forward look 38 + - The next week should be less about adrenaline and more about operational discipline. Hiring, security, and post-board execution are now the tests that decide whether this was a breakthrough or a beautiful spike (sol://facets/verona/todos/20260310).
+12 -2
tests/test_cortex.py
··· 344 344 345 345 agent = TalentProcess(use_id, mock_process, log_path) 346 346 cortex_service.running_uses[use_id] = agent 347 + cortex_service.use_requests[use_id] = { 348 + "name": "weekly_reflection", 349 + "day": "20260308", 350 + } 347 351 348 352 with patch.object(cortex_service, "_complete_use_file") as mock_complete: 349 353 cortex_service._monitor_stdout(agent) ··· 352 356 assert log_path.exists() 353 357 lines = log_path.read_text().strip().split("\n") 354 358 assert len(lines) == 2 355 - assert json.loads(lines[0])["event"] == "start" 356 - assert json.loads(lines[1])["event"] == "finish" 359 + start_event = json.loads(lines[0]) 360 + finish_event = json.loads(lines[1]) 361 + assert start_event["event"] == "start" 362 + assert start_event["name"] == "weekly_reflection" 363 + assert start_event["day"] == "20260308" 364 + assert finish_event["event"] == "finish" 365 + assert finish_event["name"] == "weekly_reflection" 366 + assert finish_event["day"] == "20260308" 357 367 358 368 # Check file was completed 359 369 mock_complete.assert_called_once_with(use_id, log_path)
+17
tests/test_formatters.py
··· 72 72 assert formatter is not None 73 73 assert formatter.__name__ == "format_markdown" 74 74 75 + def test_get_formatter_weekly_reflection(self): 76 + """Test pattern matching for weekly reflection markdown.""" 77 + from think.formatters import get_formatter 78 + 79 + formatter = get_formatter("reflections/weekly/20260308.md") 80 + assert formatter is not None 81 + assert formatter.__name__ == "format_markdown" 82 + 75 83 def test_get_formatter_no_match(self): 76 84 """Test that unmatched patterns return None.""" 77 85 from think.formatters import get_formatter ··· 1481 1489 assert meta["day"] == "20240101" 1482 1490 assert meta["facet"] == "" 1483 1491 assert meta["agent"] == "import" 1492 + 1493 + def test_weekly_reflection(self): 1494 + """Test weekly reflection path extraction.""" 1495 + from think.formatters import extract_path_metadata 1496 + 1497 + meta = extract_path_metadata("reflections/weekly/20260308.md") 1498 + assert meta["day"] == "20260308" 1499 + assert meta["facet"] == "" 1500 + assert meta["agent"] == "reflection" 1484 1501 1485 1502 def test_app_output(self): 1486 1503 """Test app output path extraction."""
+31
tests/test_journal_index.py
··· 615 615 assert "facets/work/news/20240101.md" in paths 616 616 617 617 618 + def test_find_formattable_files_includes_weekly_reflection(journal_copy): 619 + """Test tracked fixture reflections are included in indexed file discovery.""" 620 + from think.formatters import find_formattable_files 621 + 622 + fixture_path = Path("tests/fixtures/journal/reflections/weekly/20260308.md") 623 + target_path = journal_copy / "reflections" / "weekly" / "20260308.md" 624 + target_path.parent.mkdir(parents=True, exist_ok=True) 625 + target_path.write_text(fixture_path.read_text(encoding="utf-8"), encoding="utf-8") 626 + 627 + files = find_formattable_files(str(journal_copy)) 628 + 629 + assert "reflections/weekly/20260308.md" in files 630 + 631 + 618 632 def test_search_journal_empty_query(journal_fixture): 619 633 """Test search with empty query returns all results.""" 620 634 from think.indexer.journal import scan_journal, search_journal ··· 1700 1714 1701 1715 assert total >= 1 1702 1716 assert any("unique nebula phrase" in result["text"].lower() for result in results) 1717 + 1718 + 1719 + def test_weekly_reflection_is_searchable_after_rescan(journal_copy): 1720 + from think.indexer.journal import scan_journal, search_journal 1721 + 1722 + fixture_path = Path("tests/fixtures/journal/reflections/weekly/20260308.md") 1723 + target_path = journal_copy / "reflections" / "weekly" / "20260308.md" 1724 + target_path.parent.mkdir(parents=True, exist_ok=True) 1725 + target_path.write_text(fixture_path.read_text(encoding="utf-8"), encoding="utf-8") 1726 + 1727 + scan_journal(str(journal_copy), full=True) 1728 + total, results = search_journal("boardroom balcony inflection") 1729 + 1730 + assert total >= 1 1731 + assert any( 1732 + "boardroom balcony inflection" in result["text"].lower() for result in results 1733 + ) 1703 1734 1704 1735 1705 1736 def test_scan_journal_is_pure_wrt_entity_state(journal_copy):
+154
tests/test_think_weekly.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import logging 7 + from datetime import datetime 8 + from pathlib import Path 9 + from zoneinfo import ZoneInfo 10 + 11 + from think.utils import get_owner_timezone, sunday_of_week 12 + 13 + 14 + def test_get_owner_timezone_uses_configured_zone(monkeypatch): 15 + monkeypatch.setattr( 16 + "think.utils.get_config", 17 + lambda: {"identity": {"timezone": "America/New_York"}}, 18 + ) 19 + 20 + tz = get_owner_timezone() 21 + 22 + assert tz == ZoneInfo("America/New_York") 23 + 24 + 25 + def test_get_owner_timezone_falls_back_to_host_zone(monkeypatch, caplog): 26 + class FixedDateTime(datetime): 27 + @classmethod 28 + def now(cls, tz=None): 29 + return cls(2026, 4, 20, 12, 0, tzinfo=ZoneInfo("UTC")) 30 + 31 + monkeypatch.setattr( 32 + "think.utils.get_config", 33 + lambda: {"identity": {"timezone": "Mars/Olympus"}}, 34 + ) 35 + monkeypatch.setattr("think.utils.datetime", FixedDateTime) 36 + 37 + with caplog.at_level(logging.WARNING): 38 + tz = get_owner_timezone() 39 + 40 + assert tz == ZoneInfo("UTC") 41 + assert "falling back to host timezone" in caplog.text 42 + 43 + 44 + def test_sunday_of_week_returns_most_recent_sunday(): 45 + tz = ZoneInfo("America/Denver") 46 + 47 + assert sunday_of_week(datetime(2026, 3, 8, 9, 0), tz) == "20260308" 48 + assert sunday_of_week(datetime(2026, 3, 10, 9, 0), tz) == "20260308" 49 + 50 + 51 + def _patch_weekly_runtime( 52 + monkeypatch, 53 + journal_path: Path, 54 + prompts: dict[str, dict], 55 + *, 56 + enabled_facets: dict[str, dict] | None = None, 57 + active_facets: set[str] | None = None, 58 + ) -> list[tuple[str, str, dict]]: 59 + captured: list[tuple[str, str, dict]] = [] 60 + 61 + monkeypatch.setattr("think.thinking.get_owner_timezone", lambda: ZoneInfo("UTC")) 62 + monkeypatch.setattr("think.thinking.get_journal", lambda: str(journal_path)) 63 + monkeypatch.setattr("think.thinking.get_talent_configs", lambda schedule: prompts) 64 + monkeypatch.setattr("think.thinking.day_input_summary", lambda day: "summary") 65 + monkeypatch.setattr( 66 + "think.thinking.get_enabled_facets", lambda: enabled_facets or {} 67 + ) 68 + monkeypatch.setattr( 69 + "think.thinking.get_active_facets", lambda day: active_facets or set() 70 + ) 71 + monkeypatch.setattr("think.thinking._update_status", lambda **kwargs: None) 72 + monkeypatch.setattr("think.thinking.emit", lambda *args, **kwargs: None) 73 + monkeypatch.setattr("think.thinking._jsonl_log", lambda *args, **kwargs: None) 74 + monkeypatch.setattr("think.thinking._log_skip", lambda *args, **kwargs: None) 75 + 76 + def fake_request(prompt: str, name: str, config: dict) -> str: 77 + captured.append((prompt, name, config)) 78 + return f"use-{len(captured)}" 79 + 80 + monkeypatch.setattr("think.thinking._cortex_request_with_retry", fake_request) 81 + monkeypatch.setattr( 82 + "think.thinking._drain_priority_batch", 83 + lambda spawned, *_args: (len(spawned), 0, []), 84 + ) 85 + return captured 86 + 87 + 88 + def test_run_weekly_prompts_sets_weekly_reflection_output_override( 89 + tmp_path, monkeypatch 90 + ): 91 + from think.thinking import run_weekly_prompts 92 + 93 + captured = _patch_weekly_runtime( 94 + monkeypatch, 95 + tmp_path / "journal", 96 + {"weekly_reflection": {"type": "cogitate", "priority": 90}}, 97 + ) 98 + 99 + success, failed, failed_names = run_weekly_prompts( 100 + day="20260310", 101 + refresh=False, 102 + verbose=False, 103 + ) 104 + 105 + assert (success, failed, failed_names) == (1, 0, []) 106 + assert len(captured) == 1 107 + _prompt, name, config = captured[0] 108 + assert name == "weekly_reflection" 109 + assert config["day"] == "20260308" 110 + assert config["output"] == "md" 111 + assert config["output_path"] == str( 112 + tmp_path / "journal" / "reflections" / "weekly" / "20260308.md" 113 + ) 114 + assert config["env"]["SOL_DAY"] == "20260308" 115 + assert config["schedule"] == "weekly" 116 + 117 + 118 + def test_run_weekly_prompts_sets_override_for_multifacet_weekly_reflection( 119 + tmp_path, monkeypatch 120 + ): 121 + from think.thinking import run_weekly_prompts 122 + 123 + captured = _patch_weekly_runtime( 124 + monkeypatch, 125 + tmp_path / "journal", 126 + { 127 + "weekly_reflection": { 128 + "type": "cogitate", 129 + "priority": 90, 130 + "multi_facet": True, 131 + } 132 + }, 133 + enabled_facets={"work": {"title": "Work"}}, 134 + active_facets={"work"}, 135 + ) 136 + 137 + success, failed, failed_names = run_weekly_prompts( 138 + day="20260310", 139 + refresh=False, 140 + verbose=False, 141 + ) 142 + 143 + assert (success, failed, failed_names) == (1, 0, []) 144 + assert len(captured) == 1 145 + _prompt, name, config = captured[0] 146 + assert name == "weekly_reflection" 147 + assert config["facet"] == "work" 148 + assert config["day"] == "20260308" 149 + assert config["output"] == "md" 150 + assert config["output_path"] == str( 151 + tmp_path / "journal" / "reflections" / "weekly" / "20260308.md" 152 + ) 153 + assert config["env"]["SOL_DAY"] == "20260308" 154 + assert config["env"]["SOL_FACET"] == "work"
+2
think/cortex.py
··· 417 417 _req = self.use_requests.get(agent.use_id) 418 418 if _req and "name" not in event: 419 419 event["name"] = _req.get("name", "") 420 + if _req and "day" not in event: 421 + event["day"] = _req.get("day", "") 420 422 421 423 # Append to JSONL file 422 424 with open(agent.log_path, "a") as f:
+7
think/formatters.py
··· 92 92 ): 93 93 day = parts[3] 94 94 95 + if parts[0] == "reflections" and len(parts) >= 3 and parts[1] == "weekly": 96 + if DATE_RE.fullmatch(basename): 97 + day = basename 98 + 95 99 # Extract day from imports/YYYYMMDD_HHMMSS/... 96 100 if parts[0] == "imports" and len(parts) >= 2: 97 101 import_id = parts[1] ··· 106 110 if is_markdown: 107 111 if parts[0] == "facets" and len(parts) >= 4 and parts[2] == "news": 108 112 agent = "news" 113 + elif parts[0] == "reflections" and len(parts) >= 3 and parts[1] == "weekly": 114 + agent = "reflection" 109 115 elif parts[0] == "imports": 110 116 agent = "import" 111 117 elif parts[0] == "apps" and len(parts) >= 4: ··· 199 205 "*/*/*/talents/*/*.md": ("think.markdown", "format_markdown", True), 200 206 "facets/*/activities/*/*/*.md": ("think.markdown", "format_markdown", True), 201 207 "facets/*/news/*.md": ("think.markdown", "format_markdown", True), 208 + "reflections/weekly/*.md": ("think.markdown", "format_markdown", True), 202 209 "imports/*/summary.md": ("think.markdown", "format_markdown", True), 203 210 "apps/*/talents/*.md": ("think.markdown", "format_markdown", True), 204 211 }
+29 -2
think/thinking.py
··· 43 43 day_log, 44 44 day_path, 45 45 get_journal, 46 + get_owner_timezone, 46 47 get_rev, 47 48 iso_date, 48 49 iter_segments, 49 50 now_ms, 50 51 require_solstone, 51 52 setup_cli, 53 + sunday_of_week, 52 54 updated_days, 53 55 ) 54 56 ··· 1477 1479 Tuple of (success_count, fail_count, failed_names). 1478 1480 """ 1479 1481 target_schedule = "weekly" 1482 + owner_tz = get_owner_timezone() 1483 + analysis_dt = datetime.strptime(day, "%Y%m%d") 1484 + week_start = sunday_of_week(analysis_dt, owner_tz) 1485 + weekly_reflection_path = ( 1486 + Path(get_journal()) / "reflections" / "weekly" / f"{week_start}.md" 1487 + ) 1480 1488 1481 1489 # Load ALL scheduled prompts (both generators and agents) 1482 1490 all_prompts = get_talent_configs(schedule=target_schedule) ··· 1606 1614 "SOL_DAY": day, 1607 1615 "SOL_FACET": facet_name, 1608 1616 } 1617 + if prompt_name == "weekly_reflection": 1618 + request_config["day"] = week_start 1619 + request_config["output"] = "md" 1620 + request_config["output_path"] = str(weekly_reflection_path) 1621 + env["SOL_DAY"] = week_start 1609 1622 request_config["env"] = env 1610 1623 request_config["schedule"] = target_schedule 1611 1624 1612 1625 prompt = ( 1613 1626 "" 1614 1627 if is_generate 1615 - else f"Processing facet '{facet_name}' for {day_formatted}: {input_summary}. Use get_facet('{facet_name}') to load context." 1628 + else ( 1629 + f"Processing facet '{facet_name}' for {iso_date(week_start)}: " 1630 + f"{input_summary}. Use get_facet('{facet_name}') to load context." 1631 + if prompt_name == "weekly_reflection" 1632 + else f"Processing facet '{facet_name}' for {day_formatted}: {input_summary}. Use get_facet('{facet_name}') to load context." 1633 + ) 1616 1634 ) 1617 1635 1618 1636 use_id = _cortex_request_with_retry( ··· 1690 1708 if refresh: 1691 1709 request_config["refresh"] = True 1692 1710 env: dict[str, str] = {"SOL_DAY": day} 1711 + if prompt_name == "weekly_reflection": 1712 + request_config["day"] = week_start 1713 + request_config["output"] = "md" 1714 + request_config["output_path"] = str(weekly_reflection_path) 1715 + env["SOL_DAY"] = week_start 1693 1716 request_config["env"] = env 1694 1717 request_config["schedule"] = target_schedule 1695 1718 1696 1719 prompt = ( 1697 1720 "" 1698 1721 if is_generate 1699 - else f"Running scheduled task for {day_formatted}: {input_summary}." 1722 + else ( 1723 + f"Running scheduled weekly reflection for {iso_date(week_start)}: {input_summary}." 1724 + if prompt_name == "weekly_reflection" 1725 + else f"Running scheduled task for {day_formatted}: {input_summary}." 1726 + ) 1700 1727 ) 1701 1728 1702 1729 use_id = _cortex_request_with_retry(
+36 -1
think/utils.py
··· 19 19 import socket 20 20 import sys 21 21 import time 22 - from datetime import datetime 22 + from datetime import datetime, timedelta 23 23 from pathlib import Path 24 24 from typing import Any, Optional 25 + from zoneinfo import ZoneInfo, ZoneInfoNotFoundError 25 26 26 27 from timefhuman import timefhuman 27 28 ··· 499 500 ISO formatted date like "2026-01-24". 500 501 """ 501 502 return f"{day[:4]}-{day[4:6]}-{day[6:8]}" 503 + 504 + 505 + def get_owner_timezone() -> ZoneInfo: 506 + """Return the configured owner timezone or fall back to the host timezone.""" 507 + configured = str(get_config().get("identity", {}).get("timezone") or "").strip() 508 + if configured: 509 + try: 510 + return ZoneInfo(configured) 511 + except ZoneInfoNotFoundError: 512 + logging.getLogger(__name__).warning( 513 + "Invalid identity.timezone %r; falling back to host timezone", 514 + configured, 515 + ) 516 + 517 + local_tz = datetime.now().astimezone().tzinfo 518 + if isinstance(local_tz, ZoneInfo): 519 + return local_tz 520 + 521 + local_key = getattr(local_tz, "key", None) 522 + if isinstance(local_key, str): 523 + return ZoneInfo(local_key) 524 + return ZoneInfo("UTC") 525 + 526 + 527 + def sunday_of_week(dt: datetime, tz: ZoneInfo) -> str: 528 + """Return the most recent Sunday at or before ``dt`` in ``tz``.""" 529 + if dt.tzinfo is None: 530 + local_dt = dt.replace(tzinfo=tz) 531 + else: 532 + local_dt = dt.astimezone(tz) 533 + 534 + # Why: datetime.weekday() is Monday-first, but weekly_reflection is Sunday-first. 535 + days_since_sunday = (local_dt.weekday() + 1) % 7 536 + return (local_dt - timedelta(days=days_since_sunday)).strftime("%Y%m%d") 502 537 503 538 504 539 def format_segment_times(segment: str) -> tuple[str, str] | tuple[None, None]: