personal memory agent
0
fork

Configure Feed

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

Refactor extraction logic into named hook system

Move occurrence and anticipation extraction from inline code in
think/insight.py into standalone hook modules in think/insights/.
Insights now declare extraction via "hook": "occurrence" or
"hook": "anticipation" in frontmatter.

- Add occurrence.py and anticipation.py as named hooks
- Move prompts to think/insights/occurrence.md and anticipation.md
- Extract shared utilities to think/insights.py (should_skip_extraction,
write_events_jsonl, compute_source_insight)
- Update think/utils.py to resolve named hooks from frontmatter
- Remove ~170 lines of extraction logic from think/insight.py
- Update all insight frontmatter to use hook field
- Remove legacy boolean occurrences/anticipations fields
- Update apps/settings/routes.py to detect extraction via hook field
- Update docs and tests

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

+616 -428
+3 -4
apps/settings/routes.py
··· 786 786 787 787 def _build_insight_info(key: str, meta: dict) -> dict: 788 788 """Build insight info dict for API response.""" 789 - # Determine if insight supports extraction 790 - has_occ = meta.get("occurrences") not in (None, False) 791 - has_antic = meta.get("anticipations") is True 792 - has_extraction = has_occ or has_antic 789 + # Determine if insight supports extraction via named hook 790 + hook = meta.get("hook") 791 + has_extraction = hook in ("occurrence", "anticipation") 793 792 794 793 info = { 795 794 "key": key,
+22 -4
docs/APPS.md
··· 273 273 - Keys are namespaced as `{app}:{topic}` (e.g., `my_app:weekly_summary`) 274 274 - Outputs go to `JOURNAL/YYYYMMDD/insights/_<app>_<topic>.md` (or `.json` if `output: "json"`) 275 275 276 - **Metadata format:** Same schema as system insights in `think/insights/*.md` - JSON frontmatter includes `title`, `description`, `color`, `frequency` (required), `occurrences`, and `output` fields. The `frequency` field must be `"segment"` or `"daily"` - insights with missing or invalid frequency are skipped with a warning. Set `output: "json"` for structured JSON output instead of markdown. 276 + **Metadata format:** Same schema as system insights in `think/insights/*.md` - JSON frontmatter includes `title`, `description`, `color`, `frequency` (required), `hook`, and `output` fields. The `frequency` field must be `"segment"` or `"daily"` - insights with missing or invalid frequency are skipped with a warning. Set `output: "json"` for structured JSON output instead of markdown. 277 + 278 + **Event extraction via hooks:** To extract structured events from insight output, use the `hook` field: 279 + 280 + - `"hook": "occurrence"` - Extracts past events to `facets/{facet}/events/{day}.jsonl` 281 + - `"hook": "anticipation"` - Extracts future scheduled events 282 + 283 + The `occurrences` field (optional string) provides topic-specific extraction guidance when using the occurrence hook. Example: 284 + 285 + ```json 286 + { 287 + "title": "Meeting Summary", 288 + "frequency": "daily", 289 + "hook": "occurrence", 290 + "occurrences": "Each meeting should generate an occurrence with start and end times, participants, and summary." 291 + } 292 + ``` 277 293 278 294 **App-data insights:** For insights from app-specific data (not transcripts), store in `JOURNAL/apps/{app}/insights/*.md` - these are automatically indexed. 279 295 280 296 **Template variables:** Insight prompts can use template variables like `$name`, `$preferred`, `$daily_insight`, and context variables like `$day` and `$date`. See [PROMPT_TEMPLATES.md](PROMPT_TEMPLATES.md) for the complete template system documentation. 281 297 282 - **Post-processing hooks:** Insights support optional `.py` hooks for transforming output programmatically: 298 + **Custom hooks:** Insights also support custom `.py` hooks for transforming output programmatically: 283 299 284 - - Create `{topic}.py` alongside `{topic}.md` 300 + - Create `{topic}.py` alongside `{topic}.md` for co-located hooks 301 + - Or use `"hook": "my_hook"` to reference `think/insights/my_hook.py` 285 302 - Hook must define a `process(result, context)` function 286 303 - `result` is the LLM output (markdown or JSON string) 287 - - `context` dict contains: `day`, `segment`, `insight_key`, `output_path`, `insight_meta`, `transcript` 304 + - `context` dict contains: `day`, `segment`, `multi_segment`, `insight_key`, `output_path`, `insight_meta`, `transcript` 288 305 - Return modified string, or `None` to use original result 289 306 - Hook errors are logged but don't crash the pipeline (falls back to original) 290 307 ··· 297 314 298 315 **Reference implementations:** 299 316 - System insight templates: `think/insights/*.md` 317 + - Extraction hooks: `think/insights/occurrence.py`, `think/insights/anticipation.py` 300 318 - Discovery logic: `think/utils.py` - `get_insights()`, `get_insights_by_frequency()`, `get_insight_topic()` 301 319 - Hook loading: `think/utils.py` - `load_insight_hook()` 302 320
+5 -5
docs/THINK.md
··· 139 139 140 140 ## Insight map keys 141 141 142 - `think.utils.get_insights()` reads the prompt files under `think/insights` and 142 + `think.utils.get_insights()` reads the `.md` prompt files under `think/insights` and 143 143 returns a dictionary keyed by insight name. Each entry contains: 144 144 145 - - `path` – the prompt text file path 145 + - `path` – the prompt file path 146 146 - `color` – UI color hex string 147 - - `mtime` – modification time of the `.txt` file 148 - - Any additional keys from the matching `<insight>.json` metadata file such as 149 - `title`, `description`, `occurrences`, or `instructions` 147 + - `mtime` – modification time of the `.md` file 148 + - Additional keys from JSON frontmatter such as `title`, `description`, `hook`, or `instructions` 150 149 150 + The `hook` field enables event extraction by invoking named hooks like `"occurrence"` or `"anticipation"`. 151 151 The `instructions` key allows customizing system prompts and source filtering. 152 152 See [APPS.md](APPS.md#instructions-configuration) for the full schema. 153 153
+20 -8
tests/test_get_insights.py
··· 84 84 segment = utils.get_insights_by_frequency("segment") 85 85 assert len(segment) > 0 86 86 for key, meta in segment.items(): 87 - assert meta.get("frequency") == "segment", f"{key} should have frequency=segment" 87 + assert ( 88 + meta.get("frequency") == "segment" 89 + ), f"{key} should have frequency=segment" 88 90 89 91 # Verify no overlap 90 - assert not set(daily.keys()) & set(segment.keys()), "daily and segment should not overlap" 92 + assert not set(daily.keys()) & set( 93 + segment.keys() 94 + ), "daily and segment should not overlap" 91 95 92 96 # Unknown frequency returns empty dict 93 97 assert utils.get_insights_by_frequency("hourly") == {} ··· 110 114 111 115 112 116 def test_all_system_insights_have_frequency(): 113 - """Test that all system insights have valid frequency field.""" 117 + """Test that all runnable system insights have valid frequency field. 118 + 119 + Hook-only insights (occurrence, anticipation) are allowed to omit frequency 120 + since they're invoked via the 'hook' field, not scheduled directly. 121 + """ 114 122 utils = importlib.import_module("think.utils") 115 123 116 124 insights = utils.get_insights() 117 125 valid_frequencies = ("segment", "daily") 126 + # Hook-only insights that don't need frequency 127 + hook_only_insights = {"occurrence", "anticipation"} 118 128 119 129 for key, meta in insights.items(): 120 - if meta.get("source") == "system": 130 + if meta.get("source") == "system" and key not in hook_only_insights: 121 131 freq = meta.get("frequency") 122 - assert freq is not None, f"System insight '{key}' missing required 'frequency' field" 123 - assert freq in valid_frequencies, ( 124 - f"System insight '{key}' has invalid frequency '{freq}'" 125 - ) 132 + assert ( 133 + freq is not None 134 + ), f"System insight '{key}' missing required 'frequency' field" 135 + assert ( 136 + freq in valid_frequencies 137 + ), f"System insight '{key}' has invalid frequency '{freq}'"
+88 -134
tests/test_insight_full.py
··· 1 1 # SPDX-License-Identifier: AGPL-3.0-only 2 2 # Copyright (c) 2026 sol pbc 3 3 4 + """Tests for the insight generation pipeline. 5 + 6 + Tests cover: 7 + - Basic insight generation and output 8 + - Hook invocation with correct context 9 + - Named hook resolution 10 + """ 11 + 4 12 import importlib 5 13 import json 6 14 import os ··· 25 33 return dest 26 34 27 35 28 - # Mock result must be >= MIN_EXTRACTION_CHARS (50) to trigger extraction 36 + # Mock result must be >= MIN_INPUT_CHARS (50) to generate output 29 37 MOCK_RESULT = "## Meeting Summary\n\nTeam standup at 9am with Alice and Bob discussing project status." 30 38 31 39 32 - def test_ponder_main(tmp_path, monkeypatch): 40 + def test_insight_generates_output(tmp_path, monkeypatch): 41 + """Test basic insight generation saves markdown output.""" 33 42 mod = importlib.import_module("think.insight") 34 43 day_dir = copy_day(tmp_path) 35 44 prompt = tmp_path / "prompt.md" ··· 40 49 "send_insight", 41 50 lambda *a, **k: MOCK_RESULT, 42 51 ) 43 - captured = {} 44 - 45 - def fake_send_extraction(*args, **kwargs): 46 - captured["extra"] = kwargs.get("extra_instructions") 47 - return [ 48 - { 49 - "type": "meeting", 50 - "start": "00:00:00", 51 - "end": "00:00:00", 52 - "title": "t", 53 - "summary": "s", 54 - "work": True, 55 - "participants": [], 56 - "details": "", 57 - "facet": "work", 58 - } 59 - ] 60 - 61 - monkeypatch.setattr(mod, "send_extraction", fake_send_extraction) 62 52 monkeypatch.setenv("GOOGLE_API_KEY", "x") 63 - 64 53 monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 65 54 monkeypatch.setattr("sys.argv", ["sol insight", "20240101", "-f", str(prompt)]) 66 55 mod.main() 67 56 68 57 md = day_dir / "insights" / "prompt.md" 69 58 assert md.read_text() == MOCK_RESULT 70 - # Events now go to facets/{facet}/events/YYYYMMDD.jsonl 71 - events_file = tmp_path / "facets" / "work" / "events" / "20240101.jsonl" 72 - assert events_file.exists() 73 - data = json.loads(events_file.read_text().strip()) 74 - assert data["occurred"] is True 75 - assert data["topic"] == "prompt" 76 - # Facet summaries are now always included in extra_instructions 77 - assert captured["extra"] == "No facets found." 78 59 79 60 80 - def test_ponder_extra_instructions(tmp_path, monkeypatch): 61 + def test_insight_hook_invoked_with_context(tmp_path, monkeypatch): 62 + """Test that hooks receive correct context including multi_segment flag.""" 81 63 mod = importlib.import_module("think.insight") 82 - day_dir = copy_day(tmp_path) 83 - insight_file = Path(mod.__file__).resolve().parent / "insights" / "flow.md" 64 + copy_day(tmp_path) 84 65 85 - # Remove existing flow.md to ensure mock content is used 86 - flow_md = day_dir / "insights" / "flow.md" 87 - if flow_md.exists(): 88 - flow_md.unlink() 66 + # Create insight with hook 67 + insights_dir = tmp_path / "insights" 68 + insights_dir.mkdir() 89 69 90 - monkeypatch.setattr( 91 - mod, 92 - "send_insight", 93 - lambda *a, **k: MOCK_RESULT, 70 + prompt_file = insights_dir / "hooked.md" 71 + prompt_file.write_text( 72 + '{\n "title": "Hooked",\n "frequency": "daily",\n "hook": "test_hook"\n}\n\nTest prompt' 94 73 ) 95 - captured = {} 96 74 97 - def fake_send_extraction(*args, **kwargs): 98 - captured["extra"] = kwargs.get("extra_instructions") 99 - return [ 100 - { 101 - "type": "meeting", 102 - "start": "00:00:00", 103 - "end": "00:00:00", 104 - "title": "t", 105 - "summary": "s", 106 - "work": True, 107 - "participants": [], 108 - "details": "", 109 - "facet": "work", 110 - } 111 - ] 75 + # Create the hook file 76 + hooks_dir = Path(mod.__file__).resolve().parent / "insights" 77 + hook_file = hooks_dir / "test_hook.py" 78 + hook_file.write_text(""" 79 + def process(result, context): 80 + import json 81 + from pathlib import Path 82 + # Write context to file for test verification 83 + out_path = Path(context["output_path"]).parent / "context_captured.json" 84 + ctx_copy = { 85 + "day": context.get("day"), 86 + "segment": context.get("segment"), 87 + "multi_segment": context.get("multi_segment"), 88 + "insight_key": context.get("insight_key"), 89 + "has_transcript": bool(context.get("transcript")), 90 + "has_insight_meta": bool(context.get("insight_meta")), 91 + } 92 + with open(out_path, "w") as f: 93 + json.dump(ctx_copy, f) 94 + return None 95 + """) 112 96 113 - monkeypatch.setattr(mod, "send_extraction", fake_send_extraction) 114 - monkeypatch.setenv("GOOGLE_API_KEY", "x") 97 + try: 98 + monkeypatch.setattr( 99 + mod, 100 + "send_insight", 101 + lambda *a, **k: MOCK_RESULT, 102 + ) 103 + monkeypatch.setenv("GOOGLE_API_KEY", "x") 104 + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 105 + monkeypatch.setattr( 106 + "sys.argv", ["sol insight", "20240101", "-f", str(prompt_file)] 107 + ) 108 + mod.main() 115 109 116 - monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 117 - monkeypatch.setattr( 118 - "sys.argv", ["sol insight", "20240101", "-f", str(insight_file)] 119 - ) 120 - mod.main() 110 + # Read captured context 111 + captured_path = tmp_path / "20240101" / "insights" / "context_captured.json" 112 + captured = json.loads(captured_path.read_text()) 121 113 122 - md = day_dir / "insights" / "flow.md" 123 - assert md.read_text() == MOCK_RESULT 124 - # Events now go to facets/{facet}/events/YYYYMMDD.jsonl 125 - events_file = tmp_path / "facets" / "work" / "events" / "20240101.jsonl" 126 - assert events_file.exists() 127 - data = json.loads(events_file.read_text().strip()) 128 - assert data["occurred"] is True 129 - assert data["topic"] == "flow" 130 - # Facet summaries are prepended to insight-specific occurrence instructions 131 - assert captured["extra"] 132 - assert captured["extra"].startswith("No facets found.") 114 + assert captured["day"] == "20240101" 115 + assert captured["segment"] is None 116 + assert captured["multi_segment"] is False 117 + assert captured["insight_key"] == "hooked" 118 + assert captured["has_transcript"] is True 119 + assert captured["has_insight_meta"] is True 133 120 121 + finally: 122 + # Clean up test hook 123 + if hook_file.exists(): 124 + hook_file.unlink() 134 125 135 - def test_ponder_skip_minimal_content(tmp_path, monkeypatch): 136 - """Test that extraction is skipped when send_insight returns minimal content.""" 126 + 127 + def test_insight_without_hook_succeeds(tmp_path, monkeypatch): 128 + """Test that insights without hooks still work correctly.""" 137 129 mod = importlib.import_module("think.insight") 138 130 day_dir = copy_day(tmp_path) 139 - prompt = tmp_path / "prompt.md" 140 - prompt.write_text('{\n "frequency": "daily"\n}\n\nprompt') 131 + 132 + # Create insight without hook 133 + prompt = tmp_path / "nohook.md" 134 + prompt.write_text('{\n "frequency": "daily"\n}\n\nNo hook prompt') 141 135 142 - # Return minimal content that should trigger skip 143 136 monkeypatch.setattr( 144 137 mod, 145 138 "send_insight", 146 - lambda *a, **k: "No meetings detected", 139 + lambda *a, **k: MOCK_RESULT, 147 140 ) 148 - called = {} 149 - 150 - def fake_send_extraction(*args, **kwargs): 151 - called["called"] = True 152 - return [] 153 - 154 - monkeypatch.setattr(mod, "send_extraction", fake_send_extraction) 155 141 monkeypatch.setenv("GOOGLE_API_KEY", "x") 156 - 157 142 monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 158 143 monkeypatch.setattr("sys.argv", ["sol insight", "20240101", "-f", str(prompt)]) 159 144 mod.main() 160 145 161 - md = day_dir / "insights" / "prompt.md" 162 - assert md.read_text() == "No meetings detected" 163 - # Extraction should NOT have been called due to minimal content 164 - assert "called" not in called 165 - # No events file should be created 166 - events_file = tmp_path / "facets" / "work" / "events" / "20240101.jsonl" 167 - assert not events_file.exists() 146 + md = day_dir / "insights" / "nohook.md" 147 + assert md.read_text() == MOCK_RESULT 168 148 169 149 170 - def test_ponder_skip_occurrences(tmp_path, monkeypatch): 171 - mod = importlib.import_module("think.insight") 172 - day_dir = copy_day(tmp_path) 173 - insight_file = Path(mod.__file__).resolve().parent / "insights" / "flow.md" 174 - 175 - # Remove existing flow.md to ensure mock content is used 176 - flow_md = day_dir / "insights" / "flow.md" 177 - if flow_md.exists(): 178 - flow_md.unlink() 179 - 180 - def fake_get_insights(): 181 - utils = importlib.import_module("think.utils") 182 - insights = utils.get_insights() 183 - insights["flow"]["occurrences"] = False 184 - return insights 150 + def test_named_hook_resolution(tmp_path, monkeypatch): 151 + """Test that named hooks are resolved from think/insights/{hook}.py.""" 152 + utils = importlib.import_module("think.utils") 185 153 186 - monkeypatch.setattr(mod, "get_insights", fake_get_insights) 187 - monkeypatch.setattr( 188 - mod, 189 - "send_insight", 190 - lambda *a, **k: MOCK_RESULT, 154 + # Create insight with named hook 155 + insight_file = tmp_path / "test_insight.md" 156 + insight_file.write_text( 157 + '{\n "title": "Test",\n "hook": "occurrence"\n}\n\nTest prompt' 191 158 ) 192 - called = {} 193 159 194 - def fake_send_extraction(*args, **kwargs): 195 - called["called"] = True 196 - return [] 160 + meta = utils._load_insight_metadata(insight_file) 197 161 198 - monkeypatch.setattr(mod, "send_extraction", fake_send_extraction) 199 - monkeypatch.setenv("GOOGLE_API_KEY", "x") 200 - 201 - monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 202 - monkeypatch.setattr( 203 - "sys.argv", ["sol insight", "20240101", "-f", str(insight_file)] 204 - ) 205 - mod.main() 206 - 207 - md = day_dir / "insights" / "flow.md" 208 - js = day_dir / "insights" / "flow.json" 209 - assert md.read_text() == MOCK_RESULT 210 - assert not js.exists() 211 - assert "called" not in called 162 + # Should resolve to think/insights/occurrence.py 163 + assert "hook_path" in meta 164 + assert meta["hook_path"].endswith("occurrence.py") 165 + assert "think/insights/occurrence.py" in meta["hook_path"].replace("\\", "/")
+56 -4
tests/test_insight_hooks.py
··· 119 119 insights_dir.mkdir() 120 120 121 121 prompt_file = insights_dir / "hooked.md" 122 - prompt_file.write_text('{\n "title": "Hooked",\n "occurrences": false,\n "frequency": "daily"\n}\n\nTest prompt') 122 + prompt_file.write_text( 123 + '{\n "title": "Hooked",\n "occurrences": false,\n "frequency": "daily"\n}\n\nTest prompt' 124 + ) 123 125 124 126 hook_file = insights_dir / "hooked.py" 125 127 hook_file.write_text(""" ··· 157 159 insights_dir.mkdir() 158 160 159 161 prompt_file = insights_dir / "noop.md" 160 - prompt_file.write_text('{\n "title": "Noop",\n "occurrences": false,\n "frequency": "daily"\n}\n\nTest prompt') 162 + prompt_file.write_text( 163 + '{\n "title": "Noop",\n "occurrences": false,\n "frequency": "daily"\n}\n\nTest prompt' 164 + ) 161 165 162 166 hook_file = insights_dir / "noop.py" 163 167 hook_file.write_text(""" ··· 190 194 insights_dir.mkdir() 191 195 192 196 prompt_file = insights_dir / "broken.md" 193 - prompt_file.write_text('{\n "title": "Broken",\n "occurrences": false,\n "frequency": "daily"\n}\n\nTest prompt') 197 + prompt_file.write_text( 198 + '{\n "title": "Broken",\n "occurrences": false,\n "frequency": "daily"\n}\n\nTest prompt' 199 + ) 194 200 195 201 hook_file = insights_dir / "broken.py" 196 202 hook_file.write_text(""" ··· 224 230 insights_dir.mkdir() 225 231 226 232 prompt_file = insights_dir / "context_check.md" 227 - prompt_file.write_text('{\n "title": "Context Check",\n "occurrences": false,\n "frequency": "daily"\n}\n\nTest prompt') 233 + prompt_file.write_text( 234 + '{\n "title": "Context Check",\n "occurrences": false,\n "frequency": "daily"\n}\n\nTest prompt' 235 + ) 228 236 229 237 # Write captured context to a file for verification 230 238 hook_file = insights_dir / "context_check.py" ··· 267 275 assert captured["has_transcript"] is True 268 276 assert captured["has_insight_meta"] is True 269 277 assert "output_path" in captured 278 + 279 + 280 + def test_named_hook_resolution_takes_precedence(tmp_path): 281 + """Test that named hooks via 'hook' field take precedence over co-located .py files.""" 282 + utils = importlib.import_module("think.utils") 283 + 284 + # Create insight file with named hook 285 + md_file = tmp_path / "test_insight.md" 286 + md_file.write_text( 287 + '{\n "title": "Test",\n "hook": "occurrence"\n}\n\nTest prompt' 288 + ) 289 + 290 + # Also create a co-located .py file that would normally be picked up 291 + colocated_hook = tmp_path / "test_insight.py" 292 + colocated_hook.write_text("def process(r, c): return 'colocated'") 293 + 294 + meta = utils._load_insight_metadata(md_file) 295 + 296 + # Should resolve to named hook, not co-located 297 + assert "hook_path" in meta 298 + assert meta["hook_path"].endswith("occurrence.py") 299 + assert "think/insights/occurrence.py" in meta["hook_path"].replace("\\", "/") 300 + 301 + 302 + def test_named_hook_nonexistent_falls_through(tmp_path): 303 + """Test that nonexistent named hooks fall back to co-located .py files.""" 304 + utils = importlib.import_module("think.utils") 305 + 306 + # Create insight file with nonexistent named hook 307 + md_file = tmp_path / "test_insight.md" 308 + md_file.write_text( 309 + '{\n "title": "Test",\n "hook": "nonexistent_hook_xyz"\n}\n\nTest prompt' 310 + ) 311 + 312 + # Create a co-located .py file 313 + colocated_hook = tmp_path / "test_insight.py" 314 + colocated_hook.write_text("def process(r, c): return 'colocated'") 315 + 316 + meta = utils._load_insight_metadata(md_file) 317 + 318 + # Named hook doesn't exist, so no hook_path should be set (co-located not checked when named specified) 319 + # Actually the current implementation checks co-located only if hook field is not set 320 + # So with a nonexistent named hook, no hook_path should be set 321 + assert "hook_path" not in meta
+7
think/anticipation.md think/insights/anticipation.md
··· 1 + { 2 + 3 + "title": "Anticipation Extraction", 4 + "description": "Extracts structured anticipation events (future scheduled items) from insight summaries." 5 + 6 + } 7 + 1 8 # Anticipation JSON Conversion 2 9 3 10 ## Objective
+5 -254
think/insight.py
··· 2 2 # Copyright (c) 2026 sol pbc 3 3 4 4 import argparse 5 - import json 6 5 import logging 7 6 import os 8 7 from datetime import datetime ··· 23 22 format_segment_times, 24 23 get_insight_topic, 25 24 get_insights, 26 - get_journal, 27 25 load_insight_hook, 28 26 load_prompt, 29 27 segment_parse, 30 28 setup_cli, 31 29 ) 32 - 33 - 34 - def _write_events_jsonl( 35 - events: list[dict], 36 - topic: str, 37 - occurred: bool, 38 - source_insight: str, 39 - capture_day: str, 40 - ) -> list[Path]: 41 - """Write events to facet-based JSONL files. 42 - 43 - Groups events by facet and writes each to the appropriate file: 44 - facets/{facet}/events/{event_day}.jsonl 45 - 46 - Args: 47 - events: List of event dictionaries from extraction. 48 - topic: Source insight topic (e.g., "meetings", "schedule"). 49 - occurred: True for occurrences, False for anticipations. 50 - source_insight: Relative path to source insight file. 51 - capture_day: Day the insight was captured (YYYYMMDD). 52 - 53 - Returns: 54 - List of paths to written JSONL files. 55 - """ 56 - journal = get_journal() 57 - 58 - # Group events by (facet, event_day) 59 - grouped: dict[tuple[str, str], list[dict]] = {} 60 - 61 - for event in events: 62 - facet = event.get("facet", "") 63 - if not facet: 64 - continue # Skip events without facet 65 - 66 - # Determine the event day 67 - if occurred: 68 - # Occurrences use capture day 69 - event_day = capture_day 70 - else: 71 - # Anticipations use their scheduled date 72 - event_date = event.get("date", "") 73 - # Convert YYYY-MM-DD to YYYYMMDD 74 - event_day = event_date.replace("-", "") if event_date else capture_day 75 - 76 - if not event_day: 77 - continue 78 - 79 - key = (facet, event_day) 80 - if key not in grouped: 81 - grouped[key] = [] 82 - 83 - # Enrich event with metadata 84 - enriched = dict(event) 85 - enriched["topic"] = topic 86 - enriched["occurred"] = occurred 87 - enriched["source"] = source_insight 88 - 89 - grouped[key].append(enriched) 90 - 91 - # Write each group to its JSONL file 92 - written_paths: list[Path] = [] 93 - 94 - for (facet, event_day), facet_events in grouped.items(): 95 - events_dir = Path(journal) / "facets" / facet / "events" 96 - events_dir.mkdir(parents=True, exist_ok=True) 97 - 98 - jsonl_path = events_dir / f"{event_day}.jsonl" 99 - with open(jsonl_path, "a", encoding="utf-8") as f: 100 - for event in facet_events: 101 - f.write(json.dumps(event, ensure_ascii=False) + "\n") 102 - 103 - written_paths.append(jsonl_path) 104 - 105 - return written_paths 106 30 107 31 108 32 def _output_path( ··· 285 209 ) 286 210 287 211 288 - # Minimum content length for meaningful event extraction 289 - MIN_EXTRACTION_CHARS = 50 290 - 291 - 292 - def _should_skip_extraction(result: str) -> bool: 293 - """Check if result is too minimal for meaningful event extraction. 294 - 295 - When insight generation returns very short content (e.g., "No meetings detected"), 296 - there's nothing substantive to extract. Skipping extraction in these cases 297 - avoids unnecessary API calls and prevents hallucination from entity context. 298 - 299 - Args: 300 - result: The insight result from send_insight(). 301 - 302 - Returns: 303 - True if extraction should be skipped, False otherwise. 304 - """ 305 - return len(result.strip()) < MIN_EXTRACTION_CHARS 306 - 307 - 308 - def send_extraction( 309 - markdown: str, 310 - prompt: str, 311 - extra_instructions: str | None = None, 312 - insight_key: str | None = None, 313 - ) -> list: 314 - """Extract structured JSON events from markdown summary. 315 - 316 - Used for both occurrences (past events) and anticipations (future events). 317 - 318 - Parameters 319 - ---------- 320 - markdown: 321 - Markdown summary to extract events from. 322 - prompt: 323 - System instruction guiding the extraction. 324 - extra_instructions: 325 - Optional additional instructions prepended to ``markdown``. 326 - insight_key: 327 - Insight key for token usage context (e.g., "decisions"). 328 - 329 - Returns 330 - ------- 331 - list 332 - Array of extracted event objects. 333 - """ 334 - # Build context for provider routing and token logging 335 - context = f"insight.{insight_key}.extraction" if insight_key else "insight.unknown" 336 - 337 - contents = [markdown] 338 - if extra_instructions: 339 - contents.insert(0, extra_instructions) 340 - 341 - response_text = generate( 342 - contents=contents, 343 - context=context, 344 - temperature=0.3, 345 - max_output_tokens=8192 * 6, 346 - thinking_budget=8192 * 3, 347 - system_instruction=prompt, 348 - json_output=True, 349 - ) 350 - 351 - try: 352 - events = json.loads(response_text) 353 - except json.JSONDecodeError as e: 354 - raise ValueError(f"Invalid JSON response: {e}: {response_text[:100]}") 355 - 356 - if not isinstance(events, list): 357 - raise ValueError(f"Response is not an array: {response_text[:100]}") 358 - 359 - return events 212 + # Minimum content length for insight generation 213 + MIN_INPUT_CHARS = 50 360 214 361 215 362 216 def main() -> None: ··· 464 318 day_log(args.day, f"insight {get_insight_topic(topic_arg)} skipped (disabled)") 465 319 return 466 320 467 - # Check extract override from journal config 468 - # When extract is False, disable both occurrences and anticipations 469 - extract = insight_meta.get("extract") 470 - if extract is False: 471 - extra_occ = False 472 - skip_occ = True 473 - do_anticipations = False 474 - else: 475 - extra_occ = insight_meta.get("occurrences") 476 - skip_occ = extra_occ is False 477 - do_anticipations = insight_meta.get("anticipations") is True 478 - 479 321 output_format = insight_meta.get("output") # "json" or None (markdown) 480 322 success = False 481 323 ··· 491 333 system_prompt_name = instructions.get("system_prompt_name", "journal") 492 334 system_instruction = instructions["system_instruction"] 493 335 494 - # Multi-segment mode: skip extraction entirely 336 + # Track multi-segment mode for hook context 495 337 multi_segment_mode = bool(args.segments) 496 - if multi_segment_mode: 497 - skip_occ = True 498 - do_anticipations = False 499 338 500 339 # Choose clustering function based on mode, passing sources config 501 340 if args.segments: ··· 513 352 day_dir = str(day_path(args.day)) 514 353 515 354 # Skip insight generation when there's nothing to analyze 516 - if file_count == 0 or len(markdown.strip()) < MIN_EXTRACTION_CHARS: 355 + if file_count == 0 or len(markdown.strip()) < MIN_INPUT_CHARS: 517 356 logging.info( 518 357 "Insufficient input (files=%d, chars=%d), skipping insight generation", 519 358 file_count, ··· 664 503 hook_context = { 665 504 "day": args.day, 666 505 "segment": args.segment, 506 + "multi_segment": multi_segment_mode, 667 507 "insight_key": insight_key, 668 508 "output_path": str(output_path), 669 509 "insight_meta": dict(insight_meta), ··· 687 527 with open(output_path, "w") as f: 688 528 f.write(result) 689 529 print(f"Results saved to: {output_path}") 690 - 691 - # Skip extraction for JSON output (output IS the structured data) 692 - if is_json_output: 693 - success = True 694 - return 695 - 696 - if skip_occ and not do_anticipations: 697 - print('"occurrences" set to false; skipping event extraction') 698 - success = True 699 - return 700 - 701 - # Skip extraction for minimal content (prevents hallucination from entity context) 702 - if _should_skip_extraction(result): 703 - logging.info( 704 - "Minimal content (%d chars < %d), skipping event extraction", 705 - len(result.strip()), 706 - MIN_EXTRACTION_CHARS, 707 - ) 708 - success = True 709 - return 710 - 711 - # Determine which prompt to use: anticipations or occurrences 712 - prompt_name = "anticipation" if do_anticipations else "occurrence" 713 - 714 - # Load the appropriate extraction prompt 715 - try: 716 - extraction_prompt_content = load_prompt( 717 - prompt_name, base_dir=Path(__file__).parent 718 - ) 719 - except PromptNotFoundError as exc: 720 - print(exc) 721 - return 722 - 723 - extraction_prompt = extraction_prompt_content.text 724 - 725 - try: 726 - # Load facet summaries and combine with topic-specific instructions 727 - from think.facets import facet_summaries 728 - 729 - facets_context = facet_summaries(detailed_entities=True) 730 - 731 - # Combine facet summaries with topic-specific instructions 732 - if extra_occ and not do_anticipations: 733 - combined_instructions = f"{facets_context}\n\n{extra_occ}" 734 - else: 735 - combined_instructions = facets_context 736 - 737 - events = send_extraction( 738 - result, 739 - extraction_prompt, 740 - extra_instructions=combined_instructions, 741 - insight_key=insight_key, 742 - ) 743 - except ValueError as e: 744 - print(f"Error: {e}") 745 - return 746 - 747 - # Write to new JSONL format (facets/{facet}/events/{day}.jsonl) 748 - occurred = not do_anticipations 749 - insight_topic = get_insight_topic(insight_key) 750 - 751 - # Compute the relative source insight path 752 - # output_path is absolute, day_dir is the YYYYMMDD directory path 753 - # source_insight should be like "20240101/insights/meetings.md" or ".json" 754 - journal = get_journal() 755 - ext = "json" if output_format == "json" else "md" 756 - try: 757 - source_insight = os.path.relpath(str(output_path), journal) 758 - except ValueError: 759 - # Fallback: construct from day and topic 760 - source_insight = os.path.join( 761 - day, 762 - "insights" if not args.segment else args.segment, 763 - f"{insight_topic}.{ext}", 764 - ) 765 - written_paths = _write_events_jsonl( 766 - events=events, 767 - topic=insight_topic, 768 - occurred=occurred, 769 - source_insight=source_insight, 770 - capture_day=day, 771 - ) 772 - 773 - if written_paths: 774 - print(f"Events written to {len(written_paths)} JSONL file(s):") 775 - for p in written_paths: 776 - print(f" {p}") 777 - else: 778 - print("No events with valid facets to write") 779 530 780 531 success = True 781 532
+149
think/insights.py
··· 354 354 raw_chunks = chunk_markdown(text) 355 355 chunks = [{"markdown": render_chunk(c)} for c in raw_chunks] 356 356 return chunks, {} 357 + 358 + 359 + # --------------------------------------------------------------------------- 360 + # Extraction Hook Utilities 361 + # --------------------------------------------------------------------------- 362 + # Shared utilities for insight extraction hooks (occurrence.py, anticipation.py) 363 + 364 + import json 365 + import logging 366 + import os 367 + from pathlib import Path 368 + 369 + # Minimum content length for meaningful event extraction 370 + MIN_EXTRACTION_CHARS = 50 371 + 372 + 373 + def should_skip_extraction(result: str, context: dict) -> str | None: 374 + """Check if extraction should be skipped and return reason, or None to proceed. 375 + 376 + Args: 377 + result: The generated insight markdown content. 378 + context: Hook context dict with insight_meta and multi_segment. 379 + 380 + Returns: 381 + Skip reason string if extraction should be skipped, None otherwise. 382 + """ 383 + insight_meta = context.get("insight_meta", {}) 384 + 385 + # Skip if extraction disabled via journal config 386 + if insight_meta.get("extract") is False: 387 + return "extraction disabled via journal config" 388 + 389 + # Skip for JSON output (output IS the structured data) 390 + if insight_meta.get("output") == "json": 391 + return "JSON output (already structured)" 392 + 393 + # Skip in multi-segment mode 394 + if context.get("multi_segment"): 395 + return "multi-segment mode" 396 + 397 + # Skip for minimal content 398 + if len(result.strip()) < MIN_EXTRACTION_CHARS: 399 + return f"minimal content ({len(result.strip())} chars < {MIN_EXTRACTION_CHARS})" 400 + 401 + return None 402 + 403 + 404 + def write_events_jsonl( 405 + events: list[dict], 406 + topic: str, 407 + occurred: bool, 408 + source_insight: str, 409 + capture_day: str, 410 + ) -> list[Path]: 411 + """Write events to facet-based JSONL files. 412 + 413 + Groups events by facet and writes each to the appropriate file: 414 + facets/{facet}/events/{event_day}.jsonl 415 + 416 + Args: 417 + events: List of event dictionaries from extraction. 418 + topic: Source insight topic (e.g., "meetings", "schedule"). 419 + occurred: True for occurrences, False for anticipations. 420 + source_insight: Relative path to source insight file. 421 + capture_day: Day the insight was captured (YYYYMMDD). 422 + 423 + Returns: 424 + List of paths to written JSONL files. 425 + """ 426 + from think.utils import get_journal 427 + 428 + journal = get_journal() 429 + 430 + # Group events by (facet, event_day) 431 + grouped: dict[tuple[str, str], list[dict]] = {} 432 + 433 + for event in events: 434 + facet = event.get("facet", "") 435 + if not facet: 436 + continue # Skip events without facet 437 + 438 + # Determine the event day 439 + if occurred: 440 + # Occurrences use capture day 441 + event_day = capture_day 442 + else: 443 + # Anticipations use their scheduled date 444 + event_date = event.get("date", "") 445 + # Convert YYYY-MM-DD to YYYYMMDD 446 + event_day = event_date.replace("-", "") if event_date else capture_day 447 + 448 + if not event_day: 449 + continue 450 + 451 + key = (facet, event_day) 452 + if key not in grouped: 453 + grouped[key] = [] 454 + 455 + # Enrich event with metadata 456 + enriched = dict(event) 457 + enriched["topic"] = topic 458 + enriched["occurred"] = occurred 459 + enriched["source"] = source_insight 460 + 461 + grouped[key].append(enriched) 462 + 463 + # Write each group to its JSONL file 464 + written_paths: list[Path] = [] 465 + 466 + for (facet, event_day), facet_events in grouped.items(): 467 + events_dir = Path(journal) / "facets" / facet / "events" 468 + events_dir.mkdir(parents=True, exist_ok=True) 469 + 470 + jsonl_path = events_dir / f"{event_day}.jsonl" 471 + with open(jsonl_path, "a", encoding="utf-8") as f: 472 + for event in facet_events: 473 + f.write(json.dumps(event, ensure_ascii=False) + "\n") 474 + 475 + written_paths.append(jsonl_path) 476 + 477 + return written_paths 478 + 479 + 480 + def compute_source_insight(context: dict) -> str: 481 + """Compute relative source insight path from hook context. 482 + 483 + Args: 484 + context: Hook context dict with day, segment, insight_key, output_path. 485 + 486 + Returns: 487 + Relative path like "20240101/insights/meetings.md". 488 + """ 489 + from think.utils import get_insight_topic, get_journal 490 + 491 + day = context.get("day", "") 492 + output_path = context.get("output_path", "") 493 + insight_key = context.get("insight_key", "unknown") 494 + journal = get_journal() 495 + 496 + try: 497 + return os.path.relpath(output_path, journal) 498 + except ValueError: 499 + segment = context.get("segment") 500 + topic = get_insight_topic(insight_key) 501 + return os.path.join( 502 + day, 503 + "insights" if not segment else segment, 504 + f"{topic}.md", 505 + )
-1
think/insights/activity.md
··· 2 2 3 3 "title": "Activity Synthesis", 4 4 "description": "Interprets each segment to extract meaning, intent, and searchability. Focuses on the 'why' behind actions - tasks, progress states, facets, and keywords for discovery.", 5 - "occurrences": false, 6 5 "color": "#00bcd4", 7 6 "frequency": "segment" 8 7
+105
think/insights/anticipation.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Hook for extracting anticipation events from insight results. 5 + 6 + This hook is invoked via "hook": "anticipation" in insight frontmatter. 7 + It extracts structured JSON events for future scheduled items and writes 8 + them to facet-based JSONL files. 9 + """ 10 + 11 + import json 12 + import logging 13 + from pathlib import Path 14 + 15 + from muse.models import generate 16 + from think.facets import facet_summaries 17 + from think.insights import ( 18 + compute_source_insight, 19 + should_skip_extraction, 20 + write_events_jsonl, 21 + ) 22 + from think.utils import get_insight_topic, load_prompt 23 + 24 + 25 + def process(result: str, context: dict) -> str | None: 26 + """Extract anticipation events from insight result. 27 + 28 + This hook extracts structured JSON events for future scheduled items 29 + from markdown insight summaries and writes them to facet-based JSONL files. 30 + 31 + Args: 32 + result: The generated insight markdown content. 33 + context: Hook context with keys: 34 + - day: YYYYMMDD string 35 + - segment: segment key or None 36 + - insight_key: e.g., "schedule" 37 + - output_path: absolute path to output file 38 + - insight_meta: dict with frontmatter 39 + - transcript: the clustered transcript markdown 40 + - multi_segment: True if processing multiple segments 41 + 42 + Returns: 43 + None - this hook does not modify the insight result. 44 + """ 45 + # Check skip conditions 46 + skip_reason = should_skip_extraction(result, context) 47 + if skip_reason: 48 + logging.info("Skipping anticipation extraction: %s", skip_reason) 49 + return None 50 + 51 + # Load extraction prompt 52 + prompt_content = load_prompt("anticipation", base_dir=Path(__file__).parent) 53 + 54 + # Build context with facets (anticipations don't have topic-specific instructions) 55 + facets_context = facet_summaries(detailed_entities=True) 56 + 57 + # Extract events 58 + insight_key = context.get("insight_key", "unknown") 59 + contents = [facets_context, result] 60 + 61 + try: 62 + response_text = generate( 63 + contents=contents, 64 + context=f"insight.{insight_key}.extraction", 65 + temperature=0.3, 66 + max_output_tokens=8192 * 6, 67 + thinking_budget=8192 * 3, 68 + system_instruction=prompt_content.text, 69 + json_output=True, 70 + ) 71 + except Exception as e: 72 + logging.error("Extraction generation failed: %s", e) 73 + return None 74 + 75 + try: 76 + events = json.loads(response_text) 77 + except json.JSONDecodeError as e: 78 + logging.error("Invalid JSON from extraction: %s", e) 79 + return None 80 + 81 + if not isinstance(events, list): 82 + logging.error("Extraction did not return array") 83 + return None 84 + 85 + # Write to facet JSONL files (occurred=False for anticipations) 86 + source_insight = compute_source_insight(context) 87 + topic = get_insight_topic(insight_key) 88 + day = context.get("day", "") 89 + 90 + written_paths = write_events_jsonl( 91 + events=events, 92 + topic=topic, 93 + occurred=False, 94 + source_insight=source_insight, 95 + capture_day=day, 96 + ) 97 + 98 + if written_paths: 99 + print(f"Events written to {len(written_paths)} JSONL file(s):") 100 + for p in written_paths: 101 + print(f" {p}") 102 + else: 103 + print("No events with valid facets to write") 104 + 105 + return None # Don't modify insight result
+1
think/insights/decisions.md
··· 3 3 "title": "Decision Actions", 4 4 "description": "Tracks consequential decision-actions that change state, plans, resources, responsibilities, or timing in ways that affect other people.", 5 5 "occurrences": "Create an occurrence for every decision-action observed. Include the time span, decision type, actors involved, entities affected, and impact assessment. Each occurrence should capture both the intent and enactment of the decision.", 6 + "hook": "occurrence", 6 7 "color": "#dc3545", 7 8 "frequency": "daily" 8 9
+1
think/insights/documentation.md
··· 3 3 "title": "Documentation Moments", 4 4 "description": "Finds when important knowledge is shared in the transcript and suggests what should be written down. Output is a Markdown list of documentation opportunities with time ranges and destinations.", 5 5 "occurrences": "Record an occurrence whenever a new procedure, decision or troubleshooting step is described. Capture the related file or tool and where the documentation should live such as wiki or README.", 6 + "hook": "occurrence", 6 7 "color": "#007bff", 7 8 "frequency": "daily" 8 9
+1
think/insights/files.md
··· 3 3 "title": "File Interactions", 4 4 "description": "Reviews the day's transcript to capture each significant file or attachment that was opened, saved or shared. Generates a Markdown timeline with context about how the file was used.", 5 5 "occurrences": "Create an occurrence for every notable file referenced, including the path when known and the related project. Ignore terminal output and focus on visible file explorers or sharing actions.", 6 + "hook": "occurrence", 6 7 "color": "#28a745", 7 8 "frequency": "daily", 8 9 "disabled": true
+1
think/insights/flow.md
··· 3 3 "title": "Day Overview", 4 4 "description": "Summarizes the overall flow of the workday. Looks for patterns in focus, energy, context switching and highlights productivity insights in a Markdown report.", 5 5 "occurrences": "Create an occurrence for noteworthy shifts in work rhythms or focus. Include timestamps when deep work starts or ends, or when energy levels noticeably change. Classify each as work or personal based on the surrounding context.", 6 + "hook": "occurrence", 6 7 "color": "#17a2b8", 7 8 "frequency": "daily" 8 9
+1
think/insights/followups.md
··· 3 3 "title": "Follow-Up Items", 4 4 "description": "Scans the day chronologically to capture promised tasks or reminders for future action. The prompt outputs a concise Markdown list of the most important follow-ups.", 5 5 "occurrences": "Whenever a future task or commitment is mentioned, create an occurrence with the expected action and deadline if known. Note who requested it and whether it is work or personal.", 6 + "hook": "occurrence", 6 7 "color": "#ffc107", 7 8 "frequency": "daily" 8 9
-2
think/insights/importer.md
··· 3 3 "color": "blue", 4 4 "output": null, 5 5 "extract": false, 6 - "occurrences": false, 7 - "anticipations": false, 8 6 "frequency": "daily" 9 7 10 8 }
+1
think/insights/knowledge_graph.md
··· 3 3 "title": "Knowledge Graph", 4 4 "description": "Extracts people, projects, tools and other entities from the transcript and maps how they relate. Produces a Markdown report plus narrative describing network hubs and bridges discovered during the day.", 5 5 "occurrences": "For each entity interaction or relationship mentioned, create an occurrence describing the connection. Include start and end times when the relationship is visible, and capture the type of link such as works-on or discusses-with.", 6 + "hook": "occurrence", 6 7 "color": "#6f42c1", 7 8 "frequency": "daily" 8 9
+1
think/insights/media.md
··· 3 3 "title": "Media Consumption", 4 4 "description": "Identifies when videos, articles, music or social content are consumed. Classifies each instance as work or personal and reports the source in chronological sections.", 5 5 "occurrences": "Create an occurrence whenever media consumption is noted, noting the application or site and whether it was for work or leisure. Include a short summary of the topic if visible.", 6 + "hook": "occurrence", 6 7 "color": "#fd7e14", 7 8 "frequency": "daily", 8 9 "disabled": true
+1
think/insights/meetings.md
··· 3 3 "title": "Meeting Summary", 4 4 "description": "Detects all meetings throughout the day by analyzing audio and screen cues. For each meeting it notes time range, participants, topics discussed and whether slides were shown.", 5 5 "occurrences": "Each meeting should generate an occurrence with start and end times, list of participants and a concise summary. If slides are present, mention them in the details field.", 6 + "hook": "occurrence", 6 7 "color": "#e83e8c", 7 8 "frequency": "daily" 8 9
+1
think/insights/messages.md
··· 3 3 "title": "Messaging Activity", 4 4 "description": "Tracks use of email and chat applications across the day. Each interaction is summarized with participants, app used and visible message content.", 5 5 "occurrences": "Create an occurrence for every message read or sent. Include the time block, app name, contacts involved and whether Jeremie was reading or replying. Summaries should capture any visible text.", 6 + "hook": "occurrence", 6 7 "color": "#6c757d", 7 8 "frequency": "daily" 8 9
+110
think/insights/occurrence.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Hook for extracting occurrence events from insight results. 5 + 6 + This hook is invoked via "hook": "occurrence" in insight frontmatter. 7 + It extracts structured JSON events from markdown summaries and writes 8 + them to facet-based JSONL files. 9 + """ 10 + 11 + import json 12 + import logging 13 + from pathlib import Path 14 + 15 + from muse.models import generate 16 + from think.facets import facet_summaries 17 + from think.insights import ( 18 + compute_source_insight, 19 + should_skip_extraction, 20 + write_events_jsonl, 21 + ) 22 + from think.utils import get_insight_topic, load_prompt 23 + 24 + 25 + def process(result: str, context: dict) -> str | None: 26 + """Extract occurrence events from insight result. 27 + 28 + This hook extracts structured JSON events from markdown insight summaries 29 + and writes them to facet-based JSONL files. 30 + 31 + Args: 32 + result: The generated insight markdown content. 33 + context: Hook context with keys: 34 + - day: YYYYMMDD string 35 + - segment: segment key or None 36 + - insight_key: e.g., "meetings", "flow" 37 + - output_path: absolute path to output file 38 + - insight_meta: dict with frontmatter including "occurrences" 39 + - transcript: the clustered transcript markdown 40 + - multi_segment: True if processing multiple segments 41 + 42 + Returns: 43 + None - this hook does not modify the insight result. 44 + """ 45 + # Check skip conditions 46 + skip_reason = should_skip_extraction(result, context) 47 + if skip_reason: 48 + logging.info("Skipping occurrence extraction: %s", skip_reason) 49 + return None 50 + 51 + # Load extraction prompt 52 + prompt_content = load_prompt("occurrence", base_dir=Path(__file__).parent) 53 + 54 + # Build context with facets + topic-specific instructions 55 + facets_context = facet_summaries(detailed_entities=True) 56 + topic_instructions = context.get("insight_meta", {}).get("occurrences") 57 + if topic_instructions and isinstance(topic_instructions, str): 58 + extra_instructions = f"{facets_context}\n\n{topic_instructions}" 59 + else: 60 + extra_instructions = facets_context 61 + 62 + # Extract events 63 + insight_key = context.get("insight_key", "unknown") 64 + contents = [extra_instructions, result] 65 + 66 + try: 67 + response_text = generate( 68 + contents=contents, 69 + context=f"insight.{insight_key}.extraction", 70 + temperature=0.3, 71 + max_output_tokens=8192 * 6, 72 + thinking_budget=8192 * 3, 73 + system_instruction=prompt_content.text, 74 + json_output=True, 75 + ) 76 + except Exception as e: 77 + logging.error("Extraction generation failed: %s", e) 78 + return None 79 + 80 + try: 81 + events = json.loads(response_text) 82 + except json.JSONDecodeError as e: 83 + logging.error("Invalid JSON from extraction: %s", e) 84 + return None 85 + 86 + if not isinstance(events, list): 87 + logging.error("Extraction did not return array") 88 + return None 89 + 90 + # Write to facet JSONL files 91 + source_insight = compute_source_insight(context) 92 + topic = get_insight_topic(insight_key) 93 + day = context.get("day", "") 94 + 95 + written_paths = write_events_jsonl( 96 + events=events, 97 + topic=topic, 98 + occurred=True, 99 + source_insight=source_insight, 100 + capture_day=day, 101 + ) 102 + 103 + if written_paths: 104 + print(f"Events written to {len(written_paths)} JSONL file(s):") 105 + for p in written_paths: 106 + print(f" {p}") 107 + else: 108 + print("No events with valid facets to write") 109 + 110 + return None # Don't modify insight result
+1
think/insights/opportunities.md
··· 3 3 "title": "Innovation Opportunities", 4 4 "description": "Scans conversations and tasks for sparks of new ideas, problem statements and potential ventures. Outputs a list of the most promising opportunities with context and suggested next steps.", 5 5 "occurrences": "Whenever a novel idea or pain point is raised, record an occurrence describing the opportunity and any proposed solution. Include who mentioned it and classify the potential impact.", 6 + "hook": "occurrence", 6 7 "color": "#20c997", 7 8 "frequency": "daily" 8 9
+1
think/insights/research.md
··· 3 3 "title": "Research Needs", 4 4 "description": "Highlights moments where additional information would help progress work. Produces a list of targeted research tasks with time ranges and context.", 5 5 "occurrences": "Log an occurrence each time a knowledge gap or open question appears. Mention the problem area and any suggested resources to investigate so the task can be assigned.", 6 + "hook": "occurrence", 6 7 "color": "#ff5722", 7 8 "frequency": "daily" 8 9
+1 -2
think/insights/schedule.md
··· 2 2 3 3 "title": "Upcoming Schedule", 4 4 "description": "Identifies all future calendar events and scheduled activities noted in transcripts. Extracts dates, times, participants, and event details for anything scheduled beyond today.", 5 - "occurrences": false, 6 - "anticipations": true, 5 + "hook": "anticipation", 7 6 "color": "#6f42c1", 8 7 "frequency": "daily" 9 8
-1
think/insights/screen.md
··· 2 2 3 3 "title": "Screen Record", 4 4 "description": "Creates a detailed documentary record of screen activity. Focuses on the 'what' - chronological account with preserved details, excerpts, and entities.", 5 - "occurrences": false, 6 5 "color": "#9c27b0", 7 6 "frequency": "segment" 8 7
+1
think/insights/timeline.md
··· 3 3 "title": "Day Timeline", 4 4 "description": "Constructs a detailed chronological timeline documenting every activity, task shift, and event throughout the workday. Creates a comprehensive historical record with rich descriptions of what happened when.", 5 5 "occurrences": "Create an occurrence for each hour segment, don't break down hours into any smaller segments the goal for timeline occurrences is for them to capture whatever happened within each hour of the day where there was activity.", 6 + "hook": "occurrence", 6 7 "color": "#9c27b0", 7 8 "frequency": "daily" 8 9
+1
think/insights/tools.md
··· 3 3 "title": "Tool Usage", 4 4 "description": "Catalogues every application or service used throughout the day and how long it was active. The report details which tools are critical, supporting or distracting.", 5 5 "occurrences": "Whenever a tool is launched or actively used, create an occurrence noting the time span, purpose and intensity of use. Distinguish between core work tools and occasional utilities.", 6 + "hook": "occurrence", 6 7 "color": "#795548", 7 8 "frequency": "daily", 8 9 "disabled": true
+7
think/occurrence.md think/insights/occurrence.md
··· 1 + { 2 + 3 + "title": "Occurrence Extraction", 4 + "description": "Extracts structured occurrence events from insight summaries." 5 + 6 + } 7 + 1 8 # Occurrence JSON Conversion 2 9 3 10 ## Objective
+25 -9
think/utils.py
··· 14 14 from string import Template 15 15 from typing import Any, Callable, NamedTuple, Optional 16 16 17 + import frontmatter 17 18 import platformdirs 18 19 from dotenv import load_dotenv 19 - import frontmatter 20 20 from timefhuman import timefhuman 21 21 22 22 DATE_RE = re.compile(r"\d{8}") ··· 49 49 for md_path in TEMPLATES_DIR.glob("*.md"): 50 50 var_name = md_path.stem 51 51 try: 52 - post = frontmatter.load(md_path, ) 52 + post = frontmatter.load( 53 + md_path, 54 + ) 53 55 _templates_cache[var_name] = post.content.strip() 54 56 except Exception as exc: 55 57 logging.debug("Failed to load template %s: %s", md_path, exc) ··· 208 210 prompt_dir = Path(base_dir) if base_dir is not None else Path(__file__).parent 209 211 prompt_path = prompt_dir / filename 210 212 try: 211 - post = frontmatter.load(prompt_path, ) 213 + post = frontmatter.load( 214 + prompt_path, 215 + ) 212 216 text = post.content.strip() 213 217 metadata = dict(post.metadata) 214 218 except FileNotFoundError as exc: # pragma: no cover - caller handles missing prompt ··· 779 783 } 780 784 781 785 try: 782 - post = frontmatter.load(md_path, ) 786 + post = frontmatter.load( 787 + md_path, 788 + ) 783 789 if post.metadata: 784 790 info.update(post.metadata) 785 791 except Exception as exc: # pragma: no cover - metadata optional ··· 789 795 if "color" not in info: 790 796 info["color"] = "#6c757d" 791 797 792 - # Check for optional post-processing hook 793 - hook_path = md_path.with_suffix(".py") 794 - if hook_path.exists(): 795 - info["hook_path"] = str(hook_path) 798 + # Resolve hook path - named hook takes precedence over co-located .py 799 + hook_name = info.get("hook") 800 + if hook_name and isinstance(hook_name, str): 801 + # Named hook: look in think/insights/{hook}.py 802 + named_hook_path = Path(__file__).parent / "insights" / f"{hook_name}.py" 803 + if named_hook_path.exists(): 804 + info["hook_path"] = str(named_hook_path) 805 + else: 806 + # Co-located hook: check for .py file next to the .md file 807 + hook_path = md_path.with_suffix(".py") 808 + if hook_path.exists(): 809 + info["hook_path"] = str(hook_path) 796 810 797 811 return info 798 812 ··· 1150 1164 raise FileNotFoundError(f"Agent persona not found: {persona}") 1151 1165 1152 1166 # Load config from frontmatter 1153 - post = frontmatter.load(md_path, ) 1167 + post = frontmatter.load( 1168 + md_path, 1169 + ) 1154 1170 config = dict(post.metadata) if post.metadata else {} 1155 1171 1156 1172 # Extract instructions config if present