personal memory agent
0

Configure Feed

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

refactor(talent): convert briefing and entity descriptions to generate

Move morning_briefing and entities:entity_describe onto generate talents with read-only pre-hooks. Gather briefing packets in process, keep entity description dispatch ad hoc without journal writes, and gate entity generation on the configured generate provider readiness.

+870 -155
+22 -10
solstone/apps/entities/routes.py
··· 7 7 8 8 import json 9 9 import logging 10 - import os 11 10 import re 12 11 import time 13 12 import uuid ··· 1071 1070 detail="Type and name are required", 1072 1071 ) 1073 1072 1074 - # Check for Google API key 1075 - api_key = os.getenv("GOOGLE_API_KEY") 1076 - if not api_key: 1077 - return error_response( 1078 - PROVIDER_KEY_MISSING, 1079 - detail="GOOGLE_API_KEY not set", 1080 - ) 1081 - 1082 1073 try: 1083 1074 from solstone.convey.utils import spawn_agent 1084 1075 1076 + provider_error = _entity_describe_generate_readiness_error() 1077 + if provider_error is not None: 1078 + return provider_error 1079 + 1085 1080 # Build concise prompt - agent has detailed instructions 1086 1081 current_desc = current_description or "(none)" 1087 1082 prompt = ( ··· 1094 1089 use_id = spawn_agent( 1095 1090 prompt=prompt, 1096 1091 name="entities:entity_describe", 1097 - provider="google", 1098 1092 ) 1099 1093 if use_id is None: 1100 1094 return error_response( ··· 1106 1100 1107 1101 except Exception as e: 1108 1102 return error_response(AGENT_UNAVAILABLE, detail=str(e)) 1103 + 1104 + 1105 + def _entity_describe_generate_readiness_error() -> Any | None: 1106 + from solstone.think.models import resolve_provider 1107 + from solstone.think.providers.state import readiness_for_provider 1108 + from solstone.think.talent import key_to_context 1109 + 1110 + context = key_to_context("entities:entity_describe") 1111 + provider, model = resolve_provider(context, "generate") 1112 + readiness = readiness_for_provider(provider, "generate", model) 1113 + if readiness.status not in {"blocked", "unhealthy"}: 1114 + return None 1115 + 1116 + detail = readiness.message or ( 1117 + f"{provider} generate provider is not ready" 1118 + + (f" ({readiness.reason_code})" if readiness.reason_code else "") 1119 + ) 1120 + return error_response(PROVIDER_KEY_MISSING, detail=detail) 1109 1121 1110 1122 1111 1123 @entities_bp.route("/api/<facet_name>/assist", methods=["POST"])
+18 -32
solstone/apps/entities/talent/entity_describe.md
··· 1 1 { 2 - "type": "cogitate", 2 + "type": "generate", 3 3 4 4 "title": "Entity Description", 5 5 "description": "Research and generate single-sentence descriptions for attached entities", 6 6 "color": "#26a69a", 7 - "group": "Entities" 7 + "group": "Entities", 8 + "output": "md", 9 + "hook": {"pre": "entities:entity_describe"} 8 10 } 9 11 10 - $facets 11 - 12 - ## Core Mission 13 - 14 - Generate a clear, informative single-sentence description for an attached entity based on quick research within the facet context. 12 + Generate a clear, informative single-sentence description for an attached entity. 15 13 16 14 ## Input Context 17 15 18 - You receive: 19 - 1. **Entity Type** - the type of entity (Person, Company, Project, Tool, etc.) 20 - 2. **Entity Name** - the name to describe 21 - 3. **Facet** - the facet this entity belongs to (provides context for relevance) 22 - 4. **Current Description** - existing description if any (may be empty) 23 - 24 - ## Research Tools 25 - 26 - Use these `sol call` commands for quick research (be efficient, 2-3 calls max): 27 - - `sol call journal search QUERY -f FACET -n LIMIT` - find mentions in journal content, scoped to facet 28 - - `sol call journal search QUERY -a audio -n LIMIT` - find mentions in transcripts 16 + - Entity Type: $entity_type 17 + - Entity Name: $entity_name 18 + - Facet: $facet 19 + - Current Description: $current_description 29 20 30 - ## Process 21 + ## Journal Evidence 31 22 32 - 1. **Quick research** - 1-2 targeted searches for the entity name within the facet 33 - 2. **Synthesize** - combine findings into a single descriptive sentence 34 - 3. **Output** - return ONLY the description sentence, nothing else 23 + $evidence 35 24 36 25 ## Description Guidelines 37 26 38 27 **Format:** 39 28 - Single complete sentence, under 100 characters preferred 40 29 - No quotes around the description 41 - - Present tense for active entities, past tense for historical 30 + - Present tense for active entities, past tense for historical entities 31 + - Return only the description sentence, with no preamble, markdown, or explanation 42 32 43 33 **Content by type:** 44 34 ··· 58 48 - "Infrastructure-as-code framework for AWS deployments" 59 49 - "Time-series database for metrics storage" 60 50 61 - **If no research results:** 62 - - Use context from entity type and name 63 - - Generic but accurate: "Colleague from the platform team" 64 - - Never leave empty - always synthesize something 65 - 66 - ## Output 51 + **If no journal evidence is found:** 52 + - Use the entity type, entity name, facet, and current description 53 + - Produce a generic but useful sentence 54 + - Never leave the response empty 67 55 68 - Return ONLY the description sentence. No preamble, no explanation, no quotes. 69 - Conclude with the built-in finish tool (`FinishTool`) — this talent has no 70 - `emit_final`; the description sentence is your final response. 56 + Return only one plain sentence.
+85
solstone/apps/entities/talent/entity_describe.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Pre-hook for the entity description generate talent.""" 5 + 6 + from __future__ import annotations 7 + 8 + import logging 9 + 10 + from solstone.think.indexer.journal import search_journal 11 + 12 + logger = logging.getLogger(__name__) 13 + 14 + _NO_EVIDENCE = "No journal evidence found for this entity." 15 + 16 + 17 + def pre_process(config: dict) -> dict | None: 18 + """Parse entity context and attach bounded journal evidence.""" 19 + fields = _parse_prompt(str(config.get("prompt") or "")) 20 + entity_name = fields["entity_name"] 21 + if not entity_name: 22 + return {"skip_reason": "missing entity name"} 23 + 24 + evidence = _render_evidence(entity_name, fields["facet"]) 25 + return { 26 + "template_vars": { 27 + "entity_type": fields["entity_type"] or "Entity", 28 + "entity_name": entity_name, 29 + "facet": fields["facet"] or "(none)", 30 + "current_description": fields["current_description"] or "(none)", 31 + "evidence": evidence, 32 + } 33 + } 34 + 35 + 36 + def _parse_prompt(prompt: str) -> dict[str, str]: 37 + fields = { 38 + "entity_type": "", 39 + "entity_name": "", 40 + "facet": "", 41 + "current_description": "", 42 + } 43 + prefixes = { 44 + "Entity Type:": "entity_type", 45 + "Entity Name:": "entity_name", 46 + "Facet:": "facet", 47 + "Current Description:": "current_description", 48 + } 49 + for line in prompt.splitlines(): 50 + for prefix, key in prefixes.items(): 51 + if line.startswith(prefix): 52 + fields[key] = line[len(prefix) :].strip() 53 + break 54 + if fields["current_description"] == "(none)": 55 + fields["current_description"] = "" 56 + return fields 57 + 58 + 59 + def _render_evidence(entity_name: str, facet: str) -> str: 60 + try: 61 + _, results = search_journal( 62 + entity_name, 63 + limit=5, 64 + facet=facet or None, 65 + ) 66 + except Exception as exc: 67 + logger.warning("entity_describe evidence search unavailable: %s", exc) 68 + return f"Journal evidence unavailable: {exc}" 69 + 70 + if not results: 71 + return _NO_EVIDENCE 72 + 73 + lines = [] 74 + for result in results: 75 + metadata = result.get("metadata") or {} 76 + source_id = str(result.get("id") or "") 77 + day = str(metadata.get("day") or "unknown") 78 + result_facet = str(metadata.get("facet") or "unknown") 79 + text = _single_line(str(result.get("text") or "")) 80 + lines.append(f"- {source_id} [{day}, {result_facet}]: {text}") 81 + return "\n".join(lines) 82 + 83 + 84 + def _single_line(value: str) -> str: 85 + return " ".join(value.strip().split())
+60 -99
solstone/talent/morning_briefing.md
··· 1 1 { 2 - "type": "cogitate", 2 + "type": "generate", 3 3 4 4 "title": "Morning Briefing", 5 5 "description": "Synthesizes all daily agent outputs into a structured five-section morning briefing", ··· 8 8 "priority": 50, 9 9 "output": "md", 10 10 "degradation_check": true, 11 - "read_scope": ["chronicle/<day>", "facets", "entities", "imports", "health", "identity"] 11 + "hook": {"pre": "morning_briefing"} 12 12 } 13 13 14 - $facets 14 + You are generating the morning briefing for $agent_name: a structured daily briefing that synthesizes agent outputs, calendar, follow-ups, and current context into an actionable start-of-day view. 15 15 16 - You are generating the morning briefing for $agent_name — a structured daily digest that synthesizes agent outputs, calendar, follow-ups, and current context into an actionable start-of-day view. 16 + The source packet below is complete. Do not invent data outside the packet. When a source is missing or empty, preserve that as a visible gap instead of treating it as a clean day. 17 17 18 - This is not a conversation. Gather data, synthesize, then call `emit_final(content=<briefing markdown>)`. The system saves the `content` argument automatically. 18 + ## Output Contract 19 19 20 - ## Phase 1: Gather data 20 + Return only the complete briefing markdown in this exact outer shape: 21 21 22 - Call all sources upfront. Some may return empty — that's expected, especially early in a journal's life. 22 + ``` 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 + --- 23 32 24 - 1. `sol call journal facets` — list active facets 25 - 2. For each facet: `sol call journal news FACET --day $day_YYYYMMDD` — facet newsletter 26 - 3. `sol call activities list --source anticipated --day $day_YYYYMMDD` — today's scheduled items with participants 27 - 4. `read_file` `identity/pulse.md` — current pulse narrative and needs-you items 28 - 5. `read_file` `identity/partner.md` — owner behavioral profile (informs tone and emphasis) 29 - 6. `sol call journal search "" -d $day_YYYYMMDD -a followups -n 10` — follow-up items from today 30 - 7. `sol call activities list --source anticipated --from $day_YYYYMMDD --to <+7>` — forward-looking scheduled items 31 - 8. `sol call journal search "" -d $day_YYYYMMDD -a decisions -n 10` — yesterday's consequential decisions 32 - 9. For each of the next 7 days after today: `sol call activities list --source anticipated --day YYYYMMDD` — upcoming scheduled items for forward look 33 + $coverage_preamble 33 34 34 - Also run: 35 - 10. `read_file` `identity/health.md` — sol's federated health surface (synthesized by the steward talent) 35 + ## Your Day 36 + [today's prioritized agenda] 36 37 37 - ## Phase 1.5: Pre-pass audit 38 + ## Yesterday 39 + [what happened yesterday] 38 40 39 - Before synthesizing, audit what you gathered. This step uses only the data from Phase 1 — make no additional tool calls. 41 + ## Needs Attention 42 + [ranked actions and pipeline gaps] 40 43 41 - 1. **Count sources.** Tally how many results each source returned: 42 - - `segments` — total transcript segments across all journal search calls 43 - - `anticipated_activities` — anticipated activities for today (step 3) 44 - - `facet_newsletters` — facets that returned a newsletter (step 2) 45 - - `followups` — follow-up items returned (step 6) 46 - - `steward_health` — whether the steward health surface returned parseable content and how many Needs your attention bullets it surfaced 44 + ## Forward Look 45 + [next seven days] 47 46 48 - 2. **Identify gaps.** Record a gap for each source that returned zero results or is otherwise missing. A gap is not an error — it means the briefing has a blind spot in that area. Examples: `"no facet newsletters available"`, `"no follow-up items found"`, `"no anticipated activities today"`. 47 + ## Reading 48 + [facet newsletter links] 49 + ``` 49 50 50 - 3. **Catalog tool errors.** If any `sol call` in Phase 1 returned an error response, record it as a gap with the error context. 51 + Omit any section that has no content. Keep the YAML frontmatter, `sources`, `gaps`, and coverage preamble exactly as injected above. 51 52 52 - 4. **Check the steward health surface.** Read the steward's Needs your attention section. If empty, omit the Pipeline gaps subsection entirely. Otherwise surface those bullets as top-ranked operational gaps in Needs Attention, rendering them verbatim. If `identity/health.md` returned empty content, the file is missing, or the surface failed to parse: add `steward health surface unavailable` to the coverage-preamble `gaps:` list AND omit the Pipeline gaps subsection — do not emit a healthy-looking briefing without acknowledging this gap. 53 + ## Source Packet 53 54 54 - > **CRITICAL: Tool error handling.** When any `sol call` tool returns an error, you MUST: 55 - > 1. Record the error as a gap with the command or source that failed 56 - > 2. Never treat the error message text as data — do not quote, summarize, or reason about the error content as if it were journal data 57 - > 3. Note the gap in the coverage preamble 58 - > 4. Continue the briefing using whatever data succeeded 55 + ### Active Facets 59 56 60 - ## Phase 2: Synthesize 57 + $active_facets 61 58 62 - Build five sections from the gathered data. **Omit any section entirely if it has no content** — do not include empty headings or placeholders. 59 + ### Facet Newsletters 63 60 64 - ### Section rules 61 + $facet_newsletters 62 + 63 + ### Anticipated Activities Today 65 64 66 - **Source attribution.** Attribute high-consequence factual claims to their source using inline parenthetical links with `sol://` URIs. Not every claim needs attribution — anticipated activities are self-evident and the Reading section is inherently attributed. 65 + $anticipated_today 67 66 68 - `sol://` URI construction: 69 - - **Search results:** The header includes an `id` (e.g. `20260304/archon/143022_300/talents/followups.md:2`). Strip `:idx`, then strip `/talents/{agent}.md` → `sol://20260304/archon/143022_300`. 70 - - **Facet newsletters:** `sol://facets/{facet}/news/{day_YYYYMMDD}`. 67 + ### Anticipated Activities Next 7 Days 68 + 69 + $anticipated_forward 70 + 71 + ### Pulse Surface 71 72 72 - **Your Day** — What's ahead today. Lead with anticipated activities in chronological order. For each meeting, include who's attending and source-backed context from the gathered data when available. If no anticipated activities exist, lead with the highest-priority follow-ups or pulse needs. 73 + $pulse_surface 73 74 74 - **Yesterday** — What happened. Draw from facet newsletters, pulse, and decisions agent output. 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. 75 - Attribute each highlight to its source: `([facet newsletter](sol://facets/{facet}/news/{day}))`. 76 - Grade highlights by evidence strength. **High** (corroborated by multiple sources — e.g., newsletter + decision + transcript): state assertively — "Shipped the entity pipeline refactor." **Medium** (single source, clear statement): attribute and present directly — "Closed three PRs on the data pipeline ([work newsletter](sol://...))." **Low** (inferred from ambiguous context, single passing mention): hedge — "Possible progress on the auth migration" or "May have discussed budget reallocation." When upstream decision output includes a `Confidence:` score, use it to inform grading: 0.85+ high, 0.50–0.84 medium, below 0.50 low. Never hedge items corroborated by multiple sources; never state single-mention inferences assertively. 75 + ### Partner Surface 77 76 78 - **Needs Attention** — Ranked action list. Synthesize from all sources into a single prioritized list: 79 - 0. Pipeline gaps from yesterday's processing 80 - 1. Overdue commitments and missed follow-ups 81 - 2. Pending follow-ups (items flagged by the followups agent) 82 - 3. Important pulse needs without calendar time blocked 77 + $partner_surface 83 78 84 - Do NOT include pipeline gaps when the steward health surface has no Needs your attention bullets. Zero noise on normal days. 85 - Attribute commitments and follow-ups to the originating segment: `(committed [date](sol://...))`, `(flagged [date](sol://...))`. For inferred items: `(inferred from [source](sol://...))`. 86 - Grade action items by evidence strength. **High** (explicit commitment with date, or overdue follow-up): state assertively — "Follow up on Series A term sheet — committed March 20, now overdue." **Medium** (flagged by followups agent with moderate confidence, or clear single-source item): present with attribution — "Review CI pipeline logs (flagged yesterday)." **Low** (inferred obligation from ambiguous mention, or low-confidence followup): hedge — "Possible commitment to send deck to investors" or "May need to follow up on the API discussion." When upstream followup output includes a `Confidence:` score, use it: 0.85+ high, 0.50–0.84 medium, below 0.50 low. Never hedge explicit commitments with clear dates; never present inferred obligations as definite action items. 79 + ### Steward Health Surface 87 80 88 - **Forward Look** — What's coming. Draw from anticipated activity records and upcoming scheduled items (next 7 days). Note preparation needed for upcoming meetings or deadlines. 89 - Attribute schedule-derived items: `(from [schedule](sol://...))`. Data source: `sol call activities list --source anticipated` or the schedule talent output path. 90 - Grade forward items by evidence strength. **High** (confirmed scheduled item or explicit deadline): state assertively — "Board meeting Thursday — slides due Wednesday." **Medium** (schedule-derived activity record with clear basis): attribute and present — "Schedule extraction flagged quarterly review prep based on last quarter's timing." **Low** (speculative schedule inference or pattern-based prediction): hedge — "Possible need to prepare for investor update" or "May want to schedule design review based on sprint cadence." Never hedge confirmed scheduled items or explicit deadlines; never state pattern-based predictions as confirmed plans. 81 + $health_surface 91 82 92 - **Reading** — Links to full facet newsletters for deep dives. List each active facet that has a newsletter for the analysis day, with a brief one-line description of what it covers. This is the "detailed edition" for owners who want the full picture. Only include if facet newsletters exist. 83 + ### Follow-Ups 93 84 94 - ## Phase 3: Return the briefing 85 + $followups 95 86 96 - After gathering data and synthesizing, call `emit_final(content=<briefing markdown>)` with the complete briefing in this exact format: 87 + ### Decisions 97 88 98 - ``` 99 - --- 100 - type: morning_briefing 101 - date: $day_YYYYMMDD 102 - generated: [current ISO 8601 datetime] 103 - model: [model identifier you are running as] 104 - sources: 105 - segments: [count] 106 - anticipated_activities: [count] 107 - facet_newsletters: [count] 108 - followups: [count] 109 - steward_health: [present|missing] 110 - gaps: [list of gap descriptions, or empty list [] if none] 111 - --- 89 + $decisions 112 90 113 - > [coverage preamble — 1-2 sentences summarizing source counts and gaps. Example: "Built from 12 transcript segments, 4 anticipated activities, 2 facet newsletters, and 5 follow-ups. No gaps." or with gaps: "Built from 8 segments, 2 activities. Gaps: no facet newsletters today."] 91 + ## Synthesis Rules 114 92 115 - ## Your Day 116 - - **09:00** — Sync with Sarah Chen on Q2 roadmap. Last discussed launch timeline (from your [March standup](sol://20260313/archon/091500_300)). 117 - - **14:00** — Design review with UX team. 118 - [more items...] 93 + **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. 119 94 120 - ## Yesterday 121 - - Shipped the entity pipeline refactor ([work newsletter](sol://facets/work/news/20260326)). 122 - [more items...] 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. 123 96 124 - ## Needs Attention 125 - - Follow up on Series A term sheet — due yesterday (committed [March 20](sol://20260320/archon/101500_600)) 126 - - Possible commitment to update onboarding docs — mentioned once in passing (inferred from [standup](sol://20260325/archon/091500_300)) 127 - [more items...] 97 + **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. 128 98 129 - ## Forward Look 130 - - Board meeting Thursday — slides need review (confirmed on [calendar](sol://20260327/calendar)) 131 - - May want to prepare quarterly metrics based on last quarter's timing (from [schedule](sol://20260327/talents/schedule)) 132 - [more items...] 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. 133 100 134 - ## Reading 135 - [content — no attribution needed] 136 - ``` 101 + **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. 137 102 138 - Call `emit_final(content=<briefing markdown>)`. The `content` argument IS the briefing markdown (with YAML frontmatter and coverage preamble). Do not include any summary, preamble before the YAML frontmatter, explanation, follow-up commentary, or "here is the briefing" phrasing. Omit sections with no content entirely. 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. 139 104 140 - ## Guidelines 105 + ## Evidence Strength 141 106 142 - - Be concise and scannable. This is a morning read, not a report. 143 - - Lead each section with the most important item. 144 - - Use bullets, not paragraphs. 145 - - Don't include greetings, sign-offs, or meta-commentary about being an AI. 146 - - On a quiet day with minimal data, produce only the sections that have content. A briefing with just "Your Day" listing a few scheduled items or follow-ups is perfectly valid. 107 + Grade highlights and action items by evidence strength. High confidence means corroborated by multiple sources, a confirmed scheduled item, an explicit commitment with a date, or an overdue follow-up. Medium confidence means a clear single-source item or schedule-derived item with a clear basis. Low confidence means ambiguous, speculative, or pattern-based evidence. Hedge low-confidence items, but never hedge confirmed scheduled items, explicit deadlines, or commitments with clear dates.
+419
solstone/talent/morning_briefing.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Pre-hook for the morning briefing generate talent.""" 5 + 6 + from __future__ import annotations 7 + 8 + import json 9 + import logging 10 + from datetime import datetime, timedelta 11 + from pathlib import Path 12 + from typing import Any 13 + 14 + from solstone.think.activities import load_activity_records 15 + from solstone.think.facets import get_enabled_facets, get_facet_news 16 + from solstone.think.indexer.journal import search_journal 17 + from solstone.think.utils import get_journal 18 + 19 + logger = logging.getLogger(__name__) 20 + 21 + 22 + def pre_process(config: dict) -> dict | None: 23 + """Gather briefing sources and return template vars for generation.""" 24 + if config.get("dry_run"): 25 + logger.debug("morning briefing pre-hook dry_run: read-only gather") 26 + 27 + day = str(config.get("day") or "").strip() 28 + if not day: 29 + return {"skip_reason": "missing day"} 30 + 31 + try: 32 + analysis_day = datetime.strptime(day, "%Y%m%d") 33 + except ValueError: 34 + return {"skip_reason": f"invalid day: {day}"} 35 + 36 + try: 37 + journal_root = Path(get_journal()) 38 + except Exception as exc: 39 + logger.exception("morning briefing pre-hook could not resolve journal") 40 + return {"skip_reason": f"journal unavailable: {exc}"} 41 + 42 + try: 43 + packet = _build_packet( 44 + day=day, 45 + analysis_day=analysis_day, 46 + journal_root=journal_root, 47 + model=str(config.get("model") or "unknown"), 48 + ) 49 + except Exception as exc: 50 + logger.exception("morning briefing pre-hook failed") 51 + return {"skip_reason": f"morning briefing pre-hook failed: {exc}"} 52 + 53 + return {"template_vars": packet} 54 + 55 + 56 + def _build_packet( 57 + *, 58 + day: str, 59 + analysis_day: datetime, 60 + journal_root: Path, 61 + model: str, 62 + ) -> dict[str, str]: 63 + gaps: list[str] = [] 64 + counts: dict[str, int | str] = { 65 + "segments": 0, 66 + "anticipated_activities": 0, 67 + "facet_newsletters": 0, 68 + "followups": 0, 69 + "steward_health": "missing", 70 + } 71 + 72 + facets = _load_facets(gaps) 73 + newsletters = _load_facet_newsletters(facets, day, gaps) 74 + anticipated_today = _load_anticipated_activities( 75 + facets, 76 + [day], 77 + gaps, 78 + empty_gap="no anticipated activities today", 79 + ) 80 + forward_days = [ 81 + (analysis_day + timedelta(days=offset)).strftime("%Y%m%d") 82 + for offset in range(1, 8) 83 + ] 84 + anticipated_forward = _load_anticipated_activities( 85 + facets, 86 + forward_days, 87 + gaps, 88 + empty_gap="no anticipated activities in the next 7 days", 89 + ) 90 + followups_total, followup_results = _search_agent( 91 + day, 92 + "followups", 93 + "follow-up items", 94 + gaps, 95 + ) 96 + decisions_total, decision_results = _search_agent( 97 + day, 98 + "decisions", 99 + "decision items", 100 + gaps, 101 + ) 102 + 103 + pulse = _read_identity_file(journal_root, "pulse.md", "pulse surface", gaps) 104 + partner = _read_identity_file(journal_root, "partner.md", "partner profile", gaps) 105 + health = _read_identity_file( 106 + journal_root, 107 + "health.md", 108 + "steward health surface", 109 + gaps, 110 + ) 111 + 112 + counts["facet_newsletters"] = len(newsletters) 113 + counts["anticipated_activities"] = len(anticipated_today) 114 + counts["followups"] = len(followup_results) 115 + counts["steward_health"] = "present" if health else "missing" 116 + counts["segments"] = len(_distinct_result_paths(followup_results, decision_results)) 117 + 118 + return { 119 + "generated": datetime.now().isoformat(timespec="seconds"), 120 + "model": model, 121 + "active_facets": _render_facets(facets), 122 + "facet_newsletters": _render_newsletters(newsletters), 123 + "anticipated_today": _render_activities(anticipated_today), 124 + "anticipated_forward": _render_activities( 125 + anticipated_forward, group_by_day=True 126 + ), 127 + "pulse_surface": pulse or "(missing)", 128 + "partner_surface": partner or "(missing)", 129 + "health_surface": health or "(missing)", 130 + "followups": _render_search_results(followup_results), 131 + "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 + } 142 + 143 + 144 + def _load_facets(gaps: list[str]) -> dict[str, dict[str, object]]: 145 + try: 146 + facets = get_enabled_facets() 147 + except Exception as exc: 148 + logger.warning("morning briefing facets unavailable: %s", exc) 149 + gaps.append(f"active facets unavailable: {exc}") 150 + return {} 151 + if not facets: 152 + gaps.append("no active facets available") 153 + return facets 154 + 155 + 156 + def _load_facet_newsletters( 157 + facets: dict[str, dict[str, object]], 158 + day: str, 159 + gaps: list[str], 160 + ) -> list[dict[str, str]]: 161 + newsletters: list[dict[str, str]] = [] 162 + for facet in sorted(facets): 163 + try: 164 + payload = get_facet_news(facet, day=day, limit=1) 165 + except Exception as exc: 166 + logger.warning("morning briefing news unavailable for %s: %s", facet, exc) 167 + gaps.append(f"facet newsletter unavailable for {facet}: {exc}") 168 + continue 169 + days = payload.get("days") if isinstance(payload, dict) else None 170 + day_payload = days[0] if isinstance(days, list) and days else None 171 + raw_content = "" 172 + if isinstance(day_payload, dict): 173 + raw_content = str(day_payload.get("raw_content") or "").strip() 174 + if raw_content: 175 + newsletters.append({"facet": facet, "day": day, "content": raw_content}) 176 + else: 177 + gaps.append(f"no facet newsletter available for {facet}") 178 + if facets and not newsletters: 179 + gaps.append("no facet newsletters available") 180 + return newsletters 181 + 182 + 183 + def _load_anticipated_activities( 184 + facets: dict[str, dict[str, object]], 185 + days: list[str], 186 + gaps: list[str], 187 + *, 188 + empty_gap: str, 189 + ) -> list[dict[str, Any]]: 190 + activities: list[dict[str, Any]] = [] 191 + for day in days: 192 + for facet in sorted(facets): 193 + try: 194 + records = load_activity_records(facet, day, include_hidden=False) 195 + except Exception as exc: 196 + logger.warning( 197 + "morning briefing activities unavailable for %s/%s: %s", 198 + facet, 199 + day, 200 + exc, 201 + ) 202 + gaps.append( 203 + f"anticipated activities unavailable for {facet} {day}: {exc}" 204 + ) 205 + continue 206 + for record in records: 207 + if record.get("source") != "anticipated": 208 + continue 209 + item = dict(record) 210 + item["facet"] = str(item.get("facet") or facet) 211 + item["day"] = str(item.get("target_date") or day) 212 + activities.append(item) 213 + activities.sort( 214 + key=lambda item: ( 215 + str(item.get("day") or ""), 216 + str(item.get("start") or ""), 217 + str(item.get("facet") or ""), 218 + str(item.get("title") or ""), 219 + ) 220 + ) 221 + if facets and not activities: 222 + gaps.append(empty_gap) 223 + return activities 224 + 225 + 226 + def _search_agent( 227 + day: str, 228 + agent: str, 229 + label: str, 230 + gaps: list[str], 231 + ) -> tuple[int, list[dict[str, Any]]]: 232 + try: 233 + total, results = search_journal("", limit=10, day=day, agent=agent) 234 + except Exception as exc: 235 + logger.warning("morning briefing %s search unavailable: %s", agent, exc) 236 + gaps.append(f"{label} search unavailable: {exc}") 237 + return 0, [] 238 + if not results: 239 + gaps.append(f"no {label} found") 240 + return total, results 241 + 242 + 243 + def _read_identity_file( 244 + journal_root: Path, 245 + file_name: str, 246 + label: str, 247 + gaps: list[str], 248 + ) -> str: 249 + path = journal_root / "identity" / file_name 250 + if not path.exists(): 251 + gaps.append(f"{label} missing") 252 + return "" 253 + try: 254 + content = path.read_text(encoding="utf-8").strip() 255 + except Exception as exc: 256 + logger.warning( 257 + "morning briefing identity read failed for %s: %s", file_name, exc 258 + ) 259 + gaps.append(f"{label} unavailable: {exc}") 260 + return "" 261 + if not content: 262 + gaps.append(f"{label} empty") 263 + return content 264 + 265 + 266 + def _render_facets(facets: dict[str, dict[str, object]]) -> str: 267 + if not facets: 268 + return "(none)" 269 + lines = [] 270 + for name, meta in sorted(facets.items()): 271 + title = str(meta.get("title") or name) 272 + lines.append(f"- {name}: {title}") 273 + return "\n".join(lines) 274 + 275 + 276 + def _render_newsletters(newsletters: list[dict[str, str]]) -> str: 277 + if not newsletters: 278 + return "(none)" 279 + blocks = [] 280 + for item in newsletters: 281 + blocks.append( 282 + "\n".join( 283 + [ 284 + f"### {item['facet']} newsletter", 285 + f"Source: sol://facets/{item['facet']}/news/{item['day']}", 286 + item["content"], 287 + ] 288 + ) 289 + ) 290 + return "\n\n".join(blocks) 291 + 292 + 293 + def _render_activities( 294 + activities: list[dict[str, Any]], 295 + *, 296 + group_by_day: bool = False, 297 + ) -> str: 298 + if not activities: 299 + return "(none)" 300 + lines: list[str] = [] 301 + last_day: str | None = None 302 + for item in activities: 303 + day = str(item.get("day") or "") 304 + if group_by_day and day != last_day: 305 + if lines: 306 + lines.append("") 307 + lines.append(f"### {day}") 308 + last_day = day 309 + time_text = _activity_time(item) 310 + title = str(item.get("title") or item.get("activity") or "Untitled activity") 311 + activity = str(item.get("activity") or "activity") 312 + facet = str(item.get("facet") or "unknown") 313 + participants = _activity_participants(item) 314 + detail = f"- {time_text} {title} [{activity}, {facet}]" 315 + if participants: 316 + detail += f" - participants: {participants}" 317 + lines.append(detail) 318 + return "\n".join(lines) 319 + 320 + 321 + def _activity_time(item: dict[str, Any]) -> str: 322 + start = _short_time(item.get("start")) 323 + end = _short_time(item.get("end")) 324 + if start and end: 325 + return f"{start}-{end}" 326 + if start: 327 + return start 328 + return "unscheduled" 329 + 330 + 331 + def _short_time(value: Any) -> str: 332 + text = str(value or "").strip() 333 + if not text: 334 + return "" 335 + return text[:5] if len(text) >= 5 else text 336 + 337 + 338 + def _activity_participants(item: dict[str, Any]) -> str: 339 + names: list[str] = [] 340 + participation = item.get("participation") 341 + if isinstance(participation, list): 342 + for entry in participation: 343 + if not isinstance(entry, dict): 344 + continue 345 + name = str(entry.get("name") or entry.get("entity_id") or "").strip() 346 + if name: 347 + names.append(name) 348 + if not names: 349 + active_entities = item.get("active_entities") 350 + if isinstance(active_entities, list): 351 + names = [ 352 + str(value).strip() for value in active_entities if str(value).strip() 353 + ] 354 + return ", ".join(names) 355 + 356 + 357 + def _render_search_results(results: list[dict[str, Any]]) -> str: 358 + if not results: 359 + return "(none)" 360 + blocks = [] 361 + for result in results: 362 + metadata = result.get("metadata") or {} 363 + source_id = str(result.get("id") or "") 364 + facet = str(metadata.get("facet") or "unknown") 365 + day = str(metadata.get("day") or "unknown") 366 + text = str(result.get("text") or "").strip() 367 + blocks.append(f"- {source_id} [{day}, {facet}]\n {text}") 368 + return "\n".join(blocks) 369 + 370 + 371 + def _distinct_result_paths( 372 + *result_groups: list[dict[str, Any]], 373 + ) -> set[str]: 374 + paths: set[str] = set() 375 + for results in result_groups: 376 + for result in results: 377 + metadata = result.get("metadata") or {} 378 + path = str(metadata.get("path") or result.get("id") or "").strip() 379 + if path: 380 + paths.add(path) 381 + return paths 382 + 383 + 384 + def _render_source_counts(counts: dict[str, int | str]) -> str: 385 + return "\n".join( 386 + [ 387 + f" segments: {counts['segments']}", 388 + f" anticipated_activities: {counts['anticipated_activities']}", 389 + f" facet_newsletters: {counts['facet_newsletters']}", 390 + f" followups: {counts['followups']}", 391 + f" steward_health: {counts['steward_health']}", 392 + ] 393 + ) 394 + 395 + 396 + def _render_coverage_preamble( 397 + counts: dict[str, int | str], 398 + gaps: list[str], 399 + *, 400 + decisions_total: int, 401 + forward_count: int, 402 + followups_total: int, 403 + ) -> str: 404 + parts = [ 405 + f"{counts['segments']} indexed source paths", 406 + f"{counts['anticipated_activities']} anticipated activities today", 407 + f"{forward_count} forward-looking anticipated activities", 408 + f"{counts['facet_newsletters']} facet newsletters", 409 + f"{counts['followups']} follow-ups", 410 + f"{decisions_total} decision results", 411 + ] 412 + sentence = "Built from " + ", ".join(parts) + "." 413 + if followups_total > counts["followups"]: 414 + sentence += f" Follow-up search returned {followups_total} total matches." 415 + if gaps: 416 + sentence += " Gaps: " + "; ".join(gaps) + "." 417 + else: 418 + sentence += " No gaps." 419 + return sentence
+1 -1
tests/test_cogitate_contract_harness.py
··· 31 31 ("name", "guard", "expected_finalizer"), 32 32 [ 33 33 ( 34 - "morning_briefing", 34 + "weekly_reflection", 35 35 lambda c: c.get("schedule") in EMIT_FINAL_SCHEDULES, 36 36 "emit_final", 37 37 ),
+93
tests/test_entity_describe_pre_hook.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + import importlib.util 5 + from pathlib import Path 6 + 7 + 8 + def _load_entity_describe_module(): 9 + path = ( 10 + Path(__file__).resolve().parents[1] 11 + / "solstone" 12 + / "apps" 13 + / "entities" 14 + / "talent" 15 + / "entity_describe.py" 16 + ) 17 + spec = importlib.util.spec_from_file_location("test_entity_describe_hook", path) 18 + assert spec is not None 19 + assert spec.loader is not None 20 + module = importlib.util.module_from_spec(spec) 21 + spec.loader.exec_module(module) 22 + return module 23 + 24 + 25 + def _prompt(current: str = "Existing description") -> str: 26 + return "\n".join( 27 + [ 28 + "Entity Type: Person", 29 + "Entity Name: Alice Example", 30 + "Facet: work", 31 + f"Current Description: {current}", 32 + ] 33 + ) 34 + 35 + 36 + def test_entity_describe_pre_hook_renders_found_evidence(monkeypatch): 37 + module = _load_entity_describe_module() 38 + 39 + monkeypatch.setattr( 40 + module, 41 + "search_journal", 42 + lambda query, limit, facet: ( 43 + 1, 44 + [ 45 + { 46 + "id": "20260422/work/090000_300/talents/sense.md:0", 47 + "text": "Alice Example led the rollout planning.", 48 + "metadata": { 49 + "day": "20260422", 50 + "facet": "work", 51 + }, 52 + } 53 + ], 54 + ), 55 + ) 56 + 57 + vars_ = module.pre_process({"prompt": _prompt()})["template_vars"] 58 + 59 + assert vars_["entity_type"] == "Person" 60 + assert vars_["entity_name"] == "Alice Example" 61 + assert vars_["facet"] == "work" 62 + assert vars_["current_description"] == "Existing description" 63 + assert "Alice Example led the rollout planning." in vars_["evidence"] 64 + 65 + 66 + def test_entity_describe_pre_hook_empty_evidence_preserves_generic_inputs( 67 + monkeypatch, 68 + ): 69 + module = _load_entity_describe_module() 70 + monkeypatch.setattr(module, "search_journal", lambda query, limit, facet: (0, [])) 71 + 72 + vars_ = module.pre_process({"prompt": _prompt("(none)")})["template_vars"] 73 + 74 + assert vars_["entity_type"] == "Person" 75 + assert vars_["entity_name"] == "Alice Example" 76 + assert vars_["facet"] == "work" 77 + assert vars_["current_description"] == "(none)" 78 + assert vars_["evidence"] == "No journal evidence found for this entity." 79 + 80 + 81 + def test_entity_describe_ad_hoc_generate_has_no_output_path(): 82 + from solstone.think.talents import prepare_config 83 + 84 + config = prepare_config( 85 + { 86 + "name": "entities:entity_describe", 87 + "prompt": _prompt(), 88 + } 89 + ) 90 + 91 + assert config["type"] == "generate" 92 + assert config["output"] == "md" 93 + assert "output_path" not in config
+146
tests/test_morning_briefing_pre_hook.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + import json 5 + 6 + from solstone.talent import morning_briefing 7 + 8 + 9 + def _result(day: str = "20260422") -> dict: 10 + return { 11 + "id": "20260422/work/090000_300/talents/followups.md:0", 12 + "text": "Follow up with Alice about the launch checklist.", 13 + "metadata": { 14 + "day": day, 15 + "facet": "work", 16 + "agent": "followups", 17 + "stream": "work", 18 + "path": f"{day}/work/090000_300/talents/followups.md", 19 + "idx": 0, 20 + }, 21 + "score": -1.0, 22 + } 23 + 24 + 25 + def test_morning_briefing_pre_hook_builds_source_packet(tmp_path, monkeypatch): 26 + journal = tmp_path / "journal" 27 + identity = journal / "identity" 28 + identity.mkdir(parents=True) 29 + (identity / "pulse.md").write_text("Pulse needs focus time.", encoding="utf-8") 30 + (identity / "partner.md").write_text("Partner profile.", encoding="utf-8") 31 + (identity / "health.md").write_text( 32 + "## Needs your attention\n\nnone", encoding="utf-8" 33 + ) 34 + 35 + monkeypatch.setattr(morning_briefing, "get_journal", lambda: str(journal)) 36 + monkeypatch.setattr( 37 + morning_briefing, 38 + "get_enabled_facets", 39 + lambda: {"work": {"title": "Work"}}, 40 + ) 41 + monkeypatch.setattr( 42 + morning_briefing, 43 + "get_facet_news", 44 + lambda facet, **kwargs: { 45 + "days": [{"date": kwargs["day"], "raw_content": "Work shipped a release."}] 46 + }, 47 + ) 48 + 49 + def fake_load_activity_records(facet, day, *, include_hidden=False): 50 + if day == "20260422": 51 + return [ 52 + { 53 + "source": "anticipated", 54 + "activity": "meeting", 55 + "target_date": "20260422", 56 + "start": "09:00:00", 57 + "end": "10:00:00", 58 + "title": "Planning meeting", 59 + "participation": [{"name": "Alice"}], 60 + } 61 + ] 62 + if day == "20260423": 63 + return [ 64 + { 65 + "source": "anticipated", 66 + "activity": "deadline", 67 + "target_date": "20260423", 68 + "start": "17:00:00", 69 + "title": "Proposal deadline", 70 + "active_entities": ["Bob"], 71 + } 72 + ] 73 + return [] 74 + 75 + monkeypatch.setattr( 76 + morning_briefing, 77 + "load_activity_records", 78 + fake_load_activity_records, 79 + ) 80 + monkeypatch.setattr( 81 + morning_briefing, 82 + "search_journal", 83 + lambda query, limit, day, agent: (1, [_result(day)]), 84 + ) 85 + 86 + packet = morning_briefing.pre_process({"day": "20260422", "model": "test-model"})[ 87 + "template_vars" 88 + ] 89 + 90 + expected = { 91 + "active_facets", 92 + "facet_newsletters", 93 + "anticipated_today", 94 + "anticipated_forward", 95 + "pulse_surface", 96 + "partner_surface", 97 + "health_surface", 98 + "followups", 99 + "decisions", 100 + "source_counts", 101 + "source_gaps", 102 + "coverage_preamble", 103 + } 104 + assert expected <= set(packet) 105 + assert "Planning meeting" in packet["anticipated_today"] 106 + assert "Proposal deadline" in packet["anticipated_forward"] 107 + assert "Work shipped a release." in packet["facet_newsletters"] 108 + assert " anticipated_activities: 1" in packet["source_counts"] 109 + assert json.loads(packet["source_gaps"]) == [] 110 + 111 + 112 + def test_morning_briefing_pre_hook_missing_sources_are_visible_gaps( 113 + tmp_path, monkeypatch 114 + ): 115 + journal = tmp_path / "journal" 116 + journal.mkdir() 117 + 118 + monkeypatch.setattr(morning_briefing, "get_journal", lambda: str(journal)) 119 + monkeypatch.setattr( 120 + morning_briefing, 121 + "get_enabled_facets", 122 + lambda: {"work": {"title": "Work"}}, 123 + ) 124 + monkeypatch.setattr( 125 + morning_briefing, 126 + "get_facet_news", 127 + lambda facet, **kwargs: {"days": []}, 128 + ) 129 + monkeypatch.setattr( 130 + morning_briefing, 131 + "load_activity_records", 132 + lambda facet, day, *, include_hidden=False: [], 133 + ) 134 + monkeypatch.setattr( 135 + morning_briefing, 136 + "search_journal", 137 + lambda query, limit, day, agent: (0, []), 138 + ) 139 + 140 + packet = morning_briefing.pre_process({"day": "20260422"})["template_vars"] 141 + gaps = json.loads(packet["source_gaps"]) 142 + 143 + assert any("no facet newsletter available" in gap for gap in gaps) 144 + assert any("no anticipated activities today" in gap for gap in gaps) 145 + assert any("steward health surface missing" in gap for gap in gaps) 146 + assert "Gaps:" in packet["coverage_preamble"]
+26 -13
tests/test_morning_briefing_steward_migration.py
··· 1 1 # SPDX-License-Identifier: AGPL-3.0-only 2 2 # Copyright (c) 2026 sol pbc 3 3 4 + import json 4 5 from pathlib import Path 5 6 6 7 ··· 8 9 return Path("solstone/talent/morning_briefing.md").read_text(encoding="utf-8") 9 10 10 11 11 - def test_morning_briefing_reads_steward_health_surface(): 12 - prompt = _briefing_prompt() 12 + def _briefing_metadata() -> dict: 13 + text = _briefing_prompt() 14 + metadata, end = json.JSONDecoder().raw_decode(text) 15 + assert isinstance(metadata, dict) 16 + assert text[end:].startswith("\n\n") 17 + return metadata 13 18 14 - # C3/C4: the steward health surface is now read via the raw-read tool 15 - # (`identity/health.md`) rather than the bare `journal identity health` 16 - # command — the runtime contract routes no-`sol call`-verb evidence through 17 - # the read tools. 18 - assert "`identity/health.md`" in prompt 19 - assert "`journal identity health`" not in prompt 20 - assert "`sol call health pipeline --yesterday`" not in prompt 19 + 20 + def test_morning_briefing_is_generate_with_pre_hook(): 21 + metadata = _briefing_metadata() 22 + 23 + assert metadata["type"] == "generate" 24 + assert metadata["output"] == "md" 25 + assert metadata["schedule"] == "daily" 26 + assert metadata["hook"]["pre"] == "morning_briefing" 27 + assert "read_scope" not in metadata 21 28 22 29 23 - def test_morning_briefing_omits_migrated_pipeline_phrasings(): 30 + def test_morning_briefing_prompt_uses_injected_packet_only(): 24 31 prompt = _briefing_prompt() 25 32 26 - assert "Pipeline gap:" not in prompt 27 - assert "Pipeline issue:" not in prompt 28 - assert "steward health surface unavailable" in prompt 33 + assert "$health_surface" in prompt 34 + assert "gaps: $source_gaps" in prompt 35 + assert "$coverage_preamble" in prompt 36 + assert "Steward Health Surface" in prompt 37 + 38 + assert "sol call" not in prompt 39 + assert "read_file" not in prompt 40 + assert "emit_final" not in prompt 41 + assert "FinishTool" not in prompt