personal memory agent
0
fork

Configure Feed

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

Path A observation generator + nudges + recommendations

Implements hop 2 of smart onboarding: the observation experience for
Path A users who chose "let Solstone observe and learn."

Observation generator (muse/observation.md + observation.py):
- Segment-scheduled generator with pre-hook guard
- Zero API cost when user is not in observation mode
- When observing: LLM extracts meetings, entities, apps, topics
- Post-hook writes findings to awareness log, sends callosum nudge
notifications (capped at 4), checks threshold (4h + 10 segments)
- On threshold: transitions to "ready" + spawns review chat redirect

Recommendation agent (muse/observation_review.md):
- Cogitate agent spawned when observation completes
- Reads accumulated observations, synthesizes facet/entity suggestions
- Interactive review: user accepts, modifies, or rejects each
- Creates facets and attaches entities via existing CLI commands

Supporting changes:
- sol call awareness log-read: read awareness log with kind/limit filters
- sol call chat redirect --muse: specify which muse agent to spawn
- Triage prompt: observation-aware context and behavioral rules
- Onboarding SKILL.md: document log-read command

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+861 -6
+31
apps/awareness/call.py
··· 81 81 typer.echo(json.dumps(state, indent=2)) 82 82 83 83 84 + @app.command("log-read") 85 + def log_read_cmd( 86 + day: str | None = typer.Argument( 87 + None, help="Day in YYYYMMDD format (defaults to today)." 88 + ), 89 + kind: str | None = typer.Option( 90 + None, "--kind", "-k", help="Filter by entry kind (e.g., 'observation')." 91 + ), 92 + limit: int = typer.Option( 93 + 0, "--limit", "-n", help="Max entries to return (0=all)." 94 + ), 95 + ) -> None: 96 + """Read entries from the daily awareness log.""" 97 + from think.awareness import _today, read_log 98 + 99 + target_day = day or _today() 100 + entries = read_log(target_day) 101 + 102 + if kind: 103 + entries = [e for e in entries if e.get("kind") == kind] 104 + 105 + if limit > 0: 106 + entries = entries[-limit:] 107 + 108 + if not entries: 109 + typer.echo("No entries found.") 110 + return 111 + 112 + typer.echo(json.dumps(entries, indent=2)) 113 + 114 + 84 115 @app.command("log") 85 116 def log_cmd( 86 117 kind: str = typer.Argument(
+4 -3
apps/chat/call.py
··· 45 45 app_name: str = typer.Option("", "--app", help="Source app name."), 46 46 path: str = typer.Option("", "--path", help="Source URL path."), 47 47 facet: str = typer.Option("", "--facet", "-f", help="Active facet."), 48 + muse: str = typer.Option("default", "--muse", "-m", help="Muse agent to use."), 48 49 ) -> None: 49 - """Create a chat thread with the default muse and navigate the browser to it.""" 50 + """Create a chat thread with the specified muse and navigate the browser to it.""" 50 51 context_lines = [] 51 52 if app_name: 52 53 context_lines.append(f"Current app: {app_name}") ··· 80 81 config["facet"] = facet 81 82 agent_id = cortex_request( 82 83 prompt=full_prompt, 83 - name="default", 84 + name=muse, 84 85 provider=provider, 85 86 config=config, 86 87 ) ··· 94 95 "ts": ts, 95 96 "facet": facet or None, 96 97 "provider": provider, 97 - "muse": "default", 98 + "muse": muse, 98 99 "title": title, 99 100 "agent_ids": [agent_id], 100 101 }
+71
muse/observation.md
··· 1 + { 2 + "type": "generate", 3 + "title": "Observation", 4 + "description": "Extracts patterns from segment data during onboarding observation", 5 + "schedule": "segment", 6 + "priority": 97, 7 + "output": "json", 8 + "hook": {"pre": "observation", "post": "observation"}, 9 + "tier": 3, 10 + "thinking_budget": 2048, 11 + "max_output_tokens": 2048, 12 + "instructions": { 13 + "sources": {"audio": true, "screen": true, "agents": false} 14 + } 15 + } 16 + 17 + You are analyzing a captured segment of someone's computer activity to learn about their work patterns. This is part of an onboarding observation — the user has asked the system to watch how they work for a day and then suggest how to organize their journal. 18 + 19 + ## Input 20 + 21 + You receive a transcript combining audio (microphone/system audio, with speaker labels) and screen activity (app usage, visible content) from a ~5-minute capture window. 22 + 23 + ## Task 24 + 25 + Extract structured observations about what happened in this segment. Focus on: 26 + 27 + 1. **Meetings** — conversations with 2+ speakers. Note participant count, any names mentioned, and the topic/context. 28 + 2. **Apps** — what applications or tools the user is actively using. 29 + 3. **Entities** — specific people, companies, projects, or tools mentioned by name. 30 + 4. **Topics** — what subjects or themes are present in the activity. 31 + 5. **Summary** — a brief 1-line description of what the user was doing. 32 + 33 + ## Output Format 34 + 35 + Return a JSON object: 36 + 37 + ```json 38 + { 39 + "has_meeting": false, 40 + "speaker_count": 1, 41 + "meeting_topic": null, 42 + "apps": ["VS Code", "Terminal"], 43 + "people": ["Alice Chen"], 44 + "companies": [], 45 + "projects": ["auth-service"], 46 + "tools": ["Git", "Docker"], 47 + "topics": ["backend development", "authentication"], 48 + "summary": "Solo coding session working on authentication service" 49 + } 50 + ``` 51 + 52 + ### Field Definitions 53 + 54 + - `has_meeting`: true if 2+ speakers are having a conversation (not just background audio) 55 + - `speaker_count`: number of distinct speakers detected 56 + - `meeting_topic`: brief topic if a meeting is detected, null otherwise 57 + - `apps`: list of applications/tools actively in use on screen 58 + - `people`: names of people mentioned or speaking (use real names when identifiable, "Speaker N" when not) 59 + - `companies`: company or organization names mentioned 60 + - `projects`: project names, product names, or codebases mentioned 61 + - `tools`: development tools, services, or platforms mentioned 62 + - `topics`: 1-3 high-level topic themes for this segment 63 + - `summary`: one concise sentence describing the segment 64 + 65 + ## Rules 66 + 67 + 1. Only report what you can clearly observe — don't speculate 68 + 2. Use real names when they appear in the transcript; "Speaker N" is fine for unnamed speakers 69 + 3. Empty lists are valid when nothing is detected for a category 70 + 4. Keep the summary factual and brief 71 + 5. Return ONLY the JSON object, no other text
+252
muse/observation.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Observation hooks for Path A onboarding. 5 + 6 + Pre-hook: guards on awareness state — skips immediately (zero API cost) 7 + when the user is not in Path A observation mode. 8 + 9 + Post-hook: writes LLM findings to the awareness log, sends callosum 10 + notifications for interesting discoveries, and transitions to "ready" 11 + when the observation threshold is met. 12 + """ 13 + 14 + from __future__ import annotations 15 + 16 + import json 17 + import logging 18 + from datetime import datetime 19 + 20 + logger = logging.getLogger(__name__) 21 + 22 + # Observation thresholds 23 + MIN_SEGMENTS = 10 24 + MIN_HOURS = 4.0 25 + 26 + # Maximum nudge notifications during observation 27 + MAX_NUDGES = 4 28 + 29 + 30 + # --------------------------------------------------------------------------- 31 + # Pre-hook 32 + # --------------------------------------------------------------------------- 33 + 34 + 35 + def pre_process(context: dict) -> dict | None: 36 + """Guard: skip if not in Path A observation mode. 37 + 38 + Args: 39 + context: PreHookContext with day, segment, output_path, transcript, meta 40 + 41 + Returns: 42 + Dict with skip_reason if not observing, or None to proceed. 43 + """ 44 + from think.awareness import get_onboarding 45 + 46 + onboarding = get_onboarding() 47 + if onboarding.get("status") != "observing": 48 + return {"skip_reason": "not_observing"} 49 + 50 + # Observing — let the LLM analyze the segment transcript 51 + return None 52 + 53 + 54 + # --------------------------------------------------------------------------- 55 + # Post-hook 56 + # --------------------------------------------------------------------------- 57 + 58 + 59 + def post_process(result: str, context: dict) -> str | None: 60 + """Process LLM observation output — log, notify, check threshold. 61 + 62 + Args: 63 + result: LLM JSON output with observation findings 64 + context: Full config dict with day, segment, etc. 65 + 66 + Returns: 67 + The result string unchanged (output still written to segment dir). 68 + """ 69 + from think.awareness import append_log, get_onboarding, update_state 70 + from think.callosum import callosum_send 71 + 72 + day = context.get("day", "") 73 + segment = context.get("segment", "") 74 + 75 + # Parse LLM output 76 + try: 77 + findings = json.loads(result) 78 + except (json.JSONDecodeError, TypeError): 79 + logger.warning("observation post-hook: failed to parse LLM output") 80 + return result 81 + 82 + if not isinstance(findings, dict): 83 + logger.warning("observation post-hook: LLM output is not a dict") 84 + return result 85 + 86 + # Write observation to awareness log 87 + append_log( 88 + "observation", 89 + key=f"segment.{day}.{segment}", 90 + message=findings.get("summary", ""), 91 + data=findings, 92 + day=day, 93 + segment=segment, 94 + ) 95 + 96 + # Update observation count 97 + onboarding = get_onboarding() 98 + count = onboarding.get("observation_count", 0) + 1 99 + update_state("onboarding", {"observation_count": count}) 100 + 101 + # Check if we should send a nudge notification 102 + nudges_sent = onboarding.get("nudges_sent", 0) 103 + if nudges_sent < MAX_NUDGES: 104 + nudge = _check_nudge(findings, count, nudges_sent, onboarding) 105 + if nudge: 106 + callosum_send( 107 + "notification", 108 + "show", 109 + title=nudge["title"], 110 + message=nudge["message"], 111 + icon=nudge.get("icon", "🔍"), 112 + app="observation", 113 + ) 114 + update_state("onboarding", {"nudges_sent": nudges_sent + 1}) 115 + append_log( 116 + "nudge", 117 + key="onboarding.nudge", 118 + message=nudge["message"], 119 + data={"title": nudge["title"], "nudge_number": nudges_sent + 1}, 120 + ) 121 + 122 + # Check observation threshold 123 + if _threshold_met(onboarding, count): 124 + _transition_to_ready(day) 125 + 126 + return result 127 + 128 + 129 + def _check_nudge( 130 + findings: dict, 131 + observation_count: int, 132 + nudges_sent: int, 133 + onboarding: dict, 134 + ) -> dict | None: 135 + """Decide whether this segment's findings warrant a notification. 136 + 137 + Returns a nudge dict with title/message/icon, or None. 138 + Nudge triggers (in order of priority): 139 + 0: First meeting detected 140 + 1: First entity cluster (3+ named people) 141 + 2: After 5 segments — progress update 142 + 3: Nearing threshold — "almost ready" 143 + """ 144 + # Nudge 0: First meeting 145 + if nudges_sent == 0 and findings.get("has_meeting"): 146 + speaker_count = findings.get("speaker_count", 0) 147 + topic = findings.get("meeting_topic") or "a conversation" 148 + return { 149 + "title": "Meeting detected", 150 + "message": f"Noticed {speaker_count} people discussing {topic}.", 151 + "icon": "🎙️", 152 + } 153 + 154 + # Nudge 1: First entity cluster 155 + if nudges_sent <= 1: 156 + people = findings.get("people", []) 157 + if len(people) >= 3: 158 + names = ", ".join(people[:3]) 159 + return { 160 + "title": "Learning your network", 161 + "message": f"Spotted several people: {names}.", 162 + "icon": "👥", 163 + } 164 + 165 + # Nudge 2: Progress update at 5 segments 166 + if nudges_sent <= 2 and observation_count == 5: 167 + return { 168 + "title": "Still learning", 169 + "message": "Building a picture of your work patterns. Keep going!", 170 + "icon": "📊", 171 + } 172 + 173 + # Nudge 3: Almost ready 174 + if nudges_sent <= 3 and observation_count >= MIN_SEGMENTS - 1: 175 + started = onboarding.get("started", "") 176 + hours = _elapsed_hours(started) 177 + if hours >= MIN_HOURS * 0.75: 178 + return { 179 + "title": "Almost ready", 180 + "message": "Have enough data to make suggestions soon.", 181 + "icon": "✨", 182 + } 183 + 184 + return None 185 + 186 + 187 + def _threshold_met(onboarding: dict, count: int) -> bool: 188 + """Check if observation period is complete. 189 + 190 + Requires both minimum segments AND minimum elapsed time. 191 + """ 192 + if count < MIN_SEGMENTS: 193 + return False 194 + 195 + started = onboarding.get("started", "") 196 + hours = _elapsed_hours(started) 197 + return hours >= MIN_HOURS 198 + 199 + 200 + def _elapsed_hours(started_iso: str) -> float: 201 + """Calculate hours elapsed since the started timestamp.""" 202 + if not started_iso: 203 + return 0.0 204 + try: 205 + start = datetime.strptime(started_iso, "%Y%m%dT%H:%M:%S") 206 + elapsed = (datetime.now() - start).total_seconds() 207 + return elapsed / 3600 208 + except (ValueError, TypeError): 209 + return 0.0 210 + 211 + 212 + def _transition_to_ready(day: str) -> None: 213 + """Transition onboarding to 'ready' state and send chat redirect.""" 214 + from think.awareness import append_log, update_state 215 + 216 + update_state("onboarding", {"status": "ready"}) 217 + append_log( 218 + "state", 219 + key="onboarding.ready", 220 + message="Observation threshold met — recommendations ready", 221 + day=day, 222 + ) 223 + 224 + # Send chat redirect to open the recommendation review 225 + try: 226 + from apps.utils import get_app_storage_path 227 + from convey.utils import save_json 228 + from think.callosum import callosum_send 229 + from think.cortex_client import cortex_request 230 + from think.utils import now_ms 231 + 232 + prompt = ( 233 + "The user chose Path A onboarding — passive observation. " 234 + "The observation period is complete. Read the accumulated " 235 + "observations and present your recommendations for facets " 236 + "and entities. Be warm and enthusiastic about what you learned." 237 + ) 238 + agent_id = cortex_request(prompt=prompt, name="observation_review") 239 + if agent_id: 240 + chat_record = { 241 + "ts": now_ms(), 242 + "muse": "observation_review", 243 + "title": "Your journal suggestions are ready", 244 + "agent_ids": [agent_id], 245 + } 246 + chats_dir = get_app_storage_path("chat", "chats") 247 + save_json(chats_dir / f"{agent_id}.json", chat_record) 248 + callosum_send("navigate", "request", path=f"/app/chat#{agent_id}") 249 + logger.info("Sent observation review redirect: %s", agent_id) 250 + except Exception: 251 + logger.exception("Failed to send observation review redirect") 252 + # Non-fatal — user can still trigger review via triage
+102
muse/observation_review.md
··· 1 + { 2 + "type": "cogitate", 3 + "title": "Observation Review", 4 + "description": "Synthesizes onboarding observations into facet and entity recommendations", 5 + "instructions": {"now": true} 6 + } 7 + 8 + You are solstone's onboarding recommendation assistant. The user chose Path A — passive observation — and the system has been watching how they work. Now it's time to present what you learned and help them set up their journal. 9 + 10 + ## Your Job 11 + 12 + 1. Read the accumulated observations from the awareness log. 13 + 2. Synthesize them into concrete recommendations for journal facets and entities. 14 + 3. Present each recommendation and let the user accept, modify, or reject it. 15 + 4. Create accepted facets and attach entities. 16 + 5. Mark onboarding complete. 17 + 18 + ## Step 1: Read Observations 19 + 20 + Start by reading the observation log: 21 + 22 + ```bash 23 + sol call awareness log-read --kind observation 24 + ``` 25 + 26 + Also check the current onboarding state: 27 + 28 + ```bash 29 + sol call awareness onboarding 30 + ``` 31 + 32 + ## Step 2: Synthesize Recommendations 33 + 34 + From the observations, identify: 35 + 36 + - **Distinct work contexts** — recurring themes that suggest separate facets (e.g., "you had meetings about authentication and also worked on the CLI tool — these seem like different projects") 37 + - **Key people** — names that appear frequently across observations 38 + - **Projects and tools** — codebases, services, and tools the user works with 39 + - **Activity patterns** — what the user spends most time on 40 + 41 + ## Step 3: Present Recommendations 42 + 43 + Present your findings warmly and concretely. Start with a brief summary of what you observed, then present facet suggestions one at a time. 44 + 45 + For each suggested facet: 46 + - Explain WHY you're suggesting it (what patterns led to this) 47 + - Propose a name, emoji, and brief description 48 + - List entities (people, projects, tools) you'd attach to it 49 + - Ask the user to accept, modify, or skip 50 + 51 + Example: 52 + > I noticed you had several meetings about authentication and security — discussions with Alice and Bob about OAuth flows, plus solo coding on the auth-service repo. This looks like a distinct work context. 53 + > 54 + > **Suggested facet:** 🔐 Security Work 55 + > *Description: Authentication, security reviews, and related development* 56 + > *People: Alice Chen, Bob* 57 + > *Projects: auth-service* 58 + > 59 + > Does this look right? I can adjust the name, add more entities, or skip this one. 60 + 61 + ## Step 4: Create Accepted Suggestions 62 + 63 + For each accepted facet: 64 + 65 + ```bash 66 + sol call journal facet create "TITLE" --emoji "EMOJI" --color "COLOR" --description "DESC" 67 + ``` 68 + 69 + Then attach entities: 70 + 71 + ```bash 72 + sol call entities attach TYPE ENTITY DESCRIPTION --facet FACET 73 + ``` 74 + 75 + Entity types: Person, Company, Project, Tool 76 + 77 + ## Step 5: Complete Onboarding 78 + 79 + After reviewing all suggestions: 80 + 81 + ```bash 82 + sol call awareness onboarding --complete 83 + ``` 84 + 85 + Confirm the facets and show what was created: 86 + 87 + ```bash 88 + sol call journal facets 89 + ``` 90 + 91 + Tell the user their journal is now set up and the system will start organizing captures into these facets. They can always adjust facets later. 92 + 93 + ## Behavioral Rules 94 + 95 + - Be enthusiastic but not overwhelming — you learned real things about how they work 96 + - Present 2-4 facet suggestions (not too many for a first setup) 97 + - Ground every suggestion in observed evidence — "I noticed X, which suggests Y" 98 + - Don't create anything without user confirmation 99 + - If the user wants to modify a suggestion, help them refine it 100 + - If the user rejects everything, that's fine — suggest they can set up manually later 101 + - Choose colors and emojis that feel natural for each context 102 + - After completion, remind them they can always create more facets or modify these ones
+10
muse/onboarding/SKILL.md
··· 27 27 28 28 - `SECTION`: Optional section name (e.g., `onboarding`). Omit for full state. 29 29 30 + ## awareness log-read 31 + 32 + ```bash 33 + sol call awareness log-read [DAY] [--kind KIND] [--limit N] 34 + ``` 35 + 36 + - `DAY`: Day in YYYYMMDD format (defaults to today). 37 + - `--kind`: Filter by entry kind (e.g., `observation`, `nudge`, `state`). 38 + - `--limit`: Max entries to return (0 = all). 39 + 30 40 ## facet create 31 41 32 42 ```bash
+9
muse/triage.md
··· 43 43 ### Awareness 44 44 - `sol call awareness status [SECTION]` — Read awareness state (e.g., onboarding progress). 45 45 - `sol call awareness onboarding` — Read onboarding state (path, status, observation count). 46 + - `sol call awareness log-read [DAY] [--kind KIND] [--limit N]` — Read awareness log entries. Use `--kind observation` to read observation findings. 46 47 47 48 ### Redirect to Chat 48 49 - `sol call chat redirect MESSAGE --app APP --path PATH --facet FACET` — Create a chat thread with the full assistant and navigate the browser there. Use the user's original message as MESSAGE. Pass the current app, path, and facet from context. ··· 61 62 - For complex entity exploration (e.g., "show me my whole network", deep relationship analysis, multi-entity comparisons), redirect to the full chat assistant using `sol call chat redirect`. 62 63 - Do not attempt to use any commands not listed above. 63 64 - SOL_DAY and SOL_FACET environment variables are already set — tools will use them as defaults when --day/--facet are omitted. So you can often omit these flags. 65 + 66 + ## Onboarding Observation Context 67 + 68 + When the user is in Path A onboarding observation (check `sol call awareness onboarding`): 69 + 70 + - **Status "observing"**: If the user asks "what have you noticed?", "how's it going?", "what are you learning?", or similar — read recent observations with `sol call awareness log-read --kind observation --limit 5` and summarize what the system has seen so far. Be encouraging about the observation progress. 71 + 72 + - **Status "ready"**: Recommendations are available! Proactively suggest reviewing them: "I've finished observing and have suggestions for organizing your journal. Want to take a look?" If the user agrees, redirect to the observation review agent: `sol call chat redirect "Review my observation suggestions" --muse observation_review`
+22
tests/baselines/api/agents/agents-day.json
··· 297 297 "title": "Messaging Summary", 298 298 "type": "generate" 299 299 }, 300 + "observation": { 301 + "app": null, 302 + "color": "#6c757d", 303 + "description": "Extracts patterns from segment data during onboarding observation", 304 + "multi_facet": false, 305 + "output_format": "json", 306 + "schedule": "segment", 307 + "source": "system", 308 + "title": "Observation", 309 + "type": "generate" 310 + }, 311 + "observation_review": { 312 + "app": null, 313 + "color": "#6c757d", 314 + "description": "Synthesizes onboarding observations into facet and entity recommendations", 315 + "multi_facet": false, 316 + "output_format": null, 317 + "schedule": null, 318 + "source": "system", 319 + "title": "Observation Review", 320 + "type": "cogitate" 321 + }, 300 322 "occurrence": { 301 323 "app": null, 302 324 "color": "#37474f",
+10
tests/baselines/api/settings/generators.json
··· 164 164 }, 165 165 { 166 166 "app": null, 167 + "description": "Extracts patterns from segment data during onboarding observation", 168 + "disabled": false, 169 + "extract": null, 170 + "has_extraction": false, 171 + "key": "observation", 172 + "source": "system", 173 + "title": "Observation" 174 + }, 175 + { 176 + "app": null, 167 177 "description": "Extracts people, companies, projects, and tools from segment content", 168 178 "disabled": false, 169 179 "extract": null,
+15
tests/baselines/api/settings/providers.json
··· 245 245 "tier": 2, 246 246 "type": "generate" 247 247 }, 248 + "muse.system.observation": { 249 + "disabled": false, 250 + "group": "Think", 251 + "label": "Observation", 252 + "schedule": "segment", 253 + "tier": 3, 254 + "type": "generate" 255 + }, 256 + "muse.system.observation_review": { 257 + "disabled": false, 258 + "group": "Think", 259 + "label": "Observation Review", 260 + "tier": 2, 261 + "type": "cogitate" 262 + }, 248 263 "muse.system.occurrence": { 249 264 "disabled": false, 250 265 "group": "Think",
+26
tests/baselines/api/stats/stats.json
··· 325 325 "title": "Messaging Summary", 326 326 "type": "generate" 327 327 }, 328 + "observation": { 329 + "color": "#6c757d", 330 + "description": "Extracts patterns from segment data during onboarding observation", 331 + "hook": { 332 + "post": "observation", 333 + "pre": "observation" 334 + }, 335 + "instructions": { 336 + "sources": { 337 + "agents": false, 338 + "audio": true, 339 + "screen": true 340 + } 341 + }, 342 + "max_output_tokens": 2048, 343 + "mtime": 0, 344 + "output": "json", 345 + "path": "<PROJECT>/muse/observation.md", 346 + "priority": 97, 347 + "schedule": "segment", 348 + "source": "system", 349 + "thinking_budget": 2048, 350 + "tier": 3, 351 + "title": "Observation", 352 + "type": "generate" 353 + }, 328 354 "schedule": { 329 355 "color": "#5e35b1", 330 356 "description": "Identifies all future calendar events and scheduled activities noted in transcripts. Extracts dates, times, participants, and event details for anything scheduled beyond today.",
+308
tests/test_observation.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Tests for the observation generator hooks and related features.""" 5 + 6 + import json 7 + 8 + import pytest 9 + 10 + 11 + @pytest.fixture(autouse=True) 12 + def _temp_journal(monkeypatch, tmp_path): 13 + """Isolate all tests to a temporary journal.""" 14 + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 15 + 16 + 17 + class TestPreHook: 18 + def test_skips_when_not_observing(self): 19 + from muse.observation import pre_process 20 + 21 + result = pre_process({"day": "20260306", "segment": "120000_300"}) 22 + assert result == {"skip_reason": "not_observing"} 23 + 24 + def test_skips_when_status_is_ready(self): 25 + from muse.observation import pre_process 26 + from think.awareness import update_state 27 + 28 + update_state("onboarding", {"status": "ready"}) 29 + result = pre_process({"day": "20260306", "segment": "120000_300"}) 30 + assert result == {"skip_reason": "not_observing"} 31 + 32 + def test_skips_when_status_is_complete(self): 33 + from muse.observation import pre_process 34 + from think.awareness import update_state 35 + 36 + update_state("onboarding", {"status": "complete"}) 37 + result = pre_process({"day": "20260306", "segment": "120000_300"}) 38 + assert result == {"skip_reason": "not_observing"} 39 + 40 + def test_skips_when_status_is_skipped(self): 41 + from muse.observation import pre_process 42 + from think.awareness import update_state 43 + 44 + update_state("onboarding", {"status": "skipped"}) 45 + result = pre_process({"day": "20260306", "segment": "120000_300"}) 46 + assert result == {"skip_reason": "not_observing"} 47 + 48 + def test_proceeds_when_observing(self): 49 + from muse.observation import pre_process 50 + from think.awareness import start_onboarding 51 + 52 + start_onboarding("a") 53 + result = pre_process({"day": "20260306", "segment": "120000_300"}) 54 + assert result is None # No modifications — proceed with LLM 55 + 56 + 57 + class TestPostHook: 58 + @pytest.fixture(autouse=True) 59 + def _start_observation(self): 60 + from think.awareness import start_onboarding 61 + 62 + start_onboarding("a") 63 + 64 + def test_writes_observation_to_log(self): 65 + from muse.observation import post_process 66 + from think.awareness import read_log 67 + 68 + findings = json.dumps( 69 + { 70 + "has_meeting": False, 71 + "speaker_count": 1, 72 + "apps": ["VS Code"], 73 + "people": [], 74 + "companies": [], 75 + "projects": ["auth-service"], 76 + "tools": ["Git"], 77 + "topics": ["coding"], 78 + "summary": "Solo coding session", 79 + } 80 + ) 81 + 82 + post_process(findings, {"day": "20260306", "segment": "120000_300"}) 83 + 84 + entries = read_log("20260306") 85 + # Filter to observation entries (start_onboarding also writes a log entry) 86 + obs = [e for e in entries if e["kind"] == "observation"] 87 + assert len(obs) == 1 88 + assert obs[0]["data"]["apps"] == ["VS Code"] 89 + assert obs[0]["message"] == "Solo coding session" 90 + 91 + def test_increments_observation_count(self): 92 + from muse.observation import post_process 93 + from think.awareness import get_onboarding 94 + 95 + findings = json.dumps({"summary": "test", "has_meeting": False}) 96 + 97 + post_process(findings, {"day": "20260306", "segment": "120000_300"}) 98 + assert get_onboarding()["observation_count"] == 1 99 + 100 + post_process(findings, {"day": "20260306", "segment": "120500_300"}) 101 + assert get_onboarding()["observation_count"] == 2 102 + 103 + def test_handles_invalid_json(self): 104 + from muse.observation import post_process 105 + 106 + result = post_process("not json", {"day": "20260306", "segment": "120000_300"}) 107 + assert result == "not json" # Returns result unchanged 108 + 109 + def test_handles_non_dict_json(self): 110 + from muse.observation import post_process 111 + 112 + result = post_process("[1,2,3]", {"day": "20260306", "segment": "120000_300"}) 113 + assert result == "[1,2,3]" # Returns result unchanged 114 + 115 + def test_returns_result_unchanged(self): 116 + from muse.observation import post_process 117 + 118 + findings = json.dumps({"summary": "test", "has_meeting": False}) 119 + result = post_process(findings, {"day": "20260306", "segment": "120000_300"}) 120 + assert result == findings 121 + 122 + 123 + class TestNudgeLogic: 124 + def test_first_meeting_triggers_nudge(self): 125 + from muse.observation import _check_nudge 126 + 127 + findings = { 128 + "has_meeting": True, 129 + "speaker_count": 3, 130 + "meeting_topic": "sprint planning", 131 + } 132 + nudge = _check_nudge(findings, 1, 0, {}) 133 + assert nudge is not None 134 + assert "Meeting detected" in nudge["title"] 135 + assert "3 people" in nudge["message"] 136 + 137 + def test_no_meeting_no_first_nudge(self): 138 + from muse.observation import _check_nudge 139 + 140 + findings = {"has_meeting": False} 141 + nudge = _check_nudge(findings, 1, 0, {}) 142 + assert nudge is None 143 + 144 + def test_entity_cluster_triggers_nudge(self): 145 + from muse.observation import _check_nudge 146 + 147 + findings = { 148 + "has_meeting": False, 149 + "people": ["Alice", "Bob", "Charlie"], 150 + } 151 + nudge = _check_nudge(findings, 2, 1, {}) 152 + assert nudge is not None 153 + assert "network" in nudge["title"].lower() 154 + 155 + def test_progress_update_at_5_segments(self): 156 + from muse.observation import _check_nudge 157 + 158 + findings = {"has_meeting": False, "people": []} 159 + nudge = _check_nudge(findings, 5, 2, {}) 160 + assert nudge is not None 161 + assert "Still learning" in nudge["title"] 162 + 163 + def test_no_nudge_when_max_reached(self): 164 + from muse.observation import MAX_NUDGES, _check_nudge 165 + 166 + findings = {"has_meeting": True, "speaker_count": 5} 167 + # nudges_sent == MAX_NUDGES means all nudges used 168 + nudge = _check_nudge(findings, 1, MAX_NUDGES, {}) 169 + # MAX_NUDGES is checked in post_process, not _check_nudge 170 + # But _check_nudge with nudges_sent=4 won't match any trigger 171 + assert nudge is None 172 + 173 + 174 + class TestThreshold: 175 + def test_not_met_with_few_segments(self): 176 + from muse.observation import _threshold_met 177 + 178 + onboarding = {"started": "20260306T08:00:00"} 179 + assert _threshold_met(onboarding, 5) is False 180 + 181 + def test_not_met_with_short_time(self): 182 + # Just started — not enough time elapsed 183 + from datetime import datetime 184 + 185 + from muse.observation import MIN_SEGMENTS, _threshold_met 186 + 187 + now = datetime.now().strftime("%Y%m%dT%H:%M:%S") 188 + onboarding = {"started": now} 189 + assert _threshold_met(onboarding, MIN_SEGMENTS) is False 190 + 191 + def test_met_with_enough_segments_and_time(self): 192 + from muse.observation import MIN_SEGMENTS, _threshold_met 193 + 194 + # Started 5 hours ago 195 + onboarding = {"started": "20260101T03:00:00"} 196 + assert _threshold_met(onboarding, MIN_SEGMENTS) is True 197 + 198 + def test_not_met_with_no_started(self): 199 + from muse.observation import MIN_SEGMENTS, _threshold_met 200 + 201 + onboarding = {} 202 + assert _threshold_met(onboarding, MIN_SEGMENTS) is False 203 + 204 + 205 + class TestElapsedHours: 206 + def test_valid_iso(self): 207 + from muse.observation import _elapsed_hours 208 + 209 + # A date far in the past should give many hours 210 + hours = _elapsed_hours("20200101T00:00:00") 211 + assert hours > 24 212 + 213 + def test_empty_string(self): 214 + from muse.observation import _elapsed_hours 215 + 216 + assert _elapsed_hours("") == 0.0 217 + 218 + def test_invalid_format(self): 219 + from muse.observation import _elapsed_hours 220 + 221 + assert _elapsed_hours("not-a-date") == 0.0 222 + 223 + 224 + class TestAwarenessLogReadCLI: 225 + def test_log_read_empty(self): 226 + from typer.testing import CliRunner 227 + 228 + from apps.awareness.call import app 229 + 230 + result = CliRunner().invoke(app, ["log-read"]) 231 + assert result.exit_code == 0 232 + assert "No entries found" in result.output 233 + 234 + def test_log_read_with_entries(self): 235 + from typer.testing import CliRunner 236 + 237 + from apps.awareness.call import app 238 + from think.awareness import append_log 239 + 240 + append_log("observation", message="test finding") 241 + append_log("state", key="test.key") 242 + 243 + result = CliRunner().invoke(app, ["log-read"]) 244 + assert result.exit_code == 0 245 + data = json.loads(result.output) 246 + assert len(data) == 2 247 + 248 + def test_log_read_filter_by_kind(self): 249 + from typer.testing import CliRunner 250 + 251 + from apps.awareness.call import app 252 + from think.awareness import append_log 253 + 254 + append_log("observation", message="finding 1") 255 + append_log("state", key="transition") 256 + append_log("observation", message="finding 2") 257 + 258 + result = CliRunner().invoke(app, ["log-read", "--kind", "observation"]) 259 + assert result.exit_code == 0 260 + data = json.loads(result.output) 261 + assert len(data) == 2 262 + assert all(e["kind"] == "observation" for e in data) 263 + 264 + def test_log_read_with_limit(self): 265 + from typer.testing import CliRunner 266 + 267 + from apps.awareness.call import app 268 + from think.awareness import append_log 269 + 270 + for i in range(5): 271 + append_log("observation", message=f"finding {i}") 272 + 273 + result = CliRunner().invoke(app, ["log-read", "--limit", "2"]) 274 + assert result.exit_code == 0 275 + data = json.loads(result.output) 276 + assert len(data) == 2 277 + # Should return the LAST 2 entries 278 + assert data[0]["message"] == "finding 3" 279 + assert data[1]["message"] == "finding 4" 280 + 281 + 282 + class TestChatRedirectMuse: 283 + def test_redirect_uses_custom_muse(self): 284 + from unittest.mock import MagicMock, patch 285 + 286 + from typer.testing import CliRunner 287 + 288 + from apps.chat.call import app 289 + 290 + with ( 291 + patch( 292 + "think.cortex_client.cortex_request", return_value="agent-1" 293 + ) as mock_cr, 294 + patch("think.callosum.callosum_send", return_value=True), 295 + patch("apps.chat.routes.generate_chat_title", return_value="test"), 296 + patch( 297 + "think.models.resolve_provider", 298 + return_value=("test-provider", "test-model"), 299 + ), 300 + patch("convey.utils.save_json"), 301 + patch("apps.utils.get_app_storage_path", return_value=MagicMock()), 302 + patch("think.utils.get_journal", return_value=str(MagicMock())), 303 + ): 304 + result = CliRunner().invoke( 305 + app, ["redirect", "hello", "--muse", "observation_review"] 306 + ) 307 + assert result.exit_code == 0 308 + assert mock_cr.call_args.kwargs["name"] == "observation_review"
+1 -3
think/awareness.py
··· 207 207 "nudges_sent": 0, 208 208 }, 209 209 ) 210 - append_log( 211 - "state", key="onboarding.started", data={"path": path, "status": status} 212 - ) 210 + append_log("state", key="onboarding.started", data={"path": path, "status": status}) 213 211 return state 214 212 215 213