diff --git a/solstone/think/cluster.py b/solstone/think/cluster.py index 99467c19e..d28da6bf8 100644 --- a/solstone/think/cluster.py +++ b/solstone/think/cluster.py @@ -12,6 +12,7 @@ from pathlib import Path from typing import Any from solstone.observe.screen import format_screen_text +from solstone.think.browser_formatter import format_browser_text from solstone.think.data_state import ( DataState, derive_modality_state, @@ -126,7 +127,7 @@ def _process_segment( segment_path: Path to segment directory date_str: Date in YYYYMMDD format transcripts: Whether to load transcript content (JSONL and markdown) - percepts: Whether to load raw screen data from *screen.jsonl files + percepts: Whether to load screen and browser percept content agents: Whether to load agent output summaries from *.md files. Can be bool (all/none) or dict for selective filtering (e.g., {"entities": True, "meetings": "required"}). @@ -270,6 +271,28 @@ def _process_segment( file=sys.stderr, ) + for browser_jsonl in sorted(segment_path.glob("browser_*.jsonl")): + try: + content = format_browser_text(browser_jsonl) + if content: + entries.append( + { + "timestamp": segment_start, + "segment_key": segment_key, + "segment_start": segment_start, + "segment_end": segment_end, + "prefix": "browser", + "content": content, + "name": f"{segment_path.name}/{browser_jsonl.name}", + "stream": stream, + } + ) + except Exception as e: # pragma: no cover - warning only + print( + f"Warning: Could not read JSONL file {browser_jsonl.name}: {e}", + file=sys.stderr, + ) + # Process text projections of talent outputs (with optional filtering). if agents: # Convert bool to filter: True -> None (all), False handled by outer if @@ -350,6 +373,7 @@ def _count_by_source(entries: list[dict[str, Any]]) -> dict[str, int]: Maps internal entry prefixes to returned source-count keys: - "transcript" -> "transcripts" - "percept" -> "percepts" + - "browser" -> "percepts" - "agent_output" -> "talents" Note: cluster input config still uses the internal "agents" source key for @@ -359,10 +383,11 @@ def _count_by_source(entries: list[dict[str, Any]]) -> dict[str, int]: Returns: Dict with counts for each source type, e.g., {"transcripts": 2, "percepts": 1, "talents": 0} """ - # Map internal prefix to source config name + # Map internal prefixes to returned source-count keys prefix_to_source = { "transcript": "transcripts", "percept": "percepts", + "browser": "percepts", "agent_output": "talents", } @@ -408,6 +433,10 @@ def _groups_to_markdown(groups: dict[str, list[dict[str, Any]]]) -> str: lines.append("### Screen Activity") lines.append(entry["content"].strip()) lines.append("") + elif entry["prefix"] == "browser": + lines.append("### Browser Content") + lines.append(entry["content"].strip()) + lines.append("") elif entry["prefix"] == "agent_output": output_name = entry.get("output_name", "output") lines.append(f"### {output_name} summary") diff --git a/solstone/think/talents.py b/solstone/think/talents.py index 59be1c948..fa82a3897 100644 --- a/solstone/think/talents.py +++ b/solstone/think/talents.py @@ -186,6 +186,12 @@ def _stream_content_description(stream: str | None) -> str: source = stream.split(".", 1)[1] return f"imported content from {source}" + if stream.endswith(".browser"): + return ( + "semantic page text and change updates from browser web apps " + "such as Gmail or Slack" + ) + return "captured content" @@ -259,6 +265,18 @@ def _stream_import_guidance(stream: str | None) -> str: "and takeaways present in this segment." ) + if stream.endswith(".browser"): + return ( + "## Content Guidance\n\n" + "This is semantic page text and change updates from web apps the " + "owner was reading in their browser, such as Gmail or Slack. Read it " + "as visible page text, not audio and not screen frames. A " + "segment_start snapshot contains the page's visible text. Delta rows " + "describe text that was added or updated during the segment; remove " + "deltas mean text left the page. Summarize what the owner was " + "reading, doing, and attending to." + ) + return "" diff --git a/tests/test_cluster.py b/tests/test_cluster.py index c95b52b56..d0e88b447 100644 --- a/tests/test_cluster.py +++ b/tests/test_cluster.py @@ -84,6 +84,28 @@ def _seed_screen_json(segment_dir: Path) -> None: ) +def _write_browser_snapshot( + path: Path, + *, + site: str, + title: str, + text: str, +) -> None: + path.write_text( + json.dumps( + { + "t": "segment_start", + "ts": 1, + "site": site, + "title": title, + "blocks": [{"type": "row", "text": text}], + } + ) + + "\n", + encoding="utf-8", + ) + + def test_cluster(tmp_path, monkeypatch): """Test cluster() uses transcripts and agent output summaries (*.md files).""" monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) @@ -327,6 +349,147 @@ def test_cluster_period_uses_raw_screen(tmp_path, monkeypatch): assert "This insight should NOT appear" not in result +def test_cluster_period_loads_browser_fixture_as_percepts(monkeypatch): + journal = Path("tests/fixtures/journal").resolve() + monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) + + mod = importlib.import_module("solstone.think.cluster") + + result, counts = mod.cluster_period( + "20260703", + "000141_317", + sources={"transcripts": False, "percepts": True, "agents": False}, + stream="suze.browser", + ) + + assert counts["transcripts"] == 0 + assert counts["percepts"] > 0 + assert "### Browser Content" in result + assert "mail.google.com" in result + assert "Browser stream contract review" in result + # Delta-exclusive marker: proves add-delta block text renders. + assert "Casey Morgan - Lunch moved to Thursday" in result + + +def test_cluster_period_loads_browser_files_in_sorted_order(tmp_path, monkeypatch): + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + day_dir = day_path("20240101") + segment = day_dir / "default" / "090000_300" + segment.mkdir(parents=True) + _write_browser_snapshot( + segment / "browser_mail-google-com.jsonl", + site="mail.google.com", + title="Mail", + text="mail sorted marker", + ) + _write_browser_snapshot( + segment / "browser_app-slack-com.jsonl", + site="app.slack.com", + title="Slack", + text="slack sorted marker", + ) + + mod = importlib.import_module("solstone.think.cluster") + + result, counts = mod.cluster_period( + "20240101", + "090000_300", + sources={"transcripts": False, "percepts": True, "agents": False}, + ) + + assert counts["percepts"] == 2 + assert "slack sorted marker" in result + assert "mail sorted marker" in result + assert result.index("slack sorted marker") < result.index("mail sorted marker") + + +def test_cluster_period_skips_bad_browser_file_without_dropping_segment( + tmp_path, monkeypatch, capsys +): + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + day_dir = day_path("20240101") + segment = day_dir / "default" / "090000_300" + segment.mkdir(parents=True) + (segment / "audio.jsonl").write_text( + '{"raw": "audio.flac"}\n' + '{"start": "00:00:01", "text": "resilient audio marker"}\n', + encoding="utf-8", + ) + _write_browser_snapshot( + segment / "browser_broken.jsonl", + site="broken.example", + title="Broken", + text="broken browser marker", + ) + _write_browser_snapshot( + segment / "browser_valid.jsonl", + site="mail.google.com", + title="Mail", + text="valid browser marker", + ) + + mod = importlib.import_module("solstone.think.cluster") + original_format_browser_text = mod.format_browser_text + + def raise_for_broken_browser(path): + if path.name == "browser_broken.jsonl": + raise RuntimeError("forced browser formatter failure") + return original_format_browser_text(path) + + monkeypatch.setattr(mod, "format_browser_text", raise_for_broken_browser) + + result, counts = mod.cluster_period( + "20240101", + "090000_300", + sources={"transcripts": True, "percepts": True, "agents": False}, + ) + + assert counts["transcripts"] == 1 + assert counts["percepts"] == 1 + assert "resilient audio marker" in result + assert "valid browser marker" in result + assert ( + "Warning: Could not read JSONL file browser_broken.jsonl" + in capsys.readouterr().err + ) + + +def test_cluster_period_browser_does_not_change_audio_output(tmp_path, monkeypatch): + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + day_dir = day_path("20240101") + segment = day_dir / "default" / "090000_300" + segment.mkdir(parents=True) + (segment / "audio.jsonl").write_text( + '{"raw": "audio.flac"}\n{"start": "00:00:01", "text": "stable audio marker"}\n', + encoding="utf-8", + ) + + mod = importlib.import_module("solstone.think.cluster") + + audio_only, audio_counts = mod.cluster_period( + "20240101", + "090000_300", + sources={"transcripts": True, "percepts": True, "agents": False}, + ) + _write_browser_snapshot( + segment / "browser_mail-google-com.jsonl", + site="mail.google.com", + title="Mail", + text="browser marker appended after audio", + ) + with_browser, browser_counts = mod.cluster_period( + "20240101", + "090000_300", + sources={"transcripts": True, "percepts": True, "agents": False}, + ) + + assert audio_counts == {"transcripts": 1, "percepts": 0, "talents": 0} + assert browser_counts == {"transcripts": 1, "percepts": 1, "talents": 0} + assert with_browser.startswith(audio_only) + assert "stable audio marker" in with_browser + assert "browser marker appended after audio" in with_browser + + def test_load_entries_from_toplevel_segment(tmp_path, monkeypatch): """_load_entries_from_segment resolves the day for top-level segment dirs.""" monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) diff --git a/tests/test_talent_stream_guidance.py b/tests/test_talent_stream_guidance.py new file mode 100644 index 000000000..e079bb0ba --- /dev/null +++ b/tests/test_talent_stream_guidance.py @@ -0,0 +1,57 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from solstone.think.talents import ( + _stream_content_description, + _stream_import_guidance, +) + + +def test_stream_content_description_browser_suffix(): + description = _stream_content_description("suze.browser") + + assert "browser web apps" in description + assert "Gmail or Slack" in description + + +def test_stream_import_guidance_browser_suffix(): + guidance = _stream_import_guidance("suze.browser") + + assert guidance.startswith("## Content Guidance\n\n") + assert "web apps the owner was reading in their browser" in guidance + assert "visible page text" in guidance + assert "segment_start snapshot" in guidance + + +def test_stream_guidance_preserves_live_capture_outputs(): + assert _stream_content_description(None) == ( + "audio transcription and screen recording" + ) + assert _stream_content_description("archon") == ( + "audio transcription and screen recording" + ) + assert _stream_import_guidance(None) == _stream_import_guidance("archon") + assert "## Live Capture Guidance" in _stream_import_guidance("archon") + + +def test_stream_guidance_preserves_exact_import_outputs(): + assert _stream_content_description("import.chatgpt") == ( + "an imported ChatGPT conversation" + ) + guidance = _stream_import_guidance("import.chatgpt") + + assert guidance.startswith("## Content Guidance\n\n") + assert "This is an AI conversation." in guidance + + +def test_stream_guidance_preserves_unknown_import_fallback(): + assert _stream_content_description("import.foo") == "imported content from foo" + guidance = _stream_import_guidance("import.foo") + + assert guidance.startswith("## Content Guidance\n\n") + assert "This is imported content." in guidance + + +def test_stream_guidance_preserves_unknown_stream_defaults(): + assert _stream_content_description("whatever") == "captured content" + assert _stream_import_guidance("whatever") == "" diff --git a/tests/test_think_segment.py b/tests/test_think_segment.py index 17ccef075..2bc5b1bdd 100644 --- a/tests/test_think_segment.py +++ b/tests/test_think_segment.py @@ -122,6 +122,39 @@ def _seed_empty_screen(segment_dir: Path, name: str = "screen") -> None: ) +def _seed_browser_content(segment_dir: Path) -> None: + rows = [ + { + "t": "segment_start", + "ts": 1, + "site": "mail.google.com", + "title": "Inbox - Gmail", + "blocks": [ + { + "type": "row", + "text": ( + "Browser-only sense input with enough semantic page text " + "to clear the no-input gate." + ), + } + ], + }, + { + "t": "delta", + "ts": 2, + "op": "add", + "block": { + "type": "row", + "text": "Added browser update about the planning thread.", + }, + }, + ] + (segment_dir / "browser_mail-google-com.jsonl").write_text( + "".join(json.dumps(row) + "\n" for row in rows), + encoding="utf-8", + ) + + def _active_sense_json() -> dict: return { "density": "active", @@ -893,6 +926,31 @@ class TestRunSegmentSense: assert spawned == ["sense"] assert result == (1, 0, []) + def test_browser_only_segment_is_not_gated_as_no_input(self, segment_dir): + from solstone.think.cluster import _count_by_source, _load_entries_from_segment + from solstone.think.talents import check_segment_has_no_input + + _seed_browser_content(segment_dir) + sources = _sense_config_with_load("sense")["sense"]["load"] + entries = _load_entries_from_segment( + str(segment_dir), + transcripts=False, + percepts=True, + agents=False, + ) + counts = _count_by_source(entries) + + assert counts["percepts"] > 0 + assert ( + check_segment_has_no_input( + "20240115", + "120000_300", + sources, + stream="default", + ) + is False + ) + def test_check_segment_has_no_input_noop_without_sources(self, segment_dir): from solstone.think.talents import _is_no_input, check_segment_has_no_input