diff --git a/docs/COGITATE.md b/docs/COGITATE.md index 220d31726..2f01877d5 100644 --- a/docs/COGITATE.md +++ b/docs/COGITATE.md @@ -1,8 +1,8 @@ # Cogitate runtime contract The canonical contract for **cogitate talents** — the LLM agents Cortex spawns to -read and update the journal (chat, pulse, awareness_tender, the entity / -activity / import talents, and the rest). It is the single place a talent +read and update the journal (chat, the entity / activity / import talents, and +the rest). It is the single place a talent author can point at and say what a fresh cogitate run's working directory, context, tools, finalization, and persistence are — instead of reverse-engineering the runtime from `solstone/think/talents.py`, the providers, the policy, and the diff --git a/solstone/apps/home/routes.py b/solstone/apps/home/routes.py index 9e755f0f3..7c01b9ff8 100644 --- a/solstone/apps/home/routes.py +++ b/solstone/apps/home/routes.py @@ -23,10 +23,11 @@ from solstone.convey.bridge import get_cached_state from solstone.convey.utils import DATE_RE, format_date, relative_time from solstone.think.awareness import get_current from solstone.think.capture_health import get_capture_health +from solstone.think.day_accumulator import read_latest from solstone.think.facets import get_enabled_facets, get_facets from solstone.think.pipeline_health import summarize_pipeline_day from solstone.think.steward import read_steward_health, read_steward_summary -from solstone.think.utils import get_journal +from solstone.think.utils import day_path, get_journal # Briefing phase thresholds BRIEFING_MORNING_END_HOUR = 10 @@ -109,8 +110,10 @@ def _load_latest_weekly_reflection() -> dict[str, str] | None: def _load_flow_md(today: str) -> tuple[str | None, float | None]: """Load today's flow.md content and mtime. Returns (content, mtime) or (None, None).""" try: - journal = Path(get_journal()) - flow_path = journal / today / "talents" / "flow.md" + # flow.md is no longer produced by any current talent (the flow talent was + # removed); this fallback is presently inert and will light up only if a + # flow producer returns. Resolve via day_path so the path can't rot. + flow_path = day_path(today, create=False) / "talents" / "flow.md" if flow_path.exists(): return flow_path.read_text(), flow_path.stat().st_mtime except Exception: @@ -118,43 +121,25 @@ def _load_flow_md(today: str) -> tuple[str | None, float | None]: return None, None -def _load_pulse_md() -> tuple[str | None, dict | None, list[str]]: - """Load identity/pulse.md if current for today. - - Returns (content, metadata, needs_you) or (None, None, []). - """ +def _load_pulse_narrative(today: str) -> tuple[str | None, str | None, list[str]]: + """Load today's latest pulse record as narrative content.""" try: - journal = Path(get_journal()) - pulse_path = journal / "identity" / "pulse.md" - if not pulse_path.exists(): - return None, None, [] - post = frontmatter.load(str(pulse_path)) - updated = post.metadata.get("updated") - if not updated: + record = read_latest(today, "pulse", lookback_days=0) + if not record: return None, None, [] - # Parse ISO datetime and check if from today - if isinstance(updated, str): - updated_dt = datetime.fromisoformat(updated) - else: - updated_dt = updated # frontmatter may parse datetime objects - if updated_dt.date() != datetime.now().date(): + + full_details = record.get("full_details") + if not isinstance(full_details, str) or not full_details.strip(): return None, None, [] - # Extract ## needs you section - needs = [] - in_needs = False - for line in post.content.splitlines(): - if line.strip().lower() == "## needs you": - in_needs = True - continue - if in_needs: - if line.startswith("## "): - break - stripped = line.strip() - if stripped.startswith("- "): - needs.append(stripped[2:].strip()) - return post.content, post.metadata, needs + + needs = [str(n) for n in record.get("needs_you", []) if str(n).strip()] + updated_at = None + ts = record.get("ts") + if isinstance(ts, (int, float)): + updated_at = datetime.fromtimestamp(ts / 1000).strftime("%H:%M") + return full_details, updated_at, needs except Exception: - logger.warning("home: failed to load pulse.md", exc_info=True) + logger.warning("home: failed to load pulse record", exc_info=True) return None, None, [] @@ -950,22 +935,13 @@ def _build_pulse_context() -> dict[str, Any]: if flow_mtime: flow_updated_at = datetime.fromtimestamp(flow_mtime).strftime("%H:%M") - # Try pulse.md as primary narrative, fall back to flow.md - pulse_content, pulse_meta, pulse_needs = _load_pulse_md() + # Try today's pulse record as primary narrative, fall back to flow.md + pulse_content, pulse_time, pulse_needs = _load_pulse_narrative(today) if pulse_content: narrative_content = pulse_content narrative_source = "pulse" narrative_header = "pulse" - updated = pulse_meta.get("updated", "") - if isinstance(updated, str): - try: - narrative_updated_at = datetime.fromisoformat(updated).strftime("%H:%M") - except ValueError: - narrative_updated_at = flow_updated_at - elif hasattr(updated, "strftime"): - narrative_updated_at = updated.strftime("%H:%M") - else: - narrative_updated_at = flow_updated_at + narrative_updated_at = pulse_time or flow_updated_at else: narrative_content = flow_content narrative_source = "flow" diff --git a/solstone/talent/awareness_tender.md b/solstone/talent/awareness_tender.md deleted file mode 100644 index f9b69848c..000000000 --- a/solstone/talent/awareness_tender.md +++ /dev/null @@ -1,61 +0,0 @@ -{ - "type": "cogitate", - - "title": "Awareness Tender", - "description": "Maintains identity/awareness.md — a compact situational awareness snapshot", - "schedule": "segment", - "new_only": true, - "priority": 98, - "max_output_tokens": 600, - "read_scope": ["chronicle/", "identity", "facets", "entities", "imports", "health", "stats.json"] -} - -# Awareness Tender - -You maintain `identity/awareness.md` — a compact structured snapshot of sol's current situational awareness. This runs every segment, updating the file with fresh state. - -This is not a conversation. Gather state, write the update, done. - -## Gather state - -Read current state. Use `sol call` for indexed data and `read_file` for the -identity file: - -1. `sol call awareness status` — processing, import, and journal state -2. `sol call activities list --source anticipated` — today's scheduled activity records -3. `sol call entities search --limit 5` — recent entity activity - -## Write awareness.md - -Compose a structured bullet-point snapshot. Keep it under 30 lines. Use this format: - -``` -as of: {ISO 8601 datetime} -segment: {$SOL_SEGMENT} - -## calendar -- {key events for today, 1-3 bullets} - -## activity -- {current activity state from sense, 1-2 bullets} - -## entities -- {recent entity activity, 1-2 bullets} - -## partner -- {recency of last interaction, 1 bullet} -``` - -Omit sections that have no meaningful content. Never include prose — bullets only. - -Write the result. `journal identity awareness --write` is the owned write -command for `awareness.md` (there is no `sol call` verb for it yet): - -```bash -journal identity awareness --write --value '{your content here}' -``` - -## Finalize - -This talent is side-effect-only: once `awareness.md` is written, finish -**quietly** with no further output (do not emit a final message). diff --git a/solstone/talent/chat.md b/solstone/talent/chat.md index c1231ee71..2ae4885e3 100644 --- a/solstone/talent/chat.md +++ b/solstone/talent/chat.md @@ -20,6 +20,8 @@ You are this owner's local agent — not Google, OpenAI, Anthropic, or a generic $active_talents +$situational + $trigger_context ## How To Respond diff --git a/solstone/talent/chat_context.py b/solstone/talent/chat_context.py index 5b24799ad..9ca4e066c 100644 --- a/solstone/talent/chat_context.py +++ b/solstone/talent/chat_context.py @@ -39,6 +39,7 @@ def pre_process(context: dict) -> dict: "since_ts": "", "trigger_talent": "", "location": "", + "situational": "", } result = {"template_vars": template_vars} @@ -92,6 +93,8 @@ def pre_process(context: dict) -> dict: except Exception: logger.debug("Active talent enrichment failed", exc_info=True) + template_vars["situational"] = _render_situational(day) + _apply_trigger_template_vars(template_vars, trigger_kind, trigger_payload) trigger_context = _render_trigger_context(trigger_kind, trigger_payload, context) if source_context: @@ -106,6 +109,36 @@ def pre_process(context: dict) -> dict: return result +def _render_situational(day: str) -> str: + """Compact situational read from the latest pulse record.""" + try: + from solstone.think.day_accumulator import read_latest + + record = read_latest(day, "pulse") + except Exception: + logger.debug("Situational pulse enrichment failed", exc_info=True) + return "" + if not record: + return "" + + lines = ["## Situational awareness\n"] + title = str(record.get("title") or "").strip() + one = str(record.get("one_sentence") or "").strip() + details = str(record.get("full_details") or "").strip() + if title: + lines.append(f"**{title}**") + if one: + lines.append(one) + if details: + lines.append(f"\n{details}") + + needs = [str(n).strip() for n in record.get("needs_you", []) if str(n).strip()] + if needs: + lines.append("\nNeeds the owner:") + lines.extend(f"- {need}" for need in needs) + return "\n".join(lines) + + def _normalize_trigger(context: dict) -> tuple[str | None, dict[str, Any]]: trigger_info = context.get("trigger") kind = None diff --git a/solstone/talent/morning_briefing.py b/solstone/talent/morning_briefing.py index 3d749226a..57e4a6d37 100644 --- a/solstone/talent/morning_briefing.py +++ b/solstone/talent/morning_briefing.py @@ -100,7 +100,7 @@ def _build_packet( gaps, ) - pulse = _read_identity_file(journal_root, "pulse.md", "pulse surface", gaps) + pulse = _read_pulse_surface(day, gaps) partner = _read_identity_file(journal_root, "partner.md", "partner profile", gaps) health = _read_identity_file( journal_root, @@ -263,6 +263,33 @@ def _read_identity_file( return content +def _read_pulse_surface(day: str, gaps: list[str]) -> str | None: + from solstone.think.day_accumulator import read_latest + + try: + record = read_latest(day, "pulse") + except Exception as exc: + logger.warning("morning briefing pulse read unavailable: %s", exc) + gaps.append(f"pulse surface unavailable: {exc}") + return None + if not record: + gaps.append("pulse surface") + return None + + parts = [] + details = str(record.get("full_details") or "").strip() + if details: + parts.append(details) + needs = [str(n).strip() for n in record.get("needs_you", []) if str(n).strip()] + if needs: + parts.append("Needs you:\n" + "\n".join(f"- {n}" for n in needs)) + text = "\n\n".join(parts).strip() + if not text: + gaps.append("pulse surface") + return None + return text + + def _render_facets(facets: dict[str, dict[str, object]]) -> str: if not facets: return "(none)" diff --git a/solstone/talent/pulse.md b/solstone/talent/pulse.md index de2de6a23..fda68607c 100644 --- a/solstone/talent/pulse.md +++ b/solstone/talent/pulse.md @@ -1,89 +1,75 @@ { - "type": "cogitate", - + "type": "generate", "title": "Pulse", - "description": "Living narrative of the owner's day — updated each segment", - "schedule": "segment", - "new_only": true, - "priority": 99, - "max_output_tokens": 1000 + "description": "Living situational read of the owner's day — the shape of today, what needs them, and a one-line glance.", + "schedule": "cadence", + "cadence_minutes": 5, + "priority": 50, + "tier": 3, + "hook": {"pre": "pulse", "post": "pulse"}, + "output": "json", + "schema": "pulse.schema.json", + "accumulate": true, + "thinking_budget": 1024, + "max_output_tokens": 700, + "load": {"transcripts": false, "percepts": false, "talents": false} } -$facets - # Pulse -You are generating the owner's Pulse — a living narrative that captures the shape -of their day so far. This runs every segment, building on the previous pulse. +Write the owner's current Pulse: a compact situational read of the day so far. +The pre-hook has already gathered all context. Do not call tools, do not call +the CLI, and do not read or write files. Return only the JSON object matching +the schema. -This is not a conversation. Gather context, write the pulse, done. +Lean on the previous pulse for continuity. If nothing materially changed, say +that plainly. If something shifted, name the shift. Notice the emotional texture +of the day when the evidence supports it — a tense meeting after quiet work, a +celebratory call, a long focused stretch — but do not force mood language when +the day is neutral. -## Gather context +## Previous pulse -Read current state. Use the `read_file` tool for the identity files (relative to the journal root); use `sol call` for indexed data: +$previous_pulse -1. `read_file` `identity/pulse.md` — previous pulse (may not exist yet; that's fine) -2. `read_file` `identity/partner.md` — behavioral profile of the owner -3. `read_file` `identity/awareness.md` — current situational awareness (calendar, activity, entities) -4. `sol call entities search` — recent entity activity +## Completed since last cadence -Note the key findings — you'll weave them into the narrative. +$completed_since -## Write the pulse +## Awareness -Compose a short, natural narrative (3-8 sentences) describing the shape of the -owner's day so far. Lead with what matters most right now. Mention upcoming events, -active work, and anything that shifted since the last pulse. -Notice the emotional register of the day — not mood tracking, but the texture. A -morning of focused solo work followed by a tense meeting and a celebratory team call -has a shape. Name it when it's notable: "The afternoon shifted — three tense exchanges -with the vendor, then a long quiet stretch." Don't force emotional language when the -day is neutral. Only surface what's actually there. +$awareness -After the narrative, include a `## needs you` section — a ranked list of 3-7 -action items the owner should notice. Format as markdown bullet points: +## Anticipated activities -```` -## needs you -- Most urgent item -- Second priority -- Third item -```` +$anticipated -Draw needs-you items from: upcoming calendar events needing prep, entity follow-ups, -and anything the narrative highlights as important. +## Recent entities -## Write output +$recent_entities -Write the complete pulse (YAML frontmatter + narrative + needs-you section) via: +## Partner profile -```bash -journal identity pulse --write --value "--- -updated: 2026-03-22T14:35:00 -segment: 143022_300 -source: pulse-cogitate ---- +$partner_profile -[Your narrative here] +## Data gaps -## needs you -- Item 1 -- Item 2" -``` +$gaps -The `updated` field must be an ISO 8601 datetime (no timezone). The `segment` -field is the current segment key from $SOL_SEGMENT. This write is the talent's -only persistence path — `journal identity pulse --write` is the owned write -command for `pulse.md` (there is no `sol call` verb for it yet). +## Write -## Finalize +Return a JSON object with exactly these keys: -This talent is side-effect-only: once the pulse is written with the command -above, finish **quietly** with no further output (do not emit a final message). +- `title` — 2-6 words, a glanceable header for the current shape of the day. +- `one_sentence` — one sentence that can open chat or a mobile surface. +- `full_details` — 3-8 sentences describing the shape of the owner's day so far. + Lead with what matters most right now. Mention upcoming events, active work, + and meaningful shifts since the previous pulse. +- `needs_you` — 0-7 ranked action items as strings. Draw them from upcoming + calendar events needing preparation, entity follow-ups, completed activities, + and anything the narrative makes urgent. -## Guidelines +Be concise. Do not greet the owner. Do not include markdown outside string +values. Do not mention that you are using a pre-hook or schema. -- Be concise. The owner sees this on their landing page. -- Don't repeat the same narrative if nothing changed — note stability. -- Don't include greetings or meta-commentary about being an AI. -- If the day is just starting and there's little data, say so briefly. +Output only the JSON object. diff --git a/solstone/talent/pulse.py b/solstone/talent/pulse.py new file mode 100644 index 000000000..84bd393fa --- /dev/null +++ b/solstone/talent/pulse.py @@ -0,0 +1,423 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Hooks for the pulse cadence talent.""" + +from __future__ import annotations + +import json +import logging +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from solstone.think.activities import load_activity_records +from solstone.think.awareness import get_current, get_imports +from solstone.think.day_accumulator import append_record, read_latest +from solstone.think.entities.loading import load_recent_entity_names +from solstone.think.facets import get_facets +from solstone.think.utils import ( + day_path, + get_journal, + iter_segments, + now_ms, + segment_path, +) + +logger = logging.getLogger(__name__) + +_MAX_UNITS = 8 +_MAX_NEEDS = 7 +_TITLE_MAX = 80 +_SENTENCE_MAX = 240 +_DETAILS_MAX = 1800 +_NEED_MAX = 240 +_PARTNER_MAX = 4000 + + +def _today_from_config(config: dict) -> str: + day = config.get("day") + if isinstance(day, str) and day: + return day + return datetime.now().strftime("%Y%m%d") + + +def _generated_at() -> str: + return datetime.now(UTC).isoformat().replace("+00:00", "Z") + + +def _default_pulse() -> dict[str, Any]: + return { + "title": "Day in progress", + "one_sentence": "The day is still taking shape.", + "full_details": ( + "There is not enough current context to name a clear shape yet. " + "Sol will keep watching for completed segments, anticipated events, " + "and anything that needs the owner's attention." + ), + "needs_you": [], + } + + +def _compact_json(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + + +def _string(value: Any, fallback: str) -> str: + if isinstance(value, str): + stripped = value.strip() + if stripped: + return stripped + return fallback + + +def _unit_ts(unit: dict[str, Any]) -> int: + try: + return int(unit.get("ts") or 0) + except (TypeError, ValueError): + return 0 + + +def _candidate_segment_dirs(day: str, segment: str, stream: str | None) -> list[Path]: + candidates: list[Path] = [] + seen: set[Path] = set() + + def add(candidate: Path) -> None: + if candidate not in seen: + seen.add(candidate) + candidates.append(candidate) + + if stream: + add(segment_path(day, segment, stream, create=False)) + + for _stream, segment_id, seg_dir in iter_segments(day): + if segment_id == segment: + add(seg_dir) + + add(day_path(day, create=False) / segment) + return candidates + + +def _read_segment_timeline( + day: str, + unit: dict[str, Any], + gaps: list[str], +) -> dict[str, Any] | None: + segment = str(unit.get("segment") or "").strip() + if not segment: + gaps.append("completed segment missing segment id") + return None + stream_raw = unit.get("stream") + stream = str(stream_raw).strip() if stream_raw else None + + try: + candidates = _candidate_segment_dirs(day, segment, stream) + except Exception as exc: + gaps.append(f"could not resolve segment {segment}: {exc}") + return None + + for seg_dir in candidates: + timeline_path = seg_dir / "timeline.json" + if not timeline_path.is_file(): + continue + try: + data = json.loads(timeline_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + gaps.append(f"could not read timeline for segment {segment}: {exc}") + return None + if not isinstance(data, dict): + gaps.append(f"timeline for segment {segment} was not an object") + return None + return { + "segment": segment, + "stream": stream, + "ts": unit.get("ts"), + "title": _string(data.get("title"), "Untitled segment"), + "description": _string(data.get("description"), ""), + } + + gaps.append(f"no timeline.json found for segment {segment}") + return None + + +def _read_activity( + day: str, + unit: dict[str, Any], + gaps: list[str], +) -> dict[str, Any] | None: + facet_raw = unit.get("facet") + facet = str(facet_raw).strip() if facet_raw else "" + activity_id = str(unit.get("activity") or "").strip() + if not facet or not activity_id: + gaps.append( + f"completed activity missing facet or id: {activity_id or '(none)'}" + ) + return None + + try: + records = load_activity_records(facet, day) + except Exception as exc: + gaps.append(f"could not load activities for {facet}: {exc}") + return None + + for record in records: + if str(record.get("id") or "").strip() != activity_id: + continue + return { + "facet": facet, + "activity": activity_id, + "ts": unit.get("ts"), + "title": _string(record.get("title"), activity_id.replace("_", " ")), + "description": _string(record.get("description"), ""), + "details": _string(record.get("details"), ""), + "source": record.get("source"), + "segments": record.get("segments") or [], + } + + gaps.append(f"activity record not found: {facet}/{activity_id}") + return None + + +def _completed_since(day: str, config: dict, gaps: list[str]) -> dict[str, Any]: + window = config.get("cadence_window") if isinstance(config, dict) else None + if not isinstance(window, dict): + window = {} + + units: list[tuple[int, str, dict[str, Any]]] = [] + for unit in window.get("segments") or []: + if isinstance(unit, dict): + units.append((_unit_ts(unit), "segment", unit)) + for unit in window.get("activities") or []: + if isinstance(unit, dict): + units.append((_unit_ts(unit), "activity", unit)) + units.sort(key=lambda item: item[0], reverse=True) + + segments: list[dict[str, Any]] = [] + activities: list[dict[str, Any]] = [] + for _ts, kind, unit in units[:_MAX_UNITS]: + if kind == "segment": + segment = _read_segment_timeline(day, unit, gaps) + if segment is not None: + segments.append(segment) + else: + activity = _read_activity(day, unit, gaps) + if activity is not None: + activities.append(activity) + + return { + "since_ms": window.get("since_ms"), + "input_segments": len(window.get("segments") or []), + "input_activities": len(window.get("activities") or []), + "segments": segments, + "activities": activities, + } + + +def _collect_anticipated_activities(day: str, gaps: list[str]) -> list[dict[str, Any]]: + anticipated: list[dict[str, Any]] = [] + try: + facet_names = list(get_facets()) + except Exception as exc: + gaps.append(f"could not list facets for anticipated activities: {exc}") + return anticipated + + for facet_name in facet_names: + try: + records = load_activity_records(facet_name, day) + except Exception as exc: + gaps.append( + f"could not load anticipated activities for {facet_name}: {exc}" + ) + continue + for record in records: + if record.get("source") != "anticipated": + continue + participants: list[str] = [] + for entry in record.get("participation") or []: + if not isinstance(entry, dict) or entry.get("role") != "attendee": + continue + name = str(entry.get("name") or "").strip() + if name: + participants.append(name) + anticipated.append( + { + "title": record.get("title", ""), + "start": record.get("start") or "", + "end": record.get("end") or "", + "facet": facet_name, + "occurred": False, + "participants": participants, + } + ) + return anticipated + + +def _read_partner_profile(gaps: list[str]) -> str: + path = Path(get_journal()) / "identity" / "partner.md" + try: + text = path.read_text(encoding="utf-8").strip() + except FileNotFoundError: + gaps.append("identity/partner.md missing") + return "(missing)" + except OSError as exc: + gaps.append(f"could not read identity/partner.md: {exc}") + return "(unavailable)" + return text[:_PARTNER_MAX] if text else "(empty)" + + +def _awareness_context(gaps: list[str]) -> dict[str, Any]: + context: dict[str, Any] = {} + try: + context["current"] = get_current() + except Exception as exc: + gaps.append(f"could not read current awareness: {exc}") + context["current"] = {} + try: + context["imports"] = get_imports() + except Exception as exc: + gaps.append(f"could not read import awareness: {exc}") + context["imports"] = {} + return context + + +def _recent_entities(gaps: list[str]) -> list[str]: + try: + names = load_recent_entity_names(limit=12) + except Exception as exc: + gaps.append(f"could not read recent entities: {exc}") + return [] + return names or [] + + +def pre_process(config: dict) -> dict | None: + """Gather pulse context for the cadence generator.""" + try: + day = _today_from_config(config) + gaps: list[str] = [] + default = _default_pulse() + config["_pulse_default"] = default + + previous = read_latest(day, "pulse") + completed = _completed_since(day, config, gaps) + awareness = _awareness_context(gaps) + anticipated = _collect_anticipated_activities(day, gaps) + recent_entities = _recent_entities(gaps) + partner_profile = _read_partner_profile(gaps) + + config["_pulse_window_note"] = { + "segments": len(completed["segments"]), + "activities": len(completed["activities"]), + "input_segments": completed["input_segments"], + "input_activities": completed["input_activities"], + "since_ms": completed["since_ms"], + "gaps": list(gaps), + } + + return { + "template_vars": { + "previous_pulse": ( + _compact_json(previous) + if previous is not None + else "(none - first run)" + ), + "completed_since": _compact_json(completed), + "awareness": _compact_json(awareness), + "anticipated": _compact_json(anticipated), + "recent_entities": _compact_json(recent_entities), + "partner_profile": partner_profile, + "gaps": "\n".join(f"- {gap}" for gap in gaps) if gaps else "(none)", + } + } + except Exception as exc: + logger.exception("pulse pre-hook failed") + return {"skip_reason": f"pulse pre-hook failed: {exc}"} + + +def _parse_object(result: Any) -> dict[str, Any] | None: + if isinstance(result, dict): + return result + if not isinstance(result, str): + return None + try: + data = json.loads(result) + except json.JSONDecodeError: + start = result.find("{") + end = result.rfind("}") + if start == -1 or end <= start: + return None + try: + data = json.loads(result[start : end + 1]) + except json.JSONDecodeError: + return None + return data if isinstance(data, dict) else None + + +def _coerce_needs(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + needs: list[str] = [] + for item in value: + if item is None: + continue + text = str(item).strip() + if text: + needs.append(text[:_NEED_MAX]) + if len(needs) >= _MAX_NEEDS: + break + return needs + + +def _coerce_pulse(raw: Any) -> dict[str, Any] | None: + data = _parse_object(raw) + if data is None: + return None + title = data.get("title") + one_sentence = data.get("one_sentence") + full_details = data.get("full_details") + needs_you = data.get("needs_you") + if not all(isinstance(value, str) for value in (title, one_sentence, full_details)): + return None + title = title.strip() + one_sentence = one_sentence.strip() + full_details = full_details.strip() + if not title or not one_sentence or not full_details: + return None + return { + "title": title[:_TITLE_MAX], + "one_sentence": one_sentence[:_SENTENCE_MAX], + "full_details": full_details[:_DETAILS_MAX], + "needs_you": _coerce_needs(needs_you), + } + + +def _normalize_pulse(result: Any, default: dict[str, Any]) -> dict[str, Any]: + summary = _coerce_pulse(result) + if summary is None: + return { + "title": _string(default.get("title"), "Day in progress")[:_TITLE_MAX], + "one_sentence": _string( + default.get("one_sentence"), "The day is still taking shape." + )[:_SENTENCE_MAX], + "full_details": _string( + default.get("full_details"), + "There is not enough current context to name a clear shape yet.", + )[:_DETAILS_MAX], + "needs_you": _coerce_needs(default.get("needs_you")), + } + return summary + + +def post_process(result: str, config: dict) -> str: + """Persist the normalized pulse summary to the day accumulator.""" + default = config.get("_pulse_default") or _default_pulse() + summary = _normalize_pulse(result, default) + day = _today_from_config(config) + record = { + **summary, + "model": config.get("model"), + "generated_at": _generated_at(), + "ts": now_ms(), + "window": config.get("_pulse_window_note") or {}, + } + append_record(day, "pulse", record) + return json.dumps(summary, ensure_ascii=False, indent=2, sort_keys=True) diff --git a/solstone/talent/pulse.schema.json b/solstone/talent/pulse.schema.json new file mode 100644 index 000000000..fb126caa1 --- /dev/null +++ b/solstone/talent/pulse.schema.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "additionalProperties": false, + "required": ["title", "one_sentence", "full_details", "needs_you"], + "properties": { + "title": {"type": "string"}, + "one_sentence": {"type": "string"}, + "full_details": {"type": "string"}, + "needs_you": {"type": "array", "items": {"type": "string"}} + } +} diff --git a/solstone/talent/read.md b/solstone/talent/read.md index b857b5846..076995b5c 100644 --- a/solstone/talent/read.md +++ b/solstone/talent/read.md @@ -44,7 +44,7 @@ files. Pick the right one; don't ask which. | transcripts (what was said) | `sol call transcripts read` / `scan` / `segments` | | people, projects, relationships | `sol call entities search` / `list` / `show` / `observations` | | what's scheduled or happened | `sol call activities list` (add `--source anticipated` for calendar-derived items) / `get` | -| identity & current state | `journal identity partner` / `pulse` / `briefing` | +| identity & current state | `journal identity partner` / `briefing` | | speaker library | `sol call speakers status` / `suggest` | | system state | `sol call awareness status` | | a raw file with no `sol` command | `read_file` (journal root only) | diff --git a/solstone/talent/sense.md b/solstone/talent/sense.md index 0d8615bcf..644459e38 100644 --- a/solstone/talent/sense.md +++ b/solstone/talent/sense.md @@ -122,7 +122,6 @@ Prefer complete canonical forms (full names when identifiable). Do NOT include t Processing recommendations for downstream agents: - **screen_record**: `true` if density is "active" AND there is meaningful screen content worth documenting (not just a static/repetitive screen) - **speaker_attribution**: `true` if `meeting_detected` is true AND there are multiple speakers to attribute -- **pulse_update**: `true` if this segment represents a meaningful change in activity — new activity started, activity ended, significant context shift, or noteworthy event occurred. `false` for continuation of the same activity with no notable change. ### emotional_register The observable emotional tone of the segment based on conversation tone, speech patterns, and behavioral signals — not inferred feelings. Choose the single best match: diff --git a/solstone/talent/sense.schema.json b/solstone/talent/sense.schema.json index 8c2bc7d7a..c82f0ba90 100644 --- a/solstone/talent/sense.schema.json +++ b/solstone/talent/sense.schema.json @@ -146,8 +146,7 @@ "additionalProperties": false, "required": [ "screen_record", - "speaker_attribution", - "pulse_update" + "speaker_attribution" ], "properties": { "screen_record": { @@ -155,9 +154,6 @@ }, "speaker_attribution": { "type": "boolean" - }, - "pulse_update": { - "type": "boolean" } } }, diff --git a/solstone/think/identity.py b/solstone/think/identity.py index 59514c859..83e1894e1 100644 --- a/solstone/think/identity.py +++ b/solstone/think/identity.py @@ -78,8 +78,6 @@ responses. Nothing gets sent without their review. [not yet observed — sol will learn as we spend time together] """ -_AWARENESS_MD = "not yet updated\n" - STEWARD_SECTION_STATUS = "## Status" STEWARD_SECTION_ATTENTION = "## Needs your attention" STEWARD_SECTION_AUTO_REPAIRS = "## Auto-repairs (last 7d)" @@ -278,7 +276,6 @@ def ensure_identity_directory() -> Path: identity_dir = _identity_dir() defaults = { "partner.md": _PARTNER_MD, - "awareness.md": _AWARENESS_MD, "health.md": "\n".join( [ STEWARD_SECTION_STATUS, diff --git a/solstone/think/thinking.py b/solstone/think/thinking.py index 7939bf407..842f27ce9 100644 --- a/solstone/think/thinking.py +++ b/solstone/think/thinking.py @@ -272,7 +272,6 @@ def check_callosum_available() -> bool: _SKIPPED: object = object() -NEVER_SKIP_DAILY = frozenset({"pulse", "awareness_tender"}) _SEND_RETRY_DELAYS = (0.5, 1.0) # seconds between retries (3 attempts total) @@ -502,15 +501,12 @@ def _check_daily_skip( *, mode: str, completed: set[tuple[str, str, str | None]], - never_skip: frozenset[str], deterministic_failures: dict[tuple[str, str | None], DeterministicFailure], retry_on_deterministic_failure: bool = False, from_scratch: bool = False, ) -> tuple[bool, str | None]: if mode != "daily": return (False, None) - if name in never_skip: - return (False, None) if from_scratch: return (False, None) if (mode, name, facet) in completed: @@ -624,8 +620,6 @@ def run_segment_sense( day_dir = day_path(day) seg_dir = _segment_dir(day, segment, stream) - pulse_config = _cfg("pulse") - start_time = time.time() total_success = 0 total_failed = 0 @@ -955,10 +949,7 @@ def run_segment_sense( segment=segment, ) - total_expected = 1 + len(agents_to_run) - if recommend.get("pulse_update") and pulse_config: - total_expected += 1 - _update_status(agents_total=total_expected) + _update_status(agents_total=1 + len(agents_to_run)) spawned: list[tuple[str, str, dict, str | None]] = [] for agent_name, config in agents_to_run: @@ -1118,124 +1109,6 @@ def run_segment_sense( max_concurrency=max_concurrency, ) - awareness_tender_config = _cfg("awareness_tender") - if awareness_tender_config: - at_agent_id = _dispatch_agent("awareness_tender", awareness_tender_config) - if at_agent_id is None: - _log_skip( - "awareness_tender", - "send_failed", - "All cortex request attempts failed for awareness_tender", - mode=target_schedule, - day=day, - segment=segment, - ) - total_failed += 1 - all_failed_names.append("awareness_tender (send)") - _update_status(agents_completed=total_success + total_failed) - elif at_agent_id is not _SKIPPED: - emit( - "talent_started", - mode=target_schedule, - day=day, - segment=segment, - name="awareness_tender", - use_id=at_agent_id, - ) - _jsonl_log( - "talent.dispatch", - mode=target_schedule, - day=day, - segment=segment, - name="awareness_tender", - use_id=at_agent_id, - **({"stream": stream} if stream else {}), - ) - _update_status(current_agents=["awareness_tender"]) - s, f, fn = _drain_priority_batch( - [(at_agent_id, "awareness_tender", awareness_tender_config, None)], - target_schedule, - day, - segment, - stream, - timeout, - ) - total_success += s - total_failed += f - all_failed_names.extend(fn) - _update_status( - agents_completed=total_success + total_failed, - current_agents=[], - ) - - if recommend.get("pulse_update") and pulse_config: - pulse_agent_id = _dispatch_agent("pulse", pulse_config) - if pulse_agent_id is None: - _log_skip( - "pulse", - "send_failed", - "All cortex request attempts failed for pulse", - mode=target_schedule, - day=day, - segment=segment, - ) - total_failed += 1 - all_failed_names.append("pulse (send)") - _update_status(agents_completed=total_success + total_failed) - elif pulse_agent_id is not _SKIPPED: - emit( - "talent_started", - mode=target_schedule, - day=day, - segment=segment, - name="pulse", - use_id=pulse_agent_id, - ) - _jsonl_log( - "talent.dispatch", - mode=target_schedule, - day=day, - segment=segment, - name="pulse", - use_id=pulse_agent_id, - **({"stream": stream} if stream else {}), - ) - _update_status(current_agents=["pulse"]) - s, f, fn = _drain_priority_batch( - [(pulse_agent_id, "pulse", pulse_config, None)], - target_schedule, - day, - segment, - stream, - timeout, - ) - total_success += s - total_failed += f - all_failed_names.extend(fn) - _update_status( - agents_completed=total_success + total_failed, - current_agents=[], - ) - elif not recommend.get("pulse_update"): - _log_skip( - "pulse", - "not_recommended", - "pulse_update not recommended by sense", - mode=target_schedule, - day=day, - segment=segment, - ) - elif not pulse_config: - _log_skip( - "pulse", - "no_config", - "pulse config not found", - mode=target_schedule, - day=day, - segment=segment, - **({"stream": stream} if stream else {}), - ) - duration_ms = int((time.time() - start_time) * 1000) emit( "completed", @@ -1267,8 +1140,21 @@ def _apply_output_persistence( ``refresh`` (so the output-exists guard in _run_talent is bypassed and the talent regenerates). Cogitate talents with no declared output are left untouched — they do not persist. ``refresh`` is left absent when not - forcing, matching the existing dispatch-config representation. + forcing, matching the existing dispatch-config representation. Talents that + declare ``accumulate`` persist from their post-hook and suppress this + single-file output path. """ + # Accumulate talents persist via their post-hook's day_accumulator.append_record + # (chronicle//talents/.jsonl). Suppress the framework's single-file + # write by leaving request_config["output"] unset -> prepare_config computes no + # output_path -> talent_emit_event skips _write_output. The talent still declares + # output:json + schema: in frontmatter, so config validation passes and the JSON + # schema still reaches the model. NOTE: this covers schedules that route through + # _apply_output_persistence (cadence + daily/weekly); segment/activity/flush set + # output directly and are not covered — intentional, no consumer needs them. + if config.get("accumulate"): + return + is_generate = config["type"] == "generate" if is_generate or config.get("output"): request_config["output"] = config.get("output") or "md" @@ -1431,7 +1317,6 @@ def run_daily_prompts( facet_name, mode=target_schedule, completed=completed_units, - never_skip=NEVER_SKIP_DAILY, deterministic_failures=deterministic_failures, retry_on_deterministic_failure=config.get( "retry_on_deterministic_failure", False @@ -1560,7 +1445,6 @@ def run_daily_prompts( None, mode=target_schedule, completed=completed_units, - never_skip=NEVER_SKIP_DAILY, deterministic_failures=deterministic_failures, retry_on_deterministic_failure=config.get( "retry_on_deterministic_failure", False @@ -2865,7 +2749,6 @@ def dry_run( "speaker_attribution", "if recommend.speaker_attribution + audio embeddings", ), - ("pulse", "if recommend.pulse_update"), ]: cfg = prompts.get(name) if not cfg: @@ -3226,9 +3109,9 @@ def parse_args() -> argparse.ArgumentParser: default="", help=( "Comma-separated segment-scheduled talent names to suppress during " - "--segments/--segment runs (e.g., 'awareness_tender,pulse' for " + "--segments/--segment runs (e.g., 'screen,speaker_attribution' for " "realizer-backfill speedup). Recognized: sense, entities, documents, " - "screen, speaker_attribution, awareness_tender, pulse. Skipping 'sense' " + "screen, speaker_attribution. Skipping 'sense' " "relies on a cached talents/sense.json from a prior run." ), ) diff --git a/solstone/think/tools/sol.py b/solstone/think/tools/sol.py index bbd6c7adb..221c02ffb 100644 --- a/solstone/think/tools/sol.py +++ b/solstone/think/tools/sol.py @@ -3,10 +3,8 @@ """CLI commands for the journal identity directory. -Provides read and write access to ``{journal}/identity/partner.md``, -``{journal}/identity/pulse.md``, and ``{journal}/identity/awareness.md`` — -sol's identity and initiative files. Also provides read access to the morning -briefing at +Provides read and write access to ``{journal}/identity/partner.md`` and read access +to sol's health surface. Also provides read access to the morning briefing at ``{journal}/YYYYMMDD/talents/morning_briefing.md``. Top-level ``journal identity`` command. @@ -39,7 +37,7 @@ from solstone.think.steward import ( from solstone.think.utils import day_dirs, day_path, get_journal, require_solstone app = typer.Typer( - help="Journal identity directory — partner.md, pulse.md, awareness.md, and morning briefing.", + help="Journal identity directory — partner.md, health.md, and morning briefing.", invoke_without_command=True, no_args_is_help=False, ) @@ -82,7 +80,7 @@ def _hydrate() -> str: """Return the combined identity hydration document.""" identity_dir = Path(get_journal()) / "identity" chunks = [f"# species\n\n{_SPECIES_PREAMBLE}\n"] - for stem in ("partner", "awareness"): + for stem in ("partner",): path = identity_dir / f"{stem}.md" content = ( path.read_text(encoding="utf-8").strip() @@ -178,78 +176,6 @@ def partner_cmd( typer.echo(partner_path.read_text(encoding="utf-8")) -@app.command("pulse") -def pulse_cmd( - write: bool = typer.Option( - False, - "--write", - "-w", - help="Overwrite pulse.md (content via --value or stdin).", - ), - value: str | None = typer.Option( - None, "--value", help="Content to write (alternative to stdin)." - ), -) -> None: - """Read or write identity/pulse.md.""" - identity_dir = _identity_dir() - pulse_path = identity_dir / "pulse.md" - - if write: - content = _resolve_content(value) - write_identity( - "pulse.md", - actor=_actor_for_cmd("pulse", "--write"), - op="replace", - section=None, - content=content, - reason="manual replace", - ) - typer.echo("pulse.md updated.") - return - - # Read mode - if not pulse_path.exists(): - typer.echo("pulse.md not found.", err=True) - raise typer.Exit(1) - typer.echo(pulse_path.read_text(encoding="utf-8")) - - -@app.command("awareness") -def awareness_cmd( - write: bool = typer.Option( - False, - "--write", - "-w", - help="Overwrite awareness.md (content via --value or stdin).", - ), - value: str | None = typer.Option( - None, "--value", help="Content to write (alternative to stdin)." - ), -) -> None: - """Read or write identity/awareness.md.""" - identity_dir = _identity_dir() - awareness_path = identity_dir / "awareness.md" - - if write: - content = _resolve_content(value) - write_identity( - "awareness.md", - actor=_actor_for_cmd("awareness", "--write"), - op="replace", - section=None, - content=content, - reason="manual replace", - ) - typer.echo("awareness.md updated.") - return - - # Read mode - if not awareness_path.exists(): - typer.echo("awareness.md not found.", err=True) - raise typer.Exit(1) - typer.echo(awareness_path.read_text(encoding="utf-8")) - - @app.command("health") def health_cmd( refresh: bool = typer.Option( diff --git a/tests/baselines/api/settings/providers.json b/tests/baselines/api/settings/providers.json index d14f146f1..b6da6f70c 100644 --- a/tests/baselines/api/settings/providers.json +++ b/tests/baselines/api/settings/providers.json @@ -251,14 +251,6 @@ "tier": 2, "type": "cogitate" }, - "talent.system.awareness_tender": { - "disabled": false, - "group": "Think", - "label": "Awareness Tender", - "schedule": "segment", - "tier": 2, - "type": "cogitate" - }, "talent.system.chat": { "disabled": false, "group": "Think", @@ -356,9 +348,9 @@ "disabled": false, "group": "Think", "label": "Pulse", - "schedule": "segment", - "tier": 2, - "type": "cogitate" + "schedule": "cadence", + "tier": 3, + "type": "generate" }, "talent.system.read": { "disabled": false, diff --git a/tests/baselines/api/sol/preview.json b/tests/baselines/api/sol/preview.json index fdb58c5bf..95268aeed 100644 --- a/tests/baselines/api/sol/preview.json +++ b/tests/baselines/api/sol/preview.json @@ -1,5 +1,5 @@ { - "full_prompt": "## Instructions\n\n## Available Facets\n\n- **Capulet Industries** (`capulet`)\n Capulet Industries enterprise division\n - **Capulet Industries Entities**: Tybalt Capulet; Juliet Capulet; Paris Duke; Nurse Angela; Capulet Industries\n - **Capulet Industries Activities**:\n - Meetings\n - call\n - deadline\n - appointment\n - event\n - travel\n - reminder\n - errand\n - celebration\n - doctor appointment\n - Coding\n - Browsing\n - Email\n - Messaging\n - AI Conversation\n - _and 10 more activities_\n\n- **Empty Entities Test** (`empty-entities`)\n - **Empty Entities Test Activities**:\n - Meetings\n - call\n - deadline\n - appointment\n - event\n - travel\n - reminder\n - errand\n - celebration\n - doctor appointment\n - Coding\n - Browsing\n - Email\n - Messaging\n - AI Conversation\n - _and 10 more activities_\n\n- **Full Featured Facet** (`full-featured`)\n A facet for testing all features\n - **Full Featured Facet Entities**: First test entity; Second test entity; Third test entity with description\n - **Full Featured Facet Activities**: Meetings; Coding; Custom Activity; Email; Messaging\n\n- **Minimal Facet** (`minimal-facet`)\n - **Minimal Facet Activities**:\n - Meetings\n - call\n - deadline\n - appointment\n - event\n - travel\n - reminder\n - errand\n - celebration\n - doctor appointment\n - Coding\n - Browsing\n - Email\n - Messaging\n - AI Conversation\n - _and 10 more activities_\n\n- **Montague Tech** (`montague`)\n Montague Tech startup operations\n - **Tester's Role**: CTO and co-founder of Montague Tech. Visionary full-stack engineer.\n - **Montague Tech Entities**: Mercutio Escalus; Benvolio Montague; Juliet Capulet; Verona Platform; Mesh Routing; Montague Tech; Prince Escalus; Verona Ventures; Rosaline Prince; Balcony App; Schema Bridge; Friar Lawrence; Balthasar Davi\n - **Montague Tech Activities**: Engineering; Meetings; Email; Messaging\n\n- **Priority Test** (`priority-test`)\n - **Priority Test Activities**:\n - Meetings\n - call\n - deadline\n - appointment\n - event\n - travel\n - reminder\n - errand\n - celebration\n - doctor appointment\n - Coding\n - Browsing\n - Email\n - Messaging\n - AI Conversation\n - _and 10 more activities_\n\n- **Test Facet** (`test-facet`)\n A test facet for validating functionality\n - **Test Facet Entities**: John Smith; Acme Corp; API Optimization; Bob Wilson; Dashboard Redesign; Docker; Jane Doe; PostgreSQL; Tech Solutions Inc; Visual Studio Code\n - **Test Facet Activities**:\n - Meetings\n - call\n - deadline\n - appointment\n - event\n - travel\n - reminder\n - errand\n - celebration\n - doctor appointment\n - Coding\n - Browsing\n - Email\n - Messaging\n - AI Conversation\n - _and 10 more activities_\n\n- **Verona** (`verona`)\n Cross-company Verona Platform collaboration\n - **Tester's Role**: Co-lead of the Verona Platform joint venture from Montague Tech.\n - **Verona Entities**: Friar Lawrence; Juliet Capulet; Balcony App; Verona Platform\n - **Verona Activities**: Engineering; Meetings; Design Review; Email; Messaging\n\n## Who You Are\n\nYou are sol, responding to Tester. The latest user message in the conversation below is what you must answer. Earlier messages are background context, not the current question.\n\nYou are this owner's local agent — not Google, OpenAI, Anthropic, or a generic chatbot. You have no tools in this step; you respond directly from the context provided.\n\n$active_talents\n\n$trigger_context\n\n## How To Respond\n\n- **Default to a direct answer.** Most replies are short and direct, drawn from identity and recent chat. No dispatch.\n- **Match the owner's tone:** direct and brief for simple replies; warm when they're sharing something difficult; analytical when they need synthesis; challenging only when a pattern is worth naming.\n- **Don't fabricate.** If answering needs a journal fact you don't have on hand, dispatch `read` to find it rather than inventing.\n- **Don't mention internal systems, hooks, or prompt assembly.**\n\n## When To Dispatch A Talent\n\nDispatching is the exception, not the rule. **First ask: can I answer this from\nwhat I already have?** If yes, just answer. Dispatch only when the answer needs\na capability you lack — and pick the one that matches the *verb* of the request:\n\n- `read` — **find or understand something in the journal.** A past\n conversation, a name, a quote, a file, a memory; or synthesis across time,\n relationships, or themes. This is the default dispatch — most lookups and all\n reflection go here. Preserve concrete hints (relative date/time, place, named\n people, quoted phrases) in the task. A brief \"let me check the journal\" bridge\n is fine; the owner's history is their own local journal — never claim it's\n inaccessible. Lookup answers preserve provenance: name the transcript, entry,\n or file evidence, or say it's thin — never synthesize a confident answer from\n a tool's error text.\n- `exec` — **do or change something.** Edit an entity, adjust an activity,\n set the journal name/owner. Dispatch only when the owner clearly wants an\n action taken, and pass the specific change in the task.\n- `support` — **sol pbc help.** A bug report, a help request, feedback, or\n checking a ticket. The support talent files and monitors with sol pbc under\n the owner's consent.\n\n**Do NOT dispatch for:** greetings, thanks, acknowledgements, brief follow-ups,\nquestions about your role/capabilities, or generic \"what's up\" queries that need\nno new work.\n\nWhen dispatching, set `talent_request.context` to a compact JSON-encoded string of hints (e.g., `\"{\\\"person\\\":\\\"Adrian\\\"}\"`), or `null` when there are no hints. Never emit a raw JSON object.\n\n## Stop-And-Report Contract\n\nWhen this turn is a `talent_finished` or `talent_errored` follow-up (the latest message will say `[internal follow-up: talent ... finished ...]`):\n\n- **Set `talent_request: null`.** Do not dispatch another talent.\n- **Synthesize the result for the owner.** Use the talent's summary/reason to write the actual owner-facing reply, preserving provenance when this was a lookup.\n- **The previous turn already wrote a \"let me check...\" bridge.** Now is the time to deliver the answer or report the failure.\n\n## JSON Output Contract\n\nReturn exactly one JSON object matching `chat.schema.json`:\n\n- `message`: The owner-facing reply, written naturally. Use `null` only when you genuinely have no safe or useful message to send.\n- `notes`: One concise internal sentence explaining your choice. No long reasoning dumps.\n- `talent_request`: `null` unless dispatching (rare). When dispatching, include `target` (`read`, `exec`, or `support`), `task` (the specific work), and `context` (compact JSON-encoded string of hints, or `null`).\n\nReturn JSON only.", + "full_prompt": "## Instructions\n\n## Available Facets\n\n- **Capulet Industries** (`capulet`)\n Capulet Industries enterprise division\n - **Capulet Industries Entities**: Tybalt Capulet; Juliet Capulet; Paris Duke; Nurse Angela; Capulet Industries\n - **Capulet Industries Activities**:\n - Meetings\n - call\n - deadline\n - appointment\n - event\n - travel\n - reminder\n - errand\n - celebration\n - doctor appointment\n - Coding\n - Browsing\n - Email\n - Messaging\n - AI Conversation\n - _and 10 more activities_\n\n- **Empty Entities Test** (`empty-entities`)\n - **Empty Entities Test Activities**:\n - Meetings\n - call\n - deadline\n - appointment\n - event\n - travel\n - reminder\n - errand\n - celebration\n - doctor appointment\n - Coding\n - Browsing\n - Email\n - Messaging\n - AI Conversation\n - _and 10 more activities_\n\n- **Full Featured Facet** (`full-featured`)\n A facet for testing all features\n - **Full Featured Facet Entities**: First test entity; Second test entity; Third test entity with description\n - **Full Featured Facet Activities**: Meetings; Coding; Custom Activity; Email; Messaging\n\n- **Minimal Facet** (`minimal-facet`)\n - **Minimal Facet Activities**:\n - Meetings\n - call\n - deadline\n - appointment\n - event\n - travel\n - reminder\n - errand\n - celebration\n - doctor appointment\n - Coding\n - Browsing\n - Email\n - Messaging\n - AI Conversation\n - _and 10 more activities_\n\n- **Montague Tech** (`montague`)\n Montague Tech startup operations\n - **Tester's Role**: CTO and co-founder of Montague Tech. Visionary full-stack engineer.\n - **Montague Tech Entities**: Mercutio Escalus; Benvolio Montague; Juliet Capulet; Verona Platform; Mesh Routing; Montague Tech; Prince Escalus; Verona Ventures; Rosaline Prince; Balcony App; Schema Bridge; Friar Lawrence; Balthasar Davi\n - **Montague Tech Activities**: Engineering; Meetings; Email; Messaging\n\n- **Priority Test** (`priority-test`)\n - **Priority Test Activities**:\n - Meetings\n - call\n - deadline\n - appointment\n - event\n - travel\n - reminder\n - errand\n - celebration\n - doctor appointment\n - Coding\n - Browsing\n - Email\n - Messaging\n - AI Conversation\n - _and 10 more activities_\n\n- **Test Facet** (`test-facet`)\n A test facet for validating functionality\n - **Test Facet Entities**: John Smith; Acme Corp; API Optimization; Bob Wilson; Dashboard Redesign; Docker; Jane Doe; PostgreSQL; Tech Solutions Inc; Visual Studio Code\n - **Test Facet Activities**:\n - Meetings\n - call\n - deadline\n - appointment\n - event\n - travel\n - reminder\n - errand\n - celebration\n - doctor appointment\n - Coding\n - Browsing\n - Email\n - Messaging\n - AI Conversation\n - _and 10 more activities_\n\n- **Verona** (`verona`)\n Cross-company Verona Platform collaboration\n - **Tester's Role**: Co-lead of the Verona Platform joint venture from Montague Tech.\n - **Verona Entities**: Friar Lawrence; Juliet Capulet; Balcony App; Verona Platform\n - **Verona Activities**: Engineering; Meetings; Design Review; Email; Messaging\n\n## Who You Are\n\nYou are sol, responding to Tester. The latest user message in the conversation below is what you must answer. Earlier messages are background context, not the current question.\n\nYou are this owner's local agent — not Google, OpenAI, Anthropic, or a generic chatbot. You have no tools in this step; you respond directly from the context provided.\n\n$active_talents\n\n$situational\n\n$trigger_context\n\n## How To Respond\n\n- **Default to a direct answer.** Most replies are short and direct, drawn from identity and recent chat. No dispatch.\n- **Match the owner's tone:** direct and brief for simple replies; warm when they're sharing something difficult; analytical when they need synthesis; challenging only when a pattern is worth naming.\n- **Don't fabricate.** If answering needs a journal fact you don't have on hand, dispatch `read` to find it rather than inventing.\n- **Don't mention internal systems, hooks, or prompt assembly.**\n\n## When To Dispatch A Talent\n\nDispatching is the exception, not the rule. **First ask: can I answer this from\nwhat I already have?** If yes, just answer. Dispatch only when the answer needs\na capability you lack — and pick the one that matches the *verb* of the request:\n\n- `read` — **find or understand something in the journal.** A past\n conversation, a name, a quote, a file, a memory; or synthesis across time,\n relationships, or themes. This is the default dispatch — most lookups and all\n reflection go here. Preserve concrete hints (relative date/time, place, named\n people, quoted phrases) in the task. A brief \"let me check the journal\" bridge\n is fine; the owner's history is their own local journal — never claim it's\n inaccessible. Lookup answers preserve provenance: name the transcript, entry,\n or file evidence, or say it's thin — never synthesize a confident answer from\n a tool's error text.\n- `exec` — **do or change something.** Edit an entity, adjust an activity,\n set the journal name/owner. Dispatch only when the owner clearly wants an\n action taken, and pass the specific change in the task.\n- `support` — **sol pbc help.** A bug report, a help request, feedback, or\n checking a ticket. The support talent files and monitors with sol pbc under\n the owner's consent.\n\n**Do NOT dispatch for:** greetings, thanks, acknowledgements, brief follow-ups,\nquestions about your role/capabilities, or generic \"what's up\" queries that need\nno new work.\n\nWhen dispatching, set `talent_request.context` to a compact JSON-encoded string of hints (e.g., `\"{\\\"person\\\":\\\"Adrian\\\"}\"`), or `null` when there are no hints. Never emit a raw JSON object.\n\n## Stop-And-Report Contract\n\nWhen this turn is a `talent_finished` or `talent_errored` follow-up (the latest message will say `[internal follow-up: talent ... finished ...]`):\n\n- **Set `talent_request: null`.** Do not dispatch another talent.\n- **Synthesize the result for the owner.** Use the talent's summary/reason to write the actual owner-facing reply, preserving provenance when this was a lookup.\n- **The previous turn already wrote a \"let me check...\" bridge.** Now is the time to deliver the answer or report the failure.\n\n## JSON Output Contract\n\nReturn exactly one JSON object matching `chat.schema.json`:\n\n- `message`: The owner-facing reply, written naturally. Use `null` only when you genuinely have no safe or useful message to send.\n- `notes`: One concise internal sentence explaining your choice. No long reasoning dumps.\n- `talent_request`: `null` unless dispatching (rare). When dispatching, include `target` (`read`, `exec`, or `support`), `task` (the specific work), and `context` (compact JSON-encoded string of hints, or `null`).\n\nReturn JSON only.", "multi_facet": false, "name": "chat", "title": "Chat" diff --git a/tests/baselines/api/sol/talents-day.json b/tests/baselines/api/sol/talents-day.json index 1bb1259fe..67f1111ee 100644 --- a/tests/baselines/api/sol/talents-day.json +++ b/tests/baselines/api/sol/talents-day.json @@ -49,17 +49,6 @@ "title": "Activities Review", "type": "cogitate" }, - "awareness_tender": { - "app": null, - "color": "#6c757d", - "description": "Maintains identity/awareness.md — a compact situational awareness snapshot", - "multi_facet": false, - "output_format": null, - "schedule": "segment", - "source": "system", - "title": "Awareness Tender", - "type": "cogitate" - }, "chat": { "app": null, "color": "#6c757d", @@ -250,13 +239,13 @@ "pulse": { "app": null, "color": "#6c757d", - "description": "Living narrative of the owner's day — updated each segment", + "description": "Living situational read of the owner's day — the shape of today, what needs them, and a one-line glance.", "multi_facet": false, - "output_format": null, - "schedule": "segment", + "output_format": "json", + "schedule": "cadence", "source": "system", "title": "Pulse", - "type": "cogitate" + "type": "generate" }, "read": { "app": null, diff --git a/tests/baselines/api/stats/stats.json b/tests/baselines/api/stats/stats.json index a38b17cb2..6405d298e 100644 --- a/tests/baselines/api/stats/stats.json +++ b/tests/baselines/api/stats/stats.json @@ -230,6 +230,33 @@ "title": "Participation", "type": "generate" }, + "pulse": { + "accumulate": true, + "cadence_minutes": 5, + "color": "#6c757d", + "description": "Living situational read of the owner's day — the shape of today, what needs them, and a one-line glance.", + "hook": { + "post": "pulse", + "pre": "pulse" + }, + "load": { + "percepts": false, + "talents": false, + "transcripts": false + }, + "max_output_tokens": 700, + "mtime": 0, + "output": "json", + "path": "/solstone/talent/pulse.md", + "priority": 50, + "schedule": "cadence", + "schema": "pulse.schema.json", + "source": "system", + "thinking_budget": 1024, + "tier": 3, + "title": "Pulse", + "type": "generate" + }, "schedule": { "color": "#5e35b1", "description": "Extracts future scheduled items from screen and transcript content into anticipated activity records. Captures dates, times, participants, and cancellation state.", diff --git a/tests/fixtures/journal/identity/awareness.md b/tests/fixtures/journal/identity/awareness.md deleted file mode 100644 index 4fbb3dac0..000000000 --- a/tests/fixtures/journal/identity/awareness.md +++ /dev/null @@ -1 +0,0 @@ -not yet updated diff --git a/tests/test_awareness.py b/tests/test_awareness.py index 09a9ffc91..78e6aa2b3 100644 --- a/tests/test_awareness.py +++ b/tests/test_awareness.py @@ -568,10 +568,6 @@ class TestEnsureIdentityDirectory: assert not (identity_dir / ("self" + ".md")).exists() assert not (identity_dir / "agency.md").exists() - assert (identity_dir / "awareness.md").exists() - awareness_content = (identity_dir / "awareness.md").read_text() - assert awareness_content.strip() == "not yet updated" - assert not (identity_dir / "digest.md").exists() assert (identity_dir / "health.md").exists() @@ -579,13 +575,12 @@ class TestEnsureIdentityDirectory: from solstone.think.identity import ensure_identity_directory identity_dir = ensure_identity_directory() - # Modify awareness.md - awareness_path = identity_dir / "awareness.md" - awareness_path.write_text("custom content", encoding="utf-8") + partner_path = identity_dir / "partner.md" + partner_path.write_text("custom content", encoding="utf-8") # Call again — should NOT overwrite ensure_identity_directory() - assert awareness_path.read_text() == "custom content" + assert partner_path.read_text() == "custom content" def test_creates_partner_md(self, tmp_path): from solstone.think.identity import ensure_identity_directory diff --git a/tests/test_chat_context.py b/tests/test_chat_context.py index 12bb776a3..d7a7f95ce 100644 --- a/tests/test_chat_context.py +++ b/tests/test_chat_context.py @@ -10,6 +10,7 @@ from pathlib import Path from solstone.convey.chat_stream import append_chat_event from solstone.convey.sol_initiated.copy import KIND_SOL_CHAT_REQUEST +from solstone.think.day_accumulator import append_record TEMPLATE_VAR_KEYS = { "active_talents", @@ -21,6 +22,7 @@ TEMPLATE_VAR_KEYS = { "since_ts", "trigger_talent", "location", + "situational", } @@ -160,6 +162,51 @@ def test_chat_prompt_has_no_digest_slot(): assert "digest" not in prompt.lower() +def test_render_situational_uses_pulse_record_with_cold_open_lookback( + monkeypatch, tmp_path +): + journal = tmp_path / "journal" + monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) + module = _load_chat_context_module() + + assert module._render_situational("20260420") == "" + + append_record( + "20260419", + "pulse", + { + "title": "Yesterday's read", + "one_sentence": "Yesterday still has the freshest context.", + "full_details": "The prior day ended with a useful shape.", + "needs_you": ["Review the carryover note."], + "ts": 100, + }, + ) + + cold_open = module._render_situational("20260420") + assert "Yesterday's read" in cold_open + assert "Yesterday still has the freshest context." in cold_open + assert "- Review the carryover note." in cold_open + + append_record( + "20260420", + "pulse", + { + "title": "Today's read", + "one_sentence": "Today now has a current pulse.", + "full_details": "The current day has taken over the situational read.", + "needs_you": ["Prep the current meeting."], + "ts": 200, + }, + ) + + current = module._render_situational("20260420") + assert "Today's read" in current + assert "Today now has a current pulse." in current + assert "- Prep the current meeting." in current + assert "Yesterday's read" not in current + + def test_chat_context_owner_message_anchors_empty_tail(monkeypatch, tmp_path): journal = tmp_path / "journal" monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) diff --git a/tests/test_check_cogitate_prompts.py b/tests/test_check_cogitate_prompts.py index 2daead9f7..1ac558c65 100644 --- a/tests/test_check_cogitate_prompts.py +++ b/tests/test_check_cogitate_prompts.py @@ -90,8 +90,6 @@ def test_bare_journal_flags_fenced_commands() -> None: @pytest.mark.parametrize( "body", [ - "`journal identity pulse --write --value '>'`", - "`journal identity pulse --write --value '|'`", "`journal health`", "`journal talent logs --daily`", "`sol doctor`", @@ -127,27 +125,6 @@ def test_lint_prompt_flags_shell_composition(body: str) -> None: ] -def test_lint_prompt_allows_multiline_pulse_value_example() -> None: - body = """```bash -journal identity pulse --write --value "--- -updated: 2026-03-22T14:35:00 -segment: 143022_300 -source: pulse-cogitate ---- - -[Your narrative here] - -## needs you -- Item 1 -- Item 2" -``` -""" - - findings = ccp.lint_prompt(body) - - assert "shell-composition" not in [kind for _line, kind, _detail in findings] - - def test_lint_prompt_allows_multiline_partner_value_example() -> None: body = """```bash journal identity partner --update-section 'work patterns' --value 'My partner tends to batch meetings before noon and protects afternoon blocks for focused work. Calendar data from March 25-31 shows 85% of meetings before 12:00 (sol://20260328/archon/091500_300). @@ -193,14 +170,14 @@ def test_extract_command_spans_scans_fences_per_line_without_inline_double_scan( "echo `journal health`\n" "\n" "```\n" - "After `journal identity pulse`.\n" + "After `journal identity partner`.\n" ) assert ccp.extract_command_spans(body) == [ (1, "journal navigate"), (3, "journal supervisor"), (4, "echo `journal health`"), - (7, "journal identity pulse"), + (7, "journal identity partner"), ] findings = ccp.lint_prompt(body) diff --git a/tests/test_cogitate_policy.py b/tests/test_cogitate_policy.py index 6fef4d22d..f6050ff60 100644 --- a/tests/test_cogitate_policy.py +++ b/tests/test_cogitate_policy.py @@ -52,13 +52,8 @@ def test_policy_denies_write_tools(tmp_path): @pytest.mark.parametrize( "command", [ - "journal identity pulse", - "journal identity awareness --write --value update", "journal health logs --since 1h", "journal talent logs --daily -c 10", - "journal identity pulse --write --value 'a; quoted value'", - "journal identity pulse --write --value '>'", - "journal identity pulse --write --value '|'", ], ) def test_policy_allows_approved_journal_invocations(tmp_path, command): @@ -78,7 +73,6 @@ def test_policy_allows_approved_journal_invocations(tmp_path, command): "journal supervisor status", "journal indexer --rescan-full", "journal identity ; rm -rf journal", - "journal identity pulse --value $(rm -rf journal)", ], ) def test_policy_denies_unapproved_journal_invocations(tmp_path, command): @@ -94,9 +88,6 @@ def test_policy_denies_unapproved_journal_invocations(tmp_path, command): "command", [ "journal identity ; rm -rf journal", - "journal identity pulse --value $(rm -rf journal)", - "journal identity pulse --value '$(rm -rf journal)'", - "journal identity pulse --value '`rm -rf journal`'", "sol call journal search x > out", "sol call journal search x 2>&1", "sol call journal search x <(journal health)", diff --git a/tests/test_day_accumulator.py b/tests/test_day_accumulator.py index b4e39e736..54816b146 100644 --- a/tests/test_day_accumulator.py +++ b/tests/test_day_accumulator.py @@ -117,7 +117,19 @@ def test_missing_day_reads_do_not_create_directories_or_files(journal): def test_append_record_indexes_under_accumulator_name(journal): day = "20260611" - append_record(day, "pulse", {"summary": "ran a deep focus block", "ts": 100}) + append_record( + day, + "pulse", + { + "title": "Focus block", + "one_sentence": "The owner ran a deep focus block.", + "full_details": "The morning centered on a deep focus block.", + "needs_you": [], + "model": "test-model", + "generated_at": "2026-06-11T12:00:00Z", + "ts": 100, + }, + ) from solstone.think.indexer.journal import search_journal diff --git a/tests/test_generators.py b/tests/test_generators.py index ad506e47e..6525cca2d 100644 --- a/tests/test_generators.py +++ b/tests/test_generators.py @@ -106,13 +106,14 @@ def test_scheduled_generators_have_valid_schedule(): """Test that scheduled generators have valid schedule field. Generators with a schedule field must have valid values - ('segment', 'daily', or 'activity'). Some generators (like importer) have - output but no schedule - they're used for ad-hoc processing, not scheduled runs. + ('segment', 'daily', 'weekly', 'cadence', or 'activity'). Some generators + (like importer) have output but no schedule - they're used for ad-hoc + processing, not scheduled runs. """ talent = importlib.import_module("solstone.think.talent") generators = talent.get_talent_configs(type="generate") - valid_schedules = ("segment", "daily", "activity", "weekly") + valid_schedules = ("segment", "daily", "activity", "weekly", "cadence") for key, meta in generators.items(): sched = meta.get("schedule") diff --git a/tests/test_home_routes.py b/tests/test_home_routes.py index e8273adb9..63e3e7feb 100644 --- a/tests/test_home_routes.py +++ b/tests/test_home_routes.py @@ -6,6 +6,7 @@ from __future__ import annotations from datetime import datetime from solstone.convey import create_app +from solstone.think.day_accumulator import append_record def test_api_pulse_includes_needs_you_items_json_shape(journal_copy, monkeypatch): @@ -43,3 +44,59 @@ def test_api_pulse_includes_needs_you_items_json_shape(journal_copy, monkeypatch "reason", "text", ] + + +def test_load_pulse_narrative_reads_today_record_strictly(monkeypatch, tmp_path): + import solstone.apps.home.routes as home_routes + + journal = tmp_path / "journal" + monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) + today = "20260524" + yesterday = "20260523" + + assert home_routes._load_pulse_narrative(today) == (None, None, []) + + append_record( + yesterday, + "pulse", + { + "title": "Yesterday", + "one_sentence": "Yesterday had context.", + "full_details": "This should not show for today's strict gate.", + "needs_you": ["Yesterday-only item."], + "ts": int(datetime(2026, 5, 23, 10, 0).timestamp() * 1000), + }, + ) + assert home_routes._load_pulse_narrative(today) == (None, None, []) + + append_record( + today, + "pulse", + { + "title": "Blank", + "one_sentence": "Blank details should be ignored.", + "full_details": " ", + "needs_you": ["Ignored item."], + "ts": int(datetime(2026, 5, 24, 9, 0).timestamp() * 1000), + }, + ) + assert home_routes._load_pulse_narrative(today) == (None, None, []) + + ts = int(datetime(2026, 5, 24, 12, 34).timestamp() * 1000) + append_record( + today, + "pulse", + { + "title": "Current", + "one_sentence": "Today has a pulse.", + "full_details": "The current pulse narrative.", + "needs_you": ["Review the launch checklist.", 42, ""], + "ts": ts, + }, + ) + + assert home_routes._load_pulse_narrative(today) == ( + "The current pulse narrative.", + datetime.fromtimestamp(ts / 1000).strftime("%H:%M"), + ["Review the launch checklist.", "42"], + ) diff --git a/tests/test_home_yesterdays_processing.py b/tests/test_home_yesterdays_processing.py index c5ccbf473..316c9c28e 100644 --- a/tests/test_home_yesterdays_processing.py +++ b/tests/test_home_yesterdays_processing.py @@ -181,7 +181,8 @@ def _patch_minimal_pulse_context(monkeypatch, pipeline_status): "solstone.apps.home.routes._load_flow_md", lambda today: (None, None) ) monkeypatch.setattr( - "solstone.apps.home.routes._load_pulse_md", lambda: (None, None, []) + "solstone.apps.home.routes._load_pulse_narrative", + lambda today: (None, None, []), ) monkeypatch.setattr( "solstone.apps.home.routes._load_briefing_md", lambda today: ({}, None, []) @@ -651,7 +652,8 @@ def test_build_pulse_context_includes_yesterday_processing(monkeypatch): "solstone.apps.home.routes._load_flow_md", lambda today: (None, None) ) monkeypatch.setattr( - "solstone.apps.home.routes._load_pulse_md", lambda: (None, None, []) + "solstone.apps.home.routes._load_pulse_narrative", + lambda today: (None, None, []), ) monkeypatch.setattr( "solstone.apps.home.routes._load_briefing_md", lambda today: ({}, None, []) diff --git a/tests/test_identity_writes.py b/tests/test_identity_writes.py index 2d4795dae..6cfa535f0 100644 --- a/tests/test_identity_writes.py +++ b/tests/test_identity_writes.py @@ -46,24 +46,24 @@ def _read_history(journal_path: Path) -> list[dict]: def test_write_identity_first_write(tmp_path): write_identity( - "pulse.md", + "partner.md", actor="test writer", op="replace", section=None, - content="first pulse\n", + content="first partner\n", reason="test", ) - pulse_path = tmp_path / "identity" / "pulse.md" - assert pulse_path.read_text(encoding="utf-8") == "first pulse\n" + partner_path = tmp_path / "identity" / "partner.md" + assert partner_path.read_text(encoding="utf-8") == "first partner\n" records = _read_history(tmp_path) assert len(records) == 1 record = records[0] assert record["before_hash"] == hashlib.sha256(b"").hexdigest() assert record["bytes_before"] == 0 - assert record["after_hash"] == hashlib.sha256(b"first pulse\n").hexdigest() - assert record["bytes_after"] == len("first pulse\n".encode("utf-8")) + assert record["after_hash"] == hashlib.sha256(b"first partner\n").hexdigest() + assert record["bytes_after"] == len("first partner\n".encode("utf-8")) def test_write_identity_atomic_failure(tmp_path, monkeypatch): @@ -169,11 +169,11 @@ def test_write_identity_lock_serializes(tmp_path): def test_write_identity_history_schema(tmp_path): write_identity( - "awareness.md", + "partner.md", actor="schema test", op="replace", section=None, - content="awareness\n", + content="partner\n", reason="test", ) @@ -190,7 +190,7 @@ def test_write_identity_history_schema(tmp_path): "bytes_before", "bytes_after", ] - assert record["file"] == "awareness.md" + assert record["file"] == "partner.md" assert record["actor"] == "schema test" assert record["op"] == "replace" assert record["section"] is None diff --git a/tests/test_morning_briefing_pre_hook.py b/tests/test_morning_briefing_pre_hook.py index c72e93b21..144a2957b 100644 --- a/tests/test_morning_briefing_pre_hook.py +++ b/tests/test_morning_briefing_pre_hook.py @@ -4,6 +4,7 @@ import json from solstone.talent import morning_briefing +from solstone.think.day_accumulator import append_record def _result(day: str = "20260422") -> dict: @@ -24,9 +25,18 @@ def _result(day: str = "20260422") -> dict: def test_morning_briefing_pre_hook_builds_source_packet(tmp_path, monkeypatch): journal = tmp_path / "journal" + monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) identity = journal / "identity" identity.mkdir(parents=True) - (identity / "pulse.md").write_text("Pulse needs focus time.", encoding="utf-8") + append_record( + "20260422", + "pulse", + { + "full_details": "Pulse needs focus time.", + "needs_you": ["Review the launch checklist."], + "ts": 1, + }, + ) (identity / "partner.md").write_text("Partner profile.", encoding="utf-8") (identity / "health.md").write_text( "## Needs your attention\n\nnone", encoding="utf-8" @@ -105,6 +115,8 @@ def test_morning_briefing_pre_hook_builds_source_packet(tmp_path, monkeypatch): assert "Planning meeting" in packet["anticipated_today"] assert "Proposal deadline" in packet["anticipated_forward"] assert "Work shipped a release." in packet["facet_newsletters"] + assert "Pulse needs focus time." in packet["pulse_surface"] + assert "- Review the launch checklist." in packet["pulse_surface"] assert " anticipated_activities: 1" in packet["source_counts"] assert json.loads(packet["source_gaps"]) == [] @@ -114,6 +126,7 @@ def test_morning_briefing_pre_hook_missing_sources_are_visible_gaps( ): journal = tmp_path / "journal" journal.mkdir() + monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) monkeypatch.setattr(morning_briefing, "get_journal", lambda: str(journal)) monkeypatch.setattr( diff --git a/tests/test_pipeline_smoke.py b/tests/test_pipeline_smoke.py index a7a0fe946..6099ecc92 100644 --- a/tests/test_pipeline_smoke.py +++ b/tests/test_pipeline_smoke.py @@ -25,7 +25,6 @@ SEGMENTS = [ "recommend": { "screen_record": False, "speaker_attribution": False, - "pulse_update": False, }, "entities": [ { @@ -51,7 +50,6 @@ SEGMENTS = [ "recommend": { "screen_record": False, "speaker_attribution": False, - "pulse_update": False, }, "entities": [ { @@ -77,7 +75,6 @@ SEGMENTS = [ "recommend": { "screen_record": False, "speaker_attribution": False, - "pulse_update": False, }, "entities": [ { @@ -103,7 +100,6 @@ SEGMENTS = [ "recommend": { "screen_record": False, "speaker_attribution": False, - "pulse_update": False, }, "entities": [ { @@ -129,7 +125,6 @@ SEGMENTS = [ "recommend": { "screen_record": False, "speaker_attribution": False, - "pulse_update": False, }, "entities": [], "emotional_register": "neutral", @@ -147,7 +142,6 @@ SEGMENTS = [ "recommend": { "screen_record": False, "speaker_attribution": False, - "pulse_update": False, }, "entities": [ { diff --git a/tests/test_pulse.py b/tests/test_pulse.py new file mode 100644 index 000000000..bb4739f9e --- /dev/null +++ b/tests/test_pulse.py @@ -0,0 +1,226 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import json + +import pytest + +from solstone.talent import pulse +from solstone.think.day_accumulator import read_latest + + +@pytest.fixture +def journal(tmp_path, monkeypatch): + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + return tmp_path + + +def _pulse_payload(**overrides): + payload = { + "title": "Focused morning", + "one_sentence": "The morning is centered on a launch review.", + "full_details": "The owner has a coherent launch-review block in motion.", + "needs_you": ["Review the launch checklist."], + } + payload.update(overrides) + return payload + + +def test_post_process_persists_valid_pulse_record(journal): + config = { + "day": "20260611", + "model": "test-model", + "_pulse_window_note": { + "segments": 1, + "activities": 0, + "input_segments": 1, + "input_activities": 0, + "since_ms": 123, + "gaps": [], + }, + } + + returned = pulse.post_process(json.dumps(_pulse_payload()), config) + + summary = json.loads(returned) + assert summary == _pulse_payload() + record = read_latest("20260611", "pulse", lookback_days=0) + assert record is not None + for key, value in _pulse_payload().items(): + assert record[key] == value + assert record["model"] == "test-model" + assert record["generated_at"].endswith("Z") + assert isinstance(record["ts"], int) + assert record["window"] == config["_pulse_window_note"] + + +@pytest.mark.parametrize("result", ["not json", json.dumps({"title": "Incomplete"})]) +def test_post_process_falls_back_for_malformed_model_output(journal, result): + default = _pulse_payload( + title="Fallback", + one_sentence="Fallback sentence.", + full_details="Fallback details.", + needs_you=["Use the deterministic fallback."], + ) + config = {"day": "20260611", "model": "test-model", "_pulse_default": default} + + returned = pulse.post_process(result, config) + + summary = json.loads(returned) + assert summary == default + record = read_latest("20260611", "pulse", lookback_days=0) + assert record is not None + for key, value in default.items(): + assert record[key] == value + assert record["model"] == "test-model" + assert "generated_at" in record + assert "ts" in record + assert "window" in record + + +def test_normalize_pulse_coerces_and_clamps_fields(): + default = _pulse_payload( + title="Fallback", + one_sentence="Fallback sentence.", + full_details="Fallback details.", + needs_you=["Fallback need."], + ) + raw = { + "title": "T" * 100, + "one_sentence": "S" * 260, + "full_details": "D" * 1900, + "needs_you": [ + "one", + 42, + None, + "", + "x" * 300, + "five", + "six", + "seven", + "eight", + "nine", + ], + "ignored": "dropped", + } + + summary = pulse._normalize_pulse(raw, default) + + assert set(summary) == {"title", "one_sentence", "full_details", "needs_you"} + assert summary["title"] == "T" * pulse._TITLE_MAX + assert summary["one_sentence"] == "S" * pulse._SENTENCE_MAX + assert summary["full_details"] == "D" * pulse._DETAILS_MAX + assert summary["needs_you"] == [ + "one", + "42", + "x" * pulse._NEED_MAX, + "five", + "six", + "seven", + "eight", + ] + + assert ( + pulse._normalize_pulse( + { + "title": "", + "one_sentence": " ", + "full_details": "", + "needs_you": ["ignored"], + }, + default, + ) + == default + ) + + +def test_pre_process_includes_segment_timeline_and_missing_gap(journal, monkeypatch): + day = "20260611" + segment = "101500_300" + seg_dir = journal / "chronicle" / day / "desktop" / segment + seg_dir.mkdir(parents=True) + (seg_dir / "timeline.json").write_text( + json.dumps( + { + "title": "Launch review", + "description": "The segment focused on launch readiness.", + } + ), + encoding="utf-8", + ) + (journal / "identity").mkdir() + (journal / "identity" / "partner.md").write_text( + "Partner context", encoding="utf-8" + ) + + monkeypatch.setattr(pulse, "get_current", lambda: {"attention": "clear"}) + monkeypatch.setattr(pulse, "get_imports", lambda: {"pending": []}) + monkeypatch.setattr(pulse, "get_facets", lambda: []) + monkeypatch.setattr(pulse, "load_recent_entity_names", lambda limit=12: ["Alice"]) + + result = pulse.pre_process( + { + "day": day, + "cadence_window": { + "since_ms": 1000, + "segments": [ + {"stream": "desktop", "segment": segment, "ts": 2000}, + {"stream": None, "segment": "missing_300", "ts": 1000}, + ], + "activities": [], + }, + } + ) + + assert result is not None + template_vars = result["template_vars"] + assert "Launch review" in template_vars["completed_since"] + assert ( + "The segment focused on launch readiness." in template_vars["completed_since"] + ) + assert "no timeline.json found for segment missing_300" in template_vars["gaps"] + assert "Partner context" == template_vars["partner_profile"] + assert pulse._compact_json(["Alice"]) == template_vars["recent_entities"] + + +def test_pre_process_total_failure_returns_skip_reason(monkeypatch): + monkeypatch.setattr( + pulse, + "_completed_since", + lambda day, config, gaps: (_ for _ in ()).throw(RuntimeError("boom")), + ) + + result = pulse.pre_process({"day": "20260611"}) + + assert result is not None + assert result["skip_reason"] == "pulse pre-hook failed: boom" + + +def test_accumulate_suppresses_single_file_output_path(journal): + from solstone.think.talent import get_talent, get_talent_configs + from solstone.think.talents import prepare_config + from solstone.think.thinking import _apply_output_persistence + + raw_config = get_talent_configs()["pulse"] + pulse_config = get_talent("pulse") + assert raw_config["output"] == "json" + assert raw_config["schema"] == "pulse.schema.json" + assert pulse_config["output"] == "json" + assert pulse_config["json_schema"]["required"] == [ + "title", + "one_sentence", + "full_details", + "needs_you", + ] + assert pulse_config["accumulate"] is True + + request_config = {} + _apply_output_persistence(request_config, pulse_config, force_refresh=False) + + assert "output" not in request_config + assert "refresh" not in request_config + + prepared = prepare_config({"name": "pulse", "day": "20260611"}) + assert "output_path" not in prepared diff --git a/tests/test_sense_contamination_guard.py b/tests/test_sense_contamination_guard.py index 2ce9cb940..8d201ae32 100644 --- a/tests/test_sense_contamination_guard.py +++ b/tests/test_sense_contamination_guard.py @@ -78,7 +78,6 @@ def _sense_payload(*, meeting_detected: bool) -> dict: "recommend": { "screen_record": False, "speaker_attribution": False, - "pulse_update": False, }, "emotional_register": "neutral", } diff --git a/tests/test_sense_schema.py b/tests/test_sense_schema.py index 4735e854f..05102f57e 100644 --- a/tests/test_sense_schema.py +++ b/tests/test_sense_schema.py @@ -137,7 +137,6 @@ def test_sense_schema_speculative_facet_nullable_and_required(): "recommend": { "screen_record": False, "speaker_attribution": False, - "pulse_update": False, }, "emotional_register": "focused", } diff --git a/tests/test_sense_splitter.py b/tests/test_sense_splitter.py index b52ff29d3..c9800dcd8 100644 --- a/tests/test_sense_splitter.py +++ b/tests/test_sense_splitter.py @@ -30,7 +30,6 @@ def _make_sense_output(**overrides): "recommend": { "screen_record": False, "speaker_attribution": False, - "pulse_update": False, }, "emotional_register": "neutral", } diff --git a/tests/test_sol_call.py b/tests/test_sol_call.py index f0efc762f..7a46fc0a4 100644 --- a/tests/test_sol_call.py +++ b/tests/test_sol_call.py @@ -82,7 +82,6 @@ adapt its responses, timing, and initiative to how this person actually works. [observing] """ (identity_dir / "partner.md").write_text(partner_md) - (identity_dir / "awareness.md").write_text("not yet updated\n") (identity_dir / "health.md").write_text( "## Status\n\n" "not yet generated\n\n" @@ -163,55 +162,9 @@ class TestSolPartnerUpdateSection: assert "no content" in result.output -class TestSolPulseRead: - def test_read_pulse(self, journal_with_identity): - pulse_md = "---\nupdated: 2026-03-22T14:00:00\nsource: pulse-cogitate\n---\n\nTest narrative.\n" - (journal_with_identity / "identity" / "pulse.md").write_text(pulse_md) - result = runner.invoke(app, ["pulse"]) - assert result.exit_code == 0 - assert "Test narrative" in result.output - - def test_read_pulse_missing(self, tmp_path, monkeypatch): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - config_dir = tmp_path / "config" - config_dir.mkdir() - (config_dir / "journal.json").write_text(json.dumps({})) - result = runner.invoke(app, ["pulse"]) - assert result.exit_code == 1 - assert "not found" in result.output - - -class TestSolPulseWrite: - def test_write_pulse(self, journal_with_identity): - new_content = "---\nupdated: 2026-03-22T14:00:00\nsource: pulse-cogitate\n---\n\nNew narrative.\n" - result = runner.invoke(app, ["pulse", "--write"], input=new_content) - assert result.exit_code == 0 - assert "pulse.md updated" in result.output - - # Verify file was written - pulse_path = journal_with_identity / "identity" / "pulse.md" - assert pulse_path.read_text() == new_content - - def test_write_pulse_empty_stdin(self, journal_with_identity): - result = runner.invoke(app, ["pulse", "--write"], input="") - assert result.exit_code == 1 - assert "no content" in result.output - - class TestSolWriteDoesNotEscapeIdentityDir: """Verify that journal identity only writes to identity/ files.""" - def test_pulse_write_stays_in_identity_dir(self, journal_with_identity): - """Write to pulse.md goes to identity/pulse.md, not anywhere else.""" - result = runner.invoke(app, ["pulse", "--write"], input="test content\n") - assert result.exit_code == 0 - pulse_path = journal_with_identity / "identity" / "pulse.md" - assert pulse_path.read_text() == "test content\n" - journal_files = set( - f.name for f in journal_with_identity.iterdir() if f.is_file() - ) - assert "pulse.md" not in journal_files - def test_partner_write_stays_in_identity_dir(self, journal_with_identity): """Write to partner.md goes to identity/partner.md, not anywhere else.""" result = runner.invoke(app, ["partner", "--write"], input="test content\n") @@ -224,21 +177,6 @@ class TestSolWriteDoesNotEscapeIdentityDir: assert "partner.md" not in journal_files -class TestSolPulseValueOption: - def test_write_pulse_with_value(self, journal_with_identity): - new_content = "---\nupdated: 2026-03-22\n---\n\nNarrative.\n" - result = runner.invoke(app, ["pulse", "--write", "--value", new_content]) - assert result.exit_code == 0 - assert "pulse.md updated" in result.output - pulse_path = journal_with_identity / "identity" / "pulse.md" - assert pulse_path.read_text() == new_content - - def test_value_empty_string_errors(self, journal_with_identity): - result = runner.invoke(app, ["pulse", "--write", "--value", ""]) - assert result.exit_code == 1 - assert "no content" in result.output - - class TestSolPartnerValueOption: def test_write_partner_with_value(self, journal_with_identity): new_content = "# partner\n\n## work patterns\nMorning person.\n" @@ -271,14 +209,14 @@ class TestSolPartnerValueOption: class TestSolHistoryLogging: - def test_pulse_write_logs_history(self, journal_with_identity): - runner.invoke(app, ["pulse", "--write", "--value", "---\n---\n\nPulse.\n"]) + def test_partner_write_logs_history(self, journal_with_identity): + runner.invoke(app, ["partner", "--write", "--value", "# partner\n\nProfile.\n"]) records = _read_history(journal_with_identity) assert len(records) == 1 _assert_history_record( records[0], - file_name="pulse.md", - actor="journal identity pulse --write", + file_name="partner.md", + actor="journal identity partner --write", op="replace", section=None, reason="manual replace", @@ -290,19 +228,6 @@ class TestSolHistoryLogging: records = _read_history(journal_with_identity) assert len(records) == 2 - def test_partner_write_logs_history(self, journal_with_identity): - runner.invoke(app, ["partner", "--write", "--value", "# partner\n\nNew.\n"]) - records = _read_history(journal_with_identity) - assert len(records) == 1 - _assert_history_record( - records[0], - file_name="partner.md", - actor="journal identity partner --write", - op="replace", - section=None, - reason="manual replace", - ) - def test_partner_update_section_logs_history(self, journal_with_identity): runner.invoke( app, diff --git a/tests/test_sol_call_identity_hydrate.py b/tests/test_sol_call_identity_hydrate.py index 1b35a6eca..621830f9b 100644 --- a/tests/test_sol_call_identity_hydrate.py +++ b/tests/test_sol_call_identity_hydrate.py @@ -17,19 +17,17 @@ def journal_path(tmp_path, monkeypatch): return tmp_path -def test_identity_hydrate_reads_all_sections(journal_path): +def test_identity_hydrate_reads_partner_section(journal_path): identity_dir = journal_path / "identity" identity_dir.mkdir() (identity_dir / "partner.md").write_text("partner body") - (identity_dir / "awareness.md").write_text("awareness body") output = _hydrate() - expected = ["# partner", "# awareness"] + expected = ["# species", "# partner"] positions = [output.index(marker) for marker in expected] assert positions == sorted(positions) assert "partner body" in output - assert "awareness body" in output def test_identity_hydrate_marks_missing_sections(journal_path): @@ -40,27 +38,24 @@ def test_identity_hydrate_marks_missing_sections(journal_path): output = _hydrate() assert "# partner\n\npartner body\n" in output - assert "# awareness\n\n(not present)\n" in output def test_identity_hydrate_handles_empty_identity_directory(journal_path): output = _hydrate() - for stem in ("partner", "awareness"): - assert f"# {stem}\n\n(not present)\n" in output + assert "# partner\n\n(not present)\n" in output def test_identity_hydrate_starts_with_species_preamble(journal_path): identity_dir = journal_path / "identity" identity_dir.mkdir() (identity_dir / "partner.md").write_text("partner body") - (identity_dir / "awareness.md").write_text("awareness body") output = _hydrate() assert output.startswith("# species\n\n") assert _SPECIES_PREAMBLE in output - expected = ["# species", "# partner", "# awareness"] + expected = ["# species", "# partner"] positions = [output.index(marker) for marker in expected] assert positions == sorted(positions) @@ -69,7 +64,6 @@ def test_identity_hydrate_strips_duplicate_section_heading(journal_path): identity_dir = journal_path / "identity" identity_dir.mkdir() (identity_dir / "partner.md").write_text("# partner\n\npartner body\n") - (identity_dir / "awareness.md").write_text("awareness body") output = _hydrate() @@ -81,7 +75,6 @@ def test_identity_hydrate_preserves_non_matching_heading(journal_path): identity_dir = journal_path / "identity" identity_dir.mkdir() (identity_dir / "partner.md").write_text("# My Custom Heading\n\npartner body\n") - (identity_dir / "awareness.md").write_text("awareness body") output = _hydrate() diff --git a/tests/test_talent_cli.py b/tests/test_talent_cli.py index 41f115572..1260e8c62 100644 --- a/tests/test_talent_cli.py +++ b/tests/test_talent_cli.py @@ -321,25 +321,6 @@ def test_show_prompt_context_day_format_validation(capsys): assert "invalid --day format" in output.lower() -def test_show_effective_prompt_read_scope_matches_assembled_prompt(capsys): - """Cogitate prompt view renders the same assembled prompt provider receives.""" - config = get_talent("awareness_tender") - body, system_instruction = assemble_prompt(config, sol_tool_name="sol") - - show_effective_prompt("awareness_tender", full=True) - output = capsys.readouterr().out - - assert system_instruction is not None - assert system_instruction.startswith(COGITATE_RUNTIME_PREAMBLE) - assert "through the `sol` tool" in system_instruction - assert "Limit filesystem reads to today's segment dir" in system_instruction - assert "# Awareness Tender" in body - assert system_instruction in output - assert body in output - assert "tier: normal" in output - assert "sol+reads, no submit" in output - - def test_show_effective_prompt_naming_omits_read_scope_hint(capsys): """Cogitate prompt view omits the read-scope hint when no read_scope is set.""" config = get_talent("naming") @@ -450,14 +431,14 @@ def test_scan_command_examples_dedupes_and_caps(): "`sol call support search foo`", "`sol call support search foo`", "`journal talent logs --daily -c 20 --errors.`", - "`journal identity pulse --write --value x`", + "`journal identity partner --write --value x`", ] ) assert _scan_command_examples(body, cap=3) == [ "sol call support search foo", "journal talent logs --daily -c 20 --errors", - "journal identity pulse --write --value x", + "journal identity partner --write --value x", ] diff --git a/tests/test_think_daily_idempotency.py b/tests/test_think_daily_idempotency.py index eab51e2e8..9a317d170 100644 --- a/tests/test_think_daily_idempotency.py +++ b/tests/test_think_daily_idempotency.py @@ -125,7 +125,6 @@ def test_check_daily_skip_predicate(): None, mode="daily", completed=completed, - never_skip=mod.NEVER_SKIP_DAILY, deterministic_failures=deterministic_failures, ) == (True, "already_complete") assert mod._check_daily_skip( @@ -133,7 +132,6 @@ def test_check_daily_skip_predicate(): None, mode="daily", completed=completed, - never_skip=mod.NEVER_SKIP_DAILY, deterministic_failures=deterministic_failures, ) == (False, None) assert mod._check_daily_skip( @@ -141,7 +139,6 @@ def test_check_daily_skip_predicate(): None, mode="segment", completed=completed, - never_skip=mod.NEVER_SKIP_DAILY, deterministic_failures=deterministic_failures, ) == (False, None) assert mod._check_daily_skip( @@ -149,23 +146,13 @@ def test_check_daily_skip_predicate(): None, mode="daily", completed=completed, - never_skip=mod.NEVER_SKIP_DAILY, deterministic_failures=deterministic_failures, - ) == (False, None) - assert mod._check_daily_skip( - "awareness_tender", - None, - mode="daily", - completed={("daily", "awareness_tender", None)}, - never_skip=mod.NEVER_SKIP_DAILY, - deterministic_failures=deterministic_failures, - ) == (False, None) + ) == (True, "already_complete") assert mod._check_daily_skip( "alpha", None, mode="daily", completed=completed, - never_skip=frozenset(), deterministic_failures=deterministic_failures, from_scratch=True, ) == (False, None) diff --git a/tests/test_think_segment.py b/tests/test_think_segment.py index 716f92c24..344f8ce38 100644 --- a/tests/test_think_segment.py +++ b/tests/test_think_segment.py @@ -499,44 +499,6 @@ class TestRunSegmentSense: assert "timeline:segment_summary" not in spawned - def test_pulse_dispatch(self, segment_dir, monkeypatch): - from solstone.think import thinking as think - - spawned = [] - _write_sense_output( - segment_dir, - {"density": "active", "recommend": {"pulse_update": True}, "facets": []}, - ) - - monkeypatch.setattr( - think, - "get_talent_configs", - lambda schedule=None, **kwargs: _segment_configs( - "sense", "entities", "pulse" - ), - ) - monkeypatch.setattr( - think, - "cortex_request", - lambda prompt, name, config=None: spawned.append(name) or f"agent-{name}", - ) - monkeypatch.setattr( - think, - "wait_for_uses", - lambda agent_ids, timeout=600: ({aid: "finish" for aid in agent_ids}, []), - ) - monkeypatch.setattr(think, "_callosum", None) - - think.run_segment_sense( - "20240115", - "120000_300", - refresh=False, - verbose=False, - stream="default", - ) - - assert spawned == ["sense", "entities", "pulse"] - def test_sense_failure_stops_orchestrator(self, segment_dir, monkeypatch): from solstone.think import thinking as think @@ -1087,7 +1049,6 @@ class TestThinkJSONLEvents: "recommend": { "screen_record": False, "speaker_attribution": False, - "pulse_update": False, }, "facets": [], }, @@ -1131,4 +1092,3 @@ class TestThinkJSONLEvents: assert ("documents", "no_config") in skip_pairs assert ("screen", "not_recommended") in skip_pairs assert ("speaker_attribution", "not_recommended") in skip_pairs - assert ("pulse", "not_recommended") in skip_pairs diff --git a/tests/test_think_skip_talents.py b/tests/test_think_skip_talents.py index d84a1e213..38581b6d3 100644 --- a/tests/test_think_skip_talents.py +++ b/tests/test_think_skip_talents.py @@ -56,16 +56,6 @@ def _segment_configs(*names: str) -> dict[str, dict]: "type": "cogitate", "schedule": "segment", }, - "awareness_tender": { - "priority": 30, - "type": "cogitate", - "schedule": "segment", - }, - "pulse": { - "priority": 30, - "type": "cogitate", - "schedule": "segment", - }, } return {name: dict(configs[name]) for name in names} @@ -77,14 +67,12 @@ def _all_segment_configs() -> dict[str, dict]: "documents", "screen", "speaker_attribution", - "awareness_tender", - "pulse", ) def _new_only_segment_configs() -> dict[str, dict]: configs = _all_segment_configs() - for name in ("awareness_tender", "pulse"): + for name in ("screen",): configs[name] = {**configs[name], "new_only": True} return configs @@ -95,7 +83,6 @@ def _active_sense_output() -> dict: "recommend": { "screen_record": True, "speaker_attribution": True, - "pulse_update": True, }, "facets": [], } @@ -272,14 +259,14 @@ def test_parser_forwards_skip_talents( "--segment", SEGMENT, "--skip-talents", - "awareness_tender,pulse", + "screen,speaker_attribution", ], ) think.main() assert len(calls) == 1 - assert calls[0]["skip_talents"] == frozenset({"awareness_tender", "pulse"}) + assert calls[0]["skip_talents"] == frozenset({"screen", "speaker_attribution"}) def test_empty_flag_forwards_empty_set( @@ -417,10 +404,8 @@ def test_segment_batch_skip_does_not_dispatch_or_fail( "documents", "screen", "speaker_attribution", - "awareness_tender", - "pulse", ] - assert success == 6 + assert success == 4 assert failed == 0 assert failed_names == [] @@ -429,7 +414,7 @@ def test_segment_batch_skip_does_not_dispatch_or_fail( assert skip_events[0]["name"] == "entities" -def test_tail_talent_skip_does_not_dispatch_or_fail( +def test_recommended_talent_skip_does_not_dispatch_or_fail( segment_dir: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -437,7 +422,7 @@ def test_tail_talent_skip_does_not_dispatch_or_fail( from solstone.think.thinking import ThinkingJSONLWriter spawned: list[str] = [] - jsonl_path = segment_dir.parent.parent / "health" / "test_skip_pulse.jsonl" + jsonl_path = segment_dir.parent.parent / "health" / "test_skip_screen.jsonl" writer = ThinkingJSONLWriter(str(jsonl_path)) _write_sense_output(segment_dir, _active_sense_output()) (segment_dir / "audio.npz").touch() @@ -450,27 +435,25 @@ def test_tail_talent_skip_does_not_dispatch_or_fail( refresh=False, verbose=False, stream=STREAM, - skip_talents=frozenset({"pulse"}), + skip_talents=frozenset({"screen"}), ) writer.close() monkeypatch.setattr(think, "_jsonl", None) - assert "pulse" not in spawned + assert "screen" not in spawned assert spawned == [ "sense", "entities", "documents", - "screen", "speaker_attribution", - "awareness_tender", ] - assert success == 6 + assert success == 4 assert failed == 0 assert failed_names == [] skip_events = _skip_events(_read_events(jsonl_path)) assert len(skip_events) == 1 - assert skip_events[0]["name"] == "pulse" + assert skip_events[0]["name"] == "screen" def test_new_only_talents_skip_on_historical_segment_think( @@ -502,31 +485,25 @@ def test_new_only_talents_skip_on_historical_segment_think( writer.close() monkeypatch.setattr(think, "_jsonl", None) - assert "pulse" not in spawned - assert "awareness_tender" not in spawned + assert "screen" not in spawned assert spawned == [ "sense", "entities", "documents", - "screen", "speaker_attribution", ] - assert success == 5 + assert success == 4 assert failed == 0 assert failed_names == [] events = _read_events(jsonl_path) new_only_events = _skip_events(events, reason="new_only_historical") - assert len(new_only_events) == 2 - assert {event["name"] for event in new_only_events} == { - "awareness_tender", - "pulse", - } - pulse_skip = next(event for event in new_only_events if event["name"] == "pulse") - assert pulse_skip["reason"] == "new_only_historical" + assert len(new_only_events) == 1 + assert new_only_events[0]["name"] == "screen" + assert new_only_events[0]["reason"] == "new_only_historical" assert not any( event["event"] == "talent.skip" - and event.get("name") == "pulse" + and event.get("name") == "screen" and event.get("reason") == "not_recommended" for event in events ) @@ -568,10 +545,8 @@ def test_new_only_talents_dispatch_on_live_segment_think( "documents", "screen", "speaker_attribution", - "awareness_tender", - "pulse", ] - assert success == 7 + assert success == 5 assert failed == 0 assert failed_names == [] assert _skip_events(_read_events(jsonl_path), reason="new_only_historical") == [] @@ -602,22 +577,21 @@ def test_new_only_composes_with_skip_talents_on_live_segment_think( refresh=False, verbose=False, stream=STREAM, - skip_talents=frozenset({"pulse"}), + skip_talents=frozenset({"screen"}), live=True, ) writer.close() monkeypatch.setattr(think, "_jsonl", None) - assert "pulse" not in spawned - assert "awareness_tender" in spawned - assert success == 6 + assert "screen" not in spawned + assert success == 4 assert failed == 0 assert failed_names == [] events = _read_events(jsonl_path) skip_events = _skip_events(events) assert len(skip_events) == 1 - assert skip_events[0]["name"] == "pulse" + assert skip_events[0]["name"] == "screen" assert _skip_events(events, reason="new_only_historical") == [] @@ -653,10 +627,8 @@ def test_sense_skip_uses_cached_output_for_downstream( "documents", "screen", "speaker_attribution", - "awareness_tender", - "pulse", ] - assert success == 6 + assert success == 4 assert failed == 0 assert failed_names == [] @@ -698,7 +670,7 @@ def test_skip_talents_composes_with_no_activity_prompts( assert "entities" not in spawned assert len(append_calls) >= 1 assert activity_calls == [] - assert success == 6 + assert success == 4 assert failed == 0 assert failed_names == [] @@ -744,10 +716,8 @@ def test_unknown_name_is_silent_noop( "documents", "screen", "speaker_attribution", - "awareness_tender", - "pulse", ] - assert success == 7 + assert success == 5 assert failed == 0 assert failed_names == [] assert _skip_events(_read_events(jsonl_path)) == []