personal memory agent
0
fork

Configure Feed

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

rename calendar app to activities and rewrite the activity CLI

+971 -1237
+506
apps/activities/call.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """CLI commands for completed activity record management. 5 + 6 + Auto-discovered by ``think.call`` and mounted as ``sol call activities ...``. 7 + """ 8 + 9 + from __future__ import annotations 10 + 11 + import json 12 + import sys 13 + from datetime import datetime, timedelta 14 + from typing import Any 15 + 16 + import typer 17 + 18 + from think.activities import ( 19 + append_activity_record, 20 + append_edit, 21 + format_activities, 22 + get_activity_by_id, 23 + get_activity_record, 24 + load_activity_records, 25 + make_activity_id, 26 + mute_activity_record, 27 + unmute_activity_record, 28 + update_activity_record, 29 + ) 30 + from think.facets import get_facets, log_call_action 31 + from think.utils import ( 32 + get_sol_facet, 33 + now_ms, 34 + require_solstone, 35 + resolve_sol_day, 36 + resolve_sol_facet, 37 + segment_parse, 38 + ) 39 + 40 + app = typer.Typer(help="Completed activity record management.") 41 + 42 + 43 + @app.callback() 44 + def _require_up() -> None: 45 + require_solstone() 46 + 47 + 48 + def _read_stdin_json() -> dict[str, Any]: 49 + """Parse a single JSON object from stdin.""" 50 + raw = sys.stdin.read().strip() 51 + if not raw: 52 + typer.echo("Error: expected JSON object on stdin.", err=True) 53 + raise typer.Exit(1) 54 + 55 + try: 56 + payload = json.loads(raw) 57 + except json.JSONDecodeError as exc: 58 + typer.echo(f"Error: invalid JSON on stdin: {exc}", err=True) 59 + raise typer.Exit(1) from None 60 + 61 + if not isinstance(payload, dict): 62 + typer.echo("Error: expected JSON object on stdin.", err=True) 63 + raise typer.Exit(1) 64 + return payload 65 + 66 + 67 + def _echo_records(records: list[dict[str, Any]]) -> None: 68 + """Render activity records using the formatter text output.""" 69 + chunks, _meta = format_activities(records) 70 + if not chunks: 71 + typer.echo("No activities found.") 72 + return 73 + typer.echo("\n\n".join(chunk["markdown"] for chunk in chunks)) 74 + 75 + 76 + def _echo_json(payload: Any) -> None: 77 + typer.echo(json.dumps(payload, indent=2, ensure_ascii=False)) 78 + 79 + 80 + def _parse_day(value: str, *, label: str) -> datetime: 81 + try: 82 + return datetime.strptime(value, "%Y%m%d") 83 + except ValueError: 84 + typer.echo(f"Error: invalid {label} '{value}'", err=True) 85 + raise typer.Exit(1) from None 86 + 87 + 88 + def _iter_days(start_day: str, end_day: str) -> list[str]: 89 + start = _parse_day(start_day, label="day") 90 + end = _parse_day(end_day, label="day") 91 + if end < start: 92 + typer.echo( 93 + f"Error: --to ({end_day}) must not be before --from ({start_day})", 94 + err=True, 95 + ) 96 + raise typer.Exit(1) 97 + 98 + days: list[str] = [] 99 + cursor = start 100 + while cursor <= end: 101 + days.append(cursor.strftime("%Y%m%d")) 102 + cursor += timedelta(days=1) 103 + return days 104 + 105 + 106 + def _resolve_list_facets(facet: str | None) -> list[str]: 107 + if facet: 108 + return [facet] 109 + 110 + env_facet = get_sol_facet() 111 + if env_facet: 112 + return [env_facet] 113 + 114 + return sorted(get_facets()) 115 + 116 + 117 + def _validate_segment_key(segment: str) -> str: 118 + start, end = segment_parse(segment) 119 + if start is None or end is None: 120 + typer.echo( 121 + f"Error: invalid --since-segment '{segment}' (expected HHMMSS_LEN)", 122 + err=True, 123 + ) 124 + raise typer.Exit(1) 125 + return segment 126 + 127 + 128 + def _list_records_for_days( 129 + facets: list[str], 130 + days: list[str], 131 + *, 132 + activity: str | None, 133 + include_hidden: bool, 134 + ) -> list[dict[str, Any]]: 135 + matches: list[dict[str, Any]] = [] 136 + for facet_name in facets: 137 + for day in days: 138 + for record in load_activity_records( 139 + facet_name, day, include_hidden=include_hidden 140 + ): 141 + if activity and record.get("activity") != activity: 142 + continue 143 + enriched = dict(record) 144 + enriched["facet"] = facet_name 145 + enriched["day"] = day 146 + matches.append(enriched) 147 + 148 + matches.sort( 149 + key=lambda record: ( 150 + record.get("day", ""), 151 + record.get("facet", ""), 152 + int(record.get("created_at", 0) or 0), 153 + str(record.get("id", "")), 154 + ) 155 + ) 156 + return matches 157 + 158 + 159 + @app.command("list") 160 + def list_records( 161 + day: str | None = typer.Option( 162 + None, 163 + "--day", 164 + "-d", 165 + help="Journal day in YYYYMMDD format (or set SOL_DAY).", 166 + ), 167 + from_day: str | None = typer.Option( 168 + None, 169 + "--from", 170 + help="Start day for an inclusive range query (YYYYMMDD).", 171 + ), 172 + to_day: str | None = typer.Option( 173 + None, 174 + "--to", 175 + help="End day for an inclusive range query (YYYYMMDD).", 176 + ), 177 + facet: str | None = typer.Option( 178 + None, 179 + "--facet", 180 + "-f", 181 + help="Facet name (or set SOL_FACET). Omit to query all facets.", 182 + ), 183 + activity: str | None = typer.Option( 184 + None, 185 + "--activity", 186 + "-a", 187 + help="Filter by activity type.", 188 + ), 189 + include_all: bool = typer.Option( 190 + False, 191 + "--all", 192 + help="Include hidden activity records.", 193 + ), 194 + json_output: bool = typer.Option(False, "--json", help="Output as JSON."), 195 + ) -> None: 196 + """List activity records for one day or an inclusive day range.""" 197 + if day and (from_day or to_day): 198 + typer.echo("Error: --day is incompatible with --from/--to.", err=True) 199 + raise typer.Exit(1) 200 + 201 + if day: 202 + resolved_days = [resolve_sol_day(day)] 203 + elif from_day or to_day: 204 + start_day = from_day or resolve_sol_day(None) 205 + end_day = to_day or start_day 206 + resolved_days = _iter_days(start_day, end_day) 207 + else: 208 + resolved_days = [resolve_sol_day(None)] 209 + 210 + facets = _resolve_list_facets(facet) 211 + records = _list_records_for_days( 212 + facets, 213 + resolved_days, 214 + activity=activity, 215 + include_hidden=include_all, 216 + ) 217 + 218 + if json_output: 219 + _echo_json(records) 220 + else: 221 + _echo_records(records) 222 + 223 + 224 + @app.command("get") 225 + def get_record( 226 + span_id: str = typer.Argument(help="Activity record ID."), 227 + facet: str | None = typer.Option( 228 + None, 229 + "--facet", 230 + "-f", 231 + help="Facet name (or set SOL_FACET).", 232 + ), 233 + day: str | None = typer.Option( 234 + None, 235 + "--day", 236 + "-d", 237 + help="Journal day in YYYYMMDD format (or set SOL_DAY).", 238 + ), 239 + json_output: bool = typer.Option(False, "--json", help="Output as JSON."), 240 + ) -> None: 241 + """Fetch one activity record by ID.""" 242 + resolved_facet = resolve_sol_facet(facet) 243 + resolved_day = resolve_sol_day(day) 244 + record = get_activity_record(resolved_facet, resolved_day, span_id) 245 + if record is None: 246 + typer.echo(f"activity not found: {span_id}", err=True) 247 + raise typer.Exit(1) 248 + 249 + if json_output: 250 + _echo_json(record) 251 + else: 252 + _echo_records([record]) 253 + 254 + 255 + @app.command("create") 256 + def create_record( 257 + facet: str | None = typer.Option( 258 + None, 259 + "--facet", 260 + "-f", 261 + help="Facet name (or set SOL_FACET).", 262 + ), 263 + day: str | None = typer.Option( 264 + None, 265 + "--day", 266 + "-d", 267 + help="Journal day in YYYYMMDD format (or set SOL_DAY).", 268 + ), 269 + since_segment: str | None = typer.Option( 270 + None, 271 + "--since-segment", 272 + help="Segment key to anchor the new activity span (HHMMSS_LEN).", 273 + ), 274 + source: str = typer.Option( 275 + "user", 276 + "--source", 277 + help="Record source label: user or cogitate.", 278 + ), 279 + json_output: bool = typer.Option(False, "--json", help="Output as JSON."), 280 + ) -> None: 281 + """Create a new synthetic activity record from JSON on stdin.""" 282 + if source not in {"cogitate", "user"}: 283 + typer.echo("Error: --source must be 'cogitate' or 'user'.", err=True) 284 + raise typer.Exit(1) 285 + 286 + resolved_facet = resolve_sol_facet(facet) 287 + resolved_day = resolve_sol_day(day) 288 + payload = _read_stdin_json() 289 + 290 + title = str(payload.get("title") or "").strip() 291 + if not title: 292 + typer.echo("Error: title is required.", err=True) 293 + raise typer.Exit(1) 294 + 295 + activity_type = str(payload.get("activity") or "").strip() 296 + if not activity_type: 297 + typer.echo("Error: activity is required.", err=True) 298 + raise typer.Exit(1) 299 + 300 + if not get_activity_by_id(resolved_facet, activity_type): 301 + typer.echo( 302 + f"Error: unknown activity for facet '{resolved_facet}': {activity_type}", 303 + err=True, 304 + ) 305 + raise typer.Exit(1) 306 + 307 + if since_segment is not None: 308 + anchor = _validate_segment_key(since_segment) 309 + segments = [anchor] 310 + else: 311 + anchor = f"user_{now_ms()}" 312 + segments = [] 313 + 314 + description = str(payload.get("description") or title).strip() or title 315 + details = str(payload.get("details") or "") 316 + actor = "cogitate:activities" if source == "cogitate" else "cli:create" 317 + span_id = make_activity_id(activity_type, anchor) 318 + record = append_edit( 319 + { 320 + "id": span_id, 321 + "activity": activity_type, 322 + "title": title, 323 + "description": description, 324 + "details": details, 325 + "segments": segments, 326 + "active_entities": [], 327 + "created_at": now_ms(), 328 + "source": source, 329 + "hidden": False, 330 + "edits": [], 331 + }, 332 + actor=actor, 333 + fields=["activity", "title", "description", "details", "source"], 334 + note="created", 335 + ) 336 + 337 + if not append_activity_record(resolved_facet, resolved_day, record): 338 + typer.echo(f"Error: activity already exists: {span_id}", err=True) 339 + raise typer.Exit(1) 340 + 341 + log_call_action( 342 + facet=resolved_facet, 343 + action="activity_create", 344 + params={"id": span_id, "activity": activity_type, "source": source}, 345 + day=resolved_day, 346 + ) 347 + 348 + if json_output: 349 + _echo_json(record) 350 + else: 351 + _echo_records([record]) 352 + 353 + 354 + @app.command("update") 355 + def update_record_command( 356 + span_id: str = typer.Argument(help="Activity record ID."), 357 + facet: str | None = typer.Option( 358 + None, 359 + "--facet", 360 + "-f", 361 + help="Facet name (or set SOL_FACET).", 362 + ), 363 + day: str | None = typer.Option( 364 + None, 365 + "--day", 366 + "-d", 367 + help="Journal day in YYYYMMDD format (or set SOL_DAY).", 368 + ), 369 + note: str | None = typer.Option(None, "--note", help="Edit note."), 370 + json_output: bool = typer.Option(False, "--json", help="Output as JSON."), 371 + ) -> None: 372 + """Apply a shallow JSON patch to one activity record.""" 373 + resolved_facet = resolve_sol_facet(facet) 374 + resolved_day = resolve_sol_day(day) 375 + payload = _read_stdin_json() 376 + 377 + patch = { 378 + key: value 379 + for key, value in payload.items() 380 + if key in {"title", "description", "details"} 381 + } 382 + if set(payload) - set(patch): 383 + extra = ", ".join(sorted(set(payload) - set(patch))) 384 + typer.echo(f"Error: disallowed update fields: {extra}", err=True) 385 + raise typer.Exit(1) 386 + 387 + if not patch: 388 + typer.echo( 389 + "Error: update payload must include at least one mutable field.", err=True 390 + ) 391 + raise typer.Exit(1) 392 + 393 + note_text = note or f"updated fields: {', '.join(sorted(patch))}" 394 + updated = update_activity_record( 395 + resolved_facet, 396 + resolved_day, 397 + span_id, 398 + patch, 399 + actor="cli:update", 400 + note=note_text, 401 + ) 402 + if updated is None: 403 + typer.echo(f"activity not found: {span_id}", err=True) 404 + raise typer.Exit(1) 405 + 406 + log_call_action( 407 + facet=resolved_facet, 408 + action="activity_update", 409 + params={"id": span_id, "fields": sorted(patch)}, 410 + day=resolved_day, 411 + ) 412 + 413 + if json_output: 414 + _echo_json(updated) 415 + else: 416 + _echo_records([updated]) 417 + 418 + 419 + @app.command("mute") 420 + def mute_record( 421 + span_id: str = typer.Argument(help="Activity record ID."), 422 + facet: str | None = typer.Option( 423 + None, 424 + "--facet", 425 + "-f", 426 + help="Facet name (or set SOL_FACET).", 427 + ), 428 + day: str | None = typer.Option( 429 + None, 430 + "--day", 431 + "-d", 432 + help="Journal day in YYYYMMDD format (or set SOL_DAY).", 433 + ), 434 + reason: str | None = typer.Option(None, "--reason", help="Mute reason."), 435 + json_output: bool = typer.Option(False, "--json", help="Output as JSON."), 436 + ) -> None: 437 + """Hide an activity record without deleting it.""" 438 + resolved_facet = resolve_sol_facet(facet) 439 + resolved_day = resolve_sol_day(day) 440 + record = mute_activity_record( 441 + resolved_facet, 442 + resolved_day, 443 + span_id, 444 + actor="cli:mute", 445 + reason=reason, 446 + ) 447 + if record is None: 448 + typer.echo(f"activity not found: {span_id}", err=True) 449 + raise typer.Exit(1) 450 + 451 + log_call_action( 452 + facet=resolved_facet, 453 + action="activity_mute", 454 + params={"id": span_id, "reason": reason}, 455 + day=resolved_day, 456 + ) 457 + 458 + if json_output: 459 + _echo_json(record) 460 + else: 461 + _echo_records([record]) 462 + 463 + 464 + @app.command("unmute") 465 + def unmute_record( 466 + span_id: str = typer.Argument(help="Activity record ID."), 467 + facet: str | None = typer.Option( 468 + None, 469 + "--facet", 470 + "-f", 471 + help="Facet name (or set SOL_FACET).", 472 + ), 473 + day: str | None = typer.Option( 474 + None, 475 + "--day", 476 + "-d", 477 + help="Journal day in YYYYMMDD format (or set SOL_DAY).", 478 + ), 479 + reason: str | None = typer.Option(None, "--reason", help="Unmute reason."), 480 + json_output: bool = typer.Option(False, "--json", help="Output as JSON."), 481 + ) -> None: 482 + """Restore a previously hidden activity record.""" 483 + resolved_facet = resolve_sol_facet(facet) 484 + resolved_day = resolve_sol_day(day) 485 + record = unmute_activity_record( 486 + resolved_facet, 487 + resolved_day, 488 + span_id, 489 + actor="cli:unmute", 490 + reason=reason, 491 + ) 492 + if record is None: 493 + typer.echo(f"activity not found: {span_id}", err=True) 494 + raise typer.Exit(1) 495 + 496 + log_call_action( 497 + facet=resolved_facet, 498 + action="activity_unmute", 499 + params={"id": span_id, "reason": reason}, 500 + day=resolved_day, 501 + ) 502 + 503 + if json_output: 504 + _echo_json(record) 505 + else: 506 + _echo_records([record])
+54
apps/activities/tests/conftest.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import json 7 + 8 + import pytest 9 + 10 + 11 + @pytest.fixture 12 + def activities_env(tmp_path, monkeypatch): 13 + """Create a temporary journal facet with activity config and day records.""" 14 + 15 + def _create( 16 + entries: list[dict] | None = None, 17 + *, 18 + day: str = "20240101", 19 + facet: str = "work", 20 + activity_config: list[dict] | None = None, 21 + ): 22 + facet_dir = tmp_path / "facets" / facet 23 + activities_dir = facet_dir / "activities" 24 + activities_dir.mkdir(parents=True, exist_ok=True) 25 + 26 + (facet_dir / "facet.json").write_text( 27 + json.dumps({"title": f"Test {facet}", "description": "Test facet"}) + "\n", 28 + encoding="utf-8", 29 + ) 30 + 31 + config_entries = activity_config or [{"id": "coding"}, {"id": "meeting"}] 32 + (activities_dir / "activities.jsonl").write_text( 33 + "".join( 34 + json.dumps(entry, ensure_ascii=False) + "\n" for entry in config_entries 35 + ), 36 + encoding="utf-8", 37 + ) 38 + 39 + day_path = activities_dir / f"{day}.jsonl" 40 + if entries is not None: 41 + day_path.write_text( 42 + "".join( 43 + json.dumps(entry, ensure_ascii=False) + "\n" for entry in entries 44 + ), 45 + encoding="utf-8", 46 + ) 47 + 48 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 49 + monkeypatch.setenv("SOL_DAY", day) 50 + monkeypatch.setenv("SOL_FACET", facet) 51 + monkeypatch.setenv("SOL_SKIP_SUPERVISOR_CHECK", "1") 52 + return tmp_path, facet, day, day_path 53 + 54 + return _create
+231
apps/activities/tests/test_call.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import json 7 + 8 + from typer.testing import CliRunner 9 + 10 + from think.call import call_app 11 + 12 + runner = CliRunner() 13 + 14 + 15 + def test_list_outputs_activity_records(activities_env): 16 + activities_env( 17 + [ 18 + { 19 + "id": "coding_090000_300", 20 + "activity": "coding", 21 + "title": "Focused coding", 22 + "description": "Implementing the CLI", 23 + "segments": ["090000_300"], 24 + "created_at": 1, 25 + } 26 + ] 27 + ) 28 + 29 + result = runner.invoke(call_app, ["activities", "list", "--facet", "work"]) 30 + 31 + assert result.exit_code == 0 32 + assert "Focused coding" in result.output 33 + assert "Activity: coding" in result.output 34 + 35 + 36 + def test_list_filters_hidden_by_default_and_allows_all(activities_env): 37 + activities_env( 38 + [ 39 + { 40 + "id": "coding_090000_300", 41 + "activity": "coding", 42 + "description": "Visible", 43 + "segments": ["090000_300"], 44 + "created_at": 1, 45 + }, 46 + { 47 + "id": "meeting_100000_300", 48 + "activity": "meeting", 49 + "description": "Muted", 50 + "segments": ["100000_300"], 51 + "created_at": 2, 52 + "hidden": True, 53 + }, 54 + ] 55 + ) 56 + 57 + hidden_default = runner.invoke(call_app, ["activities", "list", "--facet", "work"]) 58 + hidden_all = runner.invoke( 59 + call_app, ["activities", "list", "--facet", "work", "--all", "--json"] 60 + ) 61 + 62 + assert hidden_default.exit_code == 0 63 + assert "Visible" in hidden_default.output 64 + assert "Muted" not in hidden_default.output 65 + 66 + assert hidden_all.exit_code == 0 67 + payload = json.loads(hidden_all.output) 68 + assert len(payload) == 2 69 + assert any(item["hidden"] for item in payload) 70 + 71 + 72 + def test_get_returns_hidden_record_with_json_output(activities_env): 73 + activities_env( 74 + [ 75 + { 76 + "id": "meeting_100000_300", 77 + "activity": "meeting", 78 + "description": "Muted", 79 + "segments": ["100000_300"], 80 + "created_at": 2, 81 + "hidden": True, 82 + } 83 + ] 84 + ) 85 + 86 + result = runner.invoke( 87 + call_app, 88 + [ 89 + "activities", 90 + "get", 91 + "meeting_100000_300", 92 + "--facet", 93 + "work", 94 + "--json", 95 + ], 96 + ) 97 + 98 + assert result.exit_code == 0 99 + payload = json.loads(result.output) 100 + assert payload["id"] == "meeting_100000_300" 101 + assert payload["hidden"] is True 102 + 103 + 104 + def test_get_missing_exits_1(activities_env): 105 + activities_env([]) 106 + 107 + result = runner.invoke( 108 + call_app, 109 + ["activities", "get", "missing_090000_300", "--facet", "work"], 110 + ) 111 + 112 + assert result.exit_code == 1 113 + assert "activity not found: missing_090000_300" in result.output 114 + 115 + 116 + def test_create_reads_json_from_stdin(activities_env): 117 + activities_env([]) 118 + 119 + result = runner.invoke( 120 + call_app, 121 + ["activities", "create", "--facet", "work", "--json"], 122 + input=json.dumps({"title": "CLI created", "activity": "coding"}), 123 + ) 124 + 125 + assert result.exit_code == 0 126 + payload = json.loads(result.output) 127 + assert payload["title"] == "CLI created" 128 + assert payload["activity"] == "coding" 129 + assert payload["source"] == "user" 130 + assert payload["segments"] == [] 131 + assert payload["id"].startswith("coding_user_") 132 + 133 + 134 + def test_create_with_since_segment_and_cogitate_source(activities_env): 135 + activities_env([]) 136 + 137 + result = runner.invoke( 138 + call_app, 139 + [ 140 + "activities", 141 + "create", 142 + "--facet", 143 + "work", 144 + "--since-segment", 145 + "090000_300", 146 + "--source", 147 + "cogitate", 148 + "--json", 149 + ], 150 + input=json.dumps({"title": "LLM seeded", "activity": "coding"}), 151 + ) 152 + 153 + assert result.exit_code == 0 154 + payload = json.loads(result.output) 155 + assert payload["id"] == "coding_090000_300" 156 + assert payload["segments"] == ["090000_300"] 157 + assert payload["source"] == "cogitate" 158 + assert payload["edits"][-1]["actor"] == "cogitate:activities" 159 + 160 + 161 + def test_update_applies_patch_and_default_note(activities_env): 162 + activities_env( 163 + [ 164 + { 165 + "id": "coding_090000_300", 166 + "activity": "coding", 167 + "description": "Old description", 168 + "segments": ["090000_300"], 169 + "created_at": 1, 170 + } 171 + ] 172 + ) 173 + 174 + result = runner.invoke( 175 + call_app, 176 + ["activities", "update", "coding_090000_300", "--facet", "work", "--json"], 177 + input=json.dumps({"details": "New details", "title": "Focused coding"}), 178 + ) 179 + 180 + assert result.exit_code == 0 181 + payload = json.loads(result.output) 182 + assert payload["title"] == "Focused coding" 183 + assert payload["details"] == "New details" 184 + assert payload["edits"][-1]["note"] == "updated fields: details, title" 185 + 186 + 187 + def test_mute_and_unmute_toggle_hidden_state(activities_env): 188 + activities_env( 189 + [ 190 + { 191 + "id": "coding_090000_300", 192 + "activity": "coding", 193 + "description": "Old description", 194 + "segments": ["090000_300"], 195 + "created_at": 1, 196 + } 197 + ] 198 + ) 199 + 200 + muted = runner.invoke( 201 + call_app, 202 + [ 203 + "activities", 204 + "mute", 205 + "coding_090000_300", 206 + "--facet", 207 + "work", 208 + "--reason", 209 + "noise", 210 + "--json", 211 + ], 212 + ) 213 + unmuted = runner.invoke( 214 + call_app, 215 + [ 216 + "activities", 217 + "unmute", 218 + "coding_090000_300", 219 + "--facet", 220 + "work", 221 + "--json", 222 + ], 223 + ) 224 + 225 + assert muted.exit_code == 0 226 + assert json.loads(muted.output)["hidden"] is True 227 + 228 + assert unmuted.exit_code == 0 229 + unmuted_payload = json.loads(unmuted.output) 230 + assert unmuted_payload["hidden"] is False 231 + assert unmuted_payload["edits"][-1]["note"] == "unmuted"
+13
apps/activities/workspace.html
··· 1 + {# Activities app workspace - routes to different views based on view parameter #} 2 + 3 + {% set view = view|default('day') %} 4 + 5 + {% if view == 'day' %} 6 + {% include 'activities/_day.html' %} 7 + {% elif view == '_dev_screens_list' %} 8 + {% include 'activities/_dev_screens_list.html' %} 9 + {% elif view == '_dev_screens_detail' %} 10 + {% include 'activities/_dev_screens_detail.html' %} 11 + {% else %} 12 + <p>Unknown view: {{ view }}</p> 13 + {% endif %}
+4 -4
apps/calendar/_day.html apps/activities/_day.html
··· 779 779 label = diffDays < 0 ? months + ' months ago' : 'in ' + months + ' months'; 780 780 } 781 781 todayChip.textContent = '\u21a9 today (' + label + ')'; 782 - todayChip.href = '/app/calendar/' + todayStr; 782 + todayChip.href = '/app/activities/' + todayStr; 783 783 todayChip.style.display = ''; 784 784 } 785 785 } ··· 1308 1308 1309 1309 const encodedPath = out.path.split('/').map(p => encodeURIComponent(p)).join('/'); 1310 1310 try { 1311 - const resp = await fetch(`/app/calendar/api/activity_output/${encodedPath}`); 1311 + const resp = await fetch(`/app/activities/api/activity_output/${encodedPath}`); 1312 1312 const data = await resp.json(); 1313 1313 if (data.error) { 1314 1314 pane.innerHTML = `<div class="ad-output-loading">Error: ${escapeHtml(data.error)}</div>`; ··· 1362 1362 1363 1363 function loadData() { 1364 1364 Promise.all([ 1365 - fetch(`/app/calendar/api/day/${day}/events`).then(r => r.json()), 1366 - fetch(`/app/calendar/api/day/${day}/activities`).then(r => r.json()), 1365 + fetch(`/app/activities/api/day/${day}/events`).then(r => r.json()), 1366 + fetch(`/app/activities/api/day/${day}/activities`).then(r => r.json()), 1367 1367 ]).then(([evts, acts]) => { 1368 1368 allEvents = evts || []; 1369 1369 allActivities = acts || [];
+3 -3
apps/calendar/_dev_screens_detail.html apps/activities/_dev_screens_detail.html
··· 237 237 <h1>{{ title }}</h1> 238 238 239 239 <div class="screens-detail"> 240 - <a href="{{ url_for('app:calendar._dev_calendar_screens_list', day=day) }}" class="back-link"> 240 + <a href="{{ url_for('app:activities._dev_calendar_screens_list', day=day) }}" class="back-link"> 241 241 ← Back to screens list 242 242 </a> 243 243 ··· 337 337 338 338 const img = document.createElement('img'); 339 339 img.className = 'frame-image'; 340 - img.src = `/app/calendar/api/screen_frame_image/${day}/${timestamp}/${frame.frame_id}`; 340 + img.src = `/app/activities/api/screen_frame_image/${day}/${timestamp}/${frame.frame_id}`; 341 341 img.alt = `Frame ${frame.frame_id}`; 342 342 img.loading = 'lazy'; 343 343 img.onclick = function() { ··· 397 397 } 398 398 399 399 function loadFrames() { 400 - fetch(`/app/calendar/api/screen_frames/${day}/${timestamp}`) 400 + fetch(`/app/activities/api/screen_frames/${day}/${timestamp}`) 401 401 .then(r => r.json()) 402 402 .then(data => { 403 403 document.getElementById('loading').style.display = 'none';
+3 -3
apps/calendar/_dev_screens_list.html apps/activities/_dev_screens_list.html
··· 97 97 </h2> 98 98 <p style="color: #6c757d; margin-top: 8px;"> 99 99 View raw screen.jsonl files and frame analysis data for {{ day }}. 100 - <a href="{{ url_for('app:calendar.calendar_day', day=day) }}" style="margin-left: 8px;">← Back to day view</a> 100 + <a href="{{ url_for('app:activities.calendar_day', day=day) }}" style="margin-left: 8px;">← Back to day view</a> 101 101 </p> 102 102 103 103 <div id="loading" class="loading"> ··· 134 134 } 135 135 136 136 function loadScreenFiles() { 137 - fetch(`/app/calendar/api/screen_files/${day}`) 137 + fetch(`/app/activities/api/screen_files/${day}`) 138 138 .then(r => r.json()) 139 139 .then(data => { 140 140 document.getElementById('loading').style.display = 'none'; ··· 170 170 // Actions column 171 171 const actionsCell = document.createElement('td'); 172 172 const link = document.createElement('a'); 173 - link.href = `/app/calendar/${day}/screens/${file.timestamp}`; 173 + link.href = `/app/activities/${day}/screens/${file.timestamp}`; 174 174 link.textContent = 'View Frames →'; 175 175 actionsCell.appendChild(link); 176 176 row.appendChild(actionsCell);
+1 -1
apps/calendar/app.json apps/activities/app.json
··· 1 1 { 2 2 "icon": "📅", 3 - "label": "Calendar", 3 + "label": "Activities", 4 4 "date_nav": true, 5 5 "allow_future_dates": true 6 6 }
-427
apps/calendar/call.py
··· 1 - # SPDX-License-Identifier: AGPL-3.0-only 2 - # Copyright (c) 2026 sol pbc 3 - 4 - """CLI commands for calendar event management. 5 - 6 - Auto-discovered by ``think.call`` and mounted as ``sol call calendar ...``. 7 - """ 8 - 9 - from __future__ import annotations 10 - 11 - from datetime import datetime 12 - from pathlib import Path 13 - 14 - import typer 15 - 16 - from apps.calendar import event 17 - from think.facets import log_call_action 18 - from think.utils import require_solstone 19 - 20 - app = typer.Typer(help="Calendar event management.") 21 - 22 - 23 - @app.callback() 24 - def _require_up() -> None: 25 - require_solstone() 26 - 27 - 28 - def _print_day_facet(day: str, facet: str) -> bool: 29 - """Print calendar events for a single day+facet. Returns True if any exist.""" 30 - event_day = event.EventDay.load(day, facet) 31 - if not event_day.items: 32 - return False 33 - typer.echo(event_day.display()) 34 - return True 35 - 36 - 37 - def _validate_facet_or_exit(facet: str, label: str) -> None: 38 - """Exit if the facet directory does not exist.""" 39 - from think.utils import get_journal 40 - 41 - facet_path = Path(get_journal()) / "facets" / facet 42 - if not facet_path.is_dir(): 43 - typer.echo( 44 - f"Error: Facet '{facet}' ({label}) does not exist.", 45 - err=True, 46 - ) 47 - raise typer.Exit(1) 48 - 49 - 50 - @app.command("create") 51 - def create_event( 52 - title: str = typer.Argument(help="Event title."), 53 - start: str = typer.Option(..., "--start", "-s", help="Start time in HH:MM format."), 54 - day: str | None = typer.Option( 55 - None, 56 - "--day", 57 - "-d", 58 - help="Journal day YYYYMMDD (or set SOL_DAY).", 59 - ), 60 - facet: str | None = typer.Option( 61 - None, 62 - "--facet", 63 - "-f", 64 - help="Facet name (or set SOL_FACET).", 65 - ), 66 - end: str | None = typer.Option( 67 - None, "--end", "-e", help="End time in HH:MM format." 68 - ), 69 - summary: str | None = typer.Option(None, "--summary", help="Event summary."), 70 - participants: str | None = typer.Option( 71 - None, 72 - "--participants", 73 - "-p", 74 - help="Comma-separated participant names.", 75 - ), 76 - ) -> None: 77 - """Create a new calendar event.""" 78 - from think.utils import get_journal, resolve_sol_day, resolve_sol_facet 79 - 80 - get_journal() 81 - day = resolve_sol_day(day) 82 - facet = resolve_sol_facet(facet) 83 - 84 - try: 85 - datetime.strptime(day, "%Y%m%d") 86 - except ValueError: 87 - typer.echo(f"Error: invalid day format '{day}'", err=True) 88 - raise typer.Exit(1) 89 - 90 - parsed_participants = None 91 - if participants is not None: 92 - parsed_participants = [p.strip() for p in participants.split(",") if p.strip()] 93 - 94 - try: 95 - 96 - def _create(day_events: event.EventDay) -> event.EventDay: 97 - day_events.append_event( 98 - title=title, 99 - start=start, 100 - end=end, 101 - summary=summary, 102 - participants=parsed_participants, 103 - ) 104 - return day_events 105 - 106 - day_events = event.EventDay.locked_modify(day, facet, _create) 107 - item = day_events.items[-1] 108 - log_call_action( 109 - facet=facet, 110 - action="calendar_create", 111 - params={ 112 - "line_number": item.index, 113 - "title": item.title, 114 - "start": item.start, 115 - "end": item.end, 116 - "summary": item.summary, 117 - "participants": item.participants, 118 - }, 119 - day=day, 120 - ) 121 - typer.echo(day_events.display()) 122 - except event.CalendarEventEmptyTitleError: 123 - typer.echo("Error: event title cannot be empty", err=True) 124 - raise typer.Exit(1) 125 - except ValueError as exc: 126 - typer.echo(f"Error: {exc}", err=True) 127 - raise typer.Exit(1) 128 - 129 - 130 - @app.command("list") 131 - def list_events( 132 - day: str | None = typer.Argument( 133 - None, help="Journal day YYYYMMDD (or set SOL_DAY)." 134 - ), 135 - facet: str | None = typer.Option( 136 - None, "--facet", "-f", help="Facet name. Omit to show all facets." 137 - ), 138 - ) -> None: 139 - """List events for a day.""" 140 - from think.utils import get_journal, get_sol_facet, resolve_sol_day 141 - 142 - journal = get_journal() 143 - day = resolve_sol_day(day) 144 - if facet is None: 145 - facet = get_sol_facet() 146 - 147 - if facet: 148 - if not _print_day_facet(day, facet): 149 - typer.echo(f"No events found for {day}.") 150 - return 151 - 152 - facets_dir = Path(journal) / "facets" 153 - if not facets_dir.is_dir(): 154 - typer.echo(f"No events found for {day}.") 155 - return 156 - 157 - facets: list[str] = [] 158 - for facet_dir in sorted(facets_dir.iterdir()): 159 - if not facet_dir.is_dir(): 160 - continue 161 - event_path = facet_dir / "calendar" / f"{day}.jsonl" 162 - if event_path.is_file(): 163 - facets.append(facet_dir.name) 164 - 165 - if not facets: 166 - typer.echo(f"No events found for {day}.") 167 - return 168 - 169 - if len(facets) == 1: 170 - if not _print_day_facet(day, facets[0]): 171 - typer.echo(f"No events found for {day}.") 172 - return 173 - 174 - for f in facets: 175 - typer.echo(f"## {f}") 176 - _print_day_facet(day, f) 177 - typer.echo() 178 - 179 - 180 - @app.command("update") 181 - def update_event( 182 - line_number: int = typer.Argument(help="1-based line number of the event."), 183 - day: str | None = typer.Option( 184 - None, 185 - "--day", 186 - "-d", 187 - help="Journal day YYYYMMDD (or set SOL_DAY).", 188 - ), 189 - facet: str | None = typer.Option( 190 - None, 191 - "--facet", 192 - "-f", 193 - help="Facet name (or set SOL_FACET).", 194 - ), 195 - title: str | None = typer.Option(None, "--title", help="New title."), 196 - start: str | None = typer.Option( 197 - None, "--start", "-s", help="New start time HH:MM." 198 - ), 199 - end: str | None = typer.Option(None, "--end", "-e", help="New end time HH:MM."), 200 - summary: str | None = typer.Option(None, "--summary", help="New summary."), 201 - participants: str | None = typer.Option( 202 - None, 203 - "--participants", 204 - "-p", 205 - help="New comma-separated participants.", 206 - ), 207 - ) -> None: 208 - """Update fields on an existing calendar event.""" 209 - from think.utils import get_journal, resolve_sol_day, resolve_sol_facet 210 - 211 - get_journal() 212 - day = resolve_sol_day(day) 213 - facet = resolve_sol_facet(facet) 214 - 215 - parsed_participants = None 216 - if participants is not None: 217 - parsed_participants = [p.strip() for p in participants.split(",") if p.strip()] 218 - 219 - updates = { 220 - "title": title, 221 - "start": start, 222 - "end": end, 223 - "summary": summary, 224 - "participants": parsed_participants if participants is not None else None, 225 - } 226 - 227 - try: 228 - 229 - def _update( 230 - day_events: event.EventDay, 231 - ) -> tuple[event.EventDay, event.CalendarEvent]: 232 - item = day_events.update_event(line_number, **updates) 233 - return day_events, item 234 - 235 - day_events, item = event.EventDay.locked_modify(day, facet, _update) 236 - log_call_action( 237 - facet=facet, 238 - action="calendar_update", 239 - params={ 240 - "line_number": line_number, 241 - "title": item.title, 242 - "start": item.start, 243 - "end": item.end, 244 - "summary": item.summary, 245 - "participants": item.participants, 246 - }, 247 - day=day, 248 - ) 249 - typer.echo(day_events.display()) 250 - except FileNotFoundError: 251 - typer.echo(f"Error: no events found for facet '{facet}' on {day}", err=True) 252 - raise typer.Exit(1) 253 - except IndexError as exc: 254 - typer.echo(f"Error: {exc}", err=True) 255 - raise typer.Exit(1) 256 - except event.CalendarEventEmptyTitleError: 257 - typer.echo("Error: event title cannot be empty", err=True) 258 - raise typer.Exit(1) 259 - except ValueError as exc: 260 - typer.echo(f"Error: {exc}", err=True) 261 - raise typer.Exit(1) 262 - 263 - 264 - @app.command("cancel") 265 - def cancel_event( 266 - line_number: int = typer.Argument(help="1-based line number of the event."), 267 - day: str | None = typer.Option( 268 - None, 269 - "--day", 270 - "-d", 271 - help="Journal day YYYYMMDD (or set SOL_DAY).", 272 - ), 273 - facet: str | None = typer.Option( 274 - None, 275 - "--facet", 276 - "-f", 277 - help="Facet name (or set SOL_FACET).", 278 - ), 279 - ) -> None: 280 - """Cancel a calendar event.""" 281 - from think.utils import get_journal, resolve_sol_day, resolve_sol_facet 282 - 283 - get_journal() 284 - day = resolve_sol_day(day) 285 - facet = resolve_sol_facet(facet) 286 - 287 - try: 288 - 289 - def _cancel( 290 - day_events: event.EventDay, 291 - ) -> tuple[event.EventDay, event.CalendarEvent]: 292 - item = day_events.cancel_event(line_number) 293 - return day_events, item 294 - 295 - day_events, item = event.EventDay.locked_modify(day, facet, _cancel) 296 - log_call_action( 297 - facet=facet, 298 - action="calendar_cancel", 299 - params={"line_number": line_number, "title": item.title}, 300 - day=day, 301 - ) 302 - typer.echo(day_events.display()) 303 - except FileNotFoundError: 304 - typer.echo(f"Error: no events found for facet '{facet}' on {day}", err=True) 305 - raise typer.Exit(1) 306 - except IndexError as exc: 307 - typer.echo(f"Error: {exc}", err=True) 308 - raise typer.Exit(1) 309 - 310 - 311 - @app.command("move") 312 - def move_event( 313 - line_number: int = typer.Argument( 314 - help="Line number of the event to move (1-indexed)." 315 - ), 316 - day: str = typer.Option(..., "--day", help="Day in YYYYMMDD format."), 317 - from_facet: str = typer.Option(..., "--from", help="Source facet."), 318 - to_facet: str = typer.Option(..., "--to", help="Destination facet."), 319 - consent: bool = typer.Option( 320 - False, 321 - "--consent", 322 - help="Assert that explicit user approval was obtained before calling this command (agent audit trail).", 323 - ), 324 - ) -> None: 325 - """Move an open calendar event from one facet to another.""" 326 - _validate_facet_or_exit(from_facet, "--from") 327 - _validate_facet_or_exit(to_facet, "--to") 328 - 329 - try: 330 - datetime.strptime(day, "%Y%m%d") 331 - except ValueError: 332 - typer.echo( 333 - f"Error: Invalid day format '{day}', expected YYYYMMDD.", 334 - err=True, 335 - ) 336 - raise typer.Exit(1) 337 - 338 - try: 339 - source_day = event.EventDay.load(day, from_facet) 340 - if not source_day.exists: 341 - raise FileNotFoundError() 342 - event.validate_line_number(line_number, len(source_day.items)) 343 - item = source_day.items[line_number - 1] 344 - if item.cancelled: 345 - raise event.CalendarEventError("Cannot move an already cancelled event.") 346 - except FileNotFoundError: 347 - typer.echo( 348 - f"Error: No events found for day {day} in facet '{from_facet}'.", 349 - err=True, 350 - ) 351 - raise typer.Exit(1) 352 - except IndexError as exc: 353 - typer.echo(f"Error: {exc}", err=True) 354 - raise typer.Exit(1) 355 - except event.CalendarEventError as exc: 356 - typer.echo(f"Error: {exc}", err=True) 357 - raise typer.Exit(1) 358 - 359 - try: 360 - 361 - def _append_dest( 362 - day_events: event.EventDay, 363 - ) -> tuple[event.EventDay, event.CalendarEvent]: 364 - new_item = day_events.append_event( 365 - item.title, 366 - item.start, 367 - item.end, 368 - item.summary, 369 - item.participants, 370 - created_at=item.created_at, 371 - ) 372 - return day_events, new_item 373 - 374 - _, new_item = event.EventDay.locked_modify(day, to_facet, _append_dest) 375 - except Exception as exc: 376 - typer.echo( 377 - f"Error: Failed to append to destination facet '{to_facet}': {exc}. Source event is unchanged.", 378 - err=True, 379 - ) 380 - raise typer.Exit(1) 381 - 382 - try: 383 - 384 - def _cancel_source( 385 - day_events: event.EventDay, 386 - ) -> tuple[event.EventDay, event.CalendarEvent]: 387 - event.validate_line_number(line_number, len(day_events.items)) 388 - current_item = day_events.items[line_number - 1] 389 - if current_item.cancelled: 390 - raise event.CalendarEventError( 391 - "Cannot move an already cancelled event." 392 - ) 393 - cancelled_item = day_events.cancel_event( 394 - line_number, 395 - cancelled_reason="moved_to_facet", 396 - moved_to=to_facet, 397 - ) 398 - return day_events, cancelled_item 399 - 400 - _, item = event.EventDay.locked_modify(day, from_facet, _cancel_source) 401 - except (FileNotFoundError, IndexError, event.CalendarEventError): 402 - typer.echo( 403 - f"Warning: Item was appended to '{to_facet}' but could not cancel source in '{from_facet}'. Cancel it manually with: sol call calendar cancel {line_number} --day {day} --facet {from_facet}", 404 - err=True, 405 - ) 406 - raise typer.Exit(1) 407 - 408 - params_out: dict[str, object] = { 409 - "moved_from": from_facet, 410 - "moved_to": to_facet, 411 - "line_number": line_number, 412 - "title": item.title, 413 - } 414 - params_in: dict[str, object] = { 415 - "moved_from": from_facet, 416 - "moved_to": to_facet, 417 - "line_number": new_item.index, 418 - "title": new_item.title, 419 - } 420 - if consent: 421 - params_out["consent"] = True 422 - params_in["consent"] = True 423 - log_call_action(facet=from_facet, action="calendar_move_out", params=params_out) 424 - log_call_action(facet=to_facet, action="calendar_move_in", params=params_in) 425 - typer.echo( 426 - f"Moved event {line_number} ('{item.title}') from '{from_facet}' to '{to_facet}'." 427 - )
apps/calendar/event.py apps/activities/event.py
+19 -17
apps/calendar/routes.py apps/activities/routes.py
··· 15 15 from think.utils import day_path, iter_segments, segment_parse 16 16 from think.utils import segment_path as get_segment_path 17 17 18 - calendar_bp = Blueprint( 19 - "app:calendar", 18 + activities_bp = Blueprint( 19 + "app:activities", 20 20 __name__, 21 - url_prefix="/app/calendar", 21 + url_prefix="/app/activities", 22 22 ) 23 23 24 24 25 - @calendar_bp.route("/") 25 + @activities_bp.route("/") 26 26 def index(): 27 27 """Redirect to today's calendar view.""" 28 28 today = date.today().strftime("%Y%m%d") 29 - return redirect(url_for("app:calendar.calendar_day", day=today)) 29 + return redirect(url_for("app:activities.calendar_day", day=today)) 30 30 31 31 32 - @calendar_bp.route("/<day>") 32 + @activities_bp.route("/<day>") 33 33 def calendar_day(day: str) -> str: 34 34 """Render events timeline for a specific day.""" 35 35 if not DATE_RE.fullmatch(day): ··· 44 44 ) 45 45 46 46 47 - @calendar_bp.route("/api/day/<day>/events") 47 + @activities_bp.route("/api/day/<day>/events") 48 48 def calendar_day_events(day: str) -> Any: 49 49 """Return events for a specific day from facet event logs.""" 50 50 if not DATE_RE.fullmatch(day): ··· 88 88 return jsonify(result) 89 89 90 90 91 - @calendar_bp.route("/api/stats/<month>") 91 + @activities_bp.route("/api/stats/<month>") 92 92 def calendar_stats(month: str) -> Any: 93 93 """Return event counts per facet for a specific month. 94 94 ··· 112 112 return jsonify(stats) 113 113 114 114 115 - @calendar_bp.route("/api/day/<day>/activities") 115 + @activities_bp.route("/api/day/<day>/activities") 116 116 def calendar_day_activities(day: str) -> Any: 117 117 """Return enriched activity records for a specific day. 118 118 ··· 212 212 return jsonify(result) 213 213 214 214 215 - @calendar_bp.route("/api/activity_output/<path:filename>") 215 + @activities_bp.route("/api/activity_output/<path:filename>") 216 216 def calendar_activity_output(filename: str) -> Any: 217 217 """Serve an activity output file. 218 218 ··· 258 258 _frame_cache: dict = {} 259 259 260 260 261 - @calendar_bp.route("/<day>/screens") 261 + @activities_bp.route("/<day>/screens") 262 262 def _dev_calendar_screens_list(day: str) -> str: 263 263 """Render list of screen.jsonl files for a specific day.""" 264 264 if not DATE_RE.fullmatch(day): ··· 277 277 ) 278 278 279 279 280 - @calendar_bp.route("/<day>/screens/<stream>/<timestamp>") 281 - @calendar_bp.route("/<day>/screens/<stream>/<timestamp>/<filename>") 280 + @activities_bp.route("/<day>/screens/<stream>/<timestamp>") 281 + @activities_bp.route("/<day>/screens/<stream>/<timestamp>/<filename>") 282 282 def _dev_calendar_screens_detail( 283 283 day: str, stream: str, timestamp: str, filename: str = "screen.jsonl" 284 284 ) -> str: ··· 316 316 ) 317 317 318 318 319 - @calendar_bp.route("/api/screen_files/<day>") 319 + @activities_bp.route("/api/screen_files/<day>") 320 320 def _dev_screen_files(day: str) -> Any: 321 321 """Return list of *screen.jsonl files for a day.""" 322 322 if not DATE_RE.fullmatch(day): ··· 370 370 return jsonify({"files": files}) 371 371 372 372 373 - @calendar_bp.route("/api/screen_frames/<day>/<stream>/<timestamp>") 374 - @calendar_bp.route("/api/screen_frames/<day>/<stream>/<timestamp>/<filename>") 373 + @activities_bp.route("/api/screen_frames/<day>/<stream>/<timestamp>") 374 + @activities_bp.route("/api/screen_frames/<day>/<stream>/<timestamp>/<filename>") 375 375 def _dev_screen_frames( 376 376 day: str, stream: str, timestamp: str, filename: str = "screen.jsonl" 377 377 ) -> Any: ··· 474 474 return jsonify({"error": str(e)}), 500 475 475 476 476 477 - @calendar_bp.route("/api/screen_frame_image/<day>/<stream>/<timestamp>/<int:frame_id>") 477 + @activities_bp.route( 478 + "/api/screen_frame_image/<day>/<stream>/<timestamp>/<int:frame_id>" 479 + ) 478 480 def _dev_screen_frame_image( 479 481 day: str, stream: str, timestamp: str, frame_id: int 480 482 ) -> Any:
apps/calendar/talent/calendar/SKILL.md apps/activities/talent/calendar/SKILL.md
-91
apps/calendar/tests/conftest.py
··· 1 - # SPDX-License-Identifier: AGPL-3.0-only 2 - # Copyright (c) 2026 sol pbc 3 - 4 - """Self-contained fixtures for calendar app tests.""" 5 - 6 - from __future__ import annotations 7 - 8 - import json 9 - 10 - import pytest 11 - 12 - 13 - @pytest.fixture 14 - def calendar_env(tmp_path, monkeypatch): 15 - """Create a temporary journal facet with optional calendar entries.""" 16 - 17 - def _create( 18 - entries: list[dict] | None = None, 19 - day: str = "20240101", 20 - facet: str = "work", 21 - ): 22 - calendar_dir = tmp_path / "facets" / facet / "calendar" 23 - calendar_dir.mkdir(parents=True, exist_ok=True) 24 - calendar_path = calendar_dir / f"{day}.jsonl" 25 - if entries is not None: 26 - lines = [json.dumps(e, ensure_ascii=False) for e in entries] 27 - calendar_path.write_text("\n".join(lines) + "\n", encoding="utf-8") 28 - 29 - monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 30 - monkeypatch.setenv("SOL_DAY", day) 31 - monkeypatch.setenv("SOL_FACET", facet) 32 - return day, facet, calendar_path 33 - 34 - return _create 35 - 36 - 37 - @pytest.fixture 38 - def facet_env(tmp_path, monkeypatch): 39 - """Create a temporary facet with full structure for testing.""" 40 - journal = tmp_path / "journal" 41 - journal.mkdir() 42 - 43 - def _create(facet: str = "test_facet"): 44 - facet_path = journal / "facets" / facet 45 - facet_path.mkdir(parents=True) 46 - 47 - facet_json = facet_path / "facet.json" 48 - facet_json.write_text( 49 - json.dumps({"title": f"Test {facet}", "description": "Test facet"}), 50 - encoding="utf-8", 51 - ) 52 - 53 - (facet_path / "calendar").mkdir() 54 - monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) 55 - monkeypatch.setenv("SOL_FACET", facet) 56 - return journal, facet 57 - 58 - return _create 59 - 60 - 61 - @pytest.fixture 62 - def move_env(tmp_path, monkeypatch): 63 - """Create a two-facet environment for move tests.""" 64 - monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 65 - 66 - def _create( 67 - entries: list[dict] | None = None, 68 - day: str = "20240101", 69 - src_facet: str = "work", 70 - dst_facet: str = "personal", 71 - ): 72 - for facet in [src_facet, dst_facet]: 73 - facet_dir = tmp_path / "facets" / facet 74 - facet_dir.mkdir(parents=True, exist_ok=True) 75 - (facet_dir / "facet.json").write_text( 76 - json.dumps({"title": f"Test {facet}", "description": "Test facet"}), 77 - encoding="utf-8", 78 - ) 79 - 80 - calendar_dir = tmp_path / "facets" / src_facet / "calendar" 81 - calendar_dir.mkdir(parents=True, exist_ok=True) 82 - calendar_path = calendar_dir / f"{day}.jsonl" 83 - if entries: 84 - lines = [] 85 - for entry in entries: 86 - lines.append(json.dumps(entry, ensure_ascii=False)) 87 - calendar_path.write_text("\n".join(lines) + "\n", encoding="utf-8") 88 - 89 - return tmp_path, src_facet, dst_facet 90 - 91 - return _create
-501
apps/calendar/tests/test_call.py
··· 1 - # SPDX-License-Identifier: AGPL-3.0-only 2 - # Copyright (c) 2026 sol pbc 3 - 4 - """Tests for calendar CLI commands (``sol call calendar ...``).""" 5 - 6 - from __future__ import annotations 7 - 8 - import json 9 - 10 - from typer.testing import CliRunner 11 - 12 - from think.call import call_app 13 - 14 - runner = CliRunner() 15 - 16 - 17 - class TestCalendarList: 18 - """Tests for ``sol call calendar list`` command.""" 19 - 20 - def test_list_with_facet(self, calendar_env): 21 - """List events for a single day with --facet.""" 22 - calendar_env( 23 - [{"title": "Team standup", "start": "09:00", "end": "09:30"}], 24 - day="20240101", 25 - facet="work", 26 - ) 27 - 28 - result = runner.invoke( 29 - call_app, 30 - ["calendar", "list", "20240101", "--facet", "work"], 31 - ) 32 - 33 - assert result.exit_code == 0 34 - assert "1: 09:00-09:30 Team standup" in result.output 35 - 36 - def test_list_empty(self, calendar_env): 37 - """Empty day shows no-events message.""" 38 - calendar_env([], day="20240101", facet="work") 39 - 40 - result = runner.invoke( 41 - call_app, 42 - ["calendar", "list", "20240101", "--facet", "work"], 43 - ) 44 - 45 - assert result.exit_code == 0 46 - assert "No events found" in result.output 47 - 48 - def test_list_missing_file(self, calendar_env): 49 - """Missing day file (no JSONL) shows no-events message.""" 50 - calendar_env(None, day="20240101", facet="work") 51 - 52 - result = runner.invoke( 53 - call_app, 54 - ["calendar", "list", "20240101", "--facet", "work"], 55 - ) 56 - 57 - assert result.exit_code == 0 58 - assert "No events found" in result.output 59 - 60 - def test_list_all_facets(self, calendar_env, monkeypatch): 61 - """List events across all facets when --facet is omitted.""" 62 - calendar_env( 63 - [{"title": "Work sync", "start": "09:00"}], 64 - day="20240101", 65 - facet="work", 66 - ) 67 - calendar_env( 68 - [{"title": "Gym", "start": "18:00"}], 69 - day="20240101", 70 - facet="personal", 71 - ) 72 - monkeypatch.delenv("SOL_FACET", raising=False) 73 - 74 - result = runner.invoke(call_app, ["calendar", "list", "20240101"]) 75 - 76 - assert result.exit_code == 0 77 - assert "Work sync" in result.output 78 - assert "Gym" in result.output 79 - 80 - def test_list_shows_cancelled(self, calendar_env): 81 - """List includes cancelled events in strikethrough format.""" 82 - calendar_env( 83 - [{"title": "Cancelled meeting", "start": "14:00", "cancelled": True}], 84 - day="20240101", 85 - facet="work", 86 - ) 87 - 88 - result = runner.invoke( 89 - call_app, 90 - ["calendar", "list", "20240101", "--facet", "work"], 91 - ) 92 - 93 - assert result.exit_code == 0 94 - assert "~~14:00 Cancelled meeting~~" in result.output 95 - 96 - 97 - class TestCalendarCreate: 98 - """Tests for ``sol call calendar create`` command.""" 99 - 100 - def test_create_basic(self, calendar_env): 101 - """Create an event with title and start.""" 102 - calendar_env([], day="20240101", facet="work") 103 - 104 - result = runner.invoke( 105 - call_app, 106 - [ 107 - "calendar", 108 - "create", 109 - "Team standup", 110 - "--start", 111 - "09:00", 112 - "--day", 113 - "20240101", 114 - "--facet", 115 - "work", 116 - ], 117 - ) 118 - 119 - assert result.exit_code == 0 120 - assert "09:00 Team standup" in result.output 121 - 122 - def test_create_with_all_options(self, calendar_env): 123 - """Create an event with all optional fields.""" 124 - calendar_env([], day="20240101", facet="work") 125 - 126 - result = runner.invoke( 127 - call_app, 128 - [ 129 - "calendar", 130 - "create", 131 - "Planning", 132 - "--start", 133 - "10:00", 134 - "--end", 135 - "11:00", 136 - "--summary", 137 - "Sprint planning", 138 - "--participants", 139 - "Alice, Bob", 140 - "--day", 141 - "20240101", 142 - "--facet", 143 - "work", 144 - ], 145 - ) 146 - 147 - assert result.exit_code == 0 148 - assert "10:00-11:00 Planning" in result.output 149 - 150 - def test_create_invalid_time(self, calendar_env): 151 - """Invalid start time format fails.""" 152 - calendar_env([], day="20240101", facet="work") 153 - 154 - result = runner.invoke( 155 - call_app, 156 - [ 157 - "calendar", 158 - "create", 159 - "Bad event", 160 - "--start", 161 - "25:00", 162 - "--day", 163 - "20240101", 164 - "--facet", 165 - "work", 166 - ], 167 - ) 168 - 169 - assert result.exit_code == 1 170 - assert "invalid time format" in result.output 171 - 172 - def test_create_end_before_start(self, calendar_env): 173 - """End time before start fails validation.""" 174 - calendar_env([], day="20240101", facet="work") 175 - 176 - result = runner.invoke( 177 - call_app, 178 - [ 179 - "calendar", 180 - "create", 181 - "Backwards event", 182 - "--start", 183 - "11:00", 184 - "--end", 185 - "10:00", 186 - "--day", 187 - "20240101", 188 - "--facet", 189 - "work", 190 - ], 191 - ) 192 - 193 - assert result.exit_code == 1 194 - assert "end time must be greater than or equal to start time" in result.output 195 - 196 - def test_create_empty_title(self, calendar_env): 197 - """Creating with empty title fails.""" 198 - calendar_env([], day="20240101", facet="work") 199 - 200 - result = runner.invoke( 201 - call_app, 202 - [ 203 - "calendar", 204 - "create", 205 - " ", 206 - "--start", 207 - "09:00", 208 - "--day", 209 - "20240101", 210 - "--facet", 211 - "work", 212 - ], 213 - ) 214 - 215 - assert result.exit_code == 1 216 - assert "event title cannot be empty" in result.output 217 - 218 - 219 - class TestCalendarUpdate: 220 - """Tests for ``sol call calendar update`` command.""" 221 - 222 - def test_update_title(self, calendar_env): 223 - """Update event title.""" 224 - calendar_env( 225 - [{"title": "Old title", "start": "09:00"}], 226 - day="20240101", 227 - facet="work", 228 - ) 229 - 230 - result = runner.invoke( 231 - call_app, 232 - [ 233 - "calendar", 234 - "update", 235 - "1", 236 - "--title", 237 - "New title", 238 - "--day", 239 - "20240101", 240 - "--facet", 241 - "work", 242 - ], 243 - ) 244 - 245 - assert result.exit_code == 0 246 - assert "New title" in result.output 247 - 248 - def test_update_start_time(self, calendar_env): 249 - """Update event start time.""" 250 - calendar_env( 251 - [{"title": "Standup", "start": "09:00"}], 252 - day="20240101", 253 - facet="work", 254 - ) 255 - 256 - result = runner.invoke( 257 - call_app, 258 - [ 259 - "calendar", 260 - "update", 261 - "1", 262 - "--start", 263 - "10:00", 264 - "--day", 265 - "20240101", 266 - "--facet", 267 - "work", 268 - ], 269 - ) 270 - 271 - assert result.exit_code == 0 272 - assert "10:00 Standup" in result.output 273 - 274 - def test_update_nonexistent(self, calendar_env): 275 - """Updating a missing entry fails.""" 276 - calendar_env([], day="20240101", facet="work") 277 - 278 - result = runner.invoke( 279 - call_app, 280 - [ 281 - "calendar", 282 - "update", 283 - "1", 284 - "--title", 285 - "Nope", 286 - "--day", 287 - "20240101", 288 - "--facet", 289 - "work", 290 - ], 291 - ) 292 - 293 - assert result.exit_code == 1 294 - assert "out of range" in result.output 295 - 296 - def test_update_without_fields_updates_timestamp_only(self, calendar_env): 297 - """Update with no options still succeeds and preserves event content.""" 298 - calendar_env( 299 - [{"title": "Standup", "start": "09:00"}], 300 - day="20240101", 301 - facet="work", 302 - ) 303 - 304 - result = runner.invoke( 305 - call_app, 306 - ["calendar", "update", "1", "--day", "20240101", "--facet", "work"], 307 - ) 308 - 309 - assert result.exit_code == 0 310 - assert "1: 09:00 Standup" in result.output 311 - 312 - 313 - class TestCalendarCancel: 314 - """Tests for ``sol call calendar cancel`` command.""" 315 - 316 - def test_cancel_event(self, calendar_env): 317 - """Cancel an event.""" 318 - calendar_env( 319 - [{"title": "Standup", "start": "09:00"}], 320 - day="20240101", 321 - facet="work", 322 - ) 323 - 324 - result = runner.invoke( 325 - call_app, 326 - ["calendar", "cancel", "1", "--day", "20240101", "--facet", "work"], 327 - ) 328 - 329 - assert result.exit_code == 0 330 - assert "~~09:00 Standup~~" in result.output 331 - 332 - def test_cancel_nonexistent(self, calendar_env): 333 - """Cancelling a missing entry fails.""" 334 - calendar_env([], day="20240101", facet="work") 335 - 336 - result = runner.invoke( 337 - call_app, 338 - ["calendar", "cancel", "1", "--day", "20240101", "--facet", "work"], 339 - ) 340 - 341 - assert result.exit_code == 1 342 - assert "out of range" in result.output 343 - 344 - 345 - class TestCalendarMove: 346 - """Tests for ``sol call calendar move`` command.""" 347 - 348 - def test_move_event(self, move_env): 349 - journal, src_facet, dst_facet = move_env( 350 - [ 351 - { 352 - "title": "Standup", 353 - "start": "09:00", 354 - "end": "09:30", 355 - "summary": "Daily sync", 356 - "participants": ["Alice"], 357 - "created_at": 1000, 358 - "updated_at": 1000, 359 - } 360 - ] 361 - ) 362 - 363 - result = runner.invoke( 364 - call_app, 365 - [ 366 - "calendar", 367 - "move", 368 - "1", 369 - "--day", 370 - "20240101", 371 - "--from", 372 - src_facet, 373 - "--to", 374 - dst_facet, 375 - ], 376 - ) 377 - 378 - assert result.exit_code == 0 379 - source_items = [ 380 - json.loads(line) 381 - for line in (journal / "facets" / src_facet / "calendar" / "20240101.jsonl") 382 - .read_text(encoding="utf-8") 383 - .splitlines() 384 - ] 385 - dest_items = [ 386 - json.loads(line) 387 - for line in (journal / "facets" / dst_facet / "calendar" / "20240101.jsonl") 388 - .read_text(encoding="utf-8") 389 - .splitlines() 390 - ] 391 - assert source_items[0]["cancelled"] is True 392 - assert source_items[0]["cancelled_reason"] == "moved_to_facet" 393 - assert source_items[0]["moved_to"] == dst_facet 394 - assert dest_items[0]["title"] == "Standup" 395 - assert dest_items[0]["participants"] == ["Alice"] 396 - assert dest_items[0]["created_at"] == source_items[0]["created_at"] 397 - 398 - def test_move_already_cancelled(self, move_env): 399 - _, src_facet, dst_facet = move_env( 400 - [{"title": "Standup", "start": "09:00", "cancelled": True}] 401 - ) 402 - 403 - result = runner.invoke( 404 - call_app, 405 - [ 406 - "calendar", 407 - "move", 408 - "1", 409 - "--day", 410 - "20240101", 411 - "--from", 412 - src_facet, 413 - "--to", 414 - dst_facet, 415 - ], 416 - ) 417 - 418 - assert result.exit_code == 1 419 - assert "already cancelled" in result.output 420 - 421 - def test_move_invalid_line_number(self, move_env): 422 - _, src_facet, dst_facet = move_env([{"title": "Standup", "start": "09:00"}]) 423 - 424 - result = runner.invoke( 425 - call_app, 426 - [ 427 - "calendar", 428 - "move", 429 - "5", 430 - "--day", 431 - "20240101", 432 - "--from", 433 - src_facet, 434 - "--to", 435 - dst_facet, 436 - ], 437 - ) 438 - 439 - assert result.exit_code == 1 440 - assert "out of range" in result.output 441 - 442 - def test_move_missing_facet(self, move_env): 443 - move_env([{"title": "Standup", "start": "09:00"}], dst_facet="personal") 444 - 445 - result = runner.invoke( 446 - call_app, 447 - [ 448 - "calendar", 449 - "move", 450 - "1", 451 - "--day", 452 - "20240101", 453 - "--from", 454 - "work", 455 - "--to", 456 - "missing", 457 - ], 458 - ) 459 - 460 - assert result.exit_code == 1 461 - assert "does not exist" in result.output 462 - 463 - 464 - class TestCalendarEnvResolution: 465 - """Tests SOL_* env var resolution in calendar commands.""" 466 - 467 - def test_uses_sol_day_env(self, calendar_env): 468 - """Create without --day uses SOL_DAY.""" 469 - day, facet, calendar_path = calendar_env([], day="20250101", facet="work") 470 - 471 - result = runner.invoke( 472 - call_app, 473 - [ 474 - "calendar", 475 - "create", 476 - "Env day event", 477 - "--start", 478 - "09:00", 479 - "--facet", 480 - facet, 481 - ], 482 - ) 483 - 484 - assert result.exit_code == 0 485 - assert calendar_path.is_file() 486 - assert day in str(calendar_path) 487 - 488 - def test_uses_sol_facet_env(self, calendar_env, monkeypatch): 489 - """Create without --facet uses SOL_FACET.""" 490 - day, _facet, _path = calendar_env([], day="20250102", facet="work") 491 - _, _, personal_path = calendar_env([], day=day, facet="personal") 492 - monkeypatch.setenv("SOL_FACET", "personal") 493 - 494 - result = runner.invoke( 495 - call_app, 496 - ["calendar", "create", "Env facet event", "--start", "10:00", "--day", day], 497 - ) 498 - 499 - assert result.exit_code == 0 500 - assert personal_path.is_file() 501 - assert "Env facet event" in personal_path.read_text(encoding="utf-8")
-13
apps/calendar/workspace.html
··· 1 - {# Calendar app workspace - routes to different views based on view parameter #} 2 - 3 - {% set view = view|default('day') %} 4 - 5 - {% if view == 'day' %} 6 - {% include 'calendar/_day.html' %} 7 - {% elif view == '_dev_screens_list' %} 8 - {% include 'calendar/_dev_screens_list.html' %} 9 - {% elif view == '_dev_screens_detail' %} 10 - {% include 'calendar/_dev_screens_detail.html' %} 11 - {% else %} 12 - <p>Unknown view: {{ view }}</p> 13 - {% endif %}
+3 -3
apps/import/_detail.html
··· 284 284 note: 'Note', 285 285 }[item.type] || item.type; 286 286 const date = item.date ? `${item.date.slice(0, 4)}-${item.date.slice(4, 6)}-${item.date.slice(6, 8)}` : ''; 287 - const calLink = item.date ? `/app/calendar/${item.date}` : ''; 287 + const calLink = item.date ? `/app/activities/${item.date}` : ''; 288 288 html += ` 289 289 <div class="import-content-item" data-item-id="${item.id}" tabindex="0" role="button" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleContentItem(this)}"> 290 290 <div class="import-content-item-header" onclick="toggleContentItem(this.parentElement)"> ··· 300 300 ${item.meta?.location ? ` · ${escapeContentHtml(item.meta.location)}` : ''} 301 301 ${item.meta?.author ? `· ${escapeContentHtml(item.meta.author)}` : ''} 302 302 ${item.meta?.tags?.length ? `· ${item.meta.tags.map(t => escapeContentHtml(t)).join(', ')}` : ''} 303 - ${calLink ? ` · <a href="${calLink}" class="import-content-cal-link" onclick="event.stopPropagation();">View in calendar →</a>` : ''} 303 + ${calLink ? ` · <a href="${calLink}" class="import-content-cal-link" onclick="event.stopPropagation();">View in activities →</a>` : ''} 304 304 </div> 305 305 ${item.preview ? `<div class="import-content-item-preview">${escapeContentHtml(stripMarkdown(item.preview))}</div>` : ''} 306 306 <div class="import-content-item-body"><div class="no-data">Loading...</div></div> ··· 522 522 linksSection.style.display = 'block'; 523 523 524 524 let linksHtml = ''; 525 - linksHtml += `<div class="link-item">📅 <a href="/app/calendar/${data.imported_json.target_day}">View Day in Calendar</a></div>`; 525 + linksHtml += `<div class="link-item">📅 <a href="/app/activities/${data.imported_json.target_day}">View Day in Activities</a></div>`; 526 526 linksHtml += `<div class="link-item">💬 <a href="/app/transcripts/${data.imported_json.target_day}">View Transcript</a></div>`; 527 527 quickLinks.innerHTML = linksHtml; 528 528 }
+2 -2
apps/import/workspace.html
··· 964 964 html += `<td class="file-size nowrap">${formatFileSize(imp.file_size || 0)}</td>`; 965 965 html += '<td class="nowrap">'; 966 966 if (imp.target_day) { 967 - html += `<a href="/calendar/${imp.target_day}" class="timestamp-link" onclick="event.stopPropagation();">${targetDay}</a>`; 967 + html += `<a href="/app/activities/${imp.target_day}" class="timestamp-link" onclick="event.stopPropagation();">${targetDay}</a>`; 968 968 } else { 969 969 html += '-'; 970 970 } ··· 1681 1681 <div><strong>Duration:</strong> ${formatElapsed(completedInfo.duration_ms || 0)}</div> 1682 1682 <div class="import-action-row"> 1683 1683 <a class="import-secondary-btn" href="/app/import/${completedInfo.processed_timestamp || importId}#content">Browse what was imported →</a> 1684 - ${day ? `<a class="import-secondary-btn" href="/app/calendar/${day}">View in calendar</a>` : ''} 1684 + ${day ? `<a class="import-secondary-btn" href="/app/activities/${day}">View in activities</a>` : ''} 1685 1685 <a href="#" class="import-secondary-btn" onclick="showGrid(); return false;">Import another source</a> 1686 1686 </div> 1687 1687 </div>
+1 -1
apps/search/workspace.html
··· 587 587 (function() { 588 588 const searchUrl = '{{ url_for("app:search.search_journal_api") }}'; 589 589 const dayResultsUrl = '{{ url_for("app:search.day_results_api") }}'; 590 - const calendarDayBase = '{{ url_for("app:calendar.calendar_day", day="") }}'; 590 + const calendarDayBase = '{{ url_for("app:activities.calendar_day", day="") }}'; 591 591 592 592 // State 593 593 let currentQuery = '';
+3 -3
convey/config.py
··· 257 257 "selected": "work" 258 258 }, 259 259 "apps": { 260 - "order": ["home", "calendar"] 260 + "order": ["home", "activities"] 261 261 } 262 262 } 263 263 ··· 345 345 def update_app_order() -> tuple[Any, int]: 346 346 """POST /api/config/apps/order - Update app ordering. 347 347 348 - Request body: {"order": ["home", "calendar", "todos"]} 348 + Request body: {"order": ["home", "activities", "todos"]} 349 349 350 350 Returns: 351 351 JSON success/error response ··· 384 384 def toggle_app_star() -> tuple[Any, int]: 385 385 """POST /api/config/apps/star - Toggle starred status of an app. 386 386 387 - Request body: {"app": "calendar", "starred": true} 387 + Request body: {"app": "activities", "starred": true} 388 388 389 389 Returns: 390 390 JSON success/error response
+1 -1
convey/templates/app.html
··· 533 533 if (query) return 'searching: ' + query.substring(0, 50) + '\u2026'; 534 534 return 'searching your journal\u2026'; 535 535 } 536 - if (cmd.includes('sol call journal events') || cmd.includes('sol call calendar list')) return 'looking up events\u2026'; 536 + if (cmd.includes('sol call journal events') || cmd.includes('sol call activities list')) return 'looking up events\u2026'; 537 537 if (cmd.includes('sol call navigate')) return 'navigating\u2026'; 538 538 if (cmd.includes('sol call transcripts')) return 'reading transcripts\u2026'; 539 539 if (cmd.includes('sol call entity') || cmd.includes('sol call entities')) {
tests/baselines/api/calendar/day-activities.json tests/baselines/api/activities/day-activities.json
tests/baselines/api/calendar/day-events.json tests/baselines/api/activities/day-events.json
tests/baselines/api/calendar/screen-files.json tests/baselines/api/activities/screen-files.json
tests/baselines/api/calendar/stats-month.json tests/baselines/api/activities/stats-month.json
+1 -1
tests/baselines/api/config/convey.json
··· 3 3 "apps": { 4 4 "order": [ 5 5 "home", 6 - "calendar", 6 + "activities", 7 7 "todos", 8 8 "entities", 9 9 "search"
tests/baselines/visual/calendar/smoke.jpg tests/baselines/visual/activities/smoke.jpg
+1 -1
tests/fixtures/journal/config/convey.json
··· 4 4 "order": ["work", "personal", "full-featured", "montague", "capulet", "verona"] 5 5 }, 6 6 "apps": { 7 - "order": ["home", "calendar", "todos", "entities", "search"], 7 + "order": ["home", "activities", "todos", "entities", "search"], 8 8 "starred": ["home", "todos"] 9 9 } 10 10 }
+100
tests/test_app_activities.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Tests for activities app routes — activities API and output serving.""" 5 + 6 + import os 7 + 8 + import pytest 9 + 10 + from apps.activities.routes import activities_bp 11 + 12 + 13 + @pytest.fixture 14 + def fixture_journal(): 15 + """Set _SOLSTONE_JOURNAL_OVERRIDE to tests/fixtures/journal for testing.""" 16 + old = os.environ.get("_SOLSTONE_JOURNAL_OVERRIDE") 17 + os.environ["_SOLSTONE_JOURNAL_OVERRIDE"] = "tests/fixtures/journal" 18 + yield 19 + if old is None: 20 + os.environ.pop("_SOLSTONE_JOURNAL_OVERRIDE", None) 21 + else: 22 + os.environ["_SOLSTONE_JOURNAL_OVERRIDE"] = old 23 + 24 + 25 + @pytest.fixture 26 + def activities_client(fixture_journal): 27 + """Create a Flask test client with the activities blueprint.""" 28 + from flask import Flask 29 + 30 + from convey import state 31 + 32 + app = Flask(__name__) 33 + app.register_blueprint(activities_bp) 34 + state.journal_root = "tests/fixtures/journal" 35 + return app.test_client() 36 + 37 + 38 + class TestActivitiesDayRoutes: 39 + def test_returns_enriched_records(self, activities_client): 40 + resp = activities_client.get( 41 + "/app/activities/api/day/20260214/activities?facet=full-featured" 42 + ) 43 + assert resp.status_code == 200 44 + data = resp.get_json() 45 + assert isinstance(data, list) 46 + assert len(data) >= 2 47 + 48 + coding = next(a for a in data if a["activity"] == "coding") 49 + assert coding["id"] == "coding_093000_300" 50 + assert coding["facet"] == "full-featured" 51 + assert coding["description"] != "" 52 + assert coding["level_avg"] == 0.88 53 + assert coding["duration_minutes"] > 0 54 + assert "startTime" in coding 55 + assert "endTime" in coding 56 + assert len(coding["segments"]) == 4 57 + 58 + def test_includes_activity_metadata(self, activities_client): 59 + resp = activities_client.get( 60 + "/app/activities/api/day/20260214/activities?facet=full-featured" 61 + ) 62 + data = resp.get_json() 63 + coding = next(a for a in data if a["activity"] == "coding") 64 + assert coding["name"] != "" 65 + assert coding["icon"] != "" 66 + 67 + def test_lists_output_files(self, activities_client): 68 + resp = activities_client.get( 69 + "/app/activities/api/day/20260214/activities?facet=full-featured" 70 + ) 71 + data = resp.get_json() 72 + coding = next(a for a in data if a["activity"] == "coding") 73 + assert len(coding["outputs"]) >= 1 74 + output = coding["outputs"][0] 75 + assert output["filename"] == "session_review.md" 76 + assert "facets/full-featured/activities/" in output["path"] 77 + 78 + def test_invalid_day_returns_400(self, activities_client): 79 + resp = activities_client.get("/app/activities/api/day/badday/activities") 80 + assert resp.status_code == 400 81 + 82 + 83 + class TestActivitiesOutputRoutes: 84 + def test_serves_activity_output(self, activities_client): 85 + resp = activities_client.get( 86 + "/app/activities/api/activity_output/" 87 + "facets/full-featured/activities/20260214/" 88 + "coding_093000_300/session_review.md" 89 + ) 90 + assert resp.status_code == 200 91 + data = resp.get_json() 92 + assert "# Coding Session Review" in data["content"] 93 + assert data["format"] == "md" 94 + assert data["filename"] == "session_review.md" 95 + 96 + def test_rejects_non_facets_path(self, activities_client): 97 + resp = activities_client.get( 98 + "/app/activities/api/activity_output/20260214/talents/flow.md" 99 + ) 100 + assert resp.status_code == 400
-148
tests/test_app_calendar.py
··· 1 - # SPDX-License-Identifier: AGPL-3.0-only 2 - # Copyright (c) 2026 sol pbc 3 - 4 - """Tests for calendar app routes — activities API and output serving.""" 5 - 6 - import os 7 - 8 - import pytest 9 - 10 - from apps.calendar.routes import calendar_bp 11 - 12 - 13 - @pytest.fixture 14 - def fixture_journal(): 15 - """Set _SOLSTONE_JOURNAL_OVERRIDE to tests/fixtures/journal for testing.""" 16 - old = os.environ.get("_SOLSTONE_JOURNAL_OVERRIDE") 17 - os.environ["_SOLSTONE_JOURNAL_OVERRIDE"] = "tests/fixtures/journal" 18 - yield 19 - if old is None: 20 - os.environ.pop("_SOLSTONE_JOURNAL_OVERRIDE", None) 21 - else: 22 - os.environ["_SOLSTONE_JOURNAL_OVERRIDE"] = old 23 - 24 - 25 - @pytest.fixture 26 - def calendar_client(fixture_journal): 27 - """Create a Flask test client with calendar blueprint.""" 28 - from flask import Flask 29 - 30 - from convey import state 31 - 32 - app = Flask(__name__) 33 - app.register_blueprint(calendar_bp) 34 - state.journal_root = "tests/fixtures/journal" 35 - return app.test_client() 36 - 37 - 38 - class TestCalendarDayActivities: 39 - """Tests for GET /api/day/<day>/activities.""" 40 - 41 - def test_returns_enriched_records(self, calendar_client): 42 - """Activities endpoint returns records with metadata and timing.""" 43 - resp = calendar_client.get( 44 - "/app/calendar/api/day/20260214/activities?facet=full-featured" 45 - ) 46 - assert resp.status_code == 200 47 - data = resp.get_json() 48 - assert isinstance(data, list) 49 - assert len(data) >= 2 50 - 51 - # Find the coding activity 52 - coding = next(a for a in data if a["activity"] == "coding") 53 - assert coding["id"] == "coding_093000_300" 54 - assert coding["facet"] == "full-featured" 55 - assert coding["description"] != "" 56 - assert coding["level_avg"] == 0.88 57 - assert coding["duration_minutes"] > 0 58 - assert "startTime" in coding 59 - assert "endTime" in coding 60 - assert "segments" in coding 61 - assert len(coding["segments"]) == 4 62 - 63 - def test_includes_activity_metadata(self, calendar_client): 64 - """Activities include name and icon from activity definitions.""" 65 - resp = calendar_client.get( 66 - "/app/calendar/api/day/20260214/activities?facet=full-featured" 67 - ) 68 - data = resp.get_json() 69 - coding = next(a for a in data if a["activity"] == "coding") 70 - # coding is a default activity with name and icon 71 - assert coding["name"] != "" 72 - assert coding["icon"] != "" 73 - 74 - def test_lists_output_files(self, calendar_client): 75 - """Activities with output files include them in the outputs array.""" 76 - resp = calendar_client.get( 77 - "/app/calendar/api/day/20260214/activities?facet=full-featured" 78 - ) 79 - data = resp.get_json() 80 - coding = next(a for a in data if a["activity"] == "coding") 81 - assert len(coding["outputs"]) >= 1 82 - output = coding["outputs"][0] 83 - assert output["filename"] == "session_review.md" 84 - assert "facets/full-featured/activities/" in output["path"] 85 - 86 - def test_no_outputs_for_activity_without_files(self, calendar_client): 87 - """Activities without output files have empty outputs array.""" 88 - resp = calendar_client.get( 89 - "/app/calendar/api/day/20260214/activities?facet=full-featured" 90 - ) 91 - data = resp.get_json() 92 - meeting = next(a for a in data if a["activity"] == "meeting") 93 - assert meeting["outputs"] == [] 94 - 95 - def test_empty_day_returns_empty_list(self, calendar_client): 96 - """Day with no activity records returns empty array.""" 97 - resp = calendar_client.get( 98 - "/app/calendar/api/day/20260101/activities?facet=full-featured" 99 - ) 100 - assert resp.status_code == 200 101 - assert resp.get_json() == [] 102 - 103 - def test_invalid_day_returns_400(self, calendar_client): 104 - """Invalid day format returns 400.""" 105 - resp = calendar_client.get("/app/calendar/api/day/badday/activities") 106 - assert resp.status_code == 400 107 - 108 - def test_sorted_by_start_time(self, calendar_client): 109 - """Activities are sorted by start time.""" 110 - resp = calendar_client.get( 111 - "/app/calendar/api/day/20260214/activities?facet=full-featured" 112 - ) 113 - data = resp.get_json() 114 - times = [a.get("startTime", "z") for a in data] 115 - assert times == sorted(times) 116 - 117 - 118 - class TestCalendarActivityOutput: 119 - """Tests for GET /api/activity_output/<path:filename>.""" 120 - 121 - def test_serves_activity_output(self, calendar_client): 122 - """Serves markdown output file content.""" 123 - resp = calendar_client.get( 124 - "/app/calendar/api/activity_output/" 125 - "facets/full-featured/activities/20260214/" 126 - "coding_093000_300/session_review.md" 127 - ) 128 - assert resp.status_code == 200 129 - data = resp.get_json() 130 - assert "# Coding Session Review" in data["content"] 131 - assert data["format"] == "md" 132 - assert data["filename"] == "session_review.md" 133 - 134 - def test_rejects_non_facets_path(self, calendar_client): 135 - """Paths not starting with facets/ are rejected.""" 136 - resp = calendar_client.get( 137 - "/app/calendar/api/activity_output/20260214/talents/flow.md" 138 - ) 139 - assert resp.status_code == 400 140 - 141 - def test_missing_file_returns_404(self, calendar_client): 142 - """Non-existent file returns 404.""" 143 - resp = calendar_client.get( 144 - "/app/calendar/api/activity_output/" 145 - "facets/full-featured/activities/20260214/" 146 - "coding_093000_300/nonexistent.md" 147 - ) 148 - assert resp.status_code == 404
+4 -4
tests/test_conversation.py
··· 67 67 record_exchange( 68 68 ts=ts, 69 69 facet="work", 70 - app="calendar", 71 - path="/app/calendar", 70 + app="activities", 71 + path="/app/activities", 72 72 user_message="move my 3pm to 4pm", 73 73 agent_response="Done — moved 'DVD sync' to 4pm.", 74 74 talent="unified", ··· 93 93 assert "move my 3pm to 4pm" in content 94 94 assert "Done — moved 'DVD sync' to 4pm." in content 95 95 assert "**Facet:** work" in content 96 - assert "calendar" in content 96 + assert "activities" in content 97 97 98 98 99 99 def test_record_exchange_appends_multiple(journal_dir): ··· 372 372 373 373 ex = { 374 374 "ts": 1710000000000, 375 - "app": "calendar", 375 + "app": "activities", 376 376 "facet": "work", 377 377 "user_message": "what's on my schedule today?", 378 378 "agent_response": "You have 3 meetings.",
+9 -9
tests/verify_api.py
··· 87 87 "status": 200, 88 88 "sandbox_only": True, # live indexer computes differently than Flask test client 89 89 }, 90 - # apps/calendar/routes.py 90 + # apps/activities/routes.py 91 91 { 92 - "app": "calendar", 92 + "app": "activities", 93 93 "name": "day-events", 94 - "path": "/app/calendar/api/day/20260304/events", 94 + "path": "/app/activities/api/day/20260304/events", 95 95 "params": {}, 96 96 "status": 200, 97 97 }, 98 98 { 99 - "app": "calendar", 99 + "app": "activities", 100 100 "name": "stats-month", 101 - "path": "/app/calendar/api/stats/202603", 101 + "path": "/app/activities/api/stats/202603", 102 102 "params": {}, 103 103 "status": 200, 104 104 }, 105 105 { 106 - "app": "calendar", 106 + "app": "activities", 107 107 "name": "day-activities", 108 - "path": "/app/calendar/api/day/20260304/activities", 108 + "path": "/app/activities/api/day/20260304/activities", 109 109 "params": {"facet": "work"}, 110 110 "status": 200, 111 111 }, 112 112 { 113 - "app": "calendar", 113 + "app": "activities", 114 114 "name": "screen-files", 115 - "path": "/app/calendar/api/screen_files/20260304", 115 + "path": "/app/activities/api/screen_files/20260304", 116 116 "params": {}, 117 117 "status": 200, 118 118 },
+2 -2
tests/verify_browser.py
··· 33 33 ], 34 34 }, 35 35 { 36 - "app": "calendar", 36 + "app": "activities", 37 37 "name": "smoke", 38 38 "steps": [ 39 - {"do": "navigate", "path": "/app/calendar/20260304"}, 39 + {"do": "navigate", "path": "/app/activities/20260304"}, 40 40 {"do": "wait", "ms": 1000}, 41 41 {"do": "screenshot"}, 42 42 ],
+8
think/activities.py
··· 1213 1213 if activity_type: 1214 1214 lines.append(f"- Activity: {activity_type}") 1215 1215 1216 + facet = str(record.get("facet") or "").strip() 1217 + if facet: 1218 + lines.append(f"- Facet: {facet}") 1219 + 1220 + day = str(record.get("day") or "").strip() 1221 + if day: 1222 + lines.append(f"- Day: {day}") 1223 + 1216 1224 time_range = _activity_time_range(record.get("segments", [])) 1217 1225 if time_range: 1218 1226 lines.append(f"- Time: {time_range}")
+1 -1
think/routines.py
··· 22 22 from typing import Any 23 23 from zoneinfo import ZoneInfo, ZoneInfoNotFoundError 24 24 25 - from apps.calendar.event import EventDay 25 + from apps.activities.event import EventDay 26 26 from think.callosum import callosum_send 27 27 from think.cortex_client import cortex_request, wait_for_uses 28 28 from think.facets import get_facets
+1 -1
think/tools/call.py
··· 360 360 ), 361 361 ) -> None: 362 362 """Merge all data from SOURCE facet into DEST facet, then delete SOURCE.""" 363 - from apps.calendar import event as event_module 363 + from apps.activities import event as event_module 364 364 from apps.todos import todo as todo_module 365 365 from think.entities.observations import load_observations, save_observations 366 366 from think.entities.relationships import (