personal memory agent
0
fork

Configure Feed

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

Merge branch 'hopper-bh3sqy2k-dream-post-review-fixes'

# Conflicts:
# tests/test_rate_limiter.py
# think/rate_limiter.py

+255 -160
-1
apps/todos/muse/todo.md
··· 9 9 "activities": ["*"], 10 10 "priority": 10, 11 11 "group": "Todos", 12 - "hook": {"pre": "todos:todo_filter"}, 13 12 "instructions": { 14 13 "system": "journal", 15 14 "sources": {"audio": true, "screen": false, "agents": {"screen": true}},
-62
apps/todos/muse/todo_filter.py
··· 1 - # SPDX-License-Identifier: AGPL-3.0-only 2 - # Copyright (c) 2026 sol pbc 3 - 4 - """Pre-filter hook for the todo detector agent. 5 - 6 - Scans activity transcripts for commitment-language signals. When no 7 - signals are found, returns skip_reason so the agent skips without 8 - LLM invocation. 9 - """ 10 - 11 - from __future__ import annotations 12 - 13 - import logging 14 - import re 15 - 16 - logger = logging.getLogger(__name__) 17 - 18 - _SIGNAL_PATTERNS = ( 19 - # Action commitment 20 - re.compile( 21 - r"I'll|I will|I need to|I should|we should|we need to|let's|let me", 22 - re.IGNORECASE, 23 - ), 24 - # Follow-up 25 - re.compile(r"follow up|follow-up|get back to|circle back", re.IGNORECASE), 26 - # Reminders 27 - re.compile(r"remind me|don't forget|make sure to|remember to", re.IGNORECASE), 28 - # Deadlines 29 - re.compile( 30 - r"by (?:monday|tuesday|wednesday|thursday|friday|saturday|sunday|tomorrow|end of|next week)|deadline|due date|due by", 31 - re.IGNORECASE, 32 - ), 33 - # Explicit markers 34 - re.compile(r"\bTODO\b|\bFIXME\b|action items?|next steps?", re.IGNORECASE), 35 - # Task creation 36 - re.compile(r"add to\b.*\blist|put on\b.*\blist|add a task|create a task", re.IGNORECASE), 37 - ) 38 - 39 - 40 - def pre_process(context: dict) -> dict | None: 41 - """Skip the todo detector when the transcript has no commitment signals. 42 - 43 - Args: 44 - context: Agent config dict with transcript, day, activity, etc. 45 - 46 - Returns: 47 - Dict with skip_reason when no signals found, or None to proceed. 48 - """ 49 - transcript = context.get("transcript") or "" 50 - if not transcript.strip(): 51 - logger.info("todo_filter: skipping, empty transcript") 52 - return {"skip_reason": "no commitment signals in transcript"} 53 - 54 - if any(pattern.search(transcript) for pattern in _SIGNAL_PATTERNS): 55 - logger.debug("todo_filter: commitment signal found, proceeding") 56 - return None 57 - 58 - logger.info( 59 - "todo_filter: skipping, no commitment signals (transcript_len=%d)", 60 - len(transcript), 61 - ) 62 - return {"skip_reason": "no commitment signals in transcript"}
+116 -6
tests/test_dream_segment.py
··· 150 150 monkeypatch.setattr(dream, "get_enabled_facets", mock_get_enabled_facets) 151 151 monkeypatch.setattr(dream, "get_active_facets", mock_get_active_facets) 152 152 monkeypatch.setattr(dream, "run_queued_command", mock_run_queued_command) 153 - monkeypatch.setattr(dream, "_classify_segment_density", lambda *args: "active") 153 + monkeypatch.setattr( 154 + dream, 155 + "_classify_segment_density", 156 + lambda *args: { 157 + "classification": "active", 158 + "transcript_lines": 0, 159 + "screen_frames": 0, 160 + "timestamp": "2024-01-15T00:00:00+00:00", 161 + }, 162 + ) 154 163 155 164 success, failed, failed_names = dream.run_prompts_by_priority( 156 165 "20240115", "120000_300", refresh=False, verbose=False ··· 209 218 monkeypatch.setattr(dream, "wait_for_agents", mock_wait_for_agents) 210 219 monkeypatch.setattr(dream, "get_muse_configs", mock_get_muse_configs) 211 220 monkeypatch.setattr(dream, "get_enabled_facets", mock_get_enabled_facets) 212 - monkeypatch.setattr(dream, "_classify_segment_density", lambda *args: "active") 221 + monkeypatch.setattr( 222 + dream, 223 + "_classify_segment_density", 224 + lambda *args: { 225 + "classification": "active", 226 + "transcript_lines": 0, 227 + "screen_frames": 0, 228 + "timestamp": "2024-01-15T00:00:00+00:00", 229 + }, 230 + ) 213 231 214 232 success, failed, failed_names = dream.run_prompts_by_priority( 215 233 "20240115", "120000_300", refresh=False, verbose=False ··· 261 279 monkeypatch.setattr(dream, "wait_for_agents", mock_wait_for_agents) 262 280 monkeypatch.setattr(dream, "get_muse_configs", mock_get_muse_configs) 263 281 monkeypatch.setattr(dream, "get_enabled_facets", mock_get_enabled_facets) 264 - monkeypatch.setattr(dream, "_classify_segment_density", lambda *args: "active") 282 + monkeypatch.setattr( 283 + dream, 284 + "_classify_segment_density", 285 + lambda *args: { 286 + "classification": "active", 287 + "transcript_lines": 0, 288 + "screen_frames": 0, 289 + "timestamp": "2024-01-15T00:00:00+00:00", 290 + }, 291 + ) 265 292 266 293 success, failed, failed_names = dream.run_prompts_by_priority( 267 294 "20240115", "120000_300", refresh=False, verbose=False ··· 314 341 monkeypatch.setattr(dream, "get_enabled_facets", mock_get_enabled_facets) 315 342 monkeypatch.setattr(dream, "get_active_facets", mock_get_active_facets) 316 343 monkeypatch.setattr(dream, "run_queued_command", mock_run_queued_command) 317 - monkeypatch.setattr(dream, "_classify_segment_density", lambda *args: "active") 344 + monkeypatch.setattr( 345 + dream, 346 + "_classify_segment_density", 347 + lambda *args: { 348 + "classification": "active", 349 + "transcript_lines": 0, 350 + "screen_frames": 0, 351 + "timestamp": "2024-01-15T00:00:00+00:00", 352 + }, 353 + ) 318 354 319 355 dream.run_prompts_by_priority( 320 356 "20240115", "120000_300", refresh=False, verbose=False, stream="default" ··· 326 362 assert indexer_calls[0][1] == "indexer" 327 363 assert "--rescan-file" in indexer_calls[0] 328 364 365 + def test_refresh_bypasses_density_gate(self, segment_dir, monkeypatch): 366 + """When refresh=True, priority-10 agents run regardless of density.""" 367 + from think import dream 368 + 369 + spawned = [] 370 + 371 + def mock_cortex_request(prompt, name, config=None): 372 + spawned.append(name) 373 + return f"agent-{name}" 374 + 375 + def mock_wait_for_agents(agent_ids, timeout=600): 376 + return ({aid: "finish" for aid in agent_ids}, []) 377 + 378 + def mock_get_muse_configs(schedule=None, **kwargs): 379 + return { 380 + "low_priority_agent": { 381 + "priority": 10, 382 + "type": "generate", 383 + "output": "md", 384 + "schedule": "segment", 385 + }, 386 + } 387 + 388 + def mock_get_enabled_facets(): 389 + return {"work": {"title": "Work"}} 390 + 391 + density_called = [] 392 + 393 + def mock_classify(day, segment, stream): 394 + density_called.append(True) 395 + return { 396 + "classification": "idle", 397 + "transcript_lines": 0, 398 + "screen_frames": 0, 399 + "timestamp": "2024-01-15T00:00:00+00:00", 400 + } 401 + 402 + monkeypatch.setattr(dream, "cortex_request", mock_cortex_request) 403 + monkeypatch.setattr(dream, "wait_for_agents", mock_wait_for_agents) 404 + monkeypatch.setattr(dream, "get_muse_configs", mock_get_muse_configs) 405 + monkeypatch.setattr(dream, "get_enabled_facets", mock_get_enabled_facets) 406 + monkeypatch.setattr( 407 + dream, "run_queued_command", lambda cmd, day, timeout=60: True 408 + ) 409 + monkeypatch.setattr(dream, "_classify_segment_density", mock_classify) 410 + 411 + success, failed, failed_names = dream.run_prompts_by_priority( 412 + "20240115", "120000_300", refresh=True, verbose=False 413 + ) 414 + 415 + assert "low_priority_agent" in spawned 416 + assert success == 1 417 + assert failed == 0 418 + assert failed_names == [] 419 + assert len(density_called) == 0 420 + 329 421 330 422 class TestCortexRequestRetry: 331 423 """Tests for _cortex_request_with_retry.""" ··· 415 507 monkeypatch.setattr(dream, "get_muse_configs", mock_get_muse_configs) 416 508 monkeypatch.setattr(dream, "get_enabled_facets", mock_get_enabled_facets) 417 509 monkeypatch.setattr(dream, "get_active_facets", mock_get_active_facets) 418 - monkeypatch.setattr(dream, "_classify_segment_density", lambda *args: "active") 510 + monkeypatch.setattr( 511 + dream, 512 + "_classify_segment_density", 513 + lambda *args: { 514 + "classification": "active", 515 + "transcript_lines": 0, 516 + "screen_frames": 0, 517 + "timestamp": "2024-01-15T00:00:00+00:00", 518 + }, 519 + ) 419 520 420 521 success, failed, failed_names = dream.run_prompts_by_priority( 421 522 "20240115", "120000_300", refresh=False, verbose=False ··· 597 698 monkeypatch.setattr( 598 699 dream, "run_queued_command", lambda cmd, day, timeout=60: True 599 700 ) 600 - monkeypatch.setattr(dream, "_classify_segment_density", lambda *args: "active") 701 + monkeypatch.setattr( 702 + dream, 703 + "_classify_segment_density", 704 + lambda *args: { 705 + "classification": "active", 706 + "transcript_lines": 0, 707 + "screen_frames": 0, 708 + "timestamp": "2024-01-15T00:00:00+00:00", 709 + }, 710 + ) 601 711 602 712 dream.run_prompts_by_priority( 603 713 "20240115",
+102 -5
tests/test_segment_gate.py
··· 12 12 from think.dream import _classify_segment_density 13 13 14 14 monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path / "journal")) 15 - assert _classify_segment_density("20240115", "120000_300", "default") == "active" 15 + assert ( 16 + _classify_segment_density("20240115", "120000_300", "default")["classification"] 17 + == "active" 18 + ) 16 19 17 20 18 21 def test_idle_segment(tmp_path, monkeypatch): ··· 23 26 (seg_dir / "audio.jsonl").write_text(json.dumps({"raw": "audio.flac"}) + "\n") 24 27 25 28 monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path / "journal")) 26 - assert _classify_segment_density("20240115", "120000_300", "default") == "idle" 29 + assert ( 30 + _classify_segment_density("20240115", "120000_300", "default")["classification"] 31 + == "idle" 32 + ) 27 33 28 34 29 35 def test_low_change_segment(tmp_path, monkeypatch): ··· 44 50 (seg_dir / "screen.jsonl").write_text("\n".join(screen_lines) + "\n") 45 51 46 52 monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path / "journal")) 47 - assert _classify_segment_density("20240115", "120000_300", "default") == "low_change" 53 + assert ( 54 + _classify_segment_density("20240115", "120000_300", "default")["classification"] 55 + == "low_change" 56 + ) 48 57 49 58 50 59 def test_active_segment_with_imported_md(tmp_path, monkeypatch): ··· 55 64 (seg_dir / "imported.md").write_text("\n".join(f"line {i}" for i in range(12)) + "\n") 56 65 57 66 monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path / "journal")) 58 - assert _classify_segment_density("20240115", "120000_300", "default") == "active" 67 + assert ( 68 + _classify_segment_density("20240115", "120000_300", "default")["classification"] 69 + == "active" 70 + ) 59 71 60 72 61 73 def test_tmux_screen_has_no_header(tmp_path, monkeypatch): ··· 70 82 (seg_dir / "tmux_0_screen.jsonl").write_text("\n".join(screen_lines) + "\n") 71 83 72 84 monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path / "journal")) 73 - assert _classify_segment_density("20240115", "120000_300", "default") == "low_change" 85 + assert ( 86 + _classify_segment_density("20240115", "120000_300", "default")["classification"] 87 + == "low_change" 88 + ) 89 + 90 + 91 + def test_density_result_has_all_fields(tmp_path, monkeypatch): 92 + from think.dream import _classify_segment_density 93 + 94 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path / "journal")) 95 + result = _classify_segment_density("20240115", "120000_300", "default") 96 + assert isinstance(result, dict) 97 + assert set(result.keys()) == { 98 + "classification", 99 + "transcript_lines", 100 + "screen_frames", 101 + "timestamp", 102 + } 103 + assert result["classification"] == "active" 104 + assert isinstance(result["timestamp"], str) 105 + 106 + 107 + def test_one_transcript_line_not_idle(tmp_path, monkeypatch): 108 + from think.dream import _classify_segment_density 109 + 110 + seg_dir = tmp_path / "journal" / "20240115" / "default" / "120000_300" 111 + seg_dir.mkdir(parents=True) 112 + audio_lines = [ 113 + json.dumps({"raw": "audio.flac"}), 114 + json.dumps({"start": "00:00:01", "text": "line"}), 115 + ] 116 + (seg_dir / "audio.jsonl").write_text("\n".join(audio_lines) + "\n") 117 + 118 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path / "journal")) 119 + result = _classify_segment_density("20240115", "120000_300", "default") 120 + assert result["classification"] == "low_change" 121 + 122 + 123 + def test_zero_transcript_zero_screen_still_idle(tmp_path, monkeypatch): 124 + from think.dream import _classify_segment_density 125 + 126 + seg_dir = tmp_path / "journal" / "20240115" / "default" / "120000_300" 127 + seg_dir.mkdir(parents=True) 128 + (seg_dir / "audio.jsonl").write_text(json.dumps({"raw": "audio.flac"}) + "\n") 129 + 130 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path / "journal")) 131 + result = _classify_segment_density("20240115", "120000_300", "default") 132 + assert result["classification"] == "idle" 133 + 134 + 135 + def test_two_transcript_one_screen_not_idle(tmp_path, monkeypatch): 136 + from think.dream import _classify_segment_density 137 + 138 + seg_dir = tmp_path / "journal" / "20240115" / "default" / "120000_300" 139 + seg_dir.mkdir(parents=True) 140 + audio_lines = [json.dumps({"raw": "audio.flac"})] 141 + audio_lines.extend( 142 + json.dumps({"start": f"00:00:0{i}", "text": "line"}) for i in range(1, 3) 143 + ) 144 + (seg_dir / "audio.jsonl").write_text("\n".join(audio_lines) + "\n") 145 + screen_lines = [ 146 + json.dumps({"raw": "screen.webm"}), 147 + json.dumps({"timestamp": 1, "analysis": {"visual_description": "screen"}}), 148 + ] 149 + (seg_dir / "screen.jsonl").write_text("\n".join(screen_lines) + "\n") 150 + 151 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path / "journal")) 152 + result = _classify_segment_density("20240115", "120000_300", "default") 153 + assert result["classification"] == "low_change" 154 + 155 + 156 + def test_zero_transcript_one_screen_still_idle(tmp_path, monkeypatch): 157 + from think.dream import _classify_segment_density 158 + 159 + seg_dir = tmp_path / "journal" / "20240115" / "default" / "120000_300" 160 + seg_dir.mkdir(parents=True) 161 + (seg_dir / "audio.jsonl").write_text(json.dumps({"raw": "audio.flac"}) + "\n") 162 + screen_lines = [ 163 + json.dumps({"raw": "screen.webm"}), 164 + json.dumps({"timestamp": 1, "analysis": {"visual_description": "screen"}}), 165 + ] 166 + (seg_dir / "screen.jsonl").write_text("\n".join(screen_lines) + "\n") 167 + 168 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path / "journal")) 169 + result = _classify_segment_density("20240115", "120000_300", "default") 170 + assert result["classification"] == "idle"
-76
tests/test_todo_filter.py
··· 1 - # SPDX-License-Identifier: AGPL-3.0-only 2 - # Copyright (c) 2026 sol pbc 3 - 4 - """Unit tests for the todo detector pre-filter hook.""" 5 - 6 - from apps.todos.muse.todo_filter import pre_process 7 - 8 - 9 - class TestTodoFilter: 10 - def test_empty_transcript_skips(self): 11 - result = pre_process({"transcript": ""}) 12 - assert result == {"skip_reason": "no commitment signals in transcript"} 13 - 14 - def test_missing_transcript_skips(self): 15 - result = pre_process({}) 16 - assert result == {"skip_reason": "no commitment signals in transcript"} 17 - 18 - def test_none_transcript_skips(self): 19 - result = pre_process({"transcript": None}) 20 - assert result == {"skip_reason": "no commitment signals in transcript"} 21 - 22 - def test_whitespace_transcript_skips(self): 23 - result = pre_process({"transcript": " \n "}) 24 - assert result == {"skip_reason": "no commitment signals in transcript"} 25 - 26 - def test_no_signals_skips(self): 27 - result = pre_process({"transcript": "just writing some python code today"}) 28 - assert result == {"skip_reason": "no commitment signals in transcript"} 29 - 30 - def test_action_commitment_proceeds(self): 31 - result = pre_process({"transcript": "I'll send that email tomorrow"}) 32 - assert result is None 33 - 34 - def test_follow_up_proceeds(self): 35 - result = pre_process({"transcript": "need to follow up with the team"}) 36 - assert result is None 37 - 38 - def test_reminder_proceeds(self): 39 - result = pre_process({"transcript": "remind me to check the logs"}) 40 - assert result is None 41 - 42 - def test_deadline_proceeds(self): 43 - result = pre_process({"transcript": "need to finish this by Monday"}) 44 - assert result is None 45 - 46 - def test_explicit_marker_proceeds(self): 47 - result = pre_process({"transcript": "TODO: fix the auth flow"}) 48 - assert result is None 49 - 50 - def test_task_creation_proceeds(self): 51 - result = pre_process({"transcript": "add to my list: buy groceries"}) 52 - assert result is None 53 - 54 - def test_case_insensitive(self): 55 - result = pre_process({"transcript": "i'll handle it"}) 56 - assert result is None 57 - 58 - def test_we_should_proceeds(self): 59 - result = pre_process({"transcript": "we should schedule a meeting"}) 60 - assert result is None 61 - 62 - def test_circle_back_proceeds(self): 63 - result = pre_process({"transcript": "let's circle back on this"}) 64 - assert result is None 65 - 66 - def test_dont_forget_proceeds(self): 67 - result = pre_process({"transcript": "don't forget to update the docs"}) 68 - assert result is None 69 - 70 - def test_action_items_proceeds(self): 71 - result = pre_process({"transcript": "here are the action items from today"}) 72 - assert result is None 73 - 74 - def test_next_steps_proceeds(self): 75 - result = pre_process({"transcript": "the next steps are to review the PR"}) 76 - assert result is None
+37 -10
think/dream.py
··· 15 15 import sys 16 16 import threading 17 17 import time 18 - from datetime import date, datetime, timedelta 18 + from datetime import date, datetime, timedelta, timezone 19 19 from pathlib import Path 20 20 21 21 from think.activities import get_activity_output_path, load_activity_records ··· 459 459 day: str, 460 460 segment: str, 461 461 stream: str | None, 462 - ) -> str: 463 - """Classify segment content density as 'idle', 'low_change', or 'active'.""" 462 + ) -> dict: 463 + """Classify segment content density. Returns dict with classification, counts, timestamp.""" 464 464 seg_dir = _segment_dir(day, segment, stream) 465 465 if not seg_dir.exists(): 466 - return "active" 466 + return { 467 + "classification": "active", 468 + "transcript_lines": 0, 469 + "screen_frames": 0, 470 + "timestamp": datetime.now(tz=timezone.utc).isoformat(), 471 + } 467 472 468 473 transcript_lines = 0 469 474 transcript_files = sorted(seg_dir.glob("audio.jsonl")) ··· 492 497 subtract_header = False 493 498 screen_frames += max(0, len(lines) - 1 if subtract_header else len(lines)) 494 499 495 - if transcript_lines < 3 and screen_frames < 2: 496 - return "idle" 500 + if transcript_lines == 0 and screen_frames < 2: 501 + return { 502 + "classification": "idle", 503 + "transcript_lines": transcript_lines, 504 + "screen_frames": screen_frames, 505 + "timestamp": datetime.now(tz=timezone.utc).isoformat(), 506 + } 497 507 if transcript_lines < 10 and screen_frames < 5: 498 - return "low_change" 499 - return "active" 508 + return { 509 + "classification": "low_change", 510 + "transcript_lines": transcript_lines, 511 + "screen_frames": screen_frames, 512 + "timestamp": datetime.now(tz=timezone.utc).isoformat(), 513 + } 514 + return { 515 + "classification": "active", 516 + "transcript_lines": transcript_lines, 517 + "screen_frames": screen_frames, 518 + "timestamp": datetime.now(tz=timezone.utc).isoformat(), 519 + } 500 520 501 521 502 522 def _activity_state_cache_path(day: str) -> Path: ··· 746 766 priority_groups.setdefault(priority, []).append((name, config)) 747 767 748 768 segment_density = "active" 749 - if segment: 750 - segment_density = _classify_segment_density(day, segment, stream) 769 + density_result = None 770 + if segment and not refresh: 771 + density_result = _classify_segment_density(day, segment, stream) 772 + segment_density = density_result["classification"] 751 773 if segment_density != "active": 752 774 logging.info("Segment %s classified as %s", segment, segment_density) 775 + elif segment and refresh: 776 + logging.info("Segment %s: refresh mode, bypassing density gate", segment) 753 777 754 778 activity_changed = False 755 779 as_cache = _load_activity_state_cache(day) if segment else {} ··· 806 830 segment, 807 831 segment_density, 808 832 ) 833 + if density_result is not None: 834 + seg_dir = _segment_dir(day, segment, stream) 835 + _write_json_atomic(seg_dir / "agents" / "density.json", density_result) 809 836 continue 810 837 811 838 emit(