personal memory agent
0
fork

Configure Feed

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

feat(activities): accept participation on sol call activities create

The cogitate `activities_review` talent prompt has been instructing the
LLM to emit `participation[]` on the stdin payload, but the CLI's
`create` handler only read `title`/`activity`/`description`/`details` —
silently dropping the participation field on every new activity.

Extend `apps/activities/call.py::create_record` to:

- Validate an optional `participation[]` per Sprint 1's schema (role ∈
{attendee, mentioned}, source ∈ {voice, speaker_label, transcript,
screen, other}, string name required). Fail fast on the first bad
entry with an indexed stderr error and exit 1 before any record is
written.
- Resolve each entry's `entity_id` read-only via `load_entities` +
`find_matching_entity`, mirroring `talent/participation.py::post_process`.
Unmatched names store `entity_id: null`. Never writes to any entity
file.
- Persist `participation` on the new record only when the caller
provided the key (absent stays absent; empty `[]` is stored as `[]`).
- Append `"participation"` to `edits[0].fields` when the caller opted in.

Eight new CLI-level tests cover the happy path, absent vs empty, every
validation branch, the read-only resolver invariant (st_size +
st_mtime_ns snapshot), and the no-write-on-rejection contract.

No change to `update`, the cogitate prompt, `think/activities.py`, or
`talent/participation.py`.

+425 -14
+114 -14
apps/activities/call.py
··· 27 27 unmute_activity_record, 28 28 update_activity_record, 29 29 ) 30 + from think.entities.loading import load_entities 31 + from think.entities.matching import find_matching_entity 30 32 from think.facets import get_facets, log_call_action 31 33 from think.utils import ( 32 34 get_sol_facet, ··· 36 38 resolve_sol_facet, 37 39 segment_parse, 38 40 ) 41 + 42 + _PARTICIPATION_ROLES = {"attendee", "mentioned"} 43 + _PARTICIPATION_SOURCES = {"voice", "speaker_label", "transcript", "screen", "other"} 39 44 40 45 app = typer.Typer(help="Completed activity record management.") 41 46 ··· 125 130 return segment 126 131 127 132 133 + def _validate_participation(value: Any) -> list[dict[str, Any]]: 134 + if not isinstance(value, list): 135 + typer.echo("Error: participation must be an array", err=True) 136 + raise typer.Exit(1) 137 + 138 + cleaned_entries: list[dict[str, Any]] = [] 139 + for i, entry in enumerate(value): 140 + if not isinstance(entry, dict): 141 + typer.echo(f"Error: participation[{i}] must be an object", err=True) 142 + raise typer.Exit(1) 143 + 144 + name = entry.get("name") 145 + if not isinstance(name, str) or not name.strip(): 146 + typer.echo( 147 + f"Error: participation[{i}] requires a non-empty string 'name'", 148 + err=True, 149 + ) 150 + raise typer.Exit(1) 151 + 152 + role = entry.get("role") 153 + if role not in _PARTICIPATION_ROLES: 154 + typer.echo( 155 + f"Error: participation[{i}] has invalid role '{role}' " 156 + f"(must be one of {sorted(_PARTICIPATION_ROLES)})", 157 + err=True, 158 + ) 159 + raise typer.Exit(1) 160 + 161 + source = entry.get("source") 162 + if source not in _PARTICIPATION_SOURCES: 163 + typer.echo( 164 + f"Error: participation[{i}] has invalid source '{source}' " 165 + f"(must be one of {sorted(_PARTICIPATION_SOURCES)})", 166 + err=True, 167 + ) 168 + raise typer.Exit(1) 169 + 170 + confidence = entry.get("confidence") 171 + if isinstance(confidence, bool) or not isinstance(confidence, (int, float)): 172 + typer.echo( 173 + f"Error: participation[{i}] 'confidence' must be a number", 174 + err=True, 175 + ) 176 + raise typer.Exit(1) 177 + 178 + context = entry.get("context") 179 + if not isinstance(context, str): 180 + typer.echo( 181 + f"Error: participation[{i}] 'context' must be a string", 182 + err=True, 183 + ) 184 + raise typer.Exit(1) 185 + 186 + cleaned_entry = {key: item for key, item in entry.items() if key != "entity_id"} 187 + cleaned_entry["name"] = name.strip() 188 + cleaned_entry["role"] = role 189 + cleaned_entry["source"] = source 190 + cleaned_entry["confidence"] = confidence 191 + cleaned_entry["context"] = context 192 + cleaned_entries.append(cleaned_entry) 193 + 194 + return cleaned_entries 195 + 196 + 197 + def _resolve_participation_entity_ids( 198 + entries: list[dict[str, Any]], *, facet: str, day: str 199 + ) -> list[dict[str, Any]]: 200 + entities_list = load_entities(facet=facet, day=day) 201 + 202 + resolved_entries = [] 203 + for entry in entries: 204 + resolved = dict(entry) 205 + match = find_matching_entity(resolved["name"], entities_list) 206 + resolved["entity_id"] = match.get("id") if match else None 207 + resolved_entries.append(resolved) 208 + 209 + return resolved_entries 210 + 211 + 128 212 def _list_records_for_days( 129 213 facets: list[str], 130 214 days: list[str], ··· 317 401 resolved_facet = resolve_sol_facet(facet) 318 402 resolved_day = resolve_sol_day(day) 319 403 payload = _read_stdin_json() 404 + participation_provided = "participation" in payload 320 405 321 406 title = str(payload.get("title") or "").strip() 322 407 if not title: ··· 344 429 345 430 description = str(payload.get("description") or title).strip() or title 346 431 details = str(payload.get("details") or "") 432 + participation: list[dict[str, Any]] = [] 433 + if participation_provided: 434 + participation = _validate_participation(payload["participation"]) 435 + participation = _resolve_participation_entity_ids( 436 + participation, facet=resolved_facet, day=resolved_day 437 + ) 438 + 347 439 actor = "cogitate:activities" if source == "cogitate" else "cli:create" 348 440 span_id = make_activity_id(activity_type, anchor) 441 + record = { 442 + "id": span_id, 443 + "activity": activity_type, 444 + "title": title, 445 + "description": description, 446 + "details": details, 447 + "segments": segments, 448 + "active_entities": [], 449 + "created_at": now_ms(), 450 + "source": source, 451 + "hidden": False, 452 + "edits": [], 453 + } 454 + if participation_provided: 455 + record["participation"] = participation 456 + 457 + edit_fields = ["activity", "title", "description", "details", "source"] 458 + if participation_provided: 459 + edit_fields.append("participation") 460 + 349 461 record = append_edit( 350 - { 351 - "id": span_id, 352 - "activity": activity_type, 353 - "title": title, 354 - "description": description, 355 - "details": details, 356 - "segments": segments, 357 - "active_entities": [], 358 - "created_at": now_ms(), 359 - "source": source, 360 - "hidden": False, 361 - "edits": [], 362 - }, 462 + record, 363 463 actor=actor, 364 - fields=["activity", "title", "description", "details", "source"], 464 + fields=edit_fields, 365 465 note="created", 366 466 ) 367 467
+311
tests/test_activities_cli_create.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + import json 5 + 6 + from typer.testing import CliRunner 7 + 8 + from apps.activities.call import app 9 + 10 + runner = CliRunner() 11 + 12 + 13 + def _write_detected_entities(tmp_path, facet: str, day: str, rows: list[dict]) -> None: 14 + entities_path = tmp_path / "facets" / facet / "entities" / f"{day}.jsonl" 15 + entities_path.parent.mkdir(parents=True, exist_ok=True) 16 + entities_path.write_text( 17 + "".join(json.dumps(row, ensure_ascii=False) + "\n" for row in rows), 18 + encoding="utf-8", 19 + ) 20 + 21 + 22 + def _configure_cli_env(tmp_path, monkeypatch) -> None: 23 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 24 + monkeypatch.setenv("SOL_SKIP_SUPERVISOR_CHECK", "1") 25 + 26 + import think.utils 27 + 28 + think.utils._journal_path_cache = None 29 + 30 + from think.entities.loading import clear_entity_loading_cache 31 + 32 + clear_entity_loading_cache() 33 + 34 + 35 + def _base_payload() -> dict: 36 + return { 37 + "title": "Team sync", 38 + "activity": "meeting", 39 + } 40 + 41 + 42 + def _valid_participation_entry(**overrides) -> dict: 43 + entry = { 44 + "name": "JB", 45 + "role": "attendee", 46 + "source": "voice", 47 + "confidence": 0.98, 48 + "context": "Spoke during the meeting", 49 + } 50 + entry.update(overrides) 51 + return entry 52 + 53 + 54 + def _invoke_create(payload: dict, *, day: str = "20260418", facet: str = "work"): 55 + return runner.invoke( 56 + app, 57 + [ 58 + "create", 59 + "--facet", 60 + facet, 61 + "--day", 62 + day, 63 + "--since-segment", 64 + "090000_300", 65 + "--source", 66 + "cogitate", 67 + ], 68 + input=json.dumps(payload), 69 + ) 70 + 71 + 72 + def _read_written_record( 73 + tmp_path, *, day: str = "20260418", facet: str = "work" 74 + ) -> dict: 75 + records_path = tmp_path / "facets" / facet / "activities" / f"{day}.jsonl" 76 + lines = records_path.read_text(encoding="utf-8").splitlines() 77 + assert len(lines) == 1 78 + return json.loads(lines[0]) 79 + 80 + 81 + def test_create_resolves_participation_entity_ids(tmp_path, monkeypatch): 82 + _configure_cli_env(tmp_path, monkeypatch) 83 + 84 + _write_detected_entities( 85 + tmp_path, 86 + "work", 87 + "20260418", 88 + [ 89 + { 90 + "id": "john_borthwick", 91 + "type": "Person", 92 + "name": "John Borthwick", 93 + "aka": ["JB"], 94 + } 95 + ], 96 + ) 97 + 98 + payload = _base_payload() 99 + payload["participation"] = [ 100 + _valid_participation_entry(entity_id="fake_id", extra="keep-me"), 101 + _valid_participation_entry( 102 + name="Alex", 103 + role="mentioned", 104 + source="transcript", 105 + confidence=0.55, 106 + context="Mentioned as a follow-up owner", 107 + entity_id="fake_id", 108 + ), 109 + ] 110 + 111 + result = _invoke_create(payload) 112 + 113 + assert result.exit_code == 0 114 + record = _read_written_record(tmp_path) 115 + assert record["participation"][0]["entity_id"] == "john_borthwick" 116 + assert record["participation"][1]["entity_id"] is None 117 + assert record["participation"][0]["extra"] == "keep-me" 118 + assert "participation" in record["edits"][0]["fields"] 119 + 120 + import think.utils 121 + 122 + think.utils._journal_path_cache = None 123 + 124 + 125 + def test_create_omits_participation_when_not_provided(tmp_path, monkeypatch): 126 + _configure_cli_env(tmp_path, monkeypatch) 127 + 128 + result = _invoke_create(_base_payload()) 129 + 130 + assert result.exit_code == 0 131 + record = _read_written_record(tmp_path) 132 + assert "participation" not in record 133 + assert "participation" not in record["edits"][0]["fields"] 134 + 135 + import think.utils 136 + 137 + think.utils._journal_path_cache = None 138 + 139 + 140 + def test_create_persists_empty_participation_array(tmp_path, monkeypatch): 141 + _configure_cli_env(tmp_path, monkeypatch) 142 + 143 + payload = _base_payload() 144 + payload["participation"] = [] 145 + 146 + result = _invoke_create(payload) 147 + 148 + assert result.exit_code == 0 149 + record = _read_written_record(tmp_path) 150 + assert record["participation"] == [] 151 + assert "participation" in record["edits"][0]["fields"] 152 + 153 + import think.utils 154 + 155 + think.utils._journal_path_cache = None 156 + 157 + 158 + def test_create_rejects_non_list_participation(tmp_path, monkeypatch): 159 + _configure_cli_env(tmp_path, monkeypatch) 160 + 161 + payload = _base_payload() 162 + payload["participation"] = {"name": "JB"} 163 + activities_path = tmp_path / "facets" / "work" / "activities" / "20260418.jsonl" 164 + 165 + result = _invoke_create(payload) 166 + 167 + assert result.exit_code == 1 168 + assert "Error: participation must be an array" in result.output 169 + assert not activities_path.exists() 170 + 171 + import think.utils 172 + 173 + think.utils._journal_path_cache = None 174 + 175 + 176 + def test_create_rejects_non_object_participation_entry(tmp_path, monkeypatch): 177 + _configure_cli_env(tmp_path, monkeypatch) 178 + 179 + payload = _base_payload() 180 + payload["participation"] = ["JB"] 181 + activities_path = tmp_path / "facets" / "work" / "activities" / "20260418.jsonl" 182 + 183 + result = _invoke_create(payload) 184 + 185 + assert result.exit_code == 1 186 + assert "Error: participation[0] must be an object" in result.output 187 + assert not activities_path.exists() 188 + 189 + import think.utils 190 + 191 + think.utils._journal_path_cache = None 192 + 193 + 194 + def test_create_participation_resolver_is_read_only(tmp_path, monkeypatch): 195 + _configure_cli_env(tmp_path, monkeypatch) 196 + 197 + _write_detected_entities( 198 + tmp_path, 199 + "work", 200 + "20260418", 201 + [ 202 + { 203 + "id": "john_borthwick", 204 + "type": "Person", 205 + "name": "John Borthwick", 206 + "aka": ["JB"], 207 + } 208 + ], 209 + ) 210 + _write_detected_entities( 211 + tmp_path, 212 + "work", 213 + "20260417", 214 + [{"id": "other_person", "type": "Person", "name": "Other Person"}], 215 + ) 216 + 217 + entities_dir = tmp_path / "facets" / "work" / "entities" 218 + snapshot_before = { 219 + p.name: (p.stat().st_size, p.stat().st_mtime_ns) for p in entities_dir.iterdir() 220 + } 221 + 222 + payload = _base_payload() 223 + payload["participation"] = [_valid_participation_entry()] 224 + 225 + result = _invoke_create(payload) 226 + 227 + snapshot_after = { 228 + p.name: (p.stat().st_size, p.stat().st_mtime_ns) for p in entities_dir.iterdir() 229 + } 230 + assert result.exit_code == 0 231 + assert snapshot_after == snapshot_before 232 + 233 + import think.utils 234 + 235 + think.utils._journal_path_cache = None 236 + 237 + 238 + def test_create_rejects_bad_participation_name(tmp_path, monkeypatch): 239 + _configure_cli_env(tmp_path, monkeypatch) 240 + activities_path = tmp_path / "facets" / "work" / "activities" / "20260418.jsonl" 241 + 242 + cases = [ 243 + ( 244 + _valid_participation_entry(name=""), 245 + "Error: participation[0] requires a non-empty string 'name'", 246 + ), 247 + ( 248 + _valid_participation_entry(name=7), 249 + "Error: participation[0] requires a non-empty string 'name'", 250 + ), 251 + ( 252 + { 253 + key: value 254 + for key, value in _valid_participation_entry().items() 255 + if key != "name" 256 + }, 257 + "Error: participation[0] requires a non-empty string 'name'", 258 + ), 259 + ] 260 + 261 + for entry, message in cases: 262 + payload = _base_payload() 263 + payload["participation"] = [entry] 264 + 265 + result = _invoke_create(payload) 266 + 267 + assert result.exit_code == 1 268 + assert message in result.output 269 + assert not activities_path.exists() 270 + 271 + import think.utils 272 + 273 + think.utils._journal_path_cache = None 274 + 275 + 276 + def test_create_rejects_invalid_participation_fields(tmp_path, monkeypatch): 277 + _configure_cli_env(tmp_path, monkeypatch) 278 + activities_path = tmp_path / "facets" / "work" / "activities" / "20260418.jsonl" 279 + 280 + cases = [ 281 + ( 282 + _valid_participation_entry(role="observer"), 283 + "Error: participation[0] has invalid role 'observer' (must be one of ['attendee', 'mentioned'])", 284 + ), 285 + ( 286 + _valid_participation_entry(source="calendar"), 287 + "Error: participation[0] has invalid source 'calendar' (must be one of ['other', 'screen', 'speaker_label', 'transcript', 'voice'])", 288 + ), 289 + ( 290 + _valid_participation_entry(confidence="high"), 291 + "Error: participation[0] 'confidence' must be a number", 292 + ), 293 + ( 294 + _valid_participation_entry(context=7), 295 + "Error: participation[0] 'context' must be a string", 296 + ), 297 + ] 298 + 299 + for entry, message in cases: 300 + payload = _base_payload() 301 + payload["participation"] = [entry] 302 + 303 + result = _invoke_create(payload) 304 + 305 + assert result.exit_code == 1 306 + assert message in result.output 307 + assert not activities_path.exists() 308 + 309 + import think.utils 310 + 311 + think.utils._journal_path_cache = None