personal memory agent
0
fork

Configure Feed

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

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

Next structured-outputs migration: constrain the schedule talent with an
external Draft 2020-12 schema. This introduces
`talent/schedule.schema.json`, wires it via
`"schema": "schedule.schema.json"` in the frontmatter, and encodes
every field the post-hook reads. `talent/schedule.py` is unchanged:
strict hook validation remains authoritative, and the schema adds
defense-in-depth plus provider-side constraint.

The single semantic tightening is `activity`: it now uses a closed enum
of 10 values (`meeting`, `call`, `deadline`, `appointment`, `event`,
`travel`, `reminder`, `errand`, `celebration`,
`doctor_appointment`) instead of the prompt's prior "not a restricted
enum" disclaimer. The scope's seed list held: a fixture scan across
`tests/fixtures/` and `tests/baselines/` found zero real anticipated
records, so no widening was required.

The inline participation entry deliberately omits `entity_id` (5 fields
instead of the shared fragment's 6) because the schedule hook assigns it
itself via `find_matching_entity`. A drift test in
`tests/test_schedule_schema.py` asserts the inline shape equals the
shared fragment minus `entity_id`, so any future fragment change forces
an explicit update here. The loader still has no cross-file `$ref`
plumbing, so the shape remains inlined rather than referenced.

A few schema decisions are worth recording. `participation_confidence`
is required but typed `["number", "null"]`, matching the hook's
`.get(...)` and null-tolerant prompt contract. `details` is required but
typed `"string"` with no `minLength`, because empty string is valid per
both the prompt and `str(... or "")` in the hook. `start` and `end`
use `["string", "null"]` plus an `HH:MM:SS` pattern; under Draft
2020-12 that pattern only applies to string instances.

This continues the structured-outputs series after participation
(07bc7b8e), detect_created (b58bd862), daily_schedule (0e098e7b), story
(50693752), and sense (8c952dc4). Live provider validation remains
deferred per precedent in those prior lodes.

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

+302 -1
+2 -1
talent/schedule.md
··· 8 8 "schedule": "daily", 9 9 "priority": 10, 10 10 "output": "json", 11 + "schema": "schedule.schema.json", 11 12 "load": {"transcripts": true, "percepts": false, "talents": {"screen": true}} 12 13 } 13 14 ··· 70 71 71 72 ### Field-by-field 72 73 73 - - **`activity`** — Short descriptive string for the kind of scheduled item. Pick the best fit for what you're seeing. Common examples: `meeting`, `call`, `deadline`, `appointment`, `event`, `travel`, `reminder`, `errand`, `celebration`. **Not a restricted enum** — use whatever label best describes the item. Lowercase, underscore-separated if multi-word (e.g., `doctor_appointment`). 74 + - **`activity`** — Short descriptive string for the kind of scheduled item. One of: `meeting`, `call`, `deadline`, `appointment`, `event`, `travel`, `reminder`, `errand`, `celebration`, `doctor_appointment`. Lowercase, underscore-separated when multi-word. 74 75 75 76 - **`target_date`** — ISO date `YYYY-MM-DD`. The day the item is scheduled for. Must be strictly after today. 76 77
+103
talent/schedule.schema.json
··· 1 + { 2 + "$schema": "https://json-schema.org/draft/2020-12/schema", 3 + "type": "array", 4 + "items": { 5 + "type": "object", 6 + "additionalProperties": false, 7 + "required": [ 8 + "activity", 9 + "target_date", 10 + "start", 11 + "end", 12 + "title", 13 + "description", 14 + "details", 15 + "participation", 16 + "participation_confidence", 17 + "facet", 18 + "cancelled" 19 + ], 20 + "properties": { 21 + "activity": { 22 + "type": "string", 23 + "enum": [ 24 + "meeting", 25 + "call", 26 + "deadline", 27 + "appointment", 28 + "event", 29 + "travel", 30 + "reminder", 31 + "errand", 32 + "celebration", 33 + "doctor_appointment" 34 + ] 35 + }, 36 + "target_date": { 37 + "type": "string", 38 + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" 39 + }, 40 + "start": { 41 + "type": ["string", "null"], 42 + "pattern": "^\\d{2}:\\d{2}:\\d{2}$" 43 + }, 44 + "end": { 45 + "type": ["string", "null"], 46 + "pattern": "^\\d{2}:\\d{2}:\\d{2}$" 47 + }, 48 + "title": { 49 + "type": "string", 50 + "minLength": 1 51 + }, 52 + "description": { 53 + "type": "string", 54 + "minLength": 1 55 + }, 56 + "details": { 57 + "type": "string" 58 + }, 59 + "participation": { 60 + "type": "array", 61 + "items": { 62 + "type": "object", 63 + "additionalProperties": false, 64 + "required": ["name", "role", "source", "confidence", "context"], 65 + "properties": { 66 + "name": { 67 + "type": "string", 68 + "minLength": 1 69 + }, 70 + "role": { 71 + "type": "string", 72 + "enum": ["attendee", "mentioned"] 73 + }, 74 + "source": { 75 + "type": "string", 76 + "enum": ["voice", "speaker_label", "transcript", "screen", "other"] 77 + }, 78 + "confidence": { 79 + "type": "number", 80 + "minimum": 0, 81 + "maximum": 1 82 + }, 83 + "context": { 84 + "type": "string" 85 + } 86 + } 87 + } 88 + }, 89 + "participation_confidence": { 90 + "type": ["number", "null"], 91 + "minimum": 0, 92 + "maximum": 1 93 + }, 94 + "facet": { 95 + "type": "string", 96 + "minLength": 1 97 + }, 98 + "cancelled": { 99 + "type": "boolean" 100 + } 101 + } 102 + } 103 + }
+1
tests/baselines/api/stats/stats.json
··· 196 196 "path": "<PROJECT>/talent/schedule.md", 197 197 "priority": 10, 198 198 "schedule": "daily", 199 + "schema": "schedule.schema.json", 199 200 "source": "system", 200 201 "title": "Upcoming Schedule", 201 202 "type": "generate"
+196
tests/test_schedule_schema.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + import json 5 + from pathlib import Path 6 + 7 + from jsonschema import Draft202012Validator 8 + 9 + from think.talent import get_talent 10 + 11 + TALENT_DIR = Path(__file__).resolve().parents[1] / "talent" 12 + PARTICIPATION_ENTRY_SCHEMA_PATH = TALENT_DIR / "participation_entry.schema.json" 13 + SCHEDULE_SCHEMA_PATH = TALENT_DIR / "schedule.schema.json" 14 + 15 + SCHEDULE_ACTIVITY_ENUM = { 16 + "meeting", 17 + "call", 18 + "deadline", 19 + "appointment", 20 + "event", 21 + "travel", 22 + "reminder", 23 + "errand", 24 + "celebration", 25 + "doctor_appointment", 26 + } 27 + 28 + SCHEDULE_REQUIRED_FIELDS = { 29 + "activity", 30 + "target_date", 31 + "start", 32 + "end", 33 + "title", 34 + "description", 35 + "details", 36 + "participation", 37 + "participation_confidence", 38 + "facet", 39 + "cancelled", 40 + } 41 + 42 + 43 + def _load_json(path: Path) -> dict | list: 44 + return json.loads(path.read_text(encoding="utf-8")) 45 + 46 + 47 + def _load_schedule_schema() -> dict: 48 + schema = _load_json(SCHEDULE_SCHEMA_PATH) 49 + assert isinstance(schema, dict) 50 + return schema 51 + 52 + 53 + def _sample_schedule_payloads() -> list[list[dict]]: 54 + return [ 55 + [ 56 + { 57 + "activity": "meeting", 58 + "target_date": "2026-04-20", 59 + "start": "16:30:00", 60 + "end": "17:30:00", 61 + "title": "Yuri Namikawa intro call", 62 + "description": "Intro call with Yuri from Offline Ventures.", 63 + "details": "Google Meet", 64 + "participation": [ 65 + { 66 + "name": "Yuri Namikawa", 67 + "role": "attendee", 68 + "source": "screen", 69 + "confidence": 0.95, 70 + "context": "calendar invite", 71 + }, 72 + { 73 + "name": "Scott Ward", 74 + "role": "mentioned", 75 + "source": "screen", 76 + "confidence": 0.5, 77 + "context": "mentioned in notes", 78 + }, 79 + { 80 + "name": "Unknown Guest", 81 + "role": "attendee", 82 + "source": "screen", 83 + "confidence": 0.4, 84 + "context": "guest field", 85 + }, 86 + ], 87 + "participation_confidence": 0.88, 88 + "facet": "work", 89 + "cancelled": False, 90 + } 91 + ], 92 + [ 93 + { 94 + "activity": "meeting", 95 + "target_date": "2026-04-22", 96 + "start": "09:00:00", 97 + "end": "10:00:00", 98 + "title": "Scott Ward standup", 99 + "description": "Weekly standup with Scott Ward.", 100 + "details": "Recurring invite", 101 + "participation": [], 102 + "participation_confidence": 0.85, 103 + "facet": "work", 104 + "cancelled": True, 105 + } 106 + ], 107 + [ 108 + { 109 + "activity": "call", 110 + "target_date": "2026-04-21", 111 + "start": "10:30:00", 112 + "end": "11:00:00", 113 + "title": "Mari Zumbro intro", 114 + "description": "Updated invite", 115 + "details": "Google Meet", 116 + "participation": [], 117 + "participation_confidence": 0.9, 118 + "facet": "work", 119 + "cancelled": False, 120 + } 121 + ], 122 + [ 123 + { 124 + "activity": "deadline", 125 + "target_date": "2026-05-05", 126 + "start": None, 127 + "end": None, 128 + "title": "Demo Day", 129 + "description": "Betaworks Camp Demo Day.", 130 + "details": "Live demo presentation to cohort investors", 131 + "participation": [], 132 + "participation_confidence": 0.5, 133 + "facet": "work", 134 + "cancelled": False, 135 + } 136 + ], 137 + ] 138 + 139 + 140 + def test_schedule_schema_file_is_valid_draft_2020_12(): 141 + Draft202012Validator.check_schema(_load_schedule_schema()) 142 + 143 + 144 + def test_schedule_talent_loads_schema(): 145 + assert get_talent("schedule")["json_schema"] == _load_schedule_schema() 146 + 147 + 148 + def test_schedule_participation_entry_diverges_from_shared_fragment(): 149 + """Schedule omits entity_id because the hook fills it via find_matching_entity.""" 150 + schedule_schema = _load_schedule_schema() 151 + fragment = _load_json(PARTICIPATION_ENTRY_SCHEMA_PATH) 152 + 153 + assert isinstance(fragment, dict) 154 + fragment_without_schema = dict(fragment) 155 + fragment_without_schema.pop("$schema") 156 + fragment_without_schema["properties"] = dict(fragment_without_schema["properties"]) 157 + fragment_without_schema["properties"].pop("entity_id") 158 + fragment_without_schema["required"] = [ 159 + key for key in fragment_without_schema["required"] if key != "entity_id" 160 + ] 161 + 162 + inline_items = dict( 163 + schedule_schema["items"]["properties"]["participation"]["items"] 164 + ) 165 + 166 + assert inline_items == fragment_without_schema 167 + 168 + 169 + def test_schedule_schema_mirrors_hook_requirements(): 170 + schedule_schema = _load_schedule_schema() 171 + item_schema = schedule_schema["items"] 172 + properties = item_schema["properties"] 173 + participation_items = properties["participation"]["items"] 174 + fragment = _load_json(PARTICIPATION_ENTRY_SCHEMA_PATH) 175 + 176 + assert schedule_schema["type"] == "array" 177 + assert set(item_schema["required"]) == SCHEDULE_REQUIRED_FIELDS 178 + assert set(properties["activity"]["enum"]) == SCHEDULE_ACTIVITY_ENUM 179 + assert ( 180 + participation_items["properties"]["role"]["enum"] 181 + == fragment["properties"]["role"]["enum"] 182 + ) 183 + assert ( 184 + participation_items["properties"]["source"]["enum"] 185 + == fragment["properties"]["source"]["enum"] 186 + ) 187 + assert properties["start"]["type"] == ["string", "null"] 188 + assert properties["end"]["type"] == ["string", "null"] 189 + assert properties["cancelled"]["type"] == "boolean" 190 + 191 + 192 + def test_schedule_hook_fixtures_validate_against_schema(): 193 + validator = Draft202012Validator(_load_schedule_schema()) 194 + 195 + for payload in _sample_schedule_payloads(): 196 + assert list(validator.iter_errors(payload)) == []