personal memory agent
0

Configure Feed

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

feat(sense): feed browser stream text into segment input assembly

browser_*.jsonl per-site page text now loads in the per-segment percepts assembly via format_browser_text, counts as percepts so browser-only segments are not gated as no-input, and renders under its own Browser Content header.

Both stream-guidance surfaces describe a {host}.browser stream via suffix match. This is read-side assembly only; it adds no new domain writes.

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

+327 -2
+31 -2
solstone/think/cluster.py
··· 12 12 from typing import Any 13 13 14 14 from solstone.observe.screen import format_screen_text 15 + from solstone.think.browser_formatter import format_browser_text 15 16 from solstone.think.data_state import ( 16 17 DataState, 17 18 derive_modality_state, ··· 126 127 segment_path: Path to segment directory 127 128 date_str: Date in YYYYMMDD format 128 129 transcripts: Whether to load transcript content (JSONL and markdown) 129 - percepts: Whether to load raw screen data from *screen.jsonl files 130 + percepts: Whether to load screen and browser percept content 130 131 agents: Whether to load agent output summaries from *.md files. 131 132 Can be bool (all/none) or dict for selective filtering 132 133 (e.g., {"entities": True, "meetings": "required"}). ··· 270 271 file=sys.stderr, 271 272 ) 272 273 274 + for browser_jsonl in sorted(segment_path.glob("browser_*.jsonl")): 275 + try: 276 + content = format_browser_text(browser_jsonl) 277 + if content: 278 + entries.append( 279 + { 280 + "timestamp": segment_start, 281 + "segment_key": segment_key, 282 + "segment_start": segment_start, 283 + "segment_end": segment_end, 284 + "prefix": "browser", 285 + "content": content, 286 + "name": f"{segment_path.name}/{browser_jsonl.name}", 287 + "stream": stream, 288 + } 289 + ) 290 + except Exception as e: # pragma: no cover - warning only 291 + print( 292 + f"Warning: Could not read JSONL file {browser_jsonl.name}: {e}", 293 + file=sys.stderr, 294 + ) 295 + 273 296 # Process text projections of talent outputs (with optional filtering). 274 297 if agents: 275 298 # Convert bool to filter: True -> None (all), False handled by outer if ··· 350 373 Maps internal entry prefixes to returned source-count keys: 351 374 - "transcript" -> "transcripts" 352 375 - "percept" -> "percepts" 376 + - "browser" -> "percepts" 353 377 - "agent_output" -> "talents" 354 378 355 379 Note: cluster input config still uses the internal "agents" source key for ··· 359 383 Returns: 360 384 Dict with counts for each source type, e.g., {"transcripts": 2, "percepts": 1, "talents": 0} 361 385 """ 362 - # Map internal prefix to source config name 386 + # Map internal prefixes to returned source-count keys 363 387 prefix_to_source = { 364 388 "transcript": "transcripts", 365 389 "percept": "percepts", 390 + "browser": "percepts", 366 391 "agent_output": "talents", 367 392 } 368 393 ··· 406 431 lines.append("") 407 432 elif entry["prefix"] == "percept": 408 433 lines.append("### Screen Activity") 434 + lines.append(entry["content"].strip()) 435 + lines.append("") 436 + elif entry["prefix"] == "browser": 437 + lines.append("### Browser Content") 409 438 lines.append(entry["content"].strip()) 410 439 lines.append("") 411 440 elif entry["prefix"] == "agent_output":
+18
solstone/think/talents.py
··· 186 186 source = stream.split(".", 1)[1] 187 187 return f"imported content from {source}" 188 188 189 + if stream.endswith(".browser"): 190 + return ( 191 + "semantic page text and change updates from browser web apps " 192 + "such as Gmail or Slack" 193 + ) 194 + 189 195 return "captured content" 190 196 191 197 ··· 257 263 "## Content Guidance\n\n" 258 264 "This is imported content. Summarize the key topics, actions, " 259 265 "and takeaways present in this segment." 266 + ) 267 + 268 + if stream.endswith(".browser"): 269 + return ( 270 + "## Content Guidance\n\n" 271 + "This is semantic page text and change updates from web apps the " 272 + "owner was reading in their browser, such as Gmail or Slack. Read it " 273 + "as visible page text, not audio and not screen frames. A " 274 + "segment_start snapshot contains the page's visible text. Delta rows " 275 + "describe text that was added or updated during the segment; remove " 276 + "deltas mean text left the page. Summarize what the owner was " 277 + "reading, doing, and attending to." 260 278 ) 261 279 262 280 return ""
+163
tests/test_cluster.py
··· 84 84 ) 85 85 86 86 87 + def _write_browser_snapshot( 88 + path: Path, 89 + *, 90 + site: str, 91 + title: str, 92 + text: str, 93 + ) -> None: 94 + path.write_text( 95 + json.dumps( 96 + { 97 + "t": "segment_start", 98 + "ts": 1, 99 + "site": site, 100 + "title": title, 101 + "blocks": [{"type": "row", "text": text}], 102 + } 103 + ) 104 + + "\n", 105 + encoding="utf-8", 106 + ) 107 + 108 + 87 109 def test_cluster(tmp_path, monkeypatch): 88 110 """Test cluster() uses transcripts and agent output summaries (*.md files).""" 89 111 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) ··· 325 347 assert "VS Code with Python file" in result 326 348 # Insight content should NOT be present (agents=False for cluster_period) 327 349 assert "This insight should NOT appear" not in result 350 + 351 + 352 + def test_cluster_period_loads_browser_fixture_as_percepts(monkeypatch): 353 + journal = Path("tests/fixtures/journal").resolve() 354 + monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) 355 + 356 + mod = importlib.import_module("solstone.think.cluster") 357 + 358 + result, counts = mod.cluster_period( 359 + "20260703", 360 + "000141_317", 361 + sources={"transcripts": False, "percepts": True, "agents": False}, 362 + stream="suze.browser", 363 + ) 364 + 365 + assert counts["transcripts"] == 0 366 + assert counts["percepts"] > 0 367 + assert "### Browser Content" in result 368 + assert "mail.google.com" in result 369 + assert "Browser stream contract review" in result 370 + # Delta-exclusive marker: proves add-delta block text renders. 371 + assert "Casey Morgan - Lunch moved to Thursday" in result 372 + 373 + 374 + def test_cluster_period_loads_browser_files_in_sorted_order(tmp_path, monkeypatch): 375 + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 376 + day_dir = day_path("20240101") 377 + segment = day_dir / "default" / "090000_300" 378 + segment.mkdir(parents=True) 379 + _write_browser_snapshot( 380 + segment / "browser_mail-google-com.jsonl", 381 + site="mail.google.com", 382 + title="Mail", 383 + text="mail sorted marker", 384 + ) 385 + _write_browser_snapshot( 386 + segment / "browser_app-slack-com.jsonl", 387 + site="app.slack.com", 388 + title="Slack", 389 + text="slack sorted marker", 390 + ) 391 + 392 + mod = importlib.import_module("solstone.think.cluster") 393 + 394 + result, counts = mod.cluster_period( 395 + "20240101", 396 + "090000_300", 397 + sources={"transcripts": False, "percepts": True, "agents": False}, 398 + ) 399 + 400 + assert counts["percepts"] == 2 401 + assert "slack sorted marker" in result 402 + assert "mail sorted marker" in result 403 + assert result.index("slack sorted marker") < result.index("mail sorted marker") 404 + 405 + 406 + def test_cluster_period_skips_bad_browser_file_without_dropping_segment( 407 + tmp_path, monkeypatch, capsys 408 + ): 409 + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 410 + day_dir = day_path("20240101") 411 + segment = day_dir / "default" / "090000_300" 412 + segment.mkdir(parents=True) 413 + (segment / "audio.jsonl").write_text( 414 + '{"raw": "audio.flac"}\n' 415 + '{"start": "00:00:01", "text": "resilient audio marker"}\n', 416 + encoding="utf-8", 417 + ) 418 + _write_browser_snapshot( 419 + segment / "browser_broken.jsonl", 420 + site="broken.example", 421 + title="Broken", 422 + text="broken browser marker", 423 + ) 424 + _write_browser_snapshot( 425 + segment / "browser_valid.jsonl", 426 + site="mail.google.com", 427 + title="Mail", 428 + text="valid browser marker", 429 + ) 430 + 431 + mod = importlib.import_module("solstone.think.cluster") 432 + original_format_browser_text = mod.format_browser_text 433 + 434 + def raise_for_broken_browser(path): 435 + if path.name == "browser_broken.jsonl": 436 + raise RuntimeError("forced browser formatter failure") 437 + return original_format_browser_text(path) 438 + 439 + monkeypatch.setattr(mod, "format_browser_text", raise_for_broken_browser) 440 + 441 + result, counts = mod.cluster_period( 442 + "20240101", 443 + "090000_300", 444 + sources={"transcripts": True, "percepts": True, "agents": False}, 445 + ) 446 + 447 + assert counts["transcripts"] == 1 448 + assert counts["percepts"] == 1 449 + assert "resilient audio marker" in result 450 + assert "valid browser marker" in result 451 + assert ( 452 + "Warning: Could not read JSONL file browser_broken.jsonl" 453 + in capsys.readouterr().err 454 + ) 455 + 456 + 457 + def test_cluster_period_browser_does_not_change_audio_output(tmp_path, monkeypatch): 458 + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 459 + day_dir = day_path("20240101") 460 + segment = day_dir / "default" / "090000_300" 461 + segment.mkdir(parents=True) 462 + (segment / "audio.jsonl").write_text( 463 + '{"raw": "audio.flac"}\n{"start": "00:00:01", "text": "stable audio marker"}\n', 464 + encoding="utf-8", 465 + ) 466 + 467 + mod = importlib.import_module("solstone.think.cluster") 468 + 469 + audio_only, audio_counts = mod.cluster_period( 470 + "20240101", 471 + "090000_300", 472 + sources={"transcripts": True, "percepts": True, "agents": False}, 473 + ) 474 + _write_browser_snapshot( 475 + segment / "browser_mail-google-com.jsonl", 476 + site="mail.google.com", 477 + title="Mail", 478 + text="browser marker appended after audio", 479 + ) 480 + with_browser, browser_counts = mod.cluster_period( 481 + "20240101", 482 + "090000_300", 483 + sources={"transcripts": True, "percepts": True, "agents": False}, 484 + ) 485 + 486 + assert audio_counts == {"transcripts": 1, "percepts": 0, "talents": 0} 487 + assert browser_counts == {"transcripts": 1, "percepts": 1, "talents": 0} 488 + assert with_browser.startswith(audio_only) 489 + assert "stable audio marker" in with_browser 490 + assert "browser marker appended after audio" in with_browser 328 491 329 492 330 493 def test_load_entries_from_toplevel_segment(tmp_path, monkeypatch):
+57
tests/test_talent_stream_guidance.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from solstone.think.talents import ( 5 + _stream_content_description, 6 + _stream_import_guidance, 7 + ) 8 + 9 + 10 + def test_stream_content_description_browser_suffix(): 11 + description = _stream_content_description("suze.browser") 12 + 13 + assert "browser web apps" in description 14 + assert "Gmail or Slack" in description 15 + 16 + 17 + def test_stream_import_guidance_browser_suffix(): 18 + guidance = _stream_import_guidance("suze.browser") 19 + 20 + assert guidance.startswith("## Content Guidance\n\n") 21 + assert "web apps the owner was reading in their browser" in guidance 22 + assert "visible page text" in guidance 23 + assert "segment_start snapshot" in guidance 24 + 25 + 26 + def test_stream_guidance_preserves_live_capture_outputs(): 27 + assert _stream_content_description(None) == ( 28 + "audio transcription and screen recording" 29 + ) 30 + assert _stream_content_description("archon") == ( 31 + "audio transcription and screen recording" 32 + ) 33 + assert _stream_import_guidance(None) == _stream_import_guidance("archon") 34 + assert "## Live Capture Guidance" in _stream_import_guidance("archon") 35 + 36 + 37 + def test_stream_guidance_preserves_exact_import_outputs(): 38 + assert _stream_content_description("import.chatgpt") == ( 39 + "an imported ChatGPT conversation" 40 + ) 41 + guidance = _stream_import_guidance("import.chatgpt") 42 + 43 + assert guidance.startswith("## Content Guidance\n\n") 44 + assert "This is an AI conversation." in guidance 45 + 46 + 47 + def test_stream_guidance_preserves_unknown_import_fallback(): 48 + assert _stream_content_description("import.foo") == "imported content from foo" 49 + guidance = _stream_import_guidance("import.foo") 50 + 51 + assert guidance.startswith("## Content Guidance\n\n") 52 + assert "This is imported content." in guidance 53 + 54 + 55 + def test_stream_guidance_preserves_unknown_stream_defaults(): 56 + assert _stream_content_description("whatever") == "captured content" 57 + assert _stream_import_guidance("whatever") == ""
+58
tests/test_think_segment.py
··· 122 122 ) 123 123 124 124 125 + def _seed_browser_content(segment_dir: Path) -> None: 126 + rows = [ 127 + { 128 + "t": "segment_start", 129 + "ts": 1, 130 + "site": "mail.google.com", 131 + "title": "Inbox - Gmail", 132 + "blocks": [ 133 + { 134 + "type": "row", 135 + "text": ( 136 + "Browser-only sense input with enough semantic page text " 137 + "to clear the no-input gate." 138 + ), 139 + } 140 + ], 141 + }, 142 + { 143 + "t": "delta", 144 + "ts": 2, 145 + "op": "add", 146 + "block": { 147 + "type": "row", 148 + "text": "Added browser update about the planning thread.", 149 + }, 150 + }, 151 + ] 152 + (segment_dir / "browser_mail-google-com.jsonl").write_text( 153 + "".join(json.dumps(row) + "\n" for row in rows), 154 + encoding="utf-8", 155 + ) 156 + 157 + 125 158 def _active_sense_json() -> dict: 126 159 return { 127 160 "density": "active", ··· 892 925 893 926 assert spawned == ["sense"] 894 927 assert result == (1, 0, []) 928 + 929 + def test_browser_only_segment_is_not_gated_as_no_input(self, segment_dir): 930 + from solstone.think.cluster import _count_by_source, _load_entries_from_segment 931 + from solstone.think.talents import check_segment_has_no_input 932 + 933 + _seed_browser_content(segment_dir) 934 + sources = _sense_config_with_load("sense")["sense"]["load"] 935 + entries = _load_entries_from_segment( 936 + str(segment_dir), 937 + transcripts=False, 938 + percepts=True, 939 + agents=False, 940 + ) 941 + counts = _count_by_source(entries) 942 + 943 + assert counts["percepts"] > 0 944 + assert ( 945 + check_segment_has_no_input( 946 + "20240115", 947 + "120000_300", 948 + sources, 949 + stream="default", 950 + ) 951 + is False 952 + ) 895 953 896 954 def test_check_segment_has_no_input_noop_without_sources(self, segment_dir): 897 955 from solstone.think.talents import _is_no_input, check_segment_has_no_input