personal memory agent
0
fork

Configure Feed

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

Add owner onboarding trigger for cold-start speaker attribution

Wire existing speaker detection and confirmation components into the
triage muse awareness system so new journals proactively surface owner
voice detection.

Changes:
- think/awareness.py: Add owner_detection_ready() — checks centroid
existence, 14-day rejection cooldown, then runs detect_owner_candidate()
- apps/speakers/owner.py: Extract confirm_owner_candidate() and
reject_owner_candidate() as shared functions (used by both CLI and routes)
- apps/speakers/call.py: Add detect, confirm-owner (with auto-backfill),
reject-owner, and owner-ready CLI commands for triage muse
- apps/speakers/routes.py: Refactor confirm/reject handlers to use shared
functions from owner.py
- muse/triage.md: Add Owner Voice Detection Awareness section with
conversational prompt, confirm/reject handling, and sample navigation
- Tests: 10 new tests covering awareness checks, CLI commands, cooldown
expiry, and shared confirm/reject functions

Implements CPO spec section 1 (cold-start-speaker-attribution.md).

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

+466 -60
+88
apps/speakers/call.py
··· 17 17 sol call speakers suggest [--limit N] [--json] 18 18 sol call speakers link-import <name> --entity-id <ID> 19 19 sol call speakers seed-from-imports [--dry-run] [--json] 20 + sol call speakers detect [--json] 21 + sol call speakers confirm-owner [--backfill] [--json] 22 + sol call speakers reject-owner 23 + sol call speakers owner-ready 20 24 """ 21 25 22 26 from __future__ import annotations ··· 489 493 from apps.speakers.suggest import format_suggestions 490 494 491 495 typer.echo(format_suggestions(results)) 496 + 497 + 498 + @app.command("detect") 499 + def detect_cmd( 500 + json_output: bool = typer.Option(False, "--json", help="Output as JSON."), 501 + ) -> None: 502 + """Run owner voice candidate detection.""" 503 + import json as json_mod 504 + 505 + from apps.speakers.owner import detect_owner_candidate 506 + 507 + result = detect_owner_candidate() 508 + typer.echo(json_mod.dumps(result, indent=2, default=str)) 509 + 510 + 511 + @app.command("confirm-owner") 512 + def confirm_owner_cmd( 513 + backfill_after: bool = typer.Option( 514 + True, 515 + "--backfill/--no-backfill", 516 + help="Run attribution backfill after confirming.", 517 + ), 518 + json_output: bool = typer.Option(False, "--json", help="Output as JSON."), 519 + ) -> None: 520 + """Confirm the owner voice candidate and save the centroid. 521 + 522 + By default, automatically runs attribution backfill on all segments 523 + after saving the centroid. 524 + """ 525 + import json as json_mod 526 + 527 + from apps.speakers.owner import confirm_owner_candidate 528 + 529 + result = confirm_owner_candidate() 530 + if "error" in result: 531 + typer.echo(json_mod.dumps(result, indent=2), err=True) 532 + raise typer.Exit(1) 533 + 534 + if not json_output: 535 + typer.echo( 536 + f"Owner centroid confirmed (principal: {result['principal_id']}, " 537 + f"cluster_size: {result['cluster_size']})" 538 + ) 539 + 540 + if backfill_after: 541 + from apps.speakers.attribution import backfill_segments 542 + 543 + if not json_output: 544 + typer.echo("Running attribution backfill...") 545 + 546 + stats = backfill_segments(dry_run=False) 547 + 548 + if json_output: 549 + result["backfill"] = stats 550 + else: 551 + typer.echo( 552 + f"Backfill complete: {stats['processed']} segments processed, " 553 + f"{stats['already_labeled']} already labeled" 554 + ) 555 + 556 + if json_output: 557 + typer.echo(json_mod.dumps(result, indent=2, default=str)) 558 + 559 + 560 + @app.command("reject-owner") 561 + def reject_owner_cmd() -> None: 562 + """Reject the owner voice candidate and enter 14-day cooldown.""" 563 + import json as json_mod 564 + 565 + from apps.speakers.owner import reject_owner_candidate 566 + 567 + result = reject_owner_candidate() 568 + typer.echo(json_mod.dumps(result, indent=2, default=str)) 569 + 570 + 571 + @app.command("owner-ready") 572 + def owner_ready_cmd() -> None: 573 + """Check if owner voice detection should be surfaced to the user.""" 574 + import json as json_mod 575 + 576 + from think.awareness import owner_detection_ready 577 + 578 + result = owner_detection_ready() 579 + typer.echo(json_mod.dumps(result, indent=2, default=str))
+88
apps/speakers/owner.py
··· 362 362 } 363 363 ) 364 364 return results 365 + 366 + 367 + def confirm_owner_candidate() -> dict[str, Any]: 368 + """Confirm the current owner voice candidate and persist the centroid. 369 + 370 + Moves the candidate centroid from awareness/ to the principal entity's 371 + memory directory as owner_centroid.npz. Updates awareness state to 372 + "confirmed". 373 + 374 + Returns a dict with status and principal_id on success, or an error key. 375 + """ 376 + from think.entities.core import get_identity_names 377 + from think.entities.journal import ( 378 + ensure_journal_entity_memory, 379 + get_or_create_journal_entity, 380 + ) 381 + from think.entities import entity_slug 382 + 383 + candidate_path = _owner_candidate_path() 384 + if not candidate_path.exists(): 385 + return {"error": "No candidate available"} 386 + 387 + try: 388 + data = np.load(candidate_path, allow_pickle=False) 389 + centroid = data["centroid"] 390 + cluster_size = int(np.asarray(data["cluster_size"]).item()) 391 + threshold = float(np.asarray(data["threshold"]).item()) 392 + version = str(np.asarray(data["version"]).item()) 393 + except Exception as e: 394 + logger.warning("Failed to load owner candidate %s: %s", candidate_path, e) 395 + return {"error": "No candidate available"} 396 + 397 + principal = get_journal_principal() 398 + if principal is None: 399 + identity_names = get_identity_names() 400 + if not identity_names: 401 + return {"error": "No principal entity found"} 402 + principal_name = identity_names[0] 403 + principal = get_or_create_journal_entity( 404 + entity_id=entity_slug(principal_name), 405 + name=principal_name, 406 + entity_type="Person", 407 + ) 408 + 409 + owner_path = ensure_journal_entity_memory(principal["id"]) / "owner_centroid.npz" 410 + np.savez_compressed( 411 + owner_path, 412 + centroid=np.asarray(centroid, dtype=np.float32).reshape(-1), 413 + cluster_size=np.array(cluster_size, dtype=np.int32), 414 + threshold=np.array(threshold, dtype=np.float32), 415 + version=np.array(version), 416 + ) 417 + candidate_path.unlink(missing_ok=True) 418 + 419 + update_state( 420 + "voiceprint", 421 + { 422 + "status": "confirmed", 423 + "cluster_size": cluster_size, 424 + "confirmed_at": _iso_now(), 425 + }, 426 + ) 427 + 428 + return { 429 + "status": "confirmed", 430 + "principal_id": principal["id"], 431 + "cluster_size": cluster_size, 432 + } 433 + 434 + 435 + def reject_owner_candidate() -> dict[str, Any]: 436 + """Reject the current owner voice candidate and enter cooldown. 437 + 438 + Deletes the candidate file and records rejection with timestamp in 439 + awareness state. The timestamp enables 14-day cooldown enforcement. 440 + 441 + Returns a dict with the updated status. 442 + """ 443 + candidate_path = _owner_candidate_path() 444 + candidate_path.unlink(missing_ok=True) 445 + update_state( 446 + "voiceprint", 447 + { 448 + "status": "rejected", 449 + "rejected_at": _iso_now(), 450 + }, 451 + ) 452 + return {"status": "rejected"}
+10 -60
apps/speakers/routes.py
··· 31 31 from apps.speakers.discovery import discover_unknown_speakers, identify_cluster 32 32 from apps.speakers.owner import ( 33 33 classify_sentences, 34 + confirm_owner_candidate, 34 35 count_segments_with_embeddings, 35 36 detect_owner_candidate, 37 + reject_owner_candidate, 36 38 ) 37 39 from apps.utils import log_app_action 38 40 from convey import state ··· 1145 1147 @speakers_bp.route("/api/owner/confirm", methods=["POST"]) 1146 1148 def api_owner_confirm() -> Any: 1147 1149 """Confirm the current owner voice candidate and persist the centroid.""" 1148 - candidate_path = Path(get_journal()) / "awareness" / "owner_candidate.npz" 1149 - if not candidate_path.exists(): 1150 - return error_response("No candidate available", 404) 1151 - 1152 - try: 1153 - data = np.load(candidate_path, allow_pickle=False) 1154 - centroid = data["centroid"] 1155 - cluster_size = int(np.asarray(data["cluster_size"]).item()) 1156 - threshold = float(np.asarray(data["threshold"]).item()) 1157 - version = str(np.asarray(data["version"]).item()) 1158 - except Exception as e: 1159 - logger.warning("Failed to load owner candidate %s: %s", candidate_path, e) 1160 - return error_response("No candidate available", 404) 1161 - 1162 - principal = get_journal_principal() 1163 - if principal is None: 1164 - identity_names = get_identity_names() 1165 - if not identity_names: 1166 - return error_response( 1167 - "No principal entity found. Set up your identity first.", 1168 - 400, 1169 - ) 1170 - principal_name = identity_names[0] 1171 - principal = get_or_create_journal_entity( 1172 - entity_id=entity_slug(principal_name), 1173 - name=principal_name, 1174 - entity_type="Person", 1175 - ) 1176 - 1177 - owner_path = ensure_journal_entity_memory(principal["id"]) / "owner_centroid.npz" 1178 - np.savez_compressed( 1179 - owner_path, 1180 - centroid=np.asarray(centroid, dtype=np.float32).reshape(-1), 1181 - cluster_size=np.array(cluster_size, dtype=np.int32), 1182 - threshold=np.array(threshold, dtype=np.float32), 1183 - version=np.array(version), 1184 - ) 1185 - candidate_path.unlink(missing_ok=True) 1186 - 1187 - update_state( 1188 - "voiceprint", 1189 - { 1190 - "status": "confirmed", 1191 - "cluster_size": cluster_size, 1192 - "confirmed_at": datetime.now().isoformat(), 1193 - }, 1194 - ) 1150 + result = confirm_owner_candidate() 1151 + if "error" in result: 1152 + code = 404 if "No candidate" in result["error"] else 400 1153 + return error_response(result["error"], code) 1195 1154 1196 1155 log_app_action( 1197 1156 app="speakers", 1198 1157 facet=None, 1199 1158 action="owner_voiceprint_confirm", 1200 1159 params={ 1201 - "principal_id": principal["id"], 1202 - "cluster_size": cluster_size, 1203 - "version": version, 1160 + "principal_id": result["principal_id"], 1161 + "cluster_size": result["cluster_size"], 1204 1162 }, 1205 1163 ) 1206 1164 1207 - return jsonify({"status": "confirmed", "principal_id": principal["id"]}) 1165 + return jsonify({"status": "confirmed", "principal_id": result["principal_id"]}) 1208 1166 1209 1167 1210 1168 @speakers_bp.route("/api/owner/reject", methods=["POST"]) 1211 1169 def api_owner_reject() -> Any: 1212 1170 """Reject the current owner voice candidate.""" 1213 - candidate_path = Path(get_journal()) / "awareness" / "owner_candidate.npz" 1214 - candidate_path.unlink(missing_ok=True) 1215 - update_state( 1216 - "voiceprint", 1217 - { 1218 - "status": "rejected", 1219 - "rejected_at": datetime.now().isoformat(), 1220 - }, 1221 - ) 1171 + reject_owner_candidate() 1222 1172 return jsonify({"status": "needs_detection"}) 1223 1173 1224 1174
+52
apps/speakers/tests/test_owner.py
··· 465 465 assert data["cluster_size"] >= 50 466 466 assert "streams_represented" in data 467 467 assert "recommendation" in data 468 + 469 + 470 + def test_confirm_owner_candidate_no_candidate(speakers_env): 471 + from apps.speakers.owner import confirm_owner_candidate 472 + 473 + speakers_env() 474 + result = confirm_owner_candidate() 475 + assert "error" in result 476 + assert "No candidate" in result["error"] 477 + 478 + 479 + def test_confirm_owner_candidate_success(speakers_env): 480 + from apps.speakers.owner import confirm_owner_candidate 481 + 482 + env = speakers_env() 483 + principal_dir = env.create_entity("Self Person", is_principal=True) 484 + candidate_path = _candidate_path(env.journal) 485 + candidate_path.parent.mkdir(parents=True, exist_ok=True) 486 + centroid = _normalized(np.array([1.0] + [0.0] * 255, dtype=np.float32)) 487 + np.savez_compressed( 488 + candidate_path, 489 + centroid=centroid, 490 + cluster_size=np.array(88, dtype=np.int32), 491 + threshold=np.array(0.82, dtype=np.float32), 492 + version=np.array("2026-03-19T12:00:00"), 493 + ) 494 + 495 + result = confirm_owner_candidate() 496 + 497 + assert result["status"] == "confirmed" 498 + assert result["principal_id"] is not None 499 + assert result["cluster_size"] == 88 500 + assert not candidate_path.exists() 501 + assert (principal_dir / "owner_centroid.npz").exists() 502 + assert get_current()["voiceprint"]["status"] == "confirmed" 503 + 504 + 505 + def test_reject_owner_candidate(speakers_env): 506 + from apps.speakers.owner import reject_owner_candidate 507 + 508 + env = speakers_env() 509 + candidate_path = _candidate_path(env.journal) 510 + candidate_path.parent.mkdir(parents=True, exist_ok=True) 511 + candidate_path.write_bytes(b"test") 512 + 513 + result = reject_owner_candidate() 514 + 515 + assert result["status"] == "rejected" 516 + assert not candidate_path.exists() 517 + state = get_current() 518 + assert state["voiceprint"]["status"] == "rejected" 519 + assert "rejected_at" in state["voiceprint"]
+25
muse/triage.md
··· 111 111 3. If `ready` is `true`, mention that you've been getting to know the user and offer to suggest a name — or let the naming muse handle it. 112 112 4. Only do this once per session. If you've already checked or offered, don't repeat. 113 113 5. If `name_status` is `"chosen"` or `"self-named"`, do nothing. 114 + 115 + ## Owner Voice Detection Awareness 116 + 117 + When onboarding is complete, check whether owner voice detection should be surfaced: 118 + 119 + 1. Run `sol call speakers owner-ready` to check readiness. 120 + 2. If `ready` is `false`, do nothing. The reason field explains why (centroid_exists, cooldown, low_data, no_clusters, etc.). 121 + 3. If `ready` is `true`, surface the prompt conversationally: 122 + 123 + > "I've been learning voices from your recordings and I think I can identify yours. Want to listen to a few samples and confirm?" 124 + 125 + 4. Only do this once per session. If you've already checked or offered, don't repeat. 126 + 127 + ### Handling the user's response 128 + 129 + - **User confirms ("yes", "sure", "go ahead"):** 130 + 1. Run `sol call speakers confirm-owner` — this saves the centroid and automatically runs attribution backfill on all segments. 131 + 2. Report back: "Got it. I'll start labeling speakers in your transcripts." 132 + 133 + - **User declines ("no", "not now", "skip"):** 134 + 1. Run `sol call speakers reject-owner` — this enters a 14-day cooldown. 135 + 2. Respond: "No problem — I'll keep listening and try again when I have more to work with." 136 + 137 + - **User wants to hear samples first:** 138 + The `owner-ready` result includes a `samples` array with audio URLs. Navigate the user to the speakers app for the full confirmation flow: `sol call navigate "/app/speakers#owner"`
+156
tests/test_awareness.py
··· 550 550 } 551 551 552 552 553 + class TestOwnerDetectionReady: 554 + """Tests for owner_detection_ready().""" 555 + 556 + def test_not_ready_when_centroid_exists(self): 557 + """Returns not ready when owner centroid already exists.""" 558 + from think.awareness import owner_detection_ready 559 + 560 + with unittest.mock.patch( 561 + "apps.speakers.owner.load_owner_centroid", 562 + return_value=("centroid", 0.82), 563 + ): 564 + result = owner_detection_ready() 565 + 566 + assert result["ready"] is False 567 + assert result["reason"] == "centroid_exists" 568 + 569 + def test_not_ready_during_cooldown(self): 570 + """Returns not ready when rejection was within 14 days.""" 571 + from datetime import datetime 572 + 573 + from think.awareness import owner_detection_ready, update_state 574 + 575 + update_state("voiceprint", {"rejected_at": datetime.now().isoformat()}) 576 + 577 + with unittest.mock.patch( 578 + "apps.speakers.owner.load_owner_centroid", return_value=None 579 + ): 580 + result = owner_detection_ready() 581 + 582 + assert result["ready"] is False 583 + assert result["reason"] == "cooldown" 584 + assert result["days_remaining"] == 14 585 + 586 + def test_ready_when_candidate_found(self): 587 + """Returns ready when detect_owner_candidate returns positive.""" 588 + from think.awareness import owner_detection_ready 589 + 590 + mock_detection = { 591 + "status": "candidate", 592 + "recommendation": "ready", 593 + "cluster_size": 88, 594 + "streams_represented": 2, 595 + "samples": [{"day": "20240101"}], 596 + } 597 + 598 + with unittest.mock.patch( 599 + "apps.speakers.owner.load_owner_centroid", return_value=None 600 + ): 601 + with unittest.mock.patch( 602 + "apps.speakers.owner.detect_owner_candidate", 603 + return_value=mock_detection, 604 + ): 605 + result = owner_detection_ready() 606 + 607 + assert result["ready"] is True 608 + assert result["reason"] == "candidate_found" 609 + assert result["cluster_size"] == 88 610 + assert result["streams_represented"] == 2 611 + 612 + def test_not_ready_low_data(self): 613 + """Returns not ready when detection has insufficient data.""" 614 + from think.awareness import owner_detection_ready 615 + 616 + mock_detection = { 617 + "status": "low_data", 618 + "recommendation": "low_data", 619 + } 620 + 621 + with unittest.mock.patch( 622 + "apps.speakers.owner.load_owner_centroid", return_value=None 623 + ): 624 + with unittest.mock.patch( 625 + "apps.speakers.owner.detect_owner_candidate", 626 + return_value=mock_detection, 627 + ): 628 + result = owner_detection_ready() 629 + 630 + assert result["ready"] is False 631 + assert result["reason"] == "low_data" 632 + 633 + def test_not_ready_single_stream(self): 634 + """Returns not ready when candidate is single_stream (not 'ready').""" 635 + from think.awareness import owner_detection_ready 636 + 637 + mock_detection = { 638 + "status": "candidate", 639 + "recommendation": "single_stream", 640 + "cluster_size": 60, 641 + } 642 + 643 + with unittest.mock.patch( 644 + "apps.speakers.owner.load_owner_centroid", return_value=None 645 + ): 646 + with unittest.mock.patch( 647 + "apps.speakers.owner.detect_owner_candidate", 648 + return_value=mock_detection, 649 + ): 650 + result = owner_detection_ready() 651 + 652 + assert result["ready"] is False 653 + assert result["reason"] == "single_stream" 654 + 655 + def test_cooldown_expires_after_14_days(self): 656 + """Cooldown no longer blocks after 14 days.""" 657 + from datetime import datetime, timedelta 658 + 659 + from think.awareness import owner_detection_ready, update_state 660 + 661 + old_rejection = (datetime.now() - timedelta(days=15)).isoformat() 662 + update_state("voiceprint", {"rejected_at": old_rejection}) 663 + 664 + mock_detection = { 665 + "status": "candidate", 666 + "recommendation": "ready", 667 + "cluster_size": 100, 668 + "streams_represented": 3, 669 + "samples": [], 670 + } 671 + 672 + with unittest.mock.patch( 673 + "apps.speakers.owner.load_owner_centroid", return_value=None 674 + ): 675 + with unittest.mock.patch( 676 + "apps.speakers.owner.detect_owner_candidate", 677 + return_value=mock_detection, 678 + ): 679 + result = owner_detection_ready() 680 + 681 + assert result["ready"] is True 682 + 683 + 553 684 class TestThicknessCLI: 554 685 """Tests for the thickness CLI command in apps/agent/call.py.""" 555 686 ··· 573 704 assert result.exit_code == 0 574 705 data = json.loads(result.output) 575 706 assert data == mock_result 707 + 708 + 709 + class TestOwnerReadyCLI: 710 + """Tests for the owner-ready CLI command in apps/speakers/call.py.""" 711 + 712 + def test_owner_ready_command_returns_json(self): 713 + from typer.testing import CliRunner 714 + 715 + from apps.speakers.call import app 716 + 717 + mock_result = { 718 + "ready": True, 719 + "reason": "candidate_found", 720 + "cluster_size": 88, 721 + "streams_represented": 2, 722 + "samples": [], 723 + } 724 + with unittest.mock.patch( 725 + "think.awareness.owner_detection_ready", return_value=mock_result 726 + ): 727 + result = CliRunner().invoke(app, ["owner-ready"]) 728 + assert result.exit_code == 0 729 + data = json.loads(result.output) 730 + assert data["ready"] is True 731 + assert data["reason"] == "candidate_found"
+47
think/awareness.py
··· 337 337 } 338 338 339 339 340 + def owner_detection_ready() -> dict[str, Any]: 341 + """Check if owner voice detection should be surfaced to the user. 342 + 343 + Same pattern as ``compute_thickness()`` for the naming ceremony. 344 + Returns a dict with a ``ready`` boolean and contextual fields. 345 + 346 + Checks in order: 347 + 1. Owner centroid already exists → not ready 348 + 2. Recent rejection within 14 days → not ready (cooldown) 349 + 3. Calls ``detect_owner_candidate()`` → ready if positive recommendation 350 + """ 351 + from apps.speakers.owner import detect_owner_candidate, load_owner_centroid 352 + 353 + if load_owner_centroid() is not None: 354 + return {"ready": False, "reason": "centroid_exists"} 355 + 356 + voiceprint = get_current().get("voiceprint", {}) 357 + rejected_at = voiceprint.get("rejected_at") 358 + if rejected_at: 359 + try: 360 + rejection_time = datetime.fromisoformat(rejected_at) 361 + days_since = (datetime.now() - rejection_time).days 362 + if days_since < 14: 363 + return { 364 + "ready": False, 365 + "reason": "cooldown", 366 + "days_remaining": 14 - days_since, 367 + } 368 + except (ValueError, TypeError): 369 + pass 370 + 371 + result = detect_owner_candidate() 372 + if result.get("recommendation") == "ready": 373 + return { 374 + "ready": True, 375 + "reason": "candidate_found", 376 + "cluster_size": result.get("cluster_size"), 377 + "streams_represented": result.get("streams_represented"), 378 + "samples": result.get("samples", []), 379 + } 380 + 381 + return { 382 + "ready": False, 383 + "reason": result.get("recommendation", result.get("status", "unknown")), 384 + } 385 + 386 + 340 387 def record_import( 341 388 source_type: str, 342 389 source_display: str | None = None,