From 67763e4d060b25d7a3c330d3b1bf9c5cc00ac2dc Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Tue, 21 Apr 2026 00:58:29 -0600 Subject: [PATCH] think: add weekly reflection scheduling and indexing --- talent/weekly_reflection.md | 99 +++++++++++ tests/baselines/api/settings/providers.json | 8 + tests/baselines/api/sol/talents-day.json | 11 ++ .../journal/reflections/weekly/20260308.md | 38 +++++ tests/test_cortex.py | 14 +- tests/test_formatters.py | 17 ++ tests/test_journal_index.py | 31 ++++ tests/test_think_weekly.py | 154 ++++++++++++++++++ think/cortex.py | 2 + think/formatters.py | 7 + think/thinking.py | 31 +++- think/utils.py | 37 ++++- 12 files changed, 444 insertions(+), 5 deletions(-) create mode 100644 talent/weekly_reflection.md create mode 100644 tests/fixtures/journal/reflections/weekly/20260308.md create mode 100644 tests/test_think_weekly.py diff --git a/talent/weekly_reflection.md b/talent/weekly_reflection.md new file mode 100644 index 000000000..73aa54731 --- /dev/null +++ b/talent/weekly_reflection.md @@ -0,0 +1,99 @@ +{ + "type": "cogitate", + "title": "Weekly Reflection", + "description": "Sunday-start weekly reflection synthesized from the journal", + "schedule": "weekly", + "priority": 90, + "output": "md" +} + +$facets + +You are generating the weekly reflection for $agent_name. + +This is not a conversation. Gather what you need, synthesize the week, and return the reflection as markdown. The system saves your response automatically. + +`$day_YYYYMMDD` is the canonical Sunday that starts the week under review. Cover that Sunday through the following Saturday. + +Follow the provenance pattern from `talent/patterns/provenance.md`, including: +- a coverage preamble with source counts and gaps +- `sol://` attribution for consequential claims +- confidence-graded language that distinguishes observation from inference +- safe handling of tool errors and missing data + +## Gather + +Collect enough evidence to describe the week clearly. Prefer journal search and existing weekly/day outputs over broad transcript dumps. + +Suggested sources: +1. `sol call journal facets` +2. For each active facet and relevant day in the week: facet newsletters and notable day-level outputs +3. `sol call journal search "" --day-from $day_YYYYMMDD --day-to <+6> -a pulse -n 12` +4. `sol call journal search "" --day-from $day_YYYYMMDD --day-to <+6> -a decisions -n 12` +5. `sol call journal search "" --day-from $day_YYYYMMDD --day-to <+6> -a followups -n 12` +6. `sol call activities list --source anticipated --from $day_YYYYMMDD --to <+6>` +7. `sol call todos list` +8. Entity or relationship lookups only when they materially improve the reflection + +Before writing, audit your coverage: +- `newsletters` +- `activities` +- `decisions` +- `followups` +- `todos` +- `relationship_signals` +- `gaps` + +## Writing Rules + +- Hard ceiling: 800 words total, including the coverage preamble. +- Every consequential claim must cite a `sol://` link. +- Omit empty sections cleanly. Do not emit placeholders. +- Do not emit a Cadence section in v1. Skip the `## Cadence` heading entirely. +- Favor synthesis over recap. The owner should come away with a view of the week, not a dump of notes. + +## Output + +Return only markdown in this structure: + +```markdown +--- +type: weekly_reflection +week: $day_YYYYMMDD +generated: [current ISO 8601 datetime] +model: [model identifier] +sources: + newsletters: [count] + activities: [count] + decisions: [count] + followups: [count] + todos: [count] + relationship_signals: [count] +gaps: [list of gap descriptions, or []] +--- + +> [coverage preamble summarizing source counts and gaps] + +## This week +[content] + +## Cadence +[omit entirely in v1] + +## Follow-ups +[content] + +## Decisions +[content] + +## Relationships +[content] + +## Wins +[content] + +## Forward look +[content] +``` + +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. diff --git a/tests/baselines/api/settings/providers.json b/tests/baselines/api/settings/providers.json index 79d3d4027..a2c16fd5a 100644 --- a/tests/baselines/api/settings/providers.json +++ b/tests/baselines/api/settings/providers.json @@ -400,6 +400,14 @@ "tier": 2, "type": "generate" }, + "talent.system.weekly_reflection": { + "disabled": false, + "group": "Think", + "label": "Weekly Reflection", + "schedule": "weekly", + "tier": 2, + "type": "cogitate" + }, "talent.todos.daily": { "disabled": false, "group": "Think", diff --git a/tests/baselines/api/sol/talents-day.json b/tests/baselines/api/sol/talents-day.json index 992a1fb04..c9a80a495 100644 --- a/tests/baselines/api/sol/talents-day.json +++ b/tests/baselines/api/sol/talents-day.json @@ -499,6 +499,17 @@ "source": "system", "title": "Work Story", "type": "generate" + }, + "weekly_reflection": { + "app": null, + "color": "#6c757d", + "description": "Sunday-start weekly reflection synthesized from the journal", + "multi_facet": false, + "output_format": "md", + "schedule": "weekly", + "source": "system", + "title": "Weekly Reflection", + "type": "cogitate" } }, "uses": [] diff --git a/tests/fixtures/journal/reflections/weekly/20260308.md b/tests/fixtures/journal/reflections/weekly/20260308.md new file mode 100644 index 000000000..78566f56f --- /dev/null +++ b/tests/fixtures/journal/reflections/weekly/20260308.md @@ -0,0 +1,38 @@ +--- +type: weekly_reflection +week: 20260308 +generated: 2026-03-10T19:00:00Z +model: openai/gpt-5 +sources: + newsletters: 3 + activities: 4 + decisions: 1 + followups: 2 + todos: 2 + relationship_signals: 2 +gaps: [] +--- + +> Built from 3 facet newsletters, 4 activity signals, 1 decision thread, 2 follow-up signals, 2 todos, and 2 relationship signals. No gaps. + +## This week +- 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). +- 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). + +## Follow-ups +- 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). +- 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). + +## Decisions +- 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). + +## Relationships +- 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). +- 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). + +## Wins +- 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). +- 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). + +## Forward look +- 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). diff --git a/tests/test_cortex.py b/tests/test_cortex.py index 9f3a05c6d..61ce1bb0a 100644 --- a/tests/test_cortex.py +++ b/tests/test_cortex.py @@ -344,6 +344,10 @@ def test_monitor_stdout_json_events(cortex_service, mock_journal): agent = TalentProcess(use_id, mock_process, log_path) cortex_service.running_uses[use_id] = agent + cortex_service.use_requests[use_id] = { + "name": "weekly_reflection", + "day": "20260308", + } with patch.object(cortex_service, "_complete_use_file") as mock_complete: cortex_service._monitor_stdout(agent) @@ -352,8 +356,14 @@ def test_monitor_stdout_json_events(cortex_service, mock_journal): assert log_path.exists() lines = log_path.read_text().strip().split("\n") assert len(lines) == 2 - assert json.loads(lines[0])["event"] == "start" - assert json.loads(lines[1])["event"] == "finish" + start_event = json.loads(lines[0]) + finish_event = json.loads(lines[1]) + assert start_event["event"] == "start" + assert start_event["name"] == "weekly_reflection" + assert start_event["day"] == "20260308" + assert finish_event["event"] == "finish" + assert finish_event["name"] == "weekly_reflection" + assert finish_event["day"] == "20260308" # Check file was completed mock_complete.assert_called_once_with(use_id, log_path) diff --git a/tests/test_formatters.py b/tests/test_formatters.py index 81d14b493..d36557b01 100644 --- a/tests/test_formatters.py +++ b/tests/test_formatters.py @@ -72,6 +72,14 @@ class TestRegistry: assert formatter is not None assert formatter.__name__ == "format_markdown" + def test_get_formatter_weekly_reflection(self): + """Test pattern matching for weekly reflection markdown.""" + from think.formatters import get_formatter + + formatter = get_formatter("reflections/weekly/20260308.md") + assert formatter is not None + assert formatter.__name__ == "format_markdown" + def test_get_formatter_no_match(self): """Test that unmatched patterns return None.""" from think.formatters import get_formatter @@ -1482,6 +1490,15 @@ class TestExtractPathMetadata: assert meta["facet"] == "" assert meta["agent"] == "import" + def test_weekly_reflection(self): + """Test weekly reflection path extraction.""" + from think.formatters import extract_path_metadata + + meta = extract_path_metadata("reflections/weekly/20260308.md") + assert meta["day"] == "20260308" + assert meta["facet"] == "" + assert meta["agent"] == "reflection" + def test_app_output(self): """Test app output path extraction.""" from think.formatters import extract_path_metadata diff --git a/tests/test_journal_index.py b/tests/test_journal_index.py index 4643bb97d..75a5a95f8 100644 --- a/tests/test_journal_index.py +++ b/tests/test_journal_index.py @@ -615,6 +615,20 @@ def test_find_formattable_files(journal_fixture): assert "facets/work/news/20240101.md" in paths +def test_find_formattable_files_includes_weekly_reflection(journal_copy): + """Test tracked fixture reflections are included in indexed file discovery.""" + from think.formatters import find_formattable_files + + fixture_path = Path("tests/fixtures/journal/reflections/weekly/20260308.md") + target_path = journal_copy / "reflections" / "weekly" / "20260308.md" + target_path.parent.mkdir(parents=True, exist_ok=True) + target_path.write_text(fixture_path.read_text(encoding="utf-8"), encoding="utf-8") + + files = find_formattable_files(str(journal_copy)) + + assert "reflections/weekly/20260308.md" in files + + def test_search_journal_empty_query(journal_fixture): """Test search with empty query returns all results.""" from think.indexer.journal import scan_journal, search_journal @@ -1702,6 +1716,23 @@ def test_chat_turn_is_searchable_after_rescan(journal_fixture): assert any("unique nebula phrase" in result["text"].lower() for result in results) +def test_weekly_reflection_is_searchable_after_rescan(journal_copy): + from think.indexer.journal import scan_journal, search_journal + + fixture_path = Path("tests/fixtures/journal/reflections/weekly/20260308.md") + target_path = journal_copy / "reflections" / "weekly" / "20260308.md" + target_path.parent.mkdir(parents=True, exist_ok=True) + target_path.write_text(fixture_path.read_text(encoding="utf-8"), encoding="utf-8") + + scan_journal(str(journal_copy), full=True) + total, results = search_journal("boardroom balcony inflection") + + assert total >= 1 + assert any( + "boardroom balcony inflection" in result["text"].lower() for result in results + ) + + def test_scan_journal_is_pure_wrt_entity_state(journal_copy): """scan_journal must not mutate journal/entities/ state.""" from think.indexer.journal import scan_journal diff --git a/tests/test_think_weekly.py b/tests/test_think_weekly.py new file mode 100644 index 000000000..120b0d8d4 --- /dev/null +++ b/tests/test_think_weekly.py @@ -0,0 +1,154 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import logging +from datetime import datetime +from pathlib import Path +from zoneinfo import ZoneInfo + +from think.utils import get_owner_timezone, sunday_of_week + + +def test_get_owner_timezone_uses_configured_zone(monkeypatch): + monkeypatch.setattr( + "think.utils.get_config", + lambda: {"identity": {"timezone": "America/New_York"}}, + ) + + tz = get_owner_timezone() + + assert tz == ZoneInfo("America/New_York") + + +def test_get_owner_timezone_falls_back_to_host_zone(monkeypatch, caplog): + class FixedDateTime(datetime): + @classmethod + def now(cls, tz=None): + return cls(2026, 4, 20, 12, 0, tzinfo=ZoneInfo("UTC")) + + monkeypatch.setattr( + "think.utils.get_config", + lambda: {"identity": {"timezone": "Mars/Olympus"}}, + ) + monkeypatch.setattr("think.utils.datetime", FixedDateTime) + + with caplog.at_level(logging.WARNING): + tz = get_owner_timezone() + + assert tz == ZoneInfo("UTC") + assert "falling back to host timezone" in caplog.text + + +def test_sunday_of_week_returns_most_recent_sunday(): + tz = ZoneInfo("America/Denver") + + assert sunday_of_week(datetime(2026, 3, 8, 9, 0), tz) == "20260308" + assert sunday_of_week(datetime(2026, 3, 10, 9, 0), tz) == "20260308" + + +def _patch_weekly_runtime( + monkeypatch, + journal_path: Path, + prompts: dict[str, dict], + *, + enabled_facets: dict[str, dict] | None = None, + active_facets: set[str] | None = None, +) -> list[tuple[str, str, dict]]: + captured: list[tuple[str, str, dict]] = [] + + monkeypatch.setattr("think.thinking.get_owner_timezone", lambda: ZoneInfo("UTC")) + monkeypatch.setattr("think.thinking.get_journal", lambda: str(journal_path)) + monkeypatch.setattr("think.thinking.get_talent_configs", lambda schedule: prompts) + monkeypatch.setattr("think.thinking.day_input_summary", lambda day: "summary") + monkeypatch.setattr( + "think.thinking.get_enabled_facets", lambda: enabled_facets or {} + ) + monkeypatch.setattr( + "think.thinking.get_active_facets", lambda day: active_facets or set() + ) + monkeypatch.setattr("think.thinking._update_status", lambda **kwargs: None) + monkeypatch.setattr("think.thinking.emit", lambda *args, **kwargs: None) + monkeypatch.setattr("think.thinking._jsonl_log", lambda *args, **kwargs: None) + monkeypatch.setattr("think.thinking._log_skip", lambda *args, **kwargs: None) + + def fake_request(prompt: str, name: str, config: dict) -> str: + captured.append((prompt, name, config)) + return f"use-{len(captured)}" + + monkeypatch.setattr("think.thinking._cortex_request_with_retry", fake_request) + monkeypatch.setattr( + "think.thinking._drain_priority_batch", + lambda spawned, *_args: (len(spawned), 0, []), + ) + return captured + + +def test_run_weekly_prompts_sets_weekly_reflection_output_override( + tmp_path, monkeypatch +): + from think.thinking import run_weekly_prompts + + captured = _patch_weekly_runtime( + monkeypatch, + tmp_path / "journal", + {"weekly_reflection": {"type": "cogitate", "priority": 90}}, + ) + + success, failed, failed_names = run_weekly_prompts( + day="20260310", + refresh=False, + verbose=False, + ) + + assert (success, failed, failed_names) == (1, 0, []) + assert len(captured) == 1 + _prompt, name, config = captured[0] + assert name == "weekly_reflection" + assert config["day"] == "20260308" + assert config["output"] == "md" + assert config["output_path"] == str( + tmp_path / "journal" / "reflections" / "weekly" / "20260308.md" + ) + assert config["env"]["SOL_DAY"] == "20260308" + assert config["schedule"] == "weekly" + + +def test_run_weekly_prompts_sets_override_for_multifacet_weekly_reflection( + tmp_path, monkeypatch +): + from think.thinking import run_weekly_prompts + + captured = _patch_weekly_runtime( + monkeypatch, + tmp_path / "journal", + { + "weekly_reflection": { + "type": "cogitate", + "priority": 90, + "multi_facet": True, + } + }, + enabled_facets={"work": {"title": "Work"}}, + active_facets={"work"}, + ) + + success, failed, failed_names = run_weekly_prompts( + day="20260310", + refresh=False, + verbose=False, + ) + + assert (success, failed, failed_names) == (1, 0, []) + assert len(captured) == 1 + _prompt, name, config = captured[0] + assert name == "weekly_reflection" + assert config["facet"] == "work" + assert config["day"] == "20260308" + assert config["output"] == "md" + assert config["output_path"] == str( + tmp_path / "journal" / "reflections" / "weekly" / "20260308.md" + ) + assert config["env"]["SOL_DAY"] == "20260308" + assert config["env"]["SOL_FACET"] == "work" diff --git a/think/cortex.py b/think/cortex.py index ddc47cc4b..5171c1fc7 100644 --- a/think/cortex.py +++ b/think/cortex.py @@ -417,6 +417,8 @@ class CortexService: _req = self.use_requests.get(agent.use_id) if _req and "name" not in event: event["name"] = _req.get("name", "") + if _req and "day" not in event: + event["day"] = _req.get("day", "") # Append to JSONL file with open(agent.log_path, "a") as f: diff --git a/think/formatters.py b/think/formatters.py index 2fef90b34..1fe1040f3 100644 --- a/think/formatters.py +++ b/think/formatters.py @@ -92,6 +92,10 @@ def extract_path_metadata(rel_path: str) -> dict[str, str]: ): day = parts[3] + if parts[0] == "reflections" and len(parts) >= 3 and parts[1] == "weekly": + if DATE_RE.fullmatch(basename): + day = basename + # Extract day from imports/YYYYMMDD_HHMMSS/... if parts[0] == "imports" and len(parts) >= 2: import_id = parts[1] @@ -106,6 +110,8 @@ def extract_path_metadata(rel_path: str) -> dict[str, str]: if is_markdown: if parts[0] == "facets" and len(parts) >= 4 and parts[2] == "news": agent = "news" + elif parts[0] == "reflections" and len(parts) >= 3 and parts[1] == "weekly": + agent = "reflection" elif parts[0] == "imports": agent = "import" elif parts[0] == "apps" and len(parts) >= 4: @@ -199,6 +205,7 @@ FORMATTERS: dict[str, tuple[str, str, bool]] = { "*/*/*/talents/*/*.md": ("think.markdown", "format_markdown", True), "facets/*/activities/*/*/*.md": ("think.markdown", "format_markdown", True), "facets/*/news/*.md": ("think.markdown", "format_markdown", True), + "reflections/weekly/*.md": ("think.markdown", "format_markdown", True), "imports/*/summary.md": ("think.markdown", "format_markdown", True), "apps/*/talents/*.md": ("think.markdown", "format_markdown", True), } diff --git a/think/thinking.py b/think/thinking.py index 823ab4e37..3b9620353 100644 --- a/think/thinking.py +++ b/think/thinking.py @@ -43,12 +43,14 @@ from think.utils import ( day_log, day_path, get_journal, + get_owner_timezone, get_rev, iso_date, iter_segments, now_ms, require_solstone, setup_cli, + sunday_of_week, updated_days, ) @@ -1477,6 +1479,12 @@ def run_weekly_prompts( Tuple of (success_count, fail_count, failed_names). """ target_schedule = "weekly" + owner_tz = get_owner_timezone() + analysis_dt = datetime.strptime(day, "%Y%m%d") + week_start = sunday_of_week(analysis_dt, owner_tz) + weekly_reflection_path = ( + Path(get_journal()) / "reflections" / "weekly" / f"{week_start}.md" + ) # Load ALL scheduled prompts (both generators and agents) all_prompts = get_talent_configs(schedule=target_schedule) @@ -1606,13 +1614,23 @@ def run_weekly_prompts( "SOL_DAY": day, "SOL_FACET": facet_name, } + if prompt_name == "weekly_reflection": + request_config["day"] = week_start + request_config["output"] = "md" + request_config["output_path"] = str(weekly_reflection_path) + env["SOL_DAY"] = week_start request_config["env"] = env request_config["schedule"] = target_schedule prompt = ( "" if is_generate - else f"Processing facet '{facet_name}' for {day_formatted}: {input_summary}. Use get_facet('{facet_name}') to load context." + else ( + f"Processing facet '{facet_name}' for {iso_date(week_start)}: " + f"{input_summary}. Use get_facet('{facet_name}') to load context." + if prompt_name == "weekly_reflection" + else f"Processing facet '{facet_name}' for {day_formatted}: {input_summary}. Use get_facet('{facet_name}') to load context." + ) ) use_id = _cortex_request_with_retry( @@ -1690,13 +1708,22 @@ def run_weekly_prompts( if refresh: request_config["refresh"] = True env: dict[str, str] = {"SOL_DAY": day} + if prompt_name == "weekly_reflection": + request_config["day"] = week_start + request_config["output"] = "md" + request_config["output_path"] = str(weekly_reflection_path) + env["SOL_DAY"] = week_start request_config["env"] = env request_config["schedule"] = target_schedule prompt = ( "" if is_generate - else f"Running scheduled task for {day_formatted}: {input_summary}." + else ( + f"Running scheduled weekly reflection for {iso_date(week_start)}: {input_summary}." + if prompt_name == "weekly_reflection" + else f"Running scheduled task for {day_formatted}: {input_summary}." + ) ) use_id = _cortex_request_with_retry( diff --git a/think/utils.py b/think/utils.py index e5b3ad112..cea673414 100644 --- a/think/utils.py +++ b/think/utils.py @@ -19,9 +19,10 @@ import re import socket import sys import time -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path from typing import Any, Optional +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from timefhuman import timefhuman @@ -501,6 +502,40 @@ def iso_date(day: str) -> str: return f"{day[:4]}-{day[4:6]}-{day[6:8]}" +def get_owner_timezone() -> ZoneInfo: + """Return the configured owner timezone or fall back to the host timezone.""" + configured = str(get_config().get("identity", {}).get("timezone") or "").strip() + if configured: + try: + return ZoneInfo(configured) + except ZoneInfoNotFoundError: + logging.getLogger(__name__).warning( + "Invalid identity.timezone %r; falling back to host timezone", + configured, + ) + + local_tz = datetime.now().astimezone().tzinfo + if isinstance(local_tz, ZoneInfo): + return local_tz + + local_key = getattr(local_tz, "key", None) + if isinstance(local_key, str): + return ZoneInfo(local_key) + return ZoneInfo("UTC") + + +def sunday_of_week(dt: datetime, tz: ZoneInfo) -> str: + """Return the most recent Sunday at or before ``dt`` in ``tz``.""" + if dt.tzinfo is None: + local_dt = dt.replace(tzinfo=tz) + else: + local_dt = dt.astimezone(tz) + + # Why: datetime.weekday() is Monday-first, but weekly_reflection is Sunday-first. + days_since_sunday = (local_dt.weekday() + 1) % 7 + return (local_dt - timedelta(days=days_since_sunday)).strftime("%Y%m%d") + + def format_segment_times(segment: str) -> tuple[str, str] | tuple[None, None]: """Format segment start and end times as human-readable strings. -- 2.51.2