personal memory agent
0

Configure Feed

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

feat(talent): schema-bounded JSON for morning briefing

morning_briefing now emits schema-bounded JSON via the new morning_briefing.schema.json file. The schema is fully bounded and does not require any check_schema_bounds.py ALLOWLIST entry. A day-level formatter registry entry renders the JSON output back to markdown for the search index and the identity CLI, preserving the five ## headings and the morning_briefing indexer agent string that entities/context.py's NOISE_AGENTS depends on.

morning_briefing_path() previously hard-coded output_format="md", the single hard-coded lie in the path authority for this talent. It now derives the extension from the talent's declared output format through a cached _briefing_output_format() helper, so briefing_cmd's day_dirs() loop does not re-read the talent config once per day.

The consumer migration is a clean break, not a compatibility shim. _load_briefing_md and _BRIEFING_SECTIONS are deleted, and Home, voice, and the identity CLI all read through the new shared solstone/think/briefing.py loader and renderers. This incidentally removes the solstone.think.voice.tools -> solstone.apps.home.routes import, a think-to-apps dependency inversion logged as a follow-up during recon.

A post_process hook was considered and declined. The empirical finding was that _run_post_hooks runs before _write_output on both talents.py:1375 and talents.py:1673, while _schema_validation_clean at talents.py:1087 rejects on the provider's gen_result["schema_validation"] before re-validating the post-hooked string, so a post-hook cannot repair a required key the model omitted. Because every schema root is additionalProperties: false, a post-hook cannot inject an undeclared key either. OpenAI strict mode forbids optional properties, so "declared but not required" is unavailable; the model must emit the key regardless, leaving the hook's only residual value as overwriting values the model was already forced to produce. talents.py:1822 also pops template_vars off modifications and never stores it on config, so a post-hook cannot even see the pre-hook packet without inventing a new pre-hook return-key convention that exists nowhere else in the repo. Instead, the pre-hook collapses generated, model, source_counts, source_gaps, and coverage_preamble into one $briefing_metadata template var built with json.dumps(..., indent=2), retiring the two-space YAML fragment _render_source_counts used to emit and removing the JSON-escaping hazard for the preamble prose. date is dropped entirely because the path encodes the day.

Only lowercase $briefing_metadata is safe in the prompt. _apply_template_vars at talents.py:852-861 registers a .capitalize() alias that would lowercase the rest of the JSON string, so the prompt uses the lowercase token only and a test pins that constraint.

tests/eval_schemas.py now runs schema_path cases through hydrate_runtime_enums so the eval validates the runtime schema the provider actually receives. Without this, the reading[].facet __RUNTIME_FACETS__ sentinel would force the eval model to emit that literal string and the new golden case could never pass. reading[].facet carries both the sentinel enum and a maxLength because hydrate_runtime_enums drops the enum key on a zero-facet journal and the string would otherwise ship to the provider unbounded.

_strip_outer_markdown_fence runs only for output_format == "md" at talents.py:1658-1668, so JSON output is not fence-stripped. The prompt's explicit no-fence instruction is the only mitigation, matching every other output: json talent. This is not a new condition, but it is recorded here so the next reader does not assume the runner strips JSON fences.

max_output_tokens: 8192 now matches documents; the talent previously inherited the 8192 * 6 default silently. degradation_check: true is retained for the same reason 9654c0d0 retained it on documents: four other json-output talents set it, so briefing is not special. A near-empty briefing sits under the MIN_OUTPUT_TOKENS = 300 floor and could be falsely flagged degraded on a sparse day; this remains a follow-up risk.

The two API baselines flip legitimately. tests/baselines/api/sol/talents-day.json moves output_format from md to json. tests/baselines/api/stats/stats.json moves output from md to json and additionally gains schema and max_output_tokens because that API exposes raw prompt frontmatter and every other talent with those fields already shows them there. Nothing else in either file moved.

Behavior is otherwise held constant across the web contract. briefing_sections stays a dict of markdown strings and briefing_needs_deduped stays an array of strings, so home.js and workspace.html are untouched. Needs items are now objects carrying text plus a sol:// source_id, which needs_dedup_key resolves by identity; inline [label](sol://...) links stay inside text so the existing parse_sol_sources fallback keeps working. _briefing_summary now counts meetings from structured your_day items with a non-empty time rather than regexing - **HH:MM**, and produces a byte-identical string for equivalent content.

One deliberate behavior change remains: reading links now resolve to facet=<slug>, for example facet=work, where the model previously wrote **Work** and produced facet=Work. No test pinned the old casing, and the slug is what /app/search expects.

The local Qwen provider is not installed on this machine, so the eval was not executed and no pass is claimed. The command below was run with this output.

```
$ make eval-schemas; echo "exit=$?"
.venv/bin/python tests/eval_schemas.py
Local schema eval requires the bundled local provider. Run `journal install-provider local`, then start it with `journal start` (or `journal service start` for an installed service).
make: *** [Makefile:234: eval-schemas] Error 2
exit=2
```

Follow-ups deliberately not done: pulse.schema.json still has 5 unbounded nodes and steward.schema.json still has 2, both allowlisted under their "morning_briefing follow-on lode" reason, following the convention that the string names the lode a following lode will bound. solstone/apps/speakers/status.py's meetings_files counter still has the same wrong-path bug 9654c0d0 fixed for screen_files. docs/design/yesterdays-processing-card.md and docs/design/voice-server.md still describe frontmatter-based briefing reads in narrative prose beyond the one-line path references corrected here. No confidence field was added to briefing items; evidence strength stays prose hedging because a field would change rendering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

+827 -297
+5 -5
docs/design/voice-server.md
··· 229 229 | `commitments.list` | `{"state": "open"|"closed"|"dropped"|null, "facet": "<string>|null", "limit": 20|null}` | `{"commitments": [{"id": "<id>", "owner": "<owner>", "action": "<action>", "counterparty": "<counterparty>", "state": "<state>", "context": "<context>", "day_opened": "YYYY-MM-DD", "day_closed": "YYYY-MM-DD"?, "resolution": "<resolution>"?}]}` | No nav hint | `think.surfaces.ledger.list(state=..., facets=[facet] if facet else None, top=limit or 20)`. Convert each `LedgerItem` dataclass to a dict, drop `sources`, and derive `day_*` strings from the millisecond timestamps. `resolution` is best-effort only: set it to `"dropped"` when `item.state == "dropped"`, otherwise omit because the ledger surface does not expose the close-note resolution (`solstone/think/surfaces/ledger.py:441-487`, `solstone/think/surfaces/types.py:16-32`) | `{"error": "invalid state"}` | 230 230 | `commitments.complete` | `{"commitment_id": "lg_...", "resolution": "done"|"sent"|"signed"|"dropped"|"deferred"}` | `{"ok": true, "commitment": {"id": "...", "owner": "...", "action": "...", "counterparty": "...", "state": "...", "context": "...", "day_opened": "YYYY-MM-DD", "day_closed": "YYYY-MM-DD"?, "resolution": "<input-resolution>"}}` | No nav hint | Validate `resolution`. Map `dropped -> as_state="dropped", note="resolution: dropped"`. Map `done|sent|signed|deferred -> as_state="closed", note="resolution: <value>"`. Call `think.surfaces.ledger.close(...)`, catch `KeyError`, and shape the returned `LedgerItem` as above (`solstone/think/surfaces/ledger.py:497-529`, `solstone/think/activities.py:1156-1207`) | `{"error": "invalid resolution"}` or `{"error": "not found"}` | 231 231 | `calendar.today` | `{}` | `{"date": "YYYY-MM-DD", "events": [{"time": "HH:MM", "title": "<title>", "attendees": ["<name>"], "location": "<string>", "prep_notes": "<string>"}], "_nav_target": "today"}` | Always emit `_nav_target` | `think.activities.load_activity_records(facet, day)` across all enabled facets, filtered to `source == "anticipated"` using the same participation parsing pattern Home uses today (`solstone/apps/home/routes.py:305-337`, `solstone/think/activities.py:877-890`) | `{"error": "today unavailable"}` only on unexpected failures; normal empty day is `{"date": "...", "events": [], "_nav_target": "today"}` | 232 - | `briefing.get` | `{}` | `{"date": "YYYY-MM-DD", "facet": "identity", "text": "<spoken-English body>", "highlights": ["...", "..."], "_nav_target": "today"}` or `{"error": "no briefing today yet"}` | Emit `_nav_target` only when a fresh briefing exists | Reuse `solstone/apps/home/routes.py::_load_briefing_md(today)` exactly. If `metadata.date != today`, return the error object. `text` is a plain-text join of the loaded sections; `highlights` comes from `needs_attention` bullets first, then falls back to the first three bullets across the other sections (`solstone/apps/home/routes.py:149-198`) | `{"error": "no briefing today yet"}` | 232 + | `briefing.get` | `{}` | `{"date": "YYYY-MM-DD", "facet": "identity", "text": "<spoken-English body>", "highlights": ["...", "..."], "_nav_target": "today"}` or `{"error": "no briefing today yet"}` | Emit `_nav_target` only when a fresh briefing exists | Reuse `solstone.think.briefing.load_briefing(today)` and `render_briefing_sections(...)` exactly. `None` returns the error object. `text` is a plain-text join of the loaded sections; `highlights` comes from `needs_attention` items first, then falls back to the first three bullets across the other sections | `{"error": "no briefing today yet"}` | 233 233 | `observer.start_listening` | `{"mode": "meeting"|"voice_memo"}` | `{"status": "ack", "mode": "<mode>", "note": "wave-4 observer not yet wired"}` | No nav hint | No data dependency in Wave 2. Log the requested mode at INFO and return the stub acknowledgement. | `{"error": "invalid mode"}` | 234 234 235 235 Implementation notes by tool: ··· 242 242 - `entities.recent_with` sorts interactions descending by activity timestamp and truncates to a small spoken-friendly limit, default 10. 243 243 - `commitments.list` and `commitments.complete` must strip `sources` before returning anything model-facing. 244 244 - `calendar.today.location` and `calendar.today.prep_notes` default to `""` because current anticipated activity rows do not guarantee either field. 245 - - `briefing.get.facet` is the literal string `"identity"` as a fixed spoken-context label, not a facet-scoped talent output; the briefing itself is read from `chronicle/<day>/talents/morning_briefing.md`. 245 + - `briefing.get.facet` is the literal string `"identity"` as a fixed spoken-context label, not a facet-scoped talent output; the briefing itself is read from `chronicle/<day>/talents/morning_briefing.json`. 246 246 247 247 ## 6. Brain init prompt (full text) 248 248 ··· 275 275 - Read the active entities that matter right now. 276 276 - Read the open commitments. 277 277 - Read today's calendar and anticipated activities. 278 - - Read today's morning briefing at chronicle/<today>/talents/morning_briefing.md if it exists. 278 + - Read today's morning briefing at chronicle/<today>/talents/morning_briefing.json if it exists. 279 279 280 280 Then write one system instruction that does all of the following: 281 281 - Establish who {agent_name} is and how the voice should speak. ··· 417 417 - Add one narrow helper in `think.voice.tools`, for example `_today() -> datetime.date` plus a formatter helper in the same module. 418 418 - All date-sensitive voice tools (`journal.get_day`, `journal.search` day-window math, `calendar.today`, `briefing.get`) use that helper. 419 419 - Tests monkeypatch the helper to the fixture briefing date or another explicit date instead of rewriting shared fixture files. 420 - - This keeps the shared fixture journal stable and avoids clock-driven flakes from `tests/fixtures/journal/chronicle/20260327/talents/morning_briefing.md` being dated `20260327`. 420 + - This keeps the shared fixture journal stable and avoids clock-driven flakes from `tests/fixtures/journal/chronicle/20260327/talents/morning_briefing.json` being dated `20260327`. 421 421 422 422 Per-file plan: 423 423 ··· 456 456 457 457 - Brain-not-ready behavior: this design treats the bridge contract and acceptance list as canonical and returns HTTP 503 from `/api/voice/session` after a 10-second wait, instead of using the older static fallback instruction path from the scope prose. 458 458 - Routing location: this design uses a root-level `solstone/convey/voice.py` blueprint, not `solstone/apps/voice/`, because the feature is a root API and the app shell assumes `/app/<name>` plus `workspace.html`. 459 - - Briefing source path: `_load_briefing_md(...)` reads the canonical `chronicle/<day>/talents/morning_briefing.md` talent output. (Updated 2026-07-02: an earlier revision read the phantom identity-dir briefing file; retired in the H1 lode.) 459 + - Briefing source path: `solstone.think.briefing.load_briefing(...)` reads the canonical `chronicle/<day>/talents/morning_briefing.json` talent output. (Updated 2026-07-02: an earlier revision read the phantom identity-dir briefing file; retired in the H1 lode.) 460 460 - Commitments resolution mapping: this design maps `done|sent|signed|deferred -> as_state="closed"` and `dropped -> as_state="dropped"` because `think.surfaces.ledger.close(...)` only accepts `closed|dropped`. 461 461 - OpenAI key sourcing: this design uses `config.voice.openai_api_key` in `journal/config/journal.json` first, then `OPENAI_API_KEY`, and does not add `journal/config/openai.json`. 462 462 - `ask_sol` clause: this design removes it from the brain init prompt and does not add a 10th tool to the manifest.
+3 -3
docs/design/yesterdays-processing-card.md
··· 31 31 Reads `stats_data["heatmap_data"]["hours"]`, keeps the top 3 non-zero hours, sorts by minutes desc then hour asc. 32 32 33 33 - `_briefing_freshness(today: str) -> dict` 34 - Reads `chronicle/<day>/talents/morning_briefing.md` with local `frontmatter.load`. Valid only when frontmatter has `type: morning_briefing` and `date` (which may be a YAML int) equal to `today`. `generated` is used only for the display label. 34 + Reads `chronicle/<day>/talents/morning_briefing.json`. Valid only when the JSON root has the required morning-briefing keys. `metadata.generated` is used only for the display label. 35 35 36 36 - `_newsletter_attempts_from_think_logs(yesterday: str) -> tuple[int, int]` 37 37 Option A helper from section 3. Counts successful facet newsletters from files plus failed facet newsletter attempts from think logs. ··· 339 339 340 340 Supporting non-chronicle fixture: 341 341 342 - - `tests/fixtures/journal/chronicle/20260327/talents/morning_briefing.md` 343 - Valid morning-briefing frontmatter fixture for healthy cases. 342 + - `tests/fixtures/journal/chronicle/20260327/talents/morning_briefing.json` 343 + Valid morning-briefing JSON fixture for healthy cases. 344 344 Tests that need missing/invalid frontmatter can overwrite or delete it in `tmp_path`. 345 345 346 346 Fixture minimization rule:
+24 -100
solstone/apps/home/routes.py
··· 14 14 15 15 logger = logging.getLogger(__name__) 16 16 17 - import frontmatter 18 17 from flask import Blueprint, current_app, jsonify 19 18 20 19 from solstone.apps.home.health_glance import build_health_glance ··· 24 23 from solstone.convey.shell_data import _resolve_attention 25 24 from solstone.convey.utils import DATE_RE, format_date, relative_time 26 25 from solstone.think.awareness import get_current 26 + from solstone.think.briefing import ( 27 + briefing_meeting_count, 28 + briefing_needs_items, 29 + load_briefing, 30 + render_briefing_sections, 31 + ) 27 32 from solstone.think.capture_health import get_capture_health 28 33 from solstone.think.day_accumulator import read_latest 29 34 from solstone.think.facets import get_enabled_facets, get_facets ··· 36 41 BRIEFING_MORNING_END_HOUR = 10 37 42 BRIEFING_LATENESS_THRESHOLD_HOURS = 2 38 43 BRIEFING_EOD_HOUR = 20 39 - 40 - # Section heading -> key mapping 41 - _BRIEFING_SECTIONS = { 42 - "your day": "your_day", 43 - "yesterday": "yesterday", 44 - "needs attention": "needs_attention", 45 - "forward look": "forward_look", 46 - "reading": "reading", 47 - } 48 44 49 45 home_bp = Blueprint( 50 46 "app:home", ··· 148 144 return None, None, [] 149 145 150 146 151 - def _load_briefing_md( 152 - today: str | None = None, 153 - ) -> tuple[dict[str, str], dict | None, list[str]]: 154 - """Load today's briefing.md sections and needs_attention bullets.""" 155 - try: 156 - today = today or _today() 157 - briefing_path = morning_briefing_path(today) 158 - if not briefing_path.exists(): 159 - return {}, None, [] 160 - 161 - post = frontmatter.load(str(briefing_path)) 162 - metadata = post.metadata 163 - if metadata.get("type") != "morning_briefing": 164 - return {}, None, [] 165 - if str(metadata.get("date")) != today: 166 - return {}, None, [] 167 - 168 - sections = {} 169 - current_key = None 170 - current_lines: list[str] = [] 171 - 172 - def flush_section() -> None: 173 - nonlocal current_key, current_lines 174 - if not current_key: 175 - current_lines = [] 176 - return 177 - body = "\n".join(current_lines).strip() 178 - if body: 179 - sections[current_key] = body 180 - current_lines = [] 181 - 182 - for line in post.content.splitlines(): 183 - if line.startswith("## "): 184 - flush_section() 185 - heading = line[3:].strip().lower() 186 - current_key = _BRIEFING_SECTIONS.get(heading) 187 - continue 188 - if current_key: 189 - current_lines.append(line) 190 - flush_section() 191 - 192 - needs_attention_items = [] 193 - needs_body = sections.get("needs_attention", "") 194 - for line in needs_body.splitlines(): 195 - stripped = line.strip() 196 - if stripped.startswith("- "): 197 - needs_attention_items.append(stripped[2:].strip()) 198 - 199 - return sections, metadata, needs_attention_items 200 - except Exception: 201 - logger.warning("home: failed to load briefing.md", exc_info=True) 202 - return {}, None, [] 203 - 204 - 205 147 def _compute_briefing_phase( 206 148 segment_count: int, hour: int, briefing_exists: bool 207 149 ) -> str: ··· 233 175 return {"late": is_late, "late_hours": late_hours if is_late else 0} 234 176 235 177 236 - def _briefing_summary(sections: dict[str, str], needs_count: int) -> str: 178 + def _briefing_summary( 179 + briefing: dict | None, sections: dict[str, str], needs_count: int 180 + ) -> str: 237 181 """Generate a short collapsed summary for the briefing card.""" 238 - meeting_count = 0 239 - your_day = sections.get("your_day", "") 240 - for line in your_day.splitlines(): 241 - stripped = line.strip() 242 - if stripped.startswith("- ") and "**" in stripped: 243 - after_bullet = stripped[2:] 244 - if after_bullet.startswith("**") and after_bullet.count("**") >= 2: 245 - time_part = after_bullet.split("**", 2)[1] 246 - if len(time_part) == 5 and time_part[2] == ":": 247 - meeting_count += 1 182 + meeting_count = briefing_meeting_count(briefing or {}) 248 183 249 184 if meeting_count or needs_count: 250 185 meeting_label = "meeting" if meeting_count == 1 else "meetings" ··· 433 368 if not briefing_path.exists(): 434 369 return {"exists": False, "valid": False, "generated_label": None} 435 370 436 - try: 437 - metadata = frontmatter.load(str(briefing_path)).metadata 438 - except Exception: 439 - logger.warning("home: failed to load briefing freshness", exc_info=True) 371 + briefing = load_briefing(today) 372 + if briefing is None: 440 373 return {"exists": True, "valid": False, "generated_label": None} 441 - 442 - valid = ( 443 - metadata.get("type") == "morning_briefing" 444 - and str(metadata.get("date")) == today 445 - ) 446 374 447 375 generated_label = None 376 + metadata = ( 377 + briefing.get("metadata") if isinstance(briefing.get("metadata"), dict) else {} 378 + ) 448 379 generated = metadata.get("generated") 449 380 if generated is not None: 450 381 try: 451 - generated_dt = ( 452 - datetime.fromisoformat(generated) 453 - if isinstance(generated, str) 454 - else generated 455 - ) 382 + generated_dt = datetime.fromisoformat(str(generated)) 456 383 generated_label = generated_dt.astimezone().strftime("%-I:%M%p").lower() 457 384 except Exception: 458 385 generated_label = None 459 386 460 - return {"exists": True, "valid": valid, "generated_label": generated_label} 387 + return {"exists": True, "valid": True, "generated_label": generated_label} 461 388 462 389 463 390 def _newsletter_attempts_from_think_logs(yesterday: str) -> tuple[int, int]: ··· 894 821 ) 895 822 896 823 # Briefing card 897 - briefing_sections, briefing_meta, briefing_needs = _load_briefing_md(today) 824 + briefing = load_briefing(today) 825 + briefing_sections = render_briefing_sections(briefing) if briefing else {} 826 + briefing_meta = briefing.get("metadata") if briefing else None 827 + briefing_needs = briefing_needs_items(briefing) if briefing else [] 898 828 briefing_exists = bool(briefing_sections) 899 829 briefing_phase = _compute_briefing_phase(segment_count, now.hour, briefing_exists) 900 830 briefing_lateness = _briefing_lateness_state(now, briefing_phase) ··· 971 901 briefing_summary = None 972 902 if briefing_phase == "active": 973 903 briefing_summary = _briefing_summary( 974 - briefing_sections, len(briefing_needs_deduped) 904 + briefing, briefing_sections, len(briefing_needs_deduped) 975 905 ) 906 + briefing_needs_deduped_text = [item["text"] for item in briefing_needs_deduped] 976 907 977 908 pipeline_status = read_steward_health() 978 909 if pipeline_status is not None: ··· 1016 947 "briefing_lateness": briefing_lateness, 1017 948 "briefing_exists": briefing_exists, 1018 949 "briefing_summary": briefing_summary, 1019 - "briefing_needs_deduped": briefing_needs_deduped, 950 + "briefing_needs_deduped": briefing_needs_deduped_text, 1020 951 "briefing_needs_shared_count": briefing_needs_shared_count, 1021 952 "briefing_needs_badge": briefing_needs_badge, 1022 953 "latest_weekly_reflection": latest_weekly_reflection, ··· 1058 989 """Briefing-specific JSON for WebSocket-triggered refresh.""" 1059 990 ctx = _build_pulse_context() 1060 991 meta = ctx.get("briefing_meta") 1061 - if meta: 1062 - generated = meta.get("generated") 1063 - if hasattr(generated, "isoformat"): 1064 - meta = dict(meta) 1065 - meta["generated"] = generated.isoformat() 1066 - if "date" in meta: 1067 - meta["date"] = str(meta["date"]) 1068 992 return jsonify( 1069 993 { 1070 994 "exists": ctx["briefing_exists"],
+28 -32
solstone/talent/morning_briefing.md
··· 6 6 "color": "#1565c0", 7 7 "schedule": "daily", 8 8 "priority": 50, 9 - "output": "md", 9 + "output": "json", 10 + "schema": "morning_briefing.schema.json", 11 + "max_output_tokens": 8192, 10 12 "degradation_check": true, 11 13 "hook": {"pre": "morning_briefing"} 12 14 } ··· 17 19 18 20 ## Output Contract 19 21 20 - Return only the complete briefing markdown in this exact outer shape: 22 + Return only the JSON object. Do not wrap it in a markdown fence. Do not include prose before or after the object. JSON output is not fence-stripped by the runner; a fence is a hard failure. 21 23 22 24 ``` 23 - --- 24 - type: morning_briefing 25 - date: $day_YYYYMMDD 26 - generated: $generated 27 - model: $model 28 - sources: 29 - $source_counts 30 - gaps: $source_gaps 31 - --- 32 - 33 - $coverage_preamble 34 - 35 - ## Your Day 36 - [today's prioritized agenda] 37 - 38 - ## Yesterday 39 - [what happened yesterday] 40 - 41 - ## Needs Attention 42 - [ranked actions and pipeline gaps] 43 - 44 - ## Forward Look 45 - [next seven days] 46 - 47 - ## Reading 48 - [facet newsletter links] 25 + { 26 + "metadata": $briefing_metadata, 27 + "your_day": [ 28 + {"time": "HH:MM or empty string", "text": "today's prioritized agenda item"} 29 + ], 30 + "yesterday": [ 31 + "what happened yesterday" 32 + ], 33 + "needs_attention": [ 34 + {"text": "ranked action or pipeline gap", "source_id": "sol://... or empty string"} 35 + ], 36 + "forward_look": [ 37 + "next seven days" 38 + ], 39 + "reading": [ 40 + {"facet": "facet slug", "summary": "one-line newsletter summary"} 41 + ] 42 + } 49 43 ``` 50 44 51 - Omit any section that has no content. Keep the YAML frontmatter, `sources`, `gaps`, and coverage preamble exactly as injected above. 45 + Copy `metadata` exactly as injected above. Use the lowercase `$briefing_metadata` placeholder only in this prompt; never use the capitalized form because the runner's capitalization alias would corrupt the JSON string. 46 + 47 + Every root key shown above is required. Use empty arrays when a section has no content. 52 48 53 49 ## Source Packet 54 50 ··· 92 88 93 89 **Source attribution.** Attribute high-consequence factual claims to their source using inline parenthetical links with `sol://` URIs when a source URI is present in the packet. Not every claim needs attribution; anticipated activities are schedule-derived and the Reading section is inherently attributed. 94 90 95 - **Your Day** - What's ahead today. Lead with anticipated activities in chronological order. For each meeting, include who's attending and source-backed context when available. If no anticipated activities exist, lead with the highest-priority follow-ups or pulse needs. 91 + **Your Day** - What's ahead today. Lead with anticipated activities in chronological order. Put a zero-padded `HH:MM` in `time` when the item has a specific start time; otherwise use `""`. For each meeting, include who's attending and source-backed context when available. If no anticipated activities exist, lead with the highest-priority follow-ups or pulse needs. 96 92 97 93 **Yesterday** - What happened. Draw from facet newsletters, pulse, and decisions. Highlight accomplishments, consequential decisions, and notable interactions. Keep to 3-5 bullets max. Only include if facet newsletters or decisions have content for the analysis day. 98 94 99 - **Needs Attention** - Ranked action list. Start with steward health pipeline gaps when the health surface contains needs-attention items. Then include overdue commitments, missed follow-ups, pending follow-ups, and important pulse needs without calendar time blocked. Do not include pipeline gaps when the steward health surface has no needs-attention bullets. 95 + **Needs Attention** - Ranked action list. Start with steward health pipeline gaps when the health surface contains needs-attention items. Then include overdue commitments, missed follow-ups, pending follow-ups, and important pulse needs without calendar time blocked. Do not include pipeline gaps when the steward health surface has no needs-attention bullets. Set `source_id` to the primary source's `sol://` URI when one exists, else `""`. Keep inline `[label](sol://...)` links inside `text`. 100 96 101 97 **Forward Look** - What's coming. Draw from anticipated activity records and upcoming scheduled items in the next seven days. Note preparation needed for upcoming meetings or deadlines. 102 98 103 - **Reading** - Links to full facet newsletters for deeper context. List each active facet that has a newsletter for the analysis day, with a brief one-line description of what it covers. 99 + **Reading** - Links to full facet newsletters for deeper context. List each active facet slug that has a newsletter for the analysis day, with a brief one-line description of what it covers. 104 100 105 101 ## Evidence Strength 106 102
+14 -22
solstone/talent/morning_briefing.py
··· 115 115 counts["steward_health"] = "present" if health else "missing" 116 116 counts["segments"] = len(_distinct_result_paths(followup_results, decision_results)) 117 117 118 - return { 118 + metadata = { 119 119 "generated": datetime.now().isoformat(timespec="seconds"), 120 120 "model": model, 121 + "sources": counts, 122 + "gaps": gaps, 123 + "coverage_preamble": _render_coverage_preamble( 124 + counts, 125 + gaps, 126 + decisions_total=decisions_total, 127 + forward_count=len(anticipated_forward), 128 + followups_total=followups_total, 129 + ), 130 + } 131 + 132 + return { 133 + "briefing_metadata": json.dumps(metadata, indent=2), 121 134 "active_facets": _render_facets(facets), 122 135 "facet_newsletters": _render_newsletters(newsletters), 123 136 "anticipated_today": _render_activities(anticipated_today), ··· 129 142 "health_surface": health or "(missing)", 130 143 "followups": _render_search_results(followup_results), 131 144 "decisions": _render_search_results(decision_results), 132 - "source_counts": _render_source_counts(counts), 133 - "source_gaps": json.dumps(gaps), 134 - "coverage_preamble": _render_coverage_preamble( 135 - counts, 136 - gaps, 137 - decisions_total=decisions_total, 138 - forward_count=len(anticipated_forward), 139 - followups_total=followups_total, 140 - ), 141 145 } 142 146 143 147 ··· 406 410 if path: 407 411 paths.add(path) 408 412 return paths 409 - 410 - 411 - def _render_source_counts(counts: dict[str, int | str]) -> str: 412 - return "\n".join( 413 - [ 414 - f" segments: {counts['segments']}", 415 - f" anticipated_activities: {counts['anticipated_activities']}", 416 - f" facet_newsletters: {counts['facet_newsletters']}", 417 - f" followups: {counts['followups']}", 418 - f" steward_health: {counts['steward_health']}", 419 - ] 420 - ) 421 413 422 414 423 415 def _render_coverage_preamble(
+171
solstone/talent/morning_briefing.schema.json
··· 1 + { 2 + "type": "object", 3 + "additionalProperties": false, 4 + "required": [ 5 + "metadata", 6 + "your_day", 7 + "yesterday", 8 + "needs_attention", 9 + "forward_look", 10 + "reading" 11 + ], 12 + "properties": { 13 + "metadata": { 14 + "type": "object", 15 + "additionalProperties": false, 16 + "required": [ 17 + "generated", 18 + "model", 19 + "sources", 20 + "gaps", 21 + "coverage_preamble" 22 + ], 23 + "properties": { 24 + "generated": { 25 + "type": "string", 26 + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(Z|[+-]\\d{2}:?\\d{2})?$", 27 + "maxLength": 32 28 + }, 29 + "model": { 30 + "type": "string", 31 + "maxLength": 120 32 + }, 33 + "sources": { 34 + "type": "object", 35 + "additionalProperties": false, 36 + "required": [ 37 + "segments", 38 + "anticipated_activities", 39 + "facet_newsletters", 40 + "followups", 41 + "steward_health" 42 + ], 43 + "properties": { 44 + "segments": { 45 + "type": "integer", 46 + "minimum": 0 47 + }, 48 + "anticipated_activities": { 49 + "type": "integer", 50 + "minimum": 0 51 + }, 52 + "facet_newsletters": { 53 + "type": "integer", 54 + "minimum": 0 55 + }, 56 + "followups": { 57 + "type": "integer", 58 + "minimum": 0 59 + }, 60 + "steward_health": { 61 + "type": "string", 62 + "enum": [ 63 + "present", 64 + "missing" 65 + ] 66 + } 67 + } 68 + }, 69 + "gaps": { 70 + "type": "array", 71 + "maxItems": 20, 72 + "items": { 73 + "type": "string", 74 + "maxLength": 240 75 + } 76 + }, 77 + "coverage_preamble": { 78 + "type": "string", 79 + "maxLength": 1200 80 + } 81 + } 82 + }, 83 + "your_day": { 84 + "type": "array", 85 + "maxItems": 16, 86 + "items": { 87 + "type": "object", 88 + "additionalProperties": false, 89 + "required": [ 90 + "time", 91 + "text" 92 + ], 93 + "properties": { 94 + "time": { 95 + "type": "string", 96 + "pattern": "^$|^([01]\\d|2[0-3]):[0-5]\\d$", 97 + "maxLength": 5 98 + }, 99 + "text": { 100 + "type": "string", 101 + "maxLength": 700 102 + } 103 + } 104 + } 105 + }, 106 + "yesterday": { 107 + "type": "array", 108 + "maxItems": 10, 109 + "items": { 110 + "type": "string", 111 + "maxLength": 700 112 + } 113 + }, 114 + "needs_attention": { 115 + "type": "array", 116 + "maxItems": 12, 117 + "items": { 118 + "type": "object", 119 + "additionalProperties": false, 120 + "required": [ 121 + "text", 122 + "source_id" 123 + ], 124 + "properties": { 125 + "text": { 126 + "type": "string", 127 + "maxLength": 700 128 + }, 129 + "source_id": { 130 + "type": "string", 131 + "pattern": "^$|^sol://[^\\s)]+$", 132 + "maxLength": 240 133 + } 134 + } 135 + } 136 + }, 137 + "forward_look": { 138 + "type": "array", 139 + "maxItems": 16, 140 + "items": { 141 + "type": "string", 142 + "maxLength": 700 143 + } 144 + }, 145 + "reading": { 146 + "type": "array", 147 + "maxItems": 12, 148 + "items": { 149 + "type": "object", 150 + "additionalProperties": false, 151 + "required": [ 152 + "facet", 153 + "summary" 154 + ], 155 + "properties": { 156 + "facet": { 157 + "type": "string", 158 + "enum": [ 159 + "__RUNTIME_FACETS__" 160 + ], 161 + "maxLength": 80 162 + }, 163 + "summary": { 164 + "type": "string", 165 + "maxLength": 700 166 + } 167 + } 168 + } 169 + } 170 + } 171 + }
+1 -1
solstone/talent/patterns/provenance.md
··· 2 2 3 3 How cogitate agents communicate the basis and reliability of their claims. This pattern ensures briefings and reports distinguish between well-sourced facts and inferences. 4 4 5 - Canonical implementation: `solstone/talent/morning_briefing.md`. 5 + Canonical implementation: `solstone/talent/morning_briefing.md` emitting `chronicle/<day>/talents/morning_briefing.json`. 6 6 7 7 ## Four Mechanisms 8 8
+153
solstone/think/briefing.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Shared morning briefing loaders and renderers.""" 5 + 6 + from __future__ import annotations 7 + 8 + import json 9 + import logging 10 + from typing import Any 11 + 12 + from solstone.think.talent import morning_briefing_path 13 + 14 + logger = logging.getLogger(__name__) 15 + 16 + ABSENT_TEXT = "Not specified in this document" 17 + SECTION_KEYS = ( 18 + "your_day", 19 + "yesterday", 20 + "needs_attention", 21 + "forward_look", 22 + "reading", 23 + ) 24 + REQUIRED_ROOT_KEYS = ("metadata", *SECTION_KEYS) 25 + SECTION_HEADINGS = { 26 + "your_day": "Your Day", 27 + "yesterday": "Yesterday", 28 + "needs_attention": "Needs Attention", 29 + "forward_look": "Forward Look", 30 + "reading": "Reading", 31 + } 32 + 33 + 34 + def load_briefing(day: str) -> dict | None: 35 + """Load a day's JSON morning briefing if it has the required root shape.""" 36 + path = morning_briefing_path(day) 37 + if not path.exists(): 38 + return None 39 + 40 + try: 41 + with path.open(encoding="utf-8") as handle: 42 + briefing = json.load(handle) 43 + except Exception: 44 + logger.warning("failed to load morning briefing JSON %s", path, exc_info=True) 45 + return None 46 + 47 + if not isinstance(briefing, dict): 48 + return None 49 + if any(key not in briefing for key in REQUIRED_ROOT_KEYS): 50 + return None 51 + return briefing 52 + 53 + 54 + def render_briefing_sections(briefing: dict) -> dict[str, str]: 55 + """Render non-empty briefing sections as markdown bullet bodies.""" 56 + sections: dict[str, str] = {} 57 + 58 + your_day_lines = [] 59 + for item in _dict_items(briefing.get("your_day")): 60 + text = _clean(item.get("text")) 61 + if not text: 62 + continue 63 + time = _clean(item.get("time")) 64 + if time: 65 + your_day_lines.append(f"- **{time}** \u2014 {text}") 66 + else: 67 + your_day_lines.append(f"- {text}") 68 + if your_day_lines: 69 + sections["your_day"] = "\n".join(your_day_lines) 70 + 71 + yesterday_lines = [f"- {text}" for text in _string_items(briefing.get("yesterday"))] 72 + if yesterday_lines: 73 + sections["yesterday"] = "\n".join(yesterday_lines) 74 + 75 + needs_lines = [] 76 + for item in briefing_needs_items(briefing): 77 + text = _clean(item.get("text")) 78 + if text: 79 + needs_lines.append(f"- {text}") 80 + if needs_lines: 81 + sections["needs_attention"] = "\n".join(needs_lines) 82 + 83 + forward_lines = [ 84 + f"- {text}" for text in _string_items(briefing.get("forward_look")) 85 + ] 86 + if forward_lines: 87 + sections["forward_look"] = "\n".join(forward_lines) 88 + 89 + reading_lines = [] 90 + for item in _dict_items(briefing.get("reading")): 91 + facet = _clean(item.get("facet")) 92 + summary = _clean(item.get("summary")) 93 + if facet and summary: 94 + reading_lines.append(f"- **{facet}** \u2014 {summary}") 95 + elif facet: 96 + reading_lines.append(f"- **{facet}**") 97 + elif summary: 98 + reading_lines.append(f"- {summary}") 99 + if reading_lines: 100 + sections["reading"] = "\n".join(reading_lines) 101 + 102 + return sections 103 + 104 + 105 + def render_briefing_markdown(briefing: dict) -> str: 106 + """Render a full markdown projection of a morning briefing.""" 107 + sections = render_briefing_sections(briefing) 108 + metadata = ( 109 + briefing.get("metadata") if isinstance(briefing.get("metadata"), dict) else {} 110 + ) 111 + preamble = _clean(metadata.get("coverage_preamble")) 112 + 113 + lines: list[str] = [] 114 + if preamble: 115 + lines.extend(f"> {line}" if line else ">" for line in preamble.splitlines()) 116 + else: 117 + lines.append(f"> {ABSENT_TEXT}") 118 + 119 + for key in SECTION_KEYS: 120 + lines.append("") 121 + lines.append(f"## {SECTION_HEADINGS[key]}") 122 + lines.append("") 123 + lines.append(sections.get(key) or ABSENT_TEXT) 124 + 125 + return "\n".join(lines).strip() 126 + 127 + 128 + def briefing_needs_items(briefing: dict) -> list[dict]: 129 + """Return raw needs_attention item objects from a briefing.""" 130 + return _dict_items(briefing.get("needs_attention")) 131 + 132 + 133 + def briefing_meeting_count(briefing: dict) -> int: 134 + """Count Your Day items with a non-empty time.""" 135 + return sum( 136 + 1 for item in _dict_items(briefing.get("your_day")) if _clean(item.get("time")) 137 + ) 138 + 139 + 140 + def _clean(value: object) -> str: 141 + return str(value or "").strip() 142 + 143 + 144 + def _dict_items(value: Any) -> list[dict]: 145 + if not isinstance(value, list): 146 + return [] 147 + return [item for item in value if isinstance(item, dict)] 148 + 149 + 150 + def _string_items(value: Any) -> list[str]: 151 + if not isinstance(value, list): 152 + return [] 153 + return [text for text in (_clean(item) for item in value) if text]
+5
solstone/think/formatters.py
··· 233 233 "format_screen_record", 234 234 True, 235 235 ), 236 + "*/talents/morning_briefing.json": ( 237 + "solstone.think.talent_outputs", 238 + "format_morning_briefing", 239 + True, 240 + ), 236 241 "*/talents/*.jsonl": ( 237 242 "solstone.think.day_accumulator", 238 243 "format_day_accumulator",
+16 -4
solstone/think/talent.py
··· 23 23 import logging 24 24 import os 25 25 import re 26 + from functools import lru_cache 26 27 from pathlib import Path 27 28 from typing import Any, Callable 28 29 ··· 218 219 return day / "talents" / filename 219 220 220 221 222 + @lru_cache(maxsize=1) 223 + def _briefing_output_format() -> str: 224 + metadata = _load_prompt_metadata(TALENT_DIR / "morning_briefing.md") 225 + output = metadata.get("output") 226 + if not isinstance(output, str) or output not in {"md", "json"}: 227 + raise ValueError("morning_briefing talent must declare output md or json") 228 + return output 229 + 230 + 221 231 def morning_briefing_path(day: str) -> Path: 222 232 """Canonical filesystem path to a day's morning-briefing artifact. 223 233 224 234 Delegates to the shared path authority so the 225 - ``chronicle/<day>/talents/morning_briefing.md`` layout is derived in 226 - exactly one place. Callers pass a ``YYYYMMDD`` day string; no directory 227 - is created (read-verb safe). 235 + ``chronicle/<day>/talents/morning_briefing.json`` layout is derived from 236 + the talent's declared output format in exactly one place. Callers pass a 237 + ``YYYYMMDD`` day string; no directory is created (read-verb safe). 228 238 """ 229 239 from solstone.think.utils import day_path 230 240 231 241 return get_output_path( 232 - day_path(day, create=False), "morning_briefing", output_format="md" 242 + day_path(day, create=False), 243 + "morning_briefing", 244 + output_format=_briefing_output_format(), 233 245 ) 234 246 235 247
+16
solstone/think/talent_outputs.py
··· 11 11 from pathlib import Path 12 12 from typing import Any 13 13 14 + from solstone.think.briefing import render_briefing_markdown 14 15 from solstone.think.formatters import format_file, get_formatter 15 16 from solstone.think.utils import get_journal, journal_relative_path 16 17 ··· 234 235 235 236 markdown = "\n".join(lines).strip() 236 237 return [{"markdown": markdown, "timestamp": 0, "source": record}], meta 238 + 239 + 240 + def format_morning_briefing( 241 + entries: list[dict[str, Any]], 242 + context: dict[str, Any] | None = None, 243 + ) -> tuple[list[dict[str, Any]], dict[str, Any]]: 244 + """Render the structured Morning Briefing talent output.""" 245 + _ = context 246 + meta = {"indexer": {"agent": "morning_briefing"}} 247 + briefing = _first_object(entries) 248 + if briefing is None: 249 + return [], meta 250 + 251 + markdown = render_briefing_markdown(briefing) 252 + return [{"markdown": markdown, "timestamp": 0, "source": briefing}], meta 237 253 238 254 239 255 def _render_json_projection(path: Path) -> str | None:
+10 -6
solstone/think/tools/sol.py
··· 5 5 6 6 Provides read and write access to ``{journal}/identity/partner.md`` and read access 7 7 to sol's health surface. Also provides read access to the morning briefing at 8 - ``{journal}/YYYYMMDD/talents/morning_briefing.md``. 8 + ``{journal}/YYYYMMDD/talents/morning_briefing.json``. 9 9 10 10 Top-level ``journal identity`` command. 11 11 """ ··· 17 17 18 18 import typer 19 19 20 + from solstone.think.briefing import load_briefing, render_briefing_markdown 20 21 from solstone.think.cortex_client import ( 21 22 CortexNotClaimed, 22 23 CortexSpawnUnavailable, ··· 271 272 def briefing_cmd( 272 273 day: str | None = typer.Option(None, "--day", "-d", help="Specific day YYYYMMDD."), 273 274 ) -> None: 274 - """Read the morning briefing from YYYYMMDD/talents/morning_briefing.md.""" 275 + """Read the morning briefing from YYYYMMDD/talents/morning_briefing.json.""" 275 276 if day: 276 - path = morning_briefing_path(day) 277 - if not path.exists(): 277 + briefing = load_briefing(day) 278 + if briefing is None: 278 279 typer.echo("No briefing found.", err=True) 279 280 raise typer.Exit(1) 280 - typer.echo(path.read_text(encoding="utf-8")) 281 + typer.echo(render_briefing_markdown(briefing)) 281 282 return 282 283 283 284 # No day specified — find most recent 284 285 for day in sorted(day_dirs().keys(), reverse=True): 285 286 briefing = morning_briefing_path(day) 286 287 if briefing.exists() and briefing.stat().st_size > 0: 287 - typer.echo(briefing.read_text(encoding="utf-8")) 288 + data = load_briefing(day) 289 + if data is None: 290 + continue 291 + typer.echo(render_briefing_markdown(data)) 288 292 return 289 293 290 294 typer.echo("No briefing found.", err=True)
+1 -1
solstone/think/voice/brain.py
··· 46 46 - Read the active entities that matter right now. 47 47 - Read the open commitments. 48 48 - Read today's calendar and anticipated activities. 49 - - Read today's morning briefing at chronicle/<today>/talents/morning_briefing.md if it exists. 49 + - Read today's morning briefing at chronicle/<today>/talents/morning_briefing.json if it exists. 50 50 51 51 Then write one system instruction that does all of the following: 52 52 - Establish who {agent_name} is and how the voice should speak.
+9 -3
solstone/think/voice/tools.py
··· 15 15 from urllib.parse import quote_plus 16 16 17 17 from solstone.apps.entities.routes import _build_facet_relationships 18 - from solstone.apps.home.routes import _load_briefing_md 19 18 from solstone.think.activities import load_activity_records 19 + from solstone.think.briefing import ( 20 + briefing_needs_items, 21 + load_briefing, 22 + render_briefing_sections, 23 + ) 20 24 from solstone.think.cluster import cluster_segments, scan_day 21 25 from solstone.think.entities.journal import load_journal_entity 22 26 from solstone.think.facets import get_facets ··· 682 686 def handle_briefing_get(payload: dict[str, Any], app: Any) -> dict[str, Any]: 683 687 del payload, app 684 688 internal_day = _today_internal() 685 - sections, metadata, needs_attention_items = _load_briefing_md(internal_day) 686 - if not metadata or str(metadata.get("date")) != internal_day: 689 + briefing = load_briefing(internal_day) 690 + if not briefing: 687 691 return {"error": "no briefing today yet"} 692 + sections = render_briefing_sections(briefing) 693 + needs_attention_items = [item["text"] for item in briefing_needs_items(briefing)] 688 694 return { 689 695 "date": _format_day_external(internal_day), 690 696 "facet": "identity",
+1 -1
tests/baselines/api/sol/talents-day.json
··· 175 175 "color": "#1565c0", 176 176 "description": "Synthesizes all daily agent outputs into a structured five-section morning briefing", 177 177 "multi_facet": false, 178 - "output_format": "md", 178 + "output_format": "json", 179 179 "schedule": "daily", 180 180 "source": "system", 181 181 "title": "Morning Briefing",
+3 -1
tests/baselines/api/stats/stats.json
··· 247 247 "hook": { 248 248 "pre": "morning_briefing" 249 249 }, 250 + "max_output_tokens": 8192, 250 251 "mtime": 0, 251 - "output": "md", 252 + "output": "json", 252 253 "path": "<PROJECT>/solstone/talent/morning_briefing.md", 253 254 "priority": 50, 254 255 "schedule": "daily", 256 + "schema": "morning_briefing.schema.json", 255 257 "source": "system", 256 258 "title": "Morning Briefing", 257 259 "type": "generate"
+4 -1
tests/eval_schemas.py
··· 23 23 content_preservation, 24 24 schema_validity, 25 25 ) 26 + from solstone.think.talent import hydrate_runtime_enums # noqa: E402 26 27 27 28 DEFAULT_CASES = ROOT / "tests" / "fixtures" / "schema_eval" / "cases.jsonl" 28 29 DEFAULT_OUT = ROOT / "tmp" / "schema-eval" ··· 49 50 case = json.loads(stripped) 50 51 if "schema_path" in case: 51 52 schema_path = _resolve_path(Path(case["schema_path"])) 52 - case["schema"] = json.loads(schema_path.read_text(encoding="utf-8")) 53 + schema = json.loads(schema_path.read_text(encoding="utf-8")) 54 + # Match runtime provider schemas; empty facet journals drop the enum. 55 + case["schema"] = hydrate_runtime_enums(schema) 53 56 cases.append(case) 54 57 return cases 55 58
+67
tests/fixtures/journal/chronicle/20260327/talents/morning_briefing.json
··· 1 + { 2 + "metadata": { 3 + "generated": "2026-03-27T06:45:00", 4 + "model": "claude-sonnet-4-20250514", 5 + "sources": { 6 + "segments": 14, 7 + "anticipated_activities": 3, 8 + "facet_newsletters": 2, 9 + "followups": 5, 10 + "steward_health": "present" 11 + }, 12 + "gaps": [], 13 + "coverage_preamble": "Built from 14 indexed source paths, 3 anticipated activities today, 2 facet newsletters, 5 follow-ups, and steward health present. No gaps." 14 + }, 15 + "your_day": [ 16 + { 17 + "time": "09:00", 18 + "text": "Sync with Sarah Chen on the Q2 product roadmap. Last met 2 weeks ago; discussed launch timeline (from your [March standup](sol://20260313/archon/091500_300))." 19 + }, 20 + { 21 + "time": "11:30", 22 + "text": "1:1 with Marcus about the infrastructure migration. He's been blocked on the DNS cutover (from your [Thursday 1:1](sol://20260325/archon/113000_1800))." 23 + }, 24 + { 25 + "time": "14:00", 26 + "text": "Design review for the new onboarding flow with the UX team." 27 + }, 28 + { 29 + "time": "", 30 + "text": "Review and respond to the open comments on the auth middleware PR." 31 + } 32 + ], 33 + "yesterday": [ 34 + "Shipped the entity intelligence pipeline refactor \u2014 3x faster lookups on large journals ([work newsletter](sol://facets/work/news/20260326)).", 35 + "Had a productive brainstorm with Anika on the notification system. She proposed a priority-based queue ([work newsletter](sol://facets/work/news/20260326)).", 36 + "Decided to delay the mobile app beta by one week to fix the sync regression ([work newsletter](sol://facets/work/news/20260326))." 37 + ], 38 + "needs_attention": [ 39 + { 40 + "text": "Follow up with investors on the Series A term sheet \u2014 response was due yesterday (committed [March 20](sol://20260320/archon/101500_600))", 41 + "source_id": "sol://20260320/archon/101500_600" 42 + }, 43 + { 44 + "text": "The CI pipeline has been failing intermittently on the integration test suite (flagged [yesterday](sol://20260326/default/143000_300))", 45 + "source_id": "sol://20260326/default/143000_300" 46 + }, 47 + { 48 + "text": "Review the draft partnership agreement from Acme Corp (last interaction March 15)", 49 + "source_id": "" 50 + } 51 + ], 52 + "forward_look": [ 53 + "**Monday** \u2014 All-hands presentation on Q1 results. Slides need final review by Friday (from [schedule](sol://20260327/talents/schedule)).", 54 + "**Wednesday** \u2014 Deadline for the compliance audit documentation.", 55 + "Sarah mentioned wanting to discuss the API rate limiting strategy next week (from [schedule](sol://20260327/talents/schedule))." 56 + ], 57 + "reading": [ 58 + { 59 + "facet": "work", 60 + "summary": "Product analytics show a 15% increase in daily active users this week" 61 + }, 62 + { 63 + "facet": "personal", 64 + "summary": "New developments in on-device AI processing could impact the capture pipeline" 65 + } 66 + ] 67 + }
-45
tests/fixtures/journal/chronicle/20260327/talents/morning_briefing.md
··· 1 - --- 2 - type: morning_briefing 3 - date: "20260327" 4 - generated: "2026-03-27T06:45:00" 5 - model: "claude-sonnet-4-20250514" 6 - sources: 7 - segments: 14 8 - anticipated_activities: 3 9 - entities_consulted: 3 10 - facet_newsletters: 2 11 - followups: 5 12 - gaps: [] 13 - --- 14 - 15 - > Built from 14 transcript segments, 3 anticipated activities, 3 entity profiles, 2 facet newsletters, and 5 follow-ups. No gaps. 16 - 17 - ## Your Day 18 - 19 - - **09:00** — Sync with Sarah Chen on the Q2 product roadmap. Last met 2 weeks ago; discussed launch timeline (from your [March standup](sol://20260313/archon/091500_300)). 20 - - **11:30** — 1:1 with Marcus about the infrastructure migration. He's been blocked on the DNS cutover (from your [Thursday 1:1](sol://20260325/archon/113000_1800)). 21 - - **14:00** — Design review for the new onboarding flow with the UX team. 22 - - Review and respond to the open comments on the auth middleware PR. 23 - 24 - ## Yesterday 25 - 26 - - Shipped the entity intelligence pipeline refactor — 3x faster lookups on large journals ([work newsletter](sol://facets/work/news/20260326)). 27 - - Had a productive brainstorm with Anika on the notification system. She proposed a priority-based queue ([work newsletter](sol://facets/work/news/20260326)). 28 - - Decided to delay the mobile app beta by one week to fix the sync regression ([work newsletter](sol://facets/work/news/20260326)). 29 - 30 - ## Needs Attention 31 - 32 - - Follow up with investors on the Series A term sheet — response was due yesterday (committed [March 20](sol://20260320/archon/101500_600)) 33 - - The CI pipeline has been failing intermittently on the integration test suite (flagged [yesterday](sol://20260326/default/143000_300)) 34 - - Review the draft partnership agreement from Acme Corp (last interaction March 15) 35 - 36 - ## Forward Look 37 - 38 - - **Monday** — All-hands presentation on Q1 results. Slides need final review by Friday (from [schedule](sol://20260327/talents/schedule)). 39 - - **Wednesday** — Deadline for the compliance audit documentation. 40 - - Sarah mentioned wanting to discuss the API rate limiting strategy next week (from [schedule](sol://20260327/talents/schedule)). 41 - 42 - ## Reading 43 - 44 - - **Work** — Product analytics show a 15% increase in daily active users this week 45 - - **Industry** — New developments in on-device AI processing could impact the capture pipeline
+1
tests/fixtures/schema_eval/cases.jsonl
··· 3 3 {"name":"decision_flags","schema":{"type":"object","properties":{"decision":{"type":"string","maxLength":120},"approved":{"type":"boolean"},"tags":{"type":"array","maxItems":3,"items":{"type":"string","enum":["billing","security","launch","support"]}}},"required":["decision","approved","tags"],"additionalProperties":false},"system_instruction":"Return only JSON matching the schema. Capture the decision and select applicable tags.","input":"The team approved delaying the launch until the security review finishes. Billing and support are not involved.","expect_contains":["delaying","security","launch"]} 4 4 {"name":"document_analysis_trust_excerpt","schema_path":"solstone/talent/documents.schema.json","system_instruction":"Return only JSON matching the schema. Extract the document facts and do not add unsupported details.","input":"Document: Miller Family Trust Amendment. Executed March 4, 2026. Jordan Miller is the Settlor. Priya Shah is appointed primary Trustee. Evan Lee is named successor Trustee if Priya cannot serve. The Trustee may distribute the brokerage account for health and education expenses. The third anniversary of the Settlor's death triggers a mandatory review.","expect_contains":["Miller Family Trust Amendment","Jordan Miller","Priya Shah","Evan Lee","brokerage account","third anniversary"]} 5 5 {"name":"screen_record_editor_meeting","schema_path":"solstone/talent/screen.schema.json","system_instruction":"Return only JSON matching the schema. Preserve the visible screen details and entities.","input":"09:00:05 monitor main category meeting visual Zoom call with Alice Smith and Bob Chen visible in participant tiles. Shared screen shows the solstone repository in VS Code. 09:01:10 terminal command `pytest tests/test_cluster.py` fails with AssertionError in solstone/think/cluster.py. 09:02:00 browser opens https://example.test/spec for the formatter contract.","expect_contains":["Alice Smith","Bob Chen","solstone","pytest tests/test_cluster.py","solstone/think/cluster.py","https://example.test/spec"]} 6 + {"name":"morning_briefing_20260708_trimmed","schema_path":"solstone/talent/morning_briefing.schema.json","system_instruction":"Return only JSON matching the schema. Copy metadata exactly. Preserve HH:MM times and sol:// source ids from the input.","input":"metadata: {\"generated\":\"2026-07-09T04:47:41\",\"model\":\"scratch-model\",\"sources\":{\"segments\":0,\"anticipated_activities\":9,\"facet_newsletters\":6,\"followups\":0,\"steward_health\":\"present\"},\"gaps\":[\"no facet newsletter available for vconic\",\"no follow-up items found\",\"no decision items found\"],\"coverage_preamble\":\"Built from 0 indexed source paths, 9 anticipated activities today, 17 forward-looking anticipated activities, 6 facet newsletters, 0 follow-ups, 0 decision results. Gaps: no facet newsletter available for vconic; no follow-up items found; no decision items found.\"}\nToday: 11:00-12:00 Michael Bauer <> Jer Miller Meeting [meeting, solpbc]. 13:00-17:00 Travel Prep [reminder, personal].\nSteward: Pipeline issue: 82 agents failed during yesterday's processing.\nReading: awareness newsletter Source: sol://facets/awareness/news/20260708. Michael Morgan confirmed guardrails; reply required.","expect_contains":["Michael Bauer <> Jer Miller Meeting","11:00","13:00","82 agents failed","sol://facets/awareness/news/20260708","no follow-up items found"]}
+79
tests/test_formatters.py
··· 115 115 assert screen is not None 116 116 assert screen.__name__ == "format_screen_record" 117 117 118 + def test_get_formatter_day_level_morning_briefing_json(self): 119 + """Morning briefing JSON uses a day-level formatter only by registered path.""" 120 + from solstone.think.formatters import FORMATTERS, get_formatter 121 + 122 + formatter = get_formatter("20240101/talents/morning_briefing.json") 123 + 124 + assert formatter is not None 125 + assert formatter.__name__ == "format_morning_briefing" 126 + assert "*/talents/*.json" not in FORMATTERS 127 + assert get_formatter("20240101/talents/other.json") is None 128 + 118 129 def test_no_day_level_document_or_screen_json_formatter(self): 119 130 """Document and screen JSON formatters are segment-level only.""" 120 131 from solstone.think.formatters import get_formatter ··· 410 421 assert "Settlor's death" in section("Conditions and Triggers") 411 422 assert "third anniversary" in section("Important Dates") 412 423 assert "Quick reference summary" in section("Summary") 424 + 425 + 426 + def test_format_morning_briefing_renders_all_five_sections(): 427 + from solstone.think.talent_outputs import format_morning_briefing 428 + 429 + briefing = { 430 + "metadata": { 431 + "generated": "2026-03-27T06:45:00", 432 + "model": "test-model", 433 + "sources": { 434 + "segments": 1, 435 + "anticipated_activities": 1, 436 + "facet_newsletters": 1, 437 + "followups": 1, 438 + "steward_health": "present", 439 + }, 440 + "gaps": [], 441 + "coverage_preamble": "Built from test sources. No gaps.", 442 + }, 443 + "your_day": [{"time": "09:00", "text": "Meet Sarah."}], 444 + "yesterday": ["Shipped the formatter."], 445 + "needs_attention": [ 446 + { 447 + "text": "Review the report.", 448 + "source_id": "sol://20260327/default/090000_300", 449 + } 450 + ], 451 + "forward_look": ["Prepare for Monday."], 452 + "reading": [{"facet": "work", "summary": "Newsletter summary."}], 453 + } 454 + 455 + chunks, meta = format_morning_briefing([briefing]) 456 + 457 + rendered = chunks[0]["markdown"] 458 + headings = [ 459 + "Your Day", 460 + "Yesterday", 461 + "Needs Attention", 462 + "Forward Look", 463 + "Reading", 464 + ] 465 + assert meta["indexer"]["agent"] == "morning_briefing" 466 + assert chunks == [{"markdown": rendered, "timestamp": 0, "source": briefing}] 467 + assert [rendered.index(f"## {heading}") for heading in headings] == sorted( 468 + rendered.index(f"## {heading}") for heading in headings 469 + ) 470 + assert rendered.count("## ") == 5 471 + assert "> Built from test sources. No gaps." in rendered 472 + assert "- **09:00** — Meet Sarah." in rendered 473 + assert "- **work** — Newsletter summary." in rendered 474 + 475 + 476 + def test_find_formattable_includes_day_level_morning_briefing_only(tmp_path: Path): 477 + from solstone.think.formatters import find_formattable_files 478 + 479 + day_level = tmp_path / "chronicle" / "20240101" / "talents" 480 + segment_level = ( 481 + tmp_path / "chronicle" / "20240101" / "default" / "120000_300" / "talents" 482 + ) 483 + day_level.mkdir(parents=True) 484 + segment_level.mkdir(parents=True) 485 + (day_level / "morning_briefing.json").write_text("{}", encoding="utf-8") 486 + (segment_level / "morning_briefing.json").write_text("{}", encoding="utf-8") 487 + 488 + files = find_formattable_files(str(tmp_path)) 489 + 490 + assert "20240101/talents/morning_briefing.json" in files 491 + assert "20240101/default/120000_300/talents/morning_briefing.json" not in files 413 492 414 493 415 494 def test_format_screen_record_renders_narrative_entities_and_agent_meta():
+61 -20
tests/test_home_routes.py
··· 4 4 from __future__ import annotations 5 5 6 6 from datetime import datetime 7 + from pathlib import Path 7 8 from typing import Any 8 9 9 10 import pytest ··· 16 17 monkeypatch, 17 18 *, 18 19 pulse_needs: list[Any], 19 - briefing_needs: list[str], 20 + briefing_needs: list[Any], 20 21 attention: Any = None, 21 22 ): 22 23 import solstone.apps.home.routes as home_routes 23 24 24 - briefing_sections = ( 25 - {"needs_attention": "\n".join(f"- {item}" for item in briefing_needs)} 26 - if briefing_needs 27 - else {} 28 - ) 25 + briefing = None 26 + if briefing_needs: 27 + needs_items = [ 28 + item if isinstance(item, dict) else {"text": item, "source_id": ""} 29 + for item in briefing_needs 30 + ] 31 + briefing = { 32 + "metadata": {"generated": "2026-04-16T09:00:00"}, 33 + "your_day": [], 34 + "yesterday": [], 35 + "needs_attention": needs_items, 36 + "forward_look": [], 37 + "reading": [], 38 + } 29 39 monkeypatch.setattr( 30 40 home_routes, 31 41 "get_capture_health", ··· 46 56 ) 47 57 monkeypatch.setattr( 48 58 home_routes, 49 - "_load_briefing_md", 50 - lambda today: ( 51 - briefing_sections, 52 - {"generated": "2026-04-16T09:00:00"}, 53 - briefing_needs, 54 - ), 59 + "load_briefing", 60 + lambda today: briefing, 55 61 ) 56 62 monkeypatch.setattr( 57 63 home_routes, "_collect_anticipated_activities", lambda today: [] ··· 219 225 "source_id": source, 220 226 } 221 227 ], 222 - briefing_needs=[f"Look at the Q3 numbers {source}"], 228 + briefing_needs=[{"text": "Look at the Q3 numbers", "source_id": source}], 223 229 ) 224 230 225 231 ctx = home_routes._build_pulse_context() ··· 235 241 monkeypatch, 236 242 pulse_needs=[], 237 243 briefing_needs=[ 238 - f"Review the Q3 report {source}", 239 - f"Look at the Q3 numbers {source}", 244 + {"text": "Review the Q3 report", "source_id": source}, 245 + {"text": "Look at the Q3 numbers", "source_id": source}, 240 246 ], 241 247 ) 242 248 243 249 ctx = home_routes._build_pulse_context() 244 250 245 251 assert ctx["briefing_needs_shared_count"] == 0 246 - assert ctx["briefing_needs_deduped"] == [f"Review the Q3 report {source}"] 252 + assert ctx["briefing_needs_deduped"] == ["Review the Q3 report"] 247 253 248 254 249 255 def test_briefing_different_source_identities_stay_distinct(monkeypatch): ··· 253 259 monkeypatch, 254 260 pulse_needs=[], 255 261 briefing_needs=[ 256 - f"Review the report {source_a}", 257 - f"Review the report {source_b}", 262 + {"text": "Review the report", "source_id": source_a}, 263 + {"text": "Review the report", "source_id": source_b}, 258 264 ], 259 265 ) 260 266 ··· 262 268 263 269 assert ctx["briefing_needs_shared_count"] == 0 264 270 assert ctx["briefing_needs_deduped"] == [ 265 - f"Review the report {source_a}", 266 - f"Review the report {source_b}", 271 + "Review the report", 272 + "Review the report", 267 273 ] 268 274 269 275 ··· 279 285 assert len(ctx["needs_you_items"]) == 1 280 286 assert ctx["briefing_needs_shared_count"] == 1 281 287 assert ctx["briefing_needs_deduped"] == [] 288 + 289 + 290 + def test_markdown_briefing_loader_removed(): 291 + import solstone.apps.home.routes as home_routes 292 + 293 + source = Path(home_routes.__file__).read_text(encoding="utf-8") 294 + 295 + assert not hasattr(home_routes, "_load_briefing_md") 296 + assert not hasattr(home_routes, "_BRIEFING_SECTIONS") 297 + assert 'startswith("## ")' not in source 298 + 299 + 300 + def test_briefing_needs_dedup_by_inline_sol_link(monkeypatch): 301 + source = "sol://20260313/archon/091500_300" 302 + home_routes = _patch_minimal_pulse_context( 303 + monkeypatch, 304 + pulse_needs=[], 305 + briefing_needs=[ 306 + { 307 + "text": f"Review the Q3 report ([standup]({source}))", 308 + "source_id": "", 309 + }, 310 + { 311 + "text": f"Look at the Q3 numbers ([standup]({source}))", 312 + "source_id": "", 313 + }, 314 + ], 315 + ) 316 + 317 + ctx = home_routes._build_pulse_context() 318 + 319 + assert ctx["briefing_needs_shared_count"] == 0 320 + assert ctx["briefing_needs_deduped"] == [ 321 + f"Review the Q3 report ([standup]({source}))" 322 + ]
+54 -39
tests/test_home_yesterdays_processing.py
··· 17 17 BRIEFING_MORNING_END_HOUR, 18 18 _briefing_freshness, 19 19 _briefing_lateness_state, 20 + _briefing_summary, 20 21 _build_pulse_context, 21 22 _collect_activities, 22 23 _collect_anticipated_activities, ··· 118 119 day: str, 119 120 generated: str, 120 121 *, 121 - metadata_type: str = "morning_briefing", 122 - date: str | None = None, 122 + payload: object | None = None, 123 123 ) -> None: 124 124 from solstone.think.talent import morning_briefing_path 125 125 126 126 path = morning_briefing_path(day) 127 127 path.parent.mkdir(parents=True, exist_ok=True) 128 - path.write_text( 129 - ( 130 - f"---\n" 131 - f"type: {metadata_type}\n" 132 - f"date: {date if date is not None else day}\n" 133 - f'generated: "{generated}"\n' 134 - f"---\n\n" 135 - "## Your Day\n\n" 136 - "- One thing.\n" 137 - ), 138 - encoding="utf-8", 139 - ) 128 + data = payload 129 + if data is None: 130 + data = { 131 + "metadata": { 132 + "generated": generated, 133 + "model": "test-model", 134 + "sources": { 135 + "segments": 1, 136 + "anticipated_activities": 1, 137 + "facet_newsletters": 1, 138 + "followups": 0, 139 + "steward_health": "present", 140 + }, 141 + "gaps": [], 142 + "coverage_preamble": "Built from test sources. No gaps.", 143 + }, 144 + "your_day": [{"time": "", "text": "One thing."}], 145 + "yesterday": [], 146 + "needs_attention": [], 147 + "forward_look": [], 148 + "reading": [], 149 + } 150 + path.write_text(json.dumps(data), encoding="utf-8") 140 151 141 152 142 153 def _append_think_log( ··· 185 196 "solstone.apps.home.routes._load_pulse_narrative", 186 197 lambda today: (None, None, []), 187 198 ) 188 - monkeypatch.setattr( 189 - "solstone.apps.home.routes._load_briefing_md", lambda today: ({}, None, []) 190 - ) 199 + monkeypatch.setattr("solstone.apps.home.routes.load_briefing", lambda today: None) 191 200 monkeypatch.setattr( 192 201 "solstone.apps.home.routes._collect_anticipated_activities", lambda today: [] 193 202 ) ··· 456 465 ) 457 466 458 467 459 - def test_briefing_frontmatter_missing_counts_as_gap(tmp_path, monkeypatch): 468 + def test_briefing_missing_counts_as_gap(tmp_path, monkeypatch): 460 469 _seed_journal(tmp_path, monkeypatch) 461 470 462 471 monkeypatch.setattr("solstone.apps.home.routes._today", lambda: "20260416") ··· 485 494 assert result == {"exists": True, "valid": True, "generated_label": "9:30pm"} 486 495 487 496 488 - def test_briefing_freshness_accepts_unquoted_int_date(tmp_path, monkeypatch): 489 - import frontmatter 497 + def test_briefing_summary_matches_fixture_projection(monkeypatch): 498 + from solstone.think.briefing import load_briefing, render_briefing_sections 490 499 491 - from solstone.think.talent import morning_briefing_path 500 + monkeypatch.setenv("SOLSTONE_JOURNAL", str(FIXTURES)) 492 501 493 - _seed_journal(tmp_path, monkeypatch) 494 - _write_briefing("20260702", "2026-07-01T20:00:00") 502 + briefing = load_briefing("20260327") 503 + assert briefing is not None 504 + sections = render_briefing_sections(briefing) 495 505 496 - meta = frontmatter.load(str(morning_briefing_path("20260702"))).metadata 497 - 498 - assert isinstance(meta["date"], int) 499 - assert _briefing_freshness("20260702")["valid"] is True 506 + assert ( 507 + _briefing_summary(briefing, sections, len(briefing["needs_attention"])) 508 + == "Morning briefing — 3 meetings, 3 items need attention" 509 + ) 500 510 501 511 502 512 def test_briefing_freshness_invalid_when_missing(tmp_path, monkeypatch): ··· 509 519 } 510 520 511 521 512 - def test_briefing_freshness_invalid_when_wrong_type(tmp_path, monkeypatch): 522 + def test_briefing_freshness_invalid_when_root_is_list(tmp_path, monkeypatch): 513 523 _seed_journal(tmp_path, monkeypatch) 514 524 _write_briefing( 515 525 "20260416", 516 526 "2026-04-15T21:00:00", 517 - metadata_type="daily_summary", 527 + payload=[], 518 528 ) 519 529 520 530 result = _briefing_freshness("20260416") ··· 523 533 assert result["valid"] is False 524 534 525 535 526 - def test_briefing_freshness_invalid_when_date_mismatch(tmp_path, monkeypatch): 536 + def test_briefing_freshness_invalid_when_metadata_missing(tmp_path, monkeypatch): 527 537 _seed_journal(tmp_path, monkeypatch) 528 - _write_briefing("20260416", "2026-04-15T21:00:00", date="20260415") 538 + _write_briefing( 539 + "20260416", 540 + "2026-04-15T21:00:00", 541 + payload={ 542 + "your_day": [], 543 + "yesterday": [], 544 + "needs_attention": [], 545 + "forward_look": [], 546 + "reading": [], 547 + }, 548 + ) 529 549 530 550 assert _briefing_freshness("20260416")["valid"] is False 531 551 532 552 533 - def test_briefing_freshness_invalid_when_frontmatter_unparseable(tmp_path, monkeypatch): 553 + def test_briefing_freshness_invalid_when_json_malformed(tmp_path, monkeypatch): 534 554 from solstone.think.talent import morning_briefing_path 535 555 536 556 _seed_journal(tmp_path, monkeypatch) 537 557 path = morning_briefing_path("20260416") 538 558 path.parent.mkdir(parents=True, exist_ok=True) 539 - path.write_text( 540 - "---\ntype: morning_briefing\ndate: [unclosed\n---\n\nbody\n", 541 - encoding="utf-8", 542 - ) 559 + path.write_text("{not valid json", encoding="utf-8") 543 560 544 561 assert _briefing_freshness("20260416") == { 545 562 "exists": True, ··· 791 808 "solstone.apps.home.routes._load_pulse_narrative", 792 809 lambda today: (None, None, []), 793 810 ) 794 - monkeypatch.setattr( 795 - "solstone.apps.home.routes._load_briefing_md", lambda today: ({}, None, []) 796 - ) 811 + monkeypatch.setattr("solstone.apps.home.routes.load_briefing", lambda today: None) 797 812 monkeypatch.setattr( 798 813 "solstone.apps.home.routes._collect_anticipated_activities", lambda today: [] 799 814 )
+10 -7
tests/test_morning_briefing_pre_hook.py
··· 98 98 ] 99 99 100 100 expected = { 101 + "briefing_metadata", 101 102 "active_facets", 102 103 "facet_newsletters", 103 104 "anticipated_today", ··· 107 108 "health_surface", 108 109 "followups", 109 110 "decisions", 110 - "source_counts", 111 - "source_gaps", 112 - "coverage_preamble", 113 111 } 114 112 assert expected <= set(packet) 113 + assert "source_counts" not in packet 114 + assert "source_gaps" not in packet 115 + assert "coverage_preamble" not in packet 115 116 assert "Planning meeting" in packet["anticipated_today"] 116 117 assert "Proposal deadline" in packet["anticipated_forward"] 117 118 assert "Work shipped a release." in packet["facet_newsletters"] 118 119 assert "Pulse needs focus time." in packet["pulse_surface"] 119 120 assert "- Review the launch checklist." in packet["pulse_surface"] 120 - assert " anticipated_activities: 1" in packet["source_counts"] 121 - assert json.loads(packet["source_gaps"]) == [] 121 + metadata = json.loads(packet["briefing_metadata"]) 122 + assert metadata["sources"]["anticipated_activities"] == 1 123 + assert metadata["gaps"] == [] 122 124 123 125 124 126 def test_morning_briefing_pre_hook_missing_sources_are_visible_gaps( ··· 151 153 ) 152 154 153 155 packet = morning_briefing.pre_process({"day": "20260422"})["template_vars"] 154 - gaps = json.loads(packet["source_gaps"]) 156 + metadata = json.loads(packet["briefing_metadata"]) 157 + gaps = metadata["gaps"] 155 158 156 159 assert any("no facet newsletter available" in gap for gap in gaps) 157 160 assert any("no anticipated activities today" in gap for gap in gaps) 158 161 assert any("steward health surface missing" in gap for gap in gaps) 159 - assert "Gaps:" in packet["coverage_preamble"] 162 + assert "Gaps:" in metadata["coverage_preamble"]
+9 -3
tests/test_morning_briefing_steward_migration.py
··· 21 21 metadata = _briefing_metadata() 22 22 23 23 assert metadata["type"] == "generate" 24 - assert metadata["output"] == "md" 24 + assert metadata["output"] == "json" 25 + assert metadata["schema"] == "morning_briefing.schema.json" 26 + assert metadata["max_output_tokens"] == 8192 25 27 assert metadata["schedule"] == "daily" 26 28 assert metadata["hook"]["pre"] == "morning_briefing" 27 29 assert "read_scope" not in metadata ··· 31 33 prompt = _briefing_prompt() 32 34 33 35 assert "$health_surface" in prompt 34 - assert "gaps: $source_gaps" in prompt 35 - assert "$coverage_preamble" in prompt 36 + assert '"metadata": $briefing_metadata' in prompt 37 + assert "$Briefing_metadata" not in prompt 38 + assert "$source_gaps" not in prompt 39 + assert "$source_counts" not in prompt 40 + assert "$coverage_preamble" not in prompt 36 41 assert "Steward Health Surface" in prompt 42 + assert "Do not wrap it in a markdown fence" in prompt 37 43 38 44 assert "sol call" not in prompt 39 45 assert "read_file" not in prompt
+23
tests/test_schema_prep.py
··· 87 87 assert item["enum"] == ["alpha", "beta"] 88 88 89 89 90 + @pytest.mark.parametrize("provider", ["openai", "google", "anthropic"]) 91 + def test_morning_briefing_schema_is_provider_portable(provider: str) -> None: 92 + schema = json.loads( 93 + (REPO_ROOT / "solstone/talent/morning_briefing.schema.json").read_text( 94 + encoding="utf-8" 95 + ) 96 + ) 97 + 98 + prepared = prepare_provider_schema(schema, provider) 99 + 100 + assert unsupported_keyword_hits(prepared, provider) == [] 101 + assert prepared["properties"]["reading"]["items"]["properties"]["facet"][ 102 + "enum" 103 + ] == ["__RUNTIME_FACETS__"] 104 + assert ( 105 + "pattern" in prepared["properties"]["your_day"]["items"]["properties"]["time"] 106 + ) 107 + assert ( 108 + "pattern" 109 + in prepared["properties"]["needs_attention"]["items"]["properties"]["source_id"] 110 + ) 111 + 112 + 90 113 @pytest.mark.parametrize("provider", ["local", "openai", "google", "anthropic", "fake"]) 91 114 def test_prepare_provider_schema_is_pure_and_idempotent( 92 115 bounded_schema: dict[str, Any], provider: str
+30 -3
tests/test_sol_call_identity_briefing.py
··· 3 3 4 4 """Tests for ``journal identity briefing``.""" 5 5 6 + import json 7 + 6 8 from typer.testing import CliRunner 7 9 8 10 from solstone.think.tools.sol import app ··· 10 12 runner = CliRunner() 11 13 12 14 13 - def _seed_briefing(day: str, body: str) -> None: 15 + def _seed_briefing(day: str, marker: str) -> None: 14 16 from solstone.think.talent import morning_briefing_path 15 17 16 18 path = morning_briefing_path(day) 17 19 path.parent.mkdir(parents=True, exist_ok=True) 18 - path.write_text(body, encoding="utf-8") 20 + path.write_text( 21 + json.dumps( 22 + { 23 + "metadata": { 24 + "generated": "2026-01-01T09:00:00", 25 + "model": "test-model", 26 + "sources": { 27 + "segments": 1, 28 + "anticipated_activities": 0, 29 + "facet_newsletters": 0, 30 + "followups": 0, 31 + "steward_health": "present", 32 + }, 33 + "gaps": [], 34 + "coverage_preamble": "Built from test sources. No gaps.", 35 + }, 36 + "your_day": [], 37 + "yesterday": [marker], 38 + "needs_attention": [], 39 + "forward_look": [], 40 + "reading": [], 41 + } 42 + ), 43 + encoding="utf-8", 44 + ) 19 45 20 46 21 47 def test_briefing_no_day_returns_most_recent_available(tmp_path, monkeypatch): 22 48 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 23 49 24 - _seed_briefing("20260101", "# Morning Briefing\n\nolder-marker\n") 50 + _seed_briefing("20260101", "older-marker") 25 51 from solstone.think.talent import morning_briefing_path 26 52 27 53 morning_briefing_path("20260102").parent.mkdir(parents=True, exist_ok=True) ··· 29 55 result = runner.invoke(app, ["briefing"]) 30 56 31 57 assert result.exit_code == 0 58 + assert "## Yesterday" in result.stdout 32 59 assert "older-marker" in result.stdout 33 60 34 61
+26
tests/test_talent_output_schemas.py
··· 8 8 9 9 from jsonschema import Draft202012Validator 10 10 11 + from solstone.think.schema_bounds import unbounded_nodes 11 12 from solstone.think.talent import get_talent 12 13 13 14 TALENT_DIR = Path(__file__).resolve().parents[1] / "solstone" / "talent" ··· 62 63 _assert_declares_max_output_tokens("screen") 63 64 assert talent["json_schema"] == schema 64 65 assert sorted(schema["properties"]) == ["entities", "narrative"] 66 + 67 + 68 + def test_morning_briefing_talent_uses_bounded_json_schema(): 69 + from scripts.check_schema_bounds import ALLOWLIST 70 + 71 + schema = _load_schema("morning_briefing") 72 + Draft202012Validator.check_schema(schema) 73 + 74 + talent = get_talent("morning_briefing") 75 + 76 + assert talent["output"] == "json" 77 + assert talent["max_output_tokens"] == 8192 78 + _assert_declares_max_output_tokens("morning_briefing") 79 + assert talent["json_schema"] == schema 80 + assert talent["degradation_check"] is True 81 + assert unbounded_nodes(schema) == [] 82 + assert "solstone/talent/morning_briefing.schema.json" not in ALLOWLIST 83 + assert sorted(schema["properties"]) == [ 84 + "forward_look", 85 + "metadata", 86 + "needs_attention", 87 + "reading", 88 + "yesterday", 89 + "your_day", 90 + ]
+3
tests/test_voice_tools.py
··· 238 238 239 239 result = tools.handle_briefing_get({}, object()) 240 240 241 + assert set(result) == {"date", "facet", "text", "highlights", "_nav_target"} 241 242 assert result["date"] == "2026-03-27" 242 243 assert result["facet"] == "identity" 243 244 assert result["_nav_target"] == "today" 244 245 assert result["highlights"] 246 + assert len(result["highlights"]) <= 3 247 + assert "Series A term sheet" in result["highlights"][0] 245 248 246 249 247 250 def test_briefing_get_failure(monkeypatch):