personal memory agent
0
fork

Configure Feed

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

talents/sense: migrate to external sense.schema.json

Move sense's structured-output schema out of the inline ```json fenced
block in talent/sense.md into talent/sense.schema.json (Draft 2020-12,
additionalProperties:false at every closed level), wired via the
`schema` frontmatter field that landed in cde43afc. The inline ##
Output Schema block becomes a one-line pointer; the field-by-field
prose stays as the model's semantic reference.

First real consumer of the structured-outputs infrastructure landed
in c030248d (Lode 1 — provider json_schema plumbing) and cde43afc
(Lode 2 — talent dispatcher schema wiring). This lode is the L3 pilot;
remaining `output: "json"` talents migrate one at a time.

Schema design:
- 9 required top-level fields; all enums copied verbatim from the
field-by-field prose
- speakers tightened to array<string> per apps/speakers/routes.py and
apps/speakers/bootstrap.py contracts
- Provider-intersection subset only: type, enum, required,
additionalProperties, properties, items, minLength

Test fixture alignment:
- _make_sense_output() helper now returns a schema-valid default
- test_pipeline_smoke SEGMENTS padded to schema-valid shape
- test_sense_contamination_guard payloads padded to schema-valid shape
- Intentionally degraded splitter inputs ({} and null-valued dict)
retained as defensive tests with comments explaining why

Live validation deferred — provider creds not available in this
worktree environment. End-to-end wiring is verified by:
- get_talent("sense")["json_schema"] equals the on-disk schema
(new test_sense_loaded_json_schema_matches_on_disk_schema)
- existing test_generate_full.py:129/:221 cover json_schema forwarding
and schema_validation surfacing on the finish event

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

+204 -47
+2 -21
talent/sense.md
··· 10 10 "thinking_budget": 4096, 11 11 "max_output_tokens": 4096, 12 12 "output": "json", 13 + "schema": "sense.schema.json", 13 14 "load": {"transcripts": true, "percepts": true, "talents": false} 14 15 } 15 16 ··· 27 28 28 29 ## Output Schema 29 30 30 - ```json 31 - { 32 - "density": "active|low_change|idle", 33 - "content_type": "meeting|coding|browsing|email|messaging|reading|idle|mixed", 34 - "activity_summary": "1-3 sentence description of what happened", 35 - "entities": [ 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 - ], 38 - "facets": [ 39 - {"facet": "facet_id", "activity": "1-sentence description for this facet", "level": "high|medium|low"} 40 - ], 41 - "meeting_detected": false, 42 - "speakers": [], 43 - "recommend": { 44 - "screen_record": false, 45 - "speaker_attribution": false, 46 - "pulse_update": false 47 - }, 48 - "emotional_register": "high_energy|tense|focused|collaborative|flat|celebratory|strained|neutral" 49 - } 50 - ``` 31 + Authoritative schema: `sense.schema.json`. The output is a single JSON object with these top-level fields: `density`, `content_type`, `activity_summary`, `entities`, `facets`, `meeting_detected`, `speakers`, `recommend`, `emotional_register`. See Field-by-Field Instructions below for semantics and enum values. 51 32 52 33 ## Field-by-Field Instructions 53 34
+52
talent/sense.schema.json
··· 1 + { 2 + "$schema": "https://json-schema.org/draft/2020-12/schema", 3 + "type": "object", 4 + "additionalProperties": false, 5 + "required": ["density","content_type","activity_summary","entities","facets","meeting_detected","speakers","recommend","emotional_register"], 6 + "properties": { 7 + "density": {"type": "string", "enum": ["active","low_change","idle"]}, 8 + "content_type": {"type": "string", "enum": ["meeting","coding","browsing","email","messaging","reading","idle","mixed"]}, 9 + "activity_summary": {"type": "string", "minLength": 1}, 10 + "entities": { 11 + "type": "array", 12 + "items": { 13 + "type": "object", 14 + "additionalProperties": false, 15 + "required": ["type","name","role","source","context"], 16 + "properties": { 17 + "type": {"type": "string", "enum": ["Person","Company","Project","Tool"]}, 18 + "name": {"type": "string", "minLength": 1}, 19 + "role": {"type": "string", "enum": ["attendee","mentioned"]}, 20 + "source": {"type": "string", "enum": ["voice","speaker_label","transcript","screen","other"]}, 21 + "context": {"type": "string", "minLength": 1} 22 + } 23 + } 24 + }, 25 + "facets": { 26 + "type": "array", 27 + "items": { 28 + "type": "object", 29 + "additionalProperties": false, 30 + "required": ["facet","activity","level"], 31 + "properties": { 32 + "facet": {"type": "string", "minLength": 1}, 33 + "activity": {"type": "string", "minLength": 1}, 34 + "level": {"type": "string", "enum": ["high","medium","low"]} 35 + } 36 + } 37 + }, 38 + "meeting_detected": {"type": "boolean"}, 39 + "speakers": {"type": "array", "items": {"type": "string"}}, 40 + "recommend": { 41 + "type": "object", 42 + "additionalProperties": false, 43 + "required": ["screen_record","speaker_attribution","pulse_update"], 44 + "properties": { 45 + "screen_record": {"type": "boolean"}, 46 + "speaker_attribution": {"type": "boolean"}, 47 + "pulse_update": {"type": "boolean"} 48 + } 49 + }, 50 + "emotional_register": {"type": "string", "enum": ["high_energy","tense","focused","collaborative","flat","celebratory","strained","neutral"]} 51 + } 52 + }
+1
tests/baselines/api/stats/stats.json
··· 227 227 "path": "<PROJECT>/talent/sense.md", 228 228 "priority": 5, 229 229 "schedule": "segment", 230 + "schema": "sense.schema.json", 230 231 "source": "system", 231 232 "thinking_budget": 4096, 232 233 "tier": 3,
+91 -17
tests/test_pipeline_smoke.py
··· 19 19 "density": "active", 20 20 "content_type": "coding", 21 21 "activity_summary": "Implementing auth module", 22 - "facets": [{"facet": "work", "level": "high"}], 22 + "facets": [{"facet": "work", "activity": "coding", "level": "high"}], 23 23 "meeting_detected": False, 24 - "recommend": {}, 25 - "entities": [{"name": "Acme"}], 24 + "speakers": [], 25 + "recommend": { 26 + "screen_record": False, 27 + "speaker_attribution": False, 28 + "pulse_update": False, 29 + }, 30 + "entities": [ 31 + { 32 + "type": "Company", 33 + "name": "Acme", 34 + "role": "mentioned", 35 + "source": "screen", 36 + "context": "Codebase referenced during implementation work", 37 + } 38 + ], 39 + "emotional_register": "focused", 26 40 }, 27 41 ), 28 42 ( ··· 31 45 "density": "active", 32 46 "content_type": "coding", 33 47 "activity_summary": "Implementing auth module", 34 - "facets": [{"facet": "work", "level": "high"}], 48 + "facets": [{"facet": "work", "activity": "coding", "level": "high"}], 35 49 "meeting_detected": False, 36 - "recommend": {}, 37 - "entities": [{"name": "Acme"}], 50 + "speakers": [], 51 + "recommend": { 52 + "screen_record": False, 53 + "speaker_attribution": False, 54 + "pulse_update": False, 55 + }, 56 + "entities": [ 57 + { 58 + "type": "Company", 59 + "name": "Acme", 60 + "role": "mentioned", 61 + "source": "screen", 62 + "context": "Codebase referenced during implementation work", 63 + } 64 + ], 65 + "emotional_register": "focused", 38 66 }, 39 67 ), 40 68 ( ··· 43 71 "density": "active", 44 72 "content_type": "meeting", 45 73 "activity_summary": "Sprint planning standup", 46 - "facets": [{"facet": "work", "level": "medium"}], 74 + "facets": [{"facet": "work", "activity": "meeting", "level": "medium"}], 47 75 "meeting_detected": True, 48 76 "speakers": ["Alice", "Bob"], 49 - "recommend": {}, 50 - "entities": [{"name": "Acme"}], 77 + "recommend": { 78 + "screen_record": False, 79 + "speaker_attribution": False, 80 + "pulse_update": False, 81 + }, 82 + "entities": [ 83 + { 84 + "type": "Company", 85 + "name": "Acme", 86 + "role": "mentioned", 87 + "source": "screen", 88 + "context": "Organization discussed during standup", 89 + } 90 + ], 91 + "emotional_register": "collaborative", 51 92 }, 52 93 ), 53 94 ( ··· 56 97 "density": "active", 57 98 "content_type": "meeting", 58 99 "activity_summary": "Sprint planning standup", 59 - "facets": [{"facet": "work", "level": "medium"}], 100 + "facets": [{"facet": "work", "activity": "meeting", "level": "medium"}], 60 101 "meeting_detected": True, 61 102 "speakers": ["Alice", "Bob"], 62 - "recommend": {}, 63 - "entities": [{"name": "Acme"}], 103 + "recommend": { 104 + "screen_record": False, 105 + "speaker_attribution": False, 106 + "pulse_update": False, 107 + }, 108 + "entities": [ 109 + { 110 + "type": "Company", 111 + "name": "Acme", 112 + "role": "mentioned", 113 + "source": "screen", 114 + "context": "Organization discussed during standup", 115 + } 116 + ], 117 + "emotional_register": "collaborative", 64 118 }, 65 119 ), 66 120 ( ··· 68 122 { 69 123 "density": "idle", 70 124 "content_type": "idle", 71 - "activity_summary": "", 125 + "activity_summary": "Idle segment.", 72 126 "facets": [], 73 127 "meeting_detected": False, 74 - "recommend": {}, 128 + "speakers": [], 129 + "recommend": { 130 + "screen_record": False, 131 + "speaker_attribution": False, 132 + "pulse_update": False, 133 + }, 75 134 "entities": [], 135 + "emotional_register": "neutral", 76 136 }, 77 137 ), 78 138 ( ··· 81 141 "density": "active", 82 142 "content_type": "coding", 83 143 "activity_summary": "Reviewing PR feedback", 84 - "facets": [{"facet": "work", "level": "low"}], 144 + "facets": [{"facet": "work", "activity": "coding", "level": "low"}], 85 145 "meeting_detected": False, 86 - "recommend": {}, 87 - "entities": [{"name": "Acme"}], 146 + "speakers": [], 147 + "recommend": { 148 + "screen_record": False, 149 + "speaker_attribution": False, 150 + "pulse_update": False, 151 + }, 152 + "entities": [ 153 + { 154 + "type": "Company", 155 + "name": "Acme", 156 + "role": "mentioned", 157 + "source": "screen", 158 + "context": "Project organization referenced in review feedback", 159 + } 160 + ], 161 + "emotional_register": "focused", 88 162 }, 89 163 ), 90 164 ]
+29 -5
tests/test_sense_contamination_guard.py
··· 66 66 (talents_dir / "sense.json").write_text(json.dumps(payload), encoding="utf-8") 67 67 68 68 69 + def _sense_payload(*, meeting_detected: bool) -> dict: 70 + return { 71 + "density": "idle", 72 + "content_type": "idle", 73 + "activity_summary": "Idle segment.", 74 + "entities": [], 75 + "facets": [], 76 + "meeting_detected": meeting_detected, 77 + "speakers": [], 78 + "recommend": { 79 + "screen_record": False, 80 + "speaker_attribution": False, 81 + "pulse_update": False, 82 + }, 83 + "emotional_register": "neutral", 84 + } 85 + 86 + 69 87 def _activity_record(segments: list[str]) -> dict: 70 88 return { 71 89 "id": "meeting_090000_300", ··· 115 133 ) 116 134 for segment_key in segments: 117 135 _write_sense_json( 118 - tmp_path, day, stream, segment_key, {"meeting_detected": False} 136 + tmp_path, day, stream, segment_key, _sense_payload(meeting_detected=False) 119 137 ) 120 138 121 139 activity = _activity_record(segments) ··· 155 173 day, 156 174 [{"id": "guest_speaker", "type": "Person", "name": "Guest Speaker"}], 157 175 ) 158 - _write_sense_json(tmp_path, day, stream, segments[0], {"meeting_detected": False}) 159 - _write_sense_json(tmp_path, day, stream, segments[1], {"meeting_detected": True}) 176 + _write_sense_json( 177 + tmp_path, day, stream, segments[0], _sense_payload(meeting_detected=False) 178 + ) 179 + _write_sense_json( 180 + tmp_path, day, stream, segments[1], _sense_payload(meeting_detected=True) 181 + ) 160 182 161 183 activity = _activity_record(segments) 162 184 append_activity_record(facet, day, activity) ··· 193 215 ) 194 216 for segment_key in segments: 195 217 _write_sense_json( 196 - tmp_path, day, stream, segment_key, {"meeting_detected": False} 218 + tmp_path, day, stream, segment_key, _sense_payload(meeting_detected=False) 197 219 ) 198 220 199 221 activity = _activity_record(segments) ··· 243 265 [{"id": "guest_speaker", "type": "Person", "name": "Guest Speaker"}], 244 266 ) 245 267 _write_sense_json(tmp_path, day, stream, segments[0], None) 246 - _write_sense_json(tmp_path, day, stream, segments[1], {"meeting_detected": False}) 268 + _write_sense_json( 269 + tmp_path, day, stream, segments[1], _sense_payload(meeting_detected=False) 270 + ) 247 271 248 272 activity = _activity_record(segments) 249 273 append_activity_record(facet, day, activity)
+24 -3
tests/test_sense_schema.py
··· 1 1 # SPDX-License-Identifier: AGPL-3.0-only 2 2 # Copyright (c) 2026 sol pbc 3 3 4 + import json 4 5 from pathlib import Path 5 6 6 7 import frontmatter 7 8 9 + from think.talent import get_talent 10 + 8 11 SENSE_PATH = Path(__file__).resolve().parents[1] / "talent" / "sense.md" 12 + SENSE_SCHEMA_PATH = SENSE_PATH.with_suffix(".schema.json") 9 13 10 14 11 15 def _section(text: str, start: str, end: str | None = None) -> str: ··· 21 25 22 26 assert post.metadata["tier"] == 3 23 27 24 - schema = _section( 28 + output_schema = _section( 25 29 post.content, "## Output Schema", "## Field-by-Field Instructions" 26 30 ) 27 31 entities = _section(post.content, "### entities", "### facets") 32 + entity_props = get_talent("sense")["json_schema"]["properties"]["entities"][ 33 + "items" 34 + ]["properties"] 28 35 29 - assert '"role": "attendee|mentioned"' in schema 30 - assert '"source": "voice|speaker_label|transcript|screen|other"' in schema 36 + assert post.metadata["schema"] == "sense.schema.json" 37 + assert "Authoritative schema: `sense.schema.json`." in output_schema 38 + assert set(entity_props["role"]["enum"]) == {"attendee", "mentioned"} 39 + assert set(entity_props["source"]["enum"]) == { 40 + "voice", 41 + "speaker_label", 42 + "transcript", 43 + "screen", 44 + "other", 45 + } 31 46 assert "#### role" in entities 32 47 assert "#### source" in entities 48 + 49 + 50 + def test_sense_loaded_json_schema_matches_on_disk_schema(): 51 + on_disk = json.loads(SENSE_SCHEMA_PATH.read_text(encoding="utf-8")) 52 + 53 + assert get_talent("sense")["json_schema"] == on_disk 33 54 34 55 35 56 def test_role_and_source_do_not_leak_into_other_sense_sections():
+5 -1
tests/test_sense_splitter.py
··· 32 32 "speaker_attribution": False, 33 33 "pulse_update": False, 34 34 }, 35 + "emotional_register": "neutral", 35 36 } 36 37 base.update(overrides) 37 38 return base ··· 75 76 sense_json 76 77 ) 77 78 78 - def test_preserves_raw_payload_in_sense_json(self, tmp_path): 79 + def test_preserves_raw_payload_with_extra_keys_for_defensive_replay(self, tmp_path): 79 80 from think.sense_splitter import write_sense_outputs 80 81 81 82 seg_dir = Path(tmp_path) / "20260304" / "default" / "090000_300" 83 + # Advisory validation means unexpected keys can still reach the splitter. 82 84 sense_json = _make_sense_output(foo="bar") 83 85 84 86 write_sense_outputs(sense_json, seg_dir) ··· 174 176 175 177 seg_dir = Path(tmp_path) / "20260304" / "default" / "090000_300" 176 178 179 + # Advisory schema validation means the splitter must still tolerate degraded input. 177 180 write_sense_outputs({}, seg_dir) 178 181 179 182 agents_dir = seg_dir / "talents" ··· 202 205 "speakers": None, 203 206 } 204 207 208 + # Advisory schema validation means the splitter must still tolerate degraded input. 205 209 write_sense_outputs(sense_json, seg_dir) 206 210 207 211 agents_dir = seg_dir / "talents"