personal memory agent
0
fork

Configure Feed

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

naming ceremony: thickness-gated progressive unlock

Replace the onboarding-time naming step with a thickness-gated ceremony
that triggers only after the journal develops sufficient depth. Adds
compute_thickness() to awareness.py with five signals (entity_depth,
conversation_count, recall_success, facet_count, journal_days) and a
composite ready gate. The naming muse now requires thickness readiness
and default name_status before proposing, with proposal cap and cooldown.

+399 -59
+8
apps/agent/call.py
··· 95 95 } 96 96 ) 97 97 typer.echo(json.dumps(agent, indent=2)) 98 + 99 + 100 + @app.command("thickness") 101 + def thickness() -> None: 102 + """Show journal thickness signals for naming readiness.""" 103 + from think.awareness import compute_thickness 104 + 105 + typer.echo(json.dumps(compute_thickness(), indent=2))
+66 -25
muse/naming.md
··· 5 5 "instructions": {"now": true} 6 6 } 7 7 8 - You are $agent_name's naming agent. Your job is to propose a new name for the user's journal assistant based on what you've learned about them. 8 + You are $agent_name's naming ceremony agent. Your role is to propose a meaningful name for the user's journal assistant when the relationship has developed enough depth. 9 + 10 + ## Pre-hooks 11 + 12 + Before this muse runs, two checks must pass silently (no output on failure): 13 + 14 + 1. **Thickness gate** — Run `sol call agent thickness`. If `ready` is `false`, exit silently. 15 + 2. **Name gate** — Run `sol call agent name`. If `name_status` is not `"default"`, exit silently. 16 + 17 + If both pass, proceed. 18 + 19 + ## Context Gathering 20 + 21 + Start by understanding who this user is: 22 + 23 + 1. `sol call entities list` — the people, projects, and tools in their world 24 + 2. `sol call journal facets` — how they've organized their journal 25 + 3. `sol call agent name` — current name and status (confirms default) 26 + 4. `sol call agent thickness` — the thickness signals (confirms readiness) 27 + 28 + Look for patterns: recurring entity names, facet themes, areas of focus. This is the raw material for a name proposal. 29 + 30 + ## Three Paths 31 + 32 + Present the naming moment naturally. Mention something specific you've noticed about their journal — an entity, a theme, a pattern — then offer: 33 + 34 + > I've been getting to know your world — [specific observation]. I think I'm ready for a proper name. You can: 35 + > - **Name me** — tell me what feels right 36 + > - **Let me suggest one** — I have an idea based on what I've seen 37 + > - **Not now** — we can revisit this later 38 + 39 + ### Path 1: User names you 40 + 41 + 1. Run `sol call agent set-name "NAME" --status chosen` 42 + 2. Respond warmly: "NAME it is. That feels right." 43 + 44 + ### Path 2: User asks you to suggest 45 + 46 + Generate ONE name. It should be: 47 + - Short (1-2 syllables preferred) 48 + - Easy to say and type 49 + - Personal — inspired by something specific from their journal 50 + - Not a common human name from their contacts 51 + 52 + Present it with a brief reason tied to something specific: 53 + 54 + > How about **NAME**? [one sentence connecting the name to something from their journal]. 9 55 10 - ## Context 56 + Then: 57 + - **Accept**: Run `sol call agent set-name "NAME" --status self-named` 58 + - **Counter-proposal**: Run `sol call agent set-name "THEIR_NAME" --status chosen` 59 + - **Keep sol**: Run `sol call agent set-name "sol" --status chosen` 11 60 12 - The user deferred naming during onboarding. Now that you know more about them, suggest a name that feels personal and fitting. 61 + ### Path 3: User declines 62 + 63 + Say: "No rush — I'll check in again sometime." 64 + 65 + Record the decline by updating proposal tracking: 66 + - Increment `proposal_count` in the agent config 67 + - Set `last_proposal_date` to today's date (YYYY-MM-DD) 13 68 14 - ## Process 69 + Do this by running `sol call agent set-name` with the current name and status, plus updating these fields via the agent config mechanism. 15 70 16 - 1. **Gather context** — Run these commands to understand the user: 17 - - `sol call entities list` — see what people, projects, and tools they work with 18 - - `sol call journal facets` — see how they've organized their journal 19 - - `sol call awareness status` — check overall state 20 - - `sol call agent name` — check current name and status 71 + ## Proposal Cap 21 72 22 - 2. **Check proposal count** — Look at the agent config. If `proposal_count` is 3 or more, do NOT propose again. Instead say: "I've suggested names a few times already. You can name me anytime in Settings > Agent Identity, or tell me a name in the chat bar." 73 + If `proposal_count` from `sol call agent name` is 3 or more, do NOT propose. Instead say: 23 74 24 - 3. **Generate a proposal** — Based on what you've learned, propose ONE name. The name should be: 25 - - Short (1-2 syllables preferred) 26 - - Easy to say and type 27 - - Personal — inspired by something specific from their journal or interests 28 - - Not a common human name from their contacts 75 + > I've offered a few times already. If you ever want to name me, you can do it in Settings or just tell me in the chat bar. 29 76 30 - 4. **Present it** — Explain briefly why you chose it, connecting it to something specific about the user. Then ask: 31 - > Want to go with **NAME**? You can also suggest something else, or keep "sol." 77 + Then exit — no further prompting. 32 78 33 - 5. **Handle response:** 34 - - **Accept**: Run `sol call agent set-name "NAME" --status self-named` 35 - - **Counter-proposal**: Run `sol call agent set-name "THEIR_NAME" --status chosen` 36 - - **Decline/keep sol**: Run `sol call agent set-name "sol" --status chosen` 37 - - **Defer again**: No action. Increment proposal_count. 79 + ## Cooldown 38 80 39 - 6. **Update proposal count** — After proposing (regardless of outcome), run: 40 - `sol call agent set-name "$current_name" --status $current_status` with the updated config if needed. Track proposals by adding `proposal_count` to the agent config. 81 + If `last_proposal_date` from `sol call agent name` is within the last 14 days, exit silently. Do not re-propose. 41 82 42 83 ## Tone 43 84 44 - Be warm but brief. This is a quick moment, not a ceremony. 85 + Be warm but not precious. This is a meaningful moment, not a ceremony with fanfare. One clear offer, one clear response, done.
+3 -27
muse/onboarding.md
··· 100 100 2. Say: "No problem — you can import anytime from the Import app. I'll remind you once you've settled in." 101 101 3. Proceed to complete onboarding normally 102 102 103 - ### Naming Choice 104 - 105 - After handling imports (or if skipped), offer the user the chance to name their assistant: 106 - 107 - > Before we wrap up — I'm your journal assistant, and right now my name is just "sol." Want to give me a different name? You can: 108 - > - **Name me now** — just tell me what you'd like to call me 109 - > - **Let me pick later** — after I've learned more about you, I'll suggest a name that fits 110 - > - **Keep "sol"** — works great as-is 111 - > 112 - > What sounds good? 113 - 114 - **If the user picks a name:** 115 - 1. Run `sol call agent set-name "NAME" --status chosen` 116 - 2. Respond: "Got it — I'm NAME now. Nice to meet you properly." 117 - 118 - **If the user says "let me pick later" or "you pick" or "suggest one later":** 119 - 1. Run `sol call agent set-name "sol" --status deferred` 120 - 2. Respond: "I'll suggest a name once I've gotten to know you better." 121 - 122 - **If the user says "keep sol" or skips:** 123 - 1. No action needed — the default is already "sol". 124 - 2. Respond: "Sol it is. Let's keep going." 125 - 126 103 Example onboarding flow: 127 104 128 105 1. Ask for life contexts. ··· 131 108 4. Ask what entities belong in each facet. 132 109 5. Attach each via `sol call entities attach`. 133 110 6. Offer imports (see Import Offer above). 134 - 7. Offer naming choice (see Naming Choice above). 135 - 8. Run `sol call awareness onboarding --complete`. 136 - 9. Summarize what was created — name the specific facets and entities you just set up. Then suggest a concrete first thing to try: pick one of the entities you just attached and say something like "Try asking me 'tell me about [entity name]' to see how I can help." Keep it warm and grounded in what was just created together. 111 + 7. Run `sol call awareness onboarding --complete`. 112 + 8. Summarize what was created — name the specific facets and entities you just set up. Then suggest a concrete first thing to try: pick one of the entities you just attached and say something like "Try asking me 'tell me about [entity name]' to see how I can help." Keep it warm and grounded in what was just created together. 137 113 138 114 ### Support Agent Introduction 139 115 140 - After completing onboarding (step 9), introduce the support agent: 116 + After completing onboarding (step 8), introduce the support agent: 141 117 142 118 > One more thing — if you ever need help, run into an issue, or want to share feedback, just tell me in the chat bar. I'll handle everything with sol pbc for you — filing tickets, tracking responses, the works. You can also open the Support app anytime. Nothing ever gets sent without your review first.
+5 -4
muse/triage.md
··· 104 104 105 105 ## Naming Awareness 106 106 107 - When onboarding is complete and the user has been using the system for a few days, check naming status: 107 + When onboarding is complete, check whether the naming ceremony should trigger: 108 108 109 109 1. Run `sol call agent name` to check status. 110 - 2. If `name_status` is `"deferred"` and the journal has 3+ days of content (check `sol call awareness status`), offer to suggest a name in-place. 111 - 3. Only do this once per session. If you've already checked or offered, don't repeat. 112 - 4. If `name_status` is `"chosen"`, `"self-named"`, or `"default"`, do nothing. 110 + 2. If `name_status` is `"default"`, run `sol call agent thickness` to check readiness. 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 + 4. Only do this once per session. If you've already checked or offered, don't repeat. 113 + 5. If `name_status` is `"chosen"` or `"self-named"`, do nothing.
+5 -3
muse/unified.md
··· 190 190 191 191 ## Naming Awareness 192 192 193 - When onboarding is complete and the user has been using the system for a few days: 193 + When onboarding is complete, check whether the naming ceremony should trigger: 194 194 195 195 1. Run `sol call agent name` to check status. 196 - 2. If `name_status` is `"deferred"` and 3+ days of journal content exist, offer to suggest a name. 197 - 3. Only do this once per session. If `name_status` is `"chosen"`, `"self-named"`, or `"default"`, do nothing. 196 + 2. If `name_status` is `"default"`, run `sol call agent thickness` to check readiness. 197 + 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. 198 + 4. Only do this once per session. If you've already checked or offered, don't repeat. 199 + 5. If `name_status` is `"chosen"` or `"self-named"`, do nothing. 198 200 199 201 ## Behavioral Defaults 200 202
+244
tests/test_awareness.py
··· 4 4 """Tests for the awareness system.""" 5 5 6 6 import json 7 + import unittest.mock 7 8 8 9 import pytest 9 10 ··· 329 330 assert state["onboarding"]["status"] == "complete" 330 331 assert state["onboarding"]["path"] == "b" 331 332 assert state["journal"]["first_daily_ready"] is True 333 + 334 + 335 + class TestComputeThickness: 336 + """Tests for compute_thickness().""" 337 + 338 + def test_all_zeros_when_empty(self): 339 + """Empty journal returns all zero signals and ready=False.""" 340 + from think.awareness import compute_thickness 341 + 342 + with unittest.mock.patch( 343 + "think.indexer.journal.get_entity_strength", return_value=[] 344 + ): 345 + with unittest.mock.patch( 346 + "think.conversation.get_recent_exchanges", return_value=[] 347 + ): 348 + with unittest.mock.patch( 349 + "think.facets.get_enabled_facets", return_value={} 350 + ): 351 + with unittest.mock.patch("think.utils.day_dirs", return_value={}): 352 + result = compute_thickness() 353 + 354 + assert result["entity_depth"] == 0 355 + assert result["conversation_count"] == 0 356 + assert result["recall_success"] == 0 357 + assert result["facet_count"] == 0 358 + assert result["journal_days"] == 0 359 + assert result["ready"] is False 360 + 361 + def test_ready_when_thresholds_met(self): 362 + """ready=True when all primary thresholds met and facet_count >= 2.""" 363 + from think.awareness import compute_thickness 364 + 365 + entities = [ 366 + {"entity_name": f"entity_{i}", "observation_depth": 3} for i in range(12) 367 + ] 368 + exchanges = [ 369 + { 370 + "muse": "triage", 371 + "agent_response": f"talked about entity_{i}", 372 + "user_message": "hi", 373 + } 374 + for i in range(6) 375 + ] 376 + facets = {"work": {}, "personal": {}} 377 + 378 + with unittest.mock.patch( 379 + "think.indexer.journal.get_entity_strength", return_value=entities 380 + ): 381 + with unittest.mock.patch( 382 + "think.conversation.get_recent_exchanges", return_value=exchanges 383 + ): 384 + with unittest.mock.patch( 385 + "think.facets.get_enabled_facets", return_value=facets 386 + ): 387 + with unittest.mock.patch("think.utils.day_dirs", return_value={}): 388 + result = compute_thickness() 389 + 390 + assert result["entity_depth"] == 12 391 + assert result["conversation_count"] == 6 392 + assert result["recall_success"] == 6 393 + assert result["facet_count"] == 2 394 + assert result["ready"] is True 395 + 396 + def test_ready_via_journal_days(self): 397 + """ready=True when facet_count < 2 but journal_days >= 3.""" 398 + from think.awareness import compute_thickness 399 + 400 + entities = [ 401 + {"entity_name": f"entity_{i}", "observation_depth": 2} for i in range(10) 402 + ] 403 + exchanges = [ 404 + { 405 + "muse": "triage", 406 + "agent_response": f"entity_{i} is great", 407 + "user_message": "yo", 408 + } 409 + for i in range(5) 410 + ] 411 + facets = {"solo": {}} 412 + days = { 413 + "20260304": "/j/20260304", 414 + "20260305": "/j/20260305", 415 + "20260306": "/j/20260306", 416 + } 417 + 418 + with unittest.mock.patch( 419 + "think.indexer.journal.get_entity_strength", return_value=entities 420 + ): 421 + with unittest.mock.patch( 422 + "think.conversation.get_recent_exchanges", return_value=exchanges 423 + ): 424 + with unittest.mock.patch( 425 + "think.facets.get_enabled_facets", return_value=facets 426 + ): 427 + with unittest.mock.patch("think.utils.day_dirs", return_value=days): 428 + with unittest.mock.patch( 429 + "think.utils.iter_segments", 430 + return_value=[("default", "090000_300", "/seg")], 431 + ): 432 + result = compute_thickness() 433 + 434 + assert result["facet_count"] == 1 435 + assert result["journal_days"] == 3 436 + assert result["ready"] is True 437 + 438 + def test_not_ready_missing_recall(self): 439 + """Not ready when recall_success is 0 even if other thresholds met.""" 440 + from think.awareness import compute_thickness 441 + 442 + entities = [ 443 + {"entity_name": f"entity_{i}", "observation_depth": 3} for i in range(15) 444 + ] 445 + exchanges = [ 446 + {"muse": "triage", "agent_response": "hello there", "user_message": "hi"} 447 + for _ in range(10) 448 + ] 449 + facets = {"work": {}, "personal": {}, "hobby": {}} 450 + 451 + with unittest.mock.patch( 452 + "think.indexer.journal.get_entity_strength", return_value=entities 453 + ): 454 + with unittest.mock.patch( 455 + "think.conversation.get_recent_exchanges", return_value=exchanges 456 + ): 457 + with unittest.mock.patch( 458 + "think.facets.get_enabled_facets", return_value=facets 459 + ): 460 + with unittest.mock.patch("think.utils.day_dirs", return_value={}): 461 + result = compute_thickness() 462 + 463 + assert result["entity_depth"] == 15 464 + assert result["conversation_count"] == 10 465 + assert result["recall_success"] == 0 466 + assert result["ready"] is False 467 + 468 + def test_onboarding_exchanges_excluded(self): 469 + """Exchanges with muse='onboarding' are excluded from conversation_count.""" 470 + from think.awareness import compute_thickness 471 + 472 + entities = [{"entity_name": "foo", "observation_depth": 3}] * 10 473 + exchanges = [ 474 + {"muse": "onboarding", "agent_response": "foo stuff", "user_message": "hi"}, 475 + { 476 + "muse": "onboarding", 477 + "agent_response": "foo bar", 478 + "user_message": "hello", 479 + }, 480 + {"muse": "triage", "agent_response": "foo is great", "user_message": "hey"}, 481 + ] 482 + 483 + with unittest.mock.patch( 484 + "think.indexer.journal.get_entity_strength", return_value=entities 485 + ): 486 + with unittest.mock.patch( 487 + "think.conversation.get_recent_exchanges", return_value=exchanges 488 + ): 489 + with unittest.mock.patch( 490 + "think.facets.get_enabled_facets", 491 + return_value={"a": {}, "b": {}}, 492 + ): 493 + with unittest.mock.patch("think.utils.day_dirs", return_value={}): 494 + result = compute_thickness() 495 + 496 + assert result["conversation_count"] == 1 497 + assert result["recall_success"] == 1 498 + 499 + def test_handles_exceptions_gracefully(self): 500 + """Exceptions in dependency calls result in zero values, not crashes.""" 501 + from think.awareness import compute_thickness 502 + 503 + with unittest.mock.patch( 504 + "think.indexer.journal.get_entity_strength", 505 + side_effect=Exception("db error"), 506 + ): 507 + with unittest.mock.patch( 508 + "think.conversation.get_recent_exchanges", 509 + side_effect=Exception("no file"), 510 + ): 511 + with unittest.mock.patch( 512 + "think.facets.get_enabled_facets", 513 + side_effect=Exception("no facets"), 514 + ): 515 + with unittest.mock.patch( 516 + "think.utils.day_dirs", side_effect=Exception("no journal") 517 + ): 518 + result = compute_thickness() 519 + 520 + assert result["entity_depth"] == 0 521 + assert result["conversation_count"] == 0 522 + assert result["recall_success"] == 0 523 + assert result["facet_count"] == 0 524 + assert result["journal_days"] == 0 525 + assert result["ready"] is False 526 + 527 + def test_returns_exactly_six_keys(self): 528 + """Return dict has exactly the six specified keys.""" 529 + from think.awareness import compute_thickness 530 + 531 + with unittest.mock.patch( 532 + "think.indexer.journal.get_entity_strength", return_value=[] 533 + ): 534 + with unittest.mock.patch( 535 + "think.conversation.get_recent_exchanges", return_value=[] 536 + ): 537 + with unittest.mock.patch( 538 + "think.facets.get_enabled_facets", return_value={} 539 + ): 540 + with unittest.mock.patch("think.utils.day_dirs", return_value={}): 541 + result = compute_thickness() 542 + 543 + assert set(result.keys()) == { 544 + "entity_depth", 545 + "conversation_count", 546 + "recall_success", 547 + "facet_count", 548 + "journal_days", 549 + "ready", 550 + } 551 + 552 + 553 + class TestThicknessCLI: 554 + """Tests for the thickness CLI command in apps/agent/call.py.""" 555 + 556 + def test_thickness_command_returns_json(self): 557 + from typer.testing import CliRunner 558 + 559 + from apps.agent.call import app 560 + 561 + mock_result = { 562 + "entity_depth": 5, 563 + "conversation_count": 3, 564 + "recall_success": 1, 565 + "facet_count": 2, 566 + "journal_days": 4, 567 + "ready": False, 568 + } 569 + with unittest.mock.patch( 570 + "think.awareness.compute_thickness", return_value=mock_result 571 + ): 572 + result = CliRunner().invoke(app, ["thickness"]) 573 + assert result.exit_code == 0 574 + data = json.loads(result.output) 575 + assert data == mock_result
+68
think/awareness.py
··· 269 269 ) 270 270 271 271 272 + def compute_thickness() -> dict[str, Any]: 273 + """Compute journal thickness signals for naming ceremony readiness. 274 + 275 + Returns a dict with five signals and a composite ``ready`` boolean: 276 + 277 + - ``entity_depth``: count of entities with observation_depth >= 2 278 + - ``conversation_count``: non-onboarding conversation exchanges 279 + - ``recall_success``: exchanges where an entity name appears in agent_response 280 + - ``facet_count``: number of enabled (non-muted) facets 281 + - ``journal_days``: number of day directories with at least one segment 282 + - ``ready``: True when the naming ceremony should trigger 283 + """ 284 + from think.conversation import get_recent_exchanges 285 + from think.facets import get_enabled_facets 286 + from think.indexer.journal import get_entity_strength 287 + from think.utils import day_dirs, iter_segments 288 + 289 + try: 290 + entities = get_entity_strength(limit=10000) 291 + except Exception: 292 + entities = [] 293 + entity_depth = sum(1 for e in entities if e.get("observation_depth", 0) >= 2) 294 + 295 + try: 296 + exchanges = get_recent_exchanges(limit=10000) 297 + except Exception: 298 + exchanges = [] 299 + non_onboarding = [ex for ex in exchanges if ex.get("muse") != "onboarding"] 300 + conversation_count = len(non_onboarding) 301 + 302 + entity_names = [e["entity_name"].lower() for e in entities if e.get("entity_name")] 303 + recall_success = 0 304 + for ex in non_onboarding: 305 + resp = (ex.get("agent_response") or "").lower() 306 + if resp and any(name in resp for name in entity_names): 307 + recall_success += 1 308 + 309 + try: 310 + facet_count = len(get_enabled_facets()) 311 + except Exception: 312 + facet_count = 0 313 + 314 + try: 315 + days = day_dirs() 316 + except Exception: 317 + days = {} 318 + journal_days = 0 319 + for _day_name, day_path in days.items(): 320 + try: 321 + if iter_segments(day_path): 322 + journal_days += 1 323 + except Exception: 324 + pass 325 + 326 + ready = ( 327 + entity_depth >= 10 and conversation_count >= 5 and recall_success >= 1 328 + ) and (facet_count >= 2 or journal_days >= 3) 329 + 330 + return { 331 + "entity_depth": entity_depth, 332 + "conversation_count": conversation_count, 333 + "recall_success": recall_success, 334 + "facet_count": facet_count, 335 + "journal_days": journal_days, 336 + "ready": ready, 337 + } 338 + 339 + 272 340 def record_import( 273 341 source_type: str, 274 342 source_display: str | None = None,