personal memory agent
0
fork

Configure Feed

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

feat(talents): add participation talent with structured role/source per span

Introduce a per-span participation talent that produces structured
participation records (role, source, confidence, context, entity_id)
for each activity, consolidating per-segment Sense entity drafts.

Three coordinated changes:

1. Extend talent/sense.md entities schema with role (attendee|mentioned) and source (voice|speaker_label|transcript|screen|other), plus a prompt-level contamination guard for screen-only names and tool/app UI.

2. Add talent/participation.md (quality-judge, activity-scheduled,
tier 3) and talent/participation.py post-hook. The hook parses
the LLM JSON, resolves entities read-only via
think.entities.matching.find_matching_entity, and atomically
merges participation + participation_confidence onto the
activity record.

3. Generalize think/activities.update_record_description into
update_record_fields, preserving the atomic tempfile+rename
write. update_record_description becomes a thin wrapper.

Sense drafts reach participation via think/sense_splitter.py, which
now also writes a sense.md summary alongside sense.json. This was
the only viable load path because think/cluster.py only discovers
talents/**/*.md — sense.json is invisible to the existing load
mechanism. The alternative (widening cluster) was rejected as out
of scope.

active_entities on activity records is preserved unchanged for
backward compatibility. The state-machine flatten in
think/activity_state_machine.py is untouched.

The entity resolver is read-only: it calls find_matching_entity
against already-loaded entities and injects entity_id (or null) per
participation entry. It never writes under facets/{facet}/entities/.

Tests cover: Sense schema, contamination guard prose, participation
frontmatter/prompt, resolver file-count invariance, contamination
regression at the plumbing layer, and atomic activity-record merge
including malformed-JSON early return.

Co-authored-by: Codex <codex@openai.com>

+673 -7
+59
talent/participation.md
··· 1 + { 2 + "type": "generate", 3 + "title": "Participation", 4 + "description": "Consolidates per-segment Sense entity drafts into a structured per-activity participation list.", 5 + "hook": {"post": "participation"}, 6 + "schedule": "activity", 7 + "activities": ["*"], 8 + "priority": 10, 9 + "tier": 3, 10 + "output": "json", 11 + "load": { 12 + "transcripts": true, 13 + "percepts": true, 14 + "talents": { 15 + "sense": true 16 + } 17 + } 18 + } 19 + 20 + $facets 21 + 22 + $activity_context 23 + 24 + $activity_preamble 25 + 26 + # Participation Consolidation 27 + 28 + You are a quality-judge consolidating per-segment Sense drafts of entities into a single per-activity `participation` list. The loaded `sense.md` snippets provide entity candidates gathered across the activity span. Deduplicate name variants, preserve the strongest role/source signal, and keep only grounded entities that genuinely participated in or were mentioned during this activity. 29 + 30 + ## Output Schema 31 + 32 + ```json 33 + { 34 + "participation": [ 35 + { 36 + "name": "Full Name", 37 + "role": "attendee|mentioned", 38 + "source": "voice|speaker_label|transcript|screen|other", 39 + "confidence": 0.0, 40 + "context": "Short explanation of why this entity belongs in the activity", 41 + "entity_id": null 42 + } 43 + ], 44 + "participation_confidence": 0.0 45 + } 46 + ``` 47 + 48 + `entity_id` must always be `null`; the post-hook resolves it after generation. 49 + 50 + ## Rules 51 + 52 + 1. Exclude the journal owner. 53 + 2. Never mark someone `role: attendee` in a non-meeting activity. 54 + 3. No fabrication — if you didn't see them, don't list them. 55 + 4. Empty `participation: []` when no entities were involved. 56 + 5. Confidence is subjective but should reflect signal strength (`voice` > `speaker_label` > `transcript` > `screen`). 57 + 6. Dedupe variants (e.g., "JB" and "John B." → one entry with the richer name). 58 + 59 + Return only the JSON object with `participation` and optional `participation_confidence`.
+70
talent/participation.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Hook for merging participation data onto activity records.""" 5 + 6 + import json 7 + import logging 8 + 9 + from think.activities import update_record_fields 10 + from think.entities.loading import load_entities 11 + from think.entities.matching import find_matching_entity 12 + 13 + logger = logging.getLogger(__name__) 14 + 15 + 16 + def post_process(result: str, context: dict) -> str | None: 17 + """Resolve participation entries and merge them onto an activity record.""" 18 + try: 19 + data = json.loads(result.strip()) 20 + except (json.JSONDecodeError, ValueError) as exc: 21 + logger.warning("participation hook: failed to parse JSON: %s", exc) 22 + return None 23 + 24 + if not isinstance(data, dict): 25 + logger.warning("participation hook: expected top-level object") 26 + return None 27 + 28 + activity = context.get("activity") 29 + if not isinstance(activity, dict): 30 + logger.warning("participation hook: missing activity context") 31 + return None 32 + 33 + record_id = activity.get("id") 34 + if not record_id: 35 + logger.warning("participation hook: missing activity record id") 36 + return None 37 + 38 + facet = context.get("facet") 39 + day = context.get("day") 40 + if not facet or not day: 41 + logger.warning("participation hook: missing facet/day context") 42 + return None 43 + 44 + participation = data.get("participation") 45 + if not isinstance(participation, list): 46 + logger.warning("participation hook: missing participation list") 47 + return None 48 + 49 + entities_list = load_entities(facet=facet, day=day) 50 + 51 + resolved_entries = [] 52 + for entry in participation: 53 + if not isinstance(entry, dict): 54 + logger.warning("participation hook: skipping non-object entry") 55 + continue 56 + 57 + resolved_entry = dict(entry) 58 + match = find_matching_entity(resolved_entry.get("name", ""), entities_list) 59 + resolved_entry["entity_id"] = match.get("id") if match else None 60 + resolved_entries.append(resolved_entry) 61 + 62 + payload = {"participation": resolved_entries} 63 + participation_confidence = data.get("participation_confidence") 64 + if participation_confidence is not None: 65 + payload["participation_confidence"] = participation_confidence 66 + 67 + if not update_record_fields(facet, day, record_id, payload): 68 + logger.warning("participation hook: activity record not found: %s", record_id) 69 + 70 + return None
+14 -1
talent/sense.md
··· 33 33 "content_type": "meeting|coding|browsing|email|messaging|reading|idle|mixed", 34 34 "activity_summary": "1-3 sentence description of what happened", 35 35 "entities": [ 36 - {"type": "Person|Company|Project|Tool", "name": "Full Name", "context": "Why this entity matters in this segment"} 36 + {"type": "Person|Company|Project|Tool", "name": "Full Name", "role": "attendee|mentioned", "source": "voice|speaker_label|transcript|screen|other", "context": "Why this entity matters in this segment"} 37 37 ], 38 38 "facets": [ 39 39 {"facet": "facet_id", "activity": "1-sentence description for this facet", "level": "high|medium|low"} ··· 81 81 **For screen content specifically:** Extract entities from visible text in screen descriptions — article headlines, page titles, product names, people mentioned in articles, organizations referenced. If the user is browsing a website about the Renaissance, extract the specific historical figures, art movements, and institutions mentioned. 82 82 83 83 Skip URLs, domains, filenames, paths. Each entity needs type, name, and context (brief description of the entity's role in this segment). 84 + 85 + #### role 86 + - **attendee**: The entity was directly participating in the live interaction during this segment. Use only for people who were actively present in the meeting or call. 87 + - **mentioned**: The entity was referenced, quoted, shown on screen, or otherwise relevant, but was not directly participating. 88 + 89 + Contamination guard: tool or product names visible on screen must be `source: screen` and `role: mentioned`, never `attendee`. Video-conference app names such as Google Meet or Zoom are platform/tool entities, not attendees. People quoted or referenced in transcripts are `role: mentioned` unless they were actively speaking as participants in the live meeting. 90 + 91 + #### source 92 + - **voice**: Use when the entity is identified from spoken audio content. 93 + - **speaker_label**: Use when the entity comes from an explicit speaker/participant label in meeting UI or transcript metadata. 94 + - **transcript**: Use when the entity appears in transcript text but not as an actively speaking participant signal. 95 + - **screen**: Use when the entity is visible in screen content such as UI, documents, headlines, or app chrome. 96 + - **other**: Use only when the entity is grounded in another clear signal that does not fit the categories above. 84 97 85 98 ### facets 86 99 Classify into the owner's configured facets. Only include facets with clear, direct evidence of activity. Be precise — assign exactly ONE primary facet in most cases. Only add a second facet if there is genuinely distinct secondary activity. For each:
+8
tests/baselines/api/settings/providers.json
··· 335 335 "tier": 2, 336 336 "type": null 337 337 }, 338 + "talent.system.participation": { 339 + "disabled": false, 340 + "group": "Think", 341 + "label": "Participation", 342 + "schedule": "activity", 343 + "tier": 3, 344 + "type": "generate" 345 + }, 338 346 "talent.system.partner": { 339 347 "disabled": false, 340 348 "group": "Think",
+11
tests/baselines/api/sol/talents-day.json
··· 319 319 "title": "Occurrence Extraction", 320 320 "type": null 321 321 }, 322 + "participation": { 323 + "app": null, 324 + "color": "#6c757d", 325 + "description": "Consolidates per-segment Sense entity drafts into a structured per-activity participation list.", 326 + "multi_facet": false, 327 + "output_format": "json", 328 + "schedule": "activity", 329 + "source": "system", 330 + "title": "Participation", 331 + "type": "generate" 332 + }, 322 333 "partner": { 323 334 "app": null, 324 335 "color": "#6c757d",
+26
tests/baselines/api/stats/stats.json
··· 250 250 "title": "Messaging Summary", 251 251 "type": "generate" 252 252 }, 253 + "participation": { 254 + "activities": [ 255 + "*" 256 + ], 257 + "color": "#6c757d", 258 + "description": "Consolidates per-segment Sense entity drafts into a structured per-activity participation list.", 259 + "hook": { 260 + "post": "participation" 261 + }, 262 + "load": { 263 + "percepts": true, 264 + "talents": { 265 + "sense": true 266 + }, 267 + "transcripts": true 268 + }, 269 + "mtime": 0, 270 + "output": "json", 271 + "path": "<PROJECT>/talent/participation.md", 272 + "priority": 10, 273 + "schedule": "activity", 274 + "source": "system", 275 + "tier": 3, 276 + "title": "Participation", 277 + "type": "generate" 278 + }, 253 279 "schedule": { 254 280 "color": "#5e35b1", 255 281 "description": "Identifies all future calendar events and scheduled activities noted in transcripts. Extracts dates, times, participants, and event details for anything scheduled beyond today.",
+130
tests/test_activity_record_merge.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + import json 5 + 6 + 7 + def _write_detected_entities(tmp_path, facet: str, day: str, rows: list[dict]) -> None: 8 + entities_path = tmp_path / "facets" / facet / "entities" / f"{day}.jsonl" 9 + entities_path.parent.mkdir(parents=True, exist_ok=True) 10 + entities_path.write_text( 11 + "".join(json.dumps(row, ensure_ascii=False) + "\n" for row in rows), 12 + encoding="utf-8", 13 + ) 14 + 15 + 16 + def _activity_record(): 17 + return { 18 + "id": "meeting_090000_300", 19 + "activity": "meeting", 20 + "segments": ["090000_300"], 21 + "level_avg": 1.0, 22 + "description": "Team sync", 23 + "active_entities": ["JB", "Alex"], 24 + "created_at": 1, 25 + } 26 + 27 + 28 + def test_participation_post_hook_merges_fields_and_preserves_active_entities( 29 + tmp_path, monkeypatch 30 + ): 31 + from talent.participation import post_process 32 + from think.activities import append_activity_record, load_activity_records 33 + 34 + facet = "work" 35 + day = "20260418" 36 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 37 + 38 + _write_detected_entities( 39 + tmp_path, 40 + facet, 41 + day, 42 + [ 43 + { 44 + "id": "john_borthwick", 45 + "type": "Person", 46 + "name": "John Borthwick", 47 + "aka": ["JB"], 48 + } 49 + ], 50 + ) 51 + append_activity_record(facet, day, _activity_record()) 52 + 53 + post_process( 54 + json.dumps( 55 + { 56 + "participation": [ 57 + { 58 + "name": "JB", 59 + "role": "attendee", 60 + "source": "voice", 61 + "confidence": 0.91, 62 + "context": "Spoke during the meeting", 63 + "entity_id": None, 64 + }, 65 + { 66 + "name": "Alex", 67 + "role": "mentioned", 68 + "source": "transcript", 69 + "confidence": 0.42, 70 + "context": "Mentioned as a collaborator", 71 + "entity_id": None, 72 + }, 73 + ], 74 + "participation_confidence": 0.77, 75 + } 76 + ), 77 + {"facet": facet, "day": day, "activity": {"id": "meeting_090000_300"}}, 78 + ) 79 + 80 + record = load_activity_records(facet, day)[0] 81 + assert record["active_entities"] == ["JB", "Alex"] 82 + assert record["participation_confidence"] == 0.77 83 + assert record["participation"][0]["entity_id"] == "john_borthwick" 84 + assert record["participation"][1]["entity_id"] is None 85 + 86 + 87 + def test_participation_post_hook_leaves_file_unchanged_on_malformed_json( 88 + tmp_path, monkeypatch, caplog 89 + ): 90 + from talent.participation import post_process 91 + from think.activities import append_activity_record 92 + 93 + facet = "work" 94 + day = "20260418" 95 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 96 + 97 + append_activity_record(facet, day, _activity_record()) 98 + record_path = tmp_path / "facets" / facet / "activities" / f"{day}.jsonl" 99 + before = record_path.read_bytes() 100 + 101 + post_process( 102 + "{not valid json", 103 + {"facet": facet, "day": day, "activity": {"id": "meeting_090000_300"}}, 104 + ) 105 + 106 + assert record_path.read_bytes() == before 107 + assert "failed to parse JSON" in caplog.text 108 + 109 + 110 + def test_participation_post_hook_requires_activity_context( 111 + tmp_path, monkeypatch, caplog 112 + ): 113 + from talent.participation import post_process 114 + from think.activities import append_activity_record 115 + 116 + facet = "work" 117 + day = "20260418" 118 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 119 + 120 + append_activity_record(facet, day, _activity_record()) 121 + record_path = tmp_path / "facets" / facet / "activities" / f"{day}.jsonl" 122 + before = record_path.read_bytes() 123 + 124 + post_process( 125 + json.dumps({"participation": []}), 126 + {"facet": facet, "day": day}, 127 + ) 128 + 129 + assert record_path.read_bytes() == before 130 + assert "missing activity context" in caplog.text
+52
tests/test_participation_contamination_regression.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from pathlib import Path 5 + 6 + import pytest 7 + 8 + 9 + @pytest.mark.parametrize( 10 + ("entity_name", "entity_type", "source"), 11 + [ 12 + ("Claude Code", "Tool", "screen"), 13 + ("Mozilla Ventures", "Company", "transcript"), 14 + ("Google Meet", "Tool", "screen"), 15 + ], 16 + ) 17 + def test_sense_entity_role_source_survive_splitter_without_changing_active_entity_flatten( 18 + tmp_path, entity_name: str, entity_type: str, source: str 19 + ): 20 + from think.activity_state_machine import ActivityStateMachine 21 + from think.sense_splitter import write_sense_outputs 22 + 23 + # This is a schema/plumbing regression test, not an LLM-behavioral test. 24 + day = "20260418" 25 + segment = "090000_300" 26 + sense_json = { 27 + "density": "active", 28 + "content_type": "coding", 29 + "activity_summary": "Worked through captured activity.", 30 + "entities": [ 31 + { 32 + "type": entity_type, 33 + "name": entity_name, 34 + "role": "mentioned", 35 + "source": source, 36 + "context": "Detected by fixture-driven test input", 37 + } 38 + ], 39 + "facets": [{"facet": "work", "activity": "coding", "level": "high"}], 40 + "meeting_detected": False, 41 + "speakers": [], 42 + "recommend": {}, 43 + } 44 + 45 + seg_dir = Path(tmp_path) / day / "default" / segment 46 + write_sense_outputs(sense_json, seg_dir) 47 + 48 + sense_md = (seg_dir / "talents" / "sense.md").read_text(encoding="utf-8") 49 + assert f"{entity_name} (role=mentioned, source={source})" in sense_md 50 + 51 + changes = ActivityStateMachine().update(sense_json, segment, day) 52 + assert changes[0]["active_entities"] == [entity_name]
+95
tests/test_participation_resolver.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + import json 5 + 6 + 7 + def _write_detected_entities(tmp_path, facet: str, day: str, rows: list[dict]) -> None: 8 + entities_path = tmp_path / "facets" / facet / "entities" / f"{day}.jsonl" 9 + entities_path.parent.mkdir(parents=True, exist_ok=True) 10 + entities_path.write_text( 11 + "".join(json.dumps(row, ensure_ascii=False) + "\n" for row in rows), 12 + encoding="utf-8", 13 + ) 14 + 15 + 16 + def test_participation_post_hook_resolves_entity_ids_without_mutating_entities( 17 + tmp_path, monkeypatch 18 + ): 19 + from talent.participation import post_process 20 + from think.activities import append_activity_record, load_activity_records 21 + 22 + facet = "work" 23 + day = "20260418" 24 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 25 + 26 + _write_detected_entities( 27 + tmp_path, 28 + facet, 29 + day, 30 + [ 31 + { 32 + "id": "john_borthwick", 33 + "type": "Person", 34 + "name": "John Borthwick", 35 + "aka": ["JB"], 36 + }, 37 + { 38 + "id": "other_person", 39 + "type": "Person", 40 + "name": "Other Person", 41 + }, 42 + ], 43 + ) 44 + 45 + entities_dir = tmp_path / "facets" / facet / "entities" 46 + snapshot_before = {p.name: p.stat().st_size for p in entities_dir.iterdir()} 47 + 48 + append_activity_record( 49 + facet, 50 + day, 51 + { 52 + "id": "meeting_090000_300", 53 + "activity": "meeting", 54 + "segments": ["090000_300"], 55 + "level_avg": 1.0, 56 + "description": "Team sync", 57 + "active_entities": ["JB", "Alex"], 58 + "created_at": 1, 59 + }, 60 + ) 61 + 62 + result = json.dumps( 63 + { 64 + "participation": [ 65 + { 66 + "name": "JB", 67 + "role": "attendee", 68 + "source": "voice", 69 + "confidence": 0.98, 70 + "context": "Spoke during the meeting", 71 + "entity_id": "fake_id", 72 + }, 73 + { 74 + "name": "Alex", 75 + "role": "mentioned", 76 + "source": "transcript", 77 + "confidence": 0.55, 78 + "context": "Mentioned as a follow-up owner", 79 + "entity_id": "fake_id", 80 + }, 81 + ] 82 + } 83 + ) 84 + 85 + post_process( 86 + result, 87 + {"facet": facet, "day": day, "activity": {"id": "meeting_090000_300"}}, 88 + ) 89 + 90 + snapshot_after = {p.name: p.stat().st_size for p in entities_dir.iterdir()} 91 + assert snapshot_after == snapshot_before 92 + 93 + record = load_activity_records(facet, day)[0] 94 + assert record["participation"][0]["entity_id"] == "john_borthwick" 95 + assert record["participation"][1]["entity_id"] is None
+42
tests/test_participation_talent.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from pathlib import Path 5 + 6 + import frontmatter 7 + 8 + PARTICIPATION_PATH = Path(__file__).resolve().parents[1] / "talent" / "participation.md" 9 + 10 + 11 + def test_participation_talent_frontmatter_and_placeholders(): 12 + post = frontmatter.load(PARTICIPATION_PATH) 13 + 14 + assert post.metadata["schedule"] == "activity" 15 + assert post.metadata["activities"] == ["*"] 16 + assert post.metadata["tier"] == 3 17 + assert post.metadata["output"] == "json" 18 + assert post.metadata["priority"] == 10 19 + assert post.metadata["load"]["talents"]["sense"] is True 20 + 21 + body = post.content 22 + assert "$facets" in body 23 + assert "$activity_context" in body 24 + assert "$activity_preamble" in body 25 + 26 + 27 + def test_participation_talent_rules_block_is_present(): 28 + body = frontmatter.load(PARTICIPATION_PATH).content 29 + 30 + expected_rules = [ 31 + "1. Exclude the journal owner.", 32 + "2. Never mark someone `role: attendee` in a non-meeting activity.", 33 + "3. No fabrication — if you didn't see them, don't list them.", 34 + "4. Empty `participation: []` when no entities were involved.", 35 + "5. Confidence is subjective but should reflect signal strength (`voice` > `speaker_label` > `transcript` > `screen`).", 36 + '6. Dedupe variants (e.g., "JB" and "John B." → one entry with the richer name).', 37 + ] 38 + 39 + for rule in expected_rules: 40 + assert rule in body 41 + 42 + assert "`entity_id` must always be `null`" in body
+36
tests/test_sense_contamination_guard.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from pathlib import Path 5 + 6 + import frontmatter 7 + 8 + SENSE_PATH = Path(__file__).resolve().parents[1] / "talent" / "sense.md" 9 + 10 + 11 + def _role_section() -> str: 12 + content = frontmatter.load(SENSE_PATH).content 13 + start = content.index("#### role") 14 + end = content.index("#### source", start) 15 + return content[start:end] 16 + 17 + 18 + def test_sense_role_section_contains_contamination_guard(): 19 + role_section = _role_section() 20 + 21 + assert "tool or product names visible on screen" in role_section 22 + assert "`source: screen`" in role_section 23 + assert "`role: mentioned`" in role_section 24 + assert "Google Meet" in role_section 25 + assert "Zoom" in role_section 26 + assert "quoted or referenced in transcripts" in role_section 27 + assert "actively speaking as participants" in role_section 28 + 29 + 30 + def test_sense_role_section_has_screen_and_mentioned_guidance_for_tools_and_apps(): 31 + role_section = _role_section() 32 + 33 + assert "screen" in role_section 34 + assert "mentioned" in role_section 35 + assert "tool" in role_section 36 + assert "Video-conference app names" in role_section
+54
tests/test_sense_schema.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from pathlib import Path 5 + 6 + import frontmatter 7 + 8 + SENSE_PATH = Path(__file__).resolve().parents[1] / "talent" / "sense.md" 9 + 10 + 11 + def _section(text: str, start: str, end: str | None = None) -> str: 12 + section_start = text.index(start) 13 + if end is None: 14 + return text[section_start:] 15 + section_end = text.index(end, section_start) 16 + return text[section_start:section_end] 17 + 18 + 19 + def test_sense_prompt_parses_and_documents_role_and_source(): 20 + post = frontmatter.load(SENSE_PATH) 21 + 22 + assert post.metadata["tier"] == 3 23 + 24 + schema = _section( 25 + post.content, "## Output Schema", "## Field-by-Field Instructions" 26 + ) 27 + entities = _section(post.content, "### entities", "### facets") 28 + 29 + assert '"role": "attendee|mentioned"' in schema 30 + assert '"source": "voice|speaker_label|transcript|screen|other"' in schema 31 + assert "#### role" in entities 32 + assert "#### source" in entities 33 + 34 + 35 + def test_role_and_source_do_not_leak_into_other_sense_sections(): 36 + content = frontmatter.load(SENSE_PATH).content 37 + 38 + sections = [ 39 + _section(content, "### density", "### content_type"), 40 + _section(content, "### content_type", "### activity_summary"), 41 + _section(content, "### activity_summary", "### entities"), 42 + _section(content, "### facets", "### meeting_detected"), 43 + _section(content, "### meeting_detected", "### speakers"), 44 + _section(content, "### speakers", "### recommend"), 45 + _section(content, "### recommend", "### emotional_register"), 46 + _section(content, "### emotional_register", "## Rules"), 47 + _section(content, "## Rules"), 48 + ] 49 + 50 + for section in sections: 51 + assert "attendee|mentioned" not in section 52 + assert "voice|speaker_label|transcript|screen|other" not in section 53 + assert "#### role" not in section 54 + assert "#### source" not in section
+49 -1
tests/test_sense_splitter.py
··· 14 14 "content_type": "coding", 15 15 "activity_summary": "Writing unit tests for the API module.", 16 16 "entities": [ 17 - {"type": "Project", "name": "SolAPI", "context": "main project"}, 17 + { 18 + "type": "Project", 19 + "name": "SolAPI", 20 + "role": "mentioned", 21 + "source": "screen", 22 + "context": "main project", 23 + }, 18 24 ], 19 25 "facets": [ 20 26 {"facet": "work", "activity": "coding", "level": "high"}, ··· 80 86 stored = json.loads((seg_dir / "talents" / "sense.json").read_text("utf-8")) 81 87 assert stored["foo"] == "bar" 82 88 assert stored == sense_json 89 + 90 + def test_writes_sense_markdown_when_entities_exist(self, tmp_path): 91 + from think.sense_splitter import write_sense_outputs 92 + 93 + seg_dir = Path(tmp_path) / "20260304" / "default" / "090000_300" 94 + sense_json = _make_sense_output( 95 + entities=[ 96 + { 97 + "type": "Project", 98 + "name": "SolAPI", 99 + "role": "mentioned", 100 + "source": "screen", 101 + "context": "main project", 102 + }, 103 + { 104 + "type": "Person", 105 + "name": "John Borthwick", 106 + "role": "attendee", 107 + "source": "voice", 108 + "context": "active meeting participant", 109 + }, 110 + ] 111 + ) 112 + 113 + write_sense_outputs(sense_json, seg_dir) 114 + 115 + sense_md = (seg_dir / "talents" / "sense.md").read_text(encoding="utf-8") 116 + assert sense_md == ( 117 + "# Sense Entities\n\n" 118 + "- Project — SolAPI (role=mentioned, source=screen) — main project\n" 119 + "- Person — John Borthwick (role=attendee, source=voice) " 120 + "— active meeting participant" 121 + ) 122 + 123 + def test_skips_sense_markdown_when_entities_empty(self, tmp_path): 124 + from think.sense_splitter import write_sense_outputs 125 + 126 + seg_dir = Path(tmp_path) / "20260304" / "default" / "090000_300" 127 + 128 + write_sense_outputs(_make_sense_output(entities=[]), seg_dir) 129 + 130 + assert not (seg_dir / "talents" / "sense.md").exists() 83 131 84 132 85 133 class TestMeetingDetection:
+12 -5
think/activities.py
··· 776 776 return True 777 777 778 778 779 - def update_record_description( 780 - facet: str, day: str, record_id: str, description: str 779 + def update_record_fields( 780 + facet: str, day: str, record_id: str, fields: dict[str, Any] 781 781 ) -> bool: 782 - """Update the description of an existing activity record. 782 + """Update fields on an existing activity record. 783 783 784 784 Rewrites the JSONL file atomically (write temp + rename) with the updated 785 - description for the matching record. 785 + fields for the matching record. 786 786 787 787 Returns True if record was found and updated, False otherwise. 788 788 """ ··· 806 806 continue 807 807 808 808 if record.get("id") == record_id: 809 - record["description"] = description 809 + record.update(fields) 810 810 updated = True 811 811 812 812 new_lines.append(json.dumps(record, ensure_ascii=False)) ··· 823 823 raise 824 824 825 825 return updated 826 + 827 + 828 + def update_record_description( 829 + facet: str, day: str, record_id: str, description: str 830 + ) -> bool: 831 + """Update the description of an existing activity record.""" 832 + return update_record_fields(facet, day, record_id, {"description": description}) 826 833 827 834 828 835 def estimate_duration_minutes(segments: list[str]) -> int:
+15
think/sense_splitter.py
··· 32 32 33 33 density = sense_json.get("density") or "active" 34 34 activity_summary = sense_json.get("activity_summary") or "" 35 + entities = sense_json.get("entities") or [] 35 36 facets = sense_json.get("facets") or [] 36 37 meeting_detected = bool(sense_json.get("meeting_detected")) 37 38 speakers = sense_json.get("speakers") or [] ··· 48 49 }, 49 50 ) 50 51 _write_json_atomic(agents_dir / "sense.json", sense_json) 52 + 53 + if entities: 54 + lines = ["# Sense Entities", ""] 55 + for entity in entities: 56 + if not isinstance(entity, dict): 57 + continue 58 + lines.append( 59 + "- " 60 + f"{entity.get('type', '')} — {entity.get('name', '')} " 61 + f"(role={entity.get('role', '')}, source={entity.get('source', '')}) " 62 + f"— {entity.get('context', '')}" 63 + ) 64 + if len(lines) > 2: 65 + _write_text_atomic(agents_dir / "sense.md", "\n".join(lines)) 51 66 52 67 if meeting_detected: 53 68 _write_json_atomic(agents_dir / "speakers.json", speakers)