personal memory agent
0
fork

Configure Feed

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

feat(sense): close content_type enum and hydrate facet enum from runtime

Tighten talent/sense.schema.json so Gemini's response_json_schema
enforcement closes two surfaces that previously accepted garbage:

- content_type: replace 8-value enum (which included `mixed`) with the
17-value enum derived from think.activities.DEFAULT_ACTIVITIES minus
the schedule-only ids, plus the `idle` sentinel. A drift-detector
test in tests/test_sense_schema.py compares the on-disk enum to the
derivation, so adding/removing an activity will fail loudly.

- facets[].facet: replace the open `{type: string, minLength: 1}` with
a sentinel enum `[__RUNTIME_FACETS__]` that is hydrated per-call in
think.talents._execute_generate from think.facets.get_facets(),
filtered by the slug regex `^[a-z][a-z0-9_-]*$`. When the journal
has no valid facets the sentinel falls back to {type: string,
minLength: 1} so the schema remains satisfiable.

Hydration is a deep-copy, idempotent, read-only transform that lives
in think.talent. The on-disk schema and `get_talent("sense")["json_schema"]`
both still equal the raw file (existing
test_sense_loaded_json_schema_matches_on_disk_schema invariant holds);
hydration happens once before the primary provider call and is reused
for the fallback.

talent/sense.md is updated to match: the content_type field doc lists
all 17 values, the "Facet assignment rules" paragraph drops the
hardcoded pseudo-facets (`meetings`, `learning`, `technical-work`)
that were never real facet IDs, and Rule #5 stops referencing the
dropped `mixed` value.

No data migration. Existing on-disk records with content_type=mixed or
pseudo-facet entries remain valid documents; future runs simply emit
schema-conformant values.

+150 -15
+15 -9
talent/sense.md
··· 38 38 39 39 ### content_type 40 40 The dominant activity type observed: 41 - - **meeting**: Multi-person discussion with turn-taking (video call, in-person meeting, phone call) 42 - - **coding**: Writing or editing code, using a terminal, IDE, or code review tool 43 - - **browsing**: Web browsing, reading articles, searching 41 + - **meeting**: Video calls, in-person meetings, conferences with turn-taking 42 + - **coding**: Writing or editing code, IDE work, code review, debugging 43 + - **browsing**: Web browsing, research, reading articles online 44 44 - **email**: Reading or composing email 45 45 - **messaging**: Chat applications (Slack, Teams, Discord, iMessage) 46 + - **ai_conversation**: Conversations with AI assistants (ChatGPT, Claude, Gemini) 47 + - **writing**: Documents, notes, long-form writing 46 48 - **reading**: Focused reading of documents, PDFs, books 49 + - **video**: Watching video or streaming content 50 + - **gaming**: Playing games 51 + - **social**: Social media browsing and interaction 52 + - **planning**: Scheduling, calendar management, agenda setting 53 + - **productivity**: Spreadsheets, slides, task management 54 + - **terminal**: Command line / shell sessions 55 + - **design**: Design tools, image editing 56 + - **music**: Music listening 47 57 - **idle**: No meaningful activity 48 - - **mixed**: Multiple distinct activity types with no clear dominant one 49 58 50 59 ### activity_summary 51 60 Describe what $preferred did during this segment using action verbs. Be specific — name the tools, people, projects, and actions. Ban passive words: never use "reviewing", "monitoring", "tracking", "checking", "observing", "maintaining", "managing." Use instead: wrote, sent, discussed, created, switched to, typed, said, decided, asked, proposed. ··· 80 89 - `activity`: 1-sentence description of what was observed for this facet 81 90 - `level`: "high" (primary focus), "medium" (significant), "low" (brief/peripheral) 82 91 83 - **Facet assignment rules:** 84 - - "meetings" — ONLY for live synchronous group meetings where $preferred is a participant. NOT for listening to lectures, podcasts, press conferences, or recorded media. 85 - - "learning" — Educational content: lectures, tutorials, articles, research, Wikipedia, podcasts. Includes listening to press conferences or recorded briefings. 86 - - "technical-work" — Hands-on technical tasks: coding, using developer tools, configuring software, installing extensions, product research for work purposes, technical document writing, online tool usage, price comparison shopping. 92 + **Facet assignment rules:** Do not invent facet IDs that are not in the configured journal facet list. Empty array is acceptable when no configured facet matches. 87 93 88 94 ### meeting_detected 89 95 `true` ONLY if you can identify distinct, named participants in a live multi-person interaction: ··· 124 130 2. `entities` and `speakers` may be empty arrays `[]`. 125 131 3. `facets` may be empty array `[]` if no configured facets match. 126 132 4. Be precise with density — misclassifying active segments as idle is the worst error. 127 - 5. For content_type, choose the single best match. Use "mixed" sparingly — only when there are truly multiple equal activities. 133 + 5. For `content_type`, choose the single best match — the dominant activity in the segment. If two activities are roughly equal, pick the one with more durable continuation evidence (entities, repeated screen content); the `facets[]` array's `level` field already encodes secondary activity. 128 134 6. Activity summary must describe observable actions, not inferred states. 129 135 130 136 Return ONLY the JSON object, no other text or explanation.
+2 -2
talent/sense.schema.json
··· 5 5 "required": ["density","content_type","activity_summary","entities","facets","meeting_detected","speakers","recommend","emotional_register"], 6 6 "properties": { 7 7 "density": {"type": "string", "enum": ["active","low_change","idle"]}, 8 - "content_type": {"type": "string", "enum": ["meeting","coding","browsing","email","messaging","reading","idle","mixed"]}, 8 + "content_type": {"type": "string", "enum": ["meeting","coding","browsing","email","messaging","ai_conversation","writing","reading","video","gaming","social","planning","productivity","terminal","design","music","idle"]}, 9 9 "activity_summary": {"type": "string", "minLength": 1}, 10 10 "entities": { 11 11 "type": "array", ··· 29 29 "additionalProperties": false, 30 30 "required": ["facet","activity","level"], 31 31 "properties": { 32 - "facet": {"type": "string", "minLength": 1}, 32 + "facet": {"type": "string", "enum": ["__RUNTIME_FACETS__"]}, 33 33 "activity": {"type": "string", "minLength": 1}, 34 34 "level": {"type": "string", "enum": ["high","medium","low"]} 35 35 }
+72 -1
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 copy 4 5 import json 5 6 from pathlib import Path 6 7 7 8 import frontmatter 9 + from jsonschema import Draft202012Validator 8 10 9 - from think.talent import get_talent 11 + from think.activities import DEFAULT_ACTIVITIES 12 + from think.talent import ( 13 + RUNTIME_FACETS_SENTINEL, 14 + get_talent, 15 + hydrate_runtime_enums, 16 + ) 10 17 11 18 SENSE_PATH = Path(__file__).resolve().parents[1] / "talent" / "sense.md" 12 19 SENSE_SCHEMA_PATH = SENSE_PATH.with_suffix(".schema.json") ··· 51 58 on_disk = json.loads(SENSE_SCHEMA_PATH.read_text(encoding="utf-8")) 52 59 53 60 assert get_talent("sense")["json_schema"] == on_disk 61 + 62 + 63 + def test_content_type_enum_matches_default_activities_drift_detector(): 64 + schedule_only = "Scheduled events emitted by talent/schedule.md" 65 + expected = [ 66 + a["id"] 67 + for a in DEFAULT_ACTIVITIES 68 + if schedule_only not in a.get("instructions", "") 69 + ] + ["idle"] 70 + schema = json.loads(SENSE_SCHEMA_PATH.read_text(encoding="utf-8")) 71 + 72 + assert schema["properties"]["content_type"]["enum"] == expected 73 + 74 + 75 + def test_sense_schema_facet_uses_runtime_sentinel_constant(): 76 + schema = json.loads(SENSE_SCHEMA_PATH.read_text(encoding="utf-8")) 77 + facet_schema = schema["properties"]["facets"]["items"]["properties"]["facet"] 78 + 79 + assert facet_schema["enum"] == [RUNTIME_FACETS_SENTINEL] 80 + 81 + 82 + def test_hydrate_runtime_enums_replaces_facet_sentinel(monkeypatch): 83 + monkeypatch.setattr( 84 + "think.talent.get_facets", 85 + lambda: {"alpha": {}, "Beta": {}, "weird,name": {}, "valid_one": {}}, 86 + ) 87 + schema = { 88 + "properties": {"facet": {"type": "string", "enum": [RUNTIME_FACETS_SENTINEL]}} 89 + } 90 + 91 + hydrated = hydrate_runtime_enums(schema) 92 + 93 + assert hydrated["properties"]["facet"]["enum"] == ["alpha", "valid_one"] 94 + 95 + 96 + def test_hydrate_runtime_enums_empty_facets_fallback(monkeypatch): 97 + monkeypatch.setattr("think.talent.get_facets", lambda: {}) 98 + schema = { 99 + "type": "object", 100 + "properties": {"facet": {"type": "string", "enum": [RUNTIME_FACETS_SENTINEL]}}, 101 + } 102 + 103 + hydrated = hydrate_runtime_enums(schema) 104 + facet_schema = hydrated["properties"]["facet"] 105 + 106 + assert facet_schema == {"type": "string", "minLength": 1} 107 + Draft202012Validator.check_schema(hydrated) 108 + 109 + 110 + def test_hydrate_runtime_enums_idempotent_and_pure(monkeypatch): 111 + monkeypatch.setattr("think.talent.get_facets", lambda: {}) 112 + original = {"type": "object", "properties": {"x": {"type": "string"}}} 113 + saved_copy = copy.deepcopy(original) 114 + 115 + hydrated = hydrate_runtime_enums(original) 116 + 117 + assert hydrated == original 118 + assert original == saved_copy 119 + assert hydrated is not original 120 + assert hydrate_runtime_enums(hydrated) == hydrated 121 + 122 + 123 + def test_hydrate_runtime_enums_none_passthrough(): 124 + assert hydrate_runtime_enums(None) is None 54 125 55 126 56 127 def test_role_and_source_do_not_leak_into_other_sense_sections():
+57
think/talent.py
··· 17 17 18 18 from __future__ import annotations 19 19 20 + import copy 20 21 import importlib.util 21 22 import json 23 + import logging 22 24 import os 25 + import re 23 26 from pathlib import Path 24 27 from typing import Any, Callable 25 28 26 29 import frontmatter 27 30 from jsonschema import Draft202012Validator, SchemaError 28 31 32 + from think.facets import get_facets 33 + 29 34 # Import core prompt utilities from think.prompts 30 35 from think.prompts import _load_prompt_metadata, load_prompt 31 36 ··· 35 40 36 41 TALENT_DIR = Path(__file__).parent.parent / "talent" 37 42 APPS_DIR = Path(__file__).parent.parent / "apps" 43 + RUNTIME_FACETS_SENTINEL = "__RUNTIME_FACETS__" 44 + SLUG_RE = re.compile(r"^[a-z][a-z0-9_-]*$") 45 + LOG = logging.getLogger(__name__) 38 46 39 47 40 48 # --------------------------------------------------------------------------- ··· 438 446 if value is False: 439 447 return {} # No talents 440 448 return None # All talents (True or "required") 449 + 450 + 451 + def _valid_runtime_facets() -> list[str]: 452 + """Return sorted list of facet directory names matching SLUG_RE.""" 453 + return sorted(slug for slug in get_facets() if SLUG_RE.fullmatch(slug)) 454 + 455 + 456 + def hydrate_runtime_enums(schema: Any) -> Any: 457 + """Replace runtime sentinels in schema enums with current journal state. 458 + 459 + Walks the schema; wherever an `enum` is exactly [RUNTIME_FACETS_SENTINEL], 460 + replaces it with the sorted list of valid runtime facet slugs. If no 461 + valid facets exist, drops the `enum` key and sets `minLength: 1` on 462 + that node so the schema remains satisfiable. 463 + 464 + Returns None when given None. Deep-copies non-None input. Idempotent 465 + for already-hydrated schemas (sentinel is gone after first call). 466 + """ 467 + if schema is None: 468 + return None 469 + 470 + hydrated = copy.deepcopy(schema) 471 + facets = _valid_runtime_facets() 472 + used_empty_fallback = False 473 + 474 + def _walk(node: Any) -> None: 475 + nonlocal used_empty_fallback 476 + if isinstance(node, dict): 477 + if node.get("enum") == [RUNTIME_FACETS_SENTINEL]: 478 + if facets: 479 + node["enum"] = list(facets) 480 + else: 481 + node.pop("enum", None) 482 + node["minLength"] = 1 483 + used_empty_fallback = True 484 + for value in node.values(): 485 + _walk(value) 486 + elif isinstance(node, list): 487 + for item in node: 488 + _walk(item) 489 + 490 + _walk(hydrated) 491 + 492 + if used_empty_fallback: 493 + LOG.info( 494 + "hydrate_runtime_enums: no valid runtime facets; using minLength fallback" 495 + ) 496 + 497 + return hydrated 441 498 442 499 443 500 # ---------------------------------------------------------------------------
+4 -3
think/talents.py
··· 975 975 emit_event: Event emission callback 976 976 """ 977 977 from think.models import generate_with_result 978 - from think.talent import key_to_context 978 + from think.talent import hydrate_runtime_enums, key_to_context 979 979 980 980 name = config["name"] 981 981 messages = config.get("messages") ··· 1014 1014 contents = ["No input provided."] 1015 1015 1016 1016 context = key_to_context(name) 1017 + runtime_json_schema = hydrate_runtime_enums(config.get("json_schema")) 1017 1018 try: 1018 1019 gen_result = generate_with_result( 1019 1020 contents=contents, ··· 1023 1024 thinking_budget=thinking_budget, 1024 1025 system_instruction=system_instruction, 1025 1026 json_output=is_json_output, 1026 - json_schema=config.get("json_schema"), 1027 + json_schema=runtime_json_schema, 1027 1028 timeout_s=timeout_s, 1028 1029 ) 1029 1030 except Exception as exc: ··· 1069 1070 thinking_budget=thinking_budget, 1070 1071 system_instruction=system_instruction, 1071 1072 json_output=is_json_output, 1072 - json_schema=config.get("json_schema"), 1073 + json_schema=runtime_json_schema, 1073 1074 timeout_s=timeout_s, 1074 1075 provider=backup, 1075 1076 model=backup_model,