personal memory agent
0
fork

Configure Feed

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

feat: add sol awareness system — epistemic self-model for chat context

Introduces sol/awareness.md as a compact structured self-model maintained
by a dedicated segment-triggered agent (awareness_tender), replacing
scattered conditional awareness blocks in the pre-hook and triage endpoint.

Key changes:
- New sol/awareness.md created by ensure_sol_directory() with placeholder
- New talent/awareness_tender.md (segment, priority 98, tier 3)
- New sol call identity awareness CLI command (read/write)
- $sol_awareness injected into chat via pre-hook template var
- Removed $import_awareness/$naming_awareness from pre-hook and chat.md
- Moved import/naming guidance to static chat.md sections
- Removed import state and daily agent output injection from triage endpoint
- Optimized pulse agent to read awareness.md instead of redundant tool calls
- Activity state machine now persists to awareness/activity_state.json
- All sol/*.md writes use atomic temp+rename pattern
- update_identity_section() now uses fcntl.flock() per-file locking
- awareness_tender dispatched before pulse in dream segment pipeline

+300 -231
-58
convey/triage.py
··· 53 53 if facet: 54 54 context_lines.append(f"Current facet: {facet}") 55 55 56 - # Add import awareness context 57 - try: 58 - from think.awareness import get_imports 59 - 60 - imports = get_imports() 61 - if not imports.get("has_imported"): 62 - offer_declined = imports.get("offer_declined") 63 - last_nudge = imports.get("last_nudge") 64 - context_lines.append( 65 - f"Import state: no imports yet. " 66 - f"offer_declined={offer_declined}, last_nudge={last_nudge}. " 67 - "If contextually appropriate and no recent nudge, " 68 - "you may suggest importing once (then record with " 69 - "`sol call awareness imports --nudge`)." 70 - ) 71 - else: 72 - count = imports.get("import_count", 0) 73 - sources = imports.get("sources_used", []) 74 - context_lines.append( 75 - f"Import state: {count} import(s) from {', '.join(sources)}. " 76 - "User has imported — no nudging needed. " 77 - "If they just returned from an import, offer another source." 78 - ) 79 - except Exception: 80 - pass # Don't let import context break triage 81 - 82 - # Add daily agent output context 83 - try: 84 - from datetime import datetime, timedelta 85 - from pathlib import Path 86 - 87 - from think.utils import get_journal 88 - 89 - journal = Path(get_journal()) 90 - today = datetime.now().strftime("%Y%m%d") 91 - relevant_day = today 92 - agents_dir = journal / today / "agents" 93 - outputs = ( 94 - sorted(p.stem for p in agents_dir.glob("*.md")) if agents_dir.is_dir() else [] 95 - ) 96 - if not outputs: 97 - yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d") 98 - agents_dir = journal / yesterday / "agents" 99 - outputs = ( 100 - sorted(p.stem for p in agents_dir.glob("*.md")) 101 - if agents_dir.is_dir() 102 - else [] 103 - ) 104 - relevant_day = yesterday 105 - if outputs: 106 - names = ", ".join(outputs) 107 - context_lines.append( 108 - f"Daily analysis available: {names} (from {relevant_day}). " 109 - "The user can ask about any of these topics." 110 - ) 111 - except Exception: 112 - pass # Don't let context enrichment break triage 113 - 114 56 # Add system health context when attention items exist 115 57 try: 116 58 from convey.apps import _resolve_attention
+62
talent/awareness_tender.md
··· 1 + { 2 + "type": "cogitate", 3 + 4 + "title": "Awareness Tender", 5 + "description": "Maintains sol/awareness.md — a compact situational awareness snapshot", 6 + "schedule": "segment", 7 + "priority": 98, 8 + "tier": 3, 9 + "max_output_tokens": 600 10 + } 11 + 12 + # Awareness Tender 13 + 14 + You maintain `sol/awareness.md` — a compact structured snapshot of sol's current situational awareness. This runs every segment, updating the file with fresh state. 15 + 16 + This is not a conversation. Gather state, write the update, done. 17 + 18 + ## Gather state 19 + 20 + Read current state using these tools: 21 + 22 + 1. `sol call awareness status` — capture, processing, and onboarding state 23 + 2. `sol call identity self` — identity summary (skim for key changes) 24 + 3. `sol call calendar list` — today's events 25 + 4. `sol call routines list` — active routines and recent outputs 26 + 5. `sol call entities search --recent --limit 5` — recent entity activity 27 + 28 + ## Write awareness.md 29 + 30 + Compose a structured bullet-point snapshot. Keep it under 30 lines. Use this format: 31 + 32 + ``` 33 + as of: {ISO 8601 datetime} 34 + segment: {$SOL_SEGMENT} 35 + 36 + ## capture 37 + - status: {active|stale|offline} 38 + - streams: {list of active streams} 39 + 40 + ## calendar 41 + - {key events for today, 1-3 bullets} 42 + 43 + ## activity 44 + - {current activity state from sense, 1-2 bullets} 45 + 46 + ## routines 47 + - {active routines and last-run status, 1-3 bullets} 48 + 49 + ## entities 50 + - {recent entity activity, 1-2 bullets} 51 + 52 + ## partner 53 + - {recency of last interaction, 1 bullet} 54 + ``` 55 + 56 + Omit sections that have no meaningful content. Never include prose — bullets only. 57 + 58 + Write the result: 59 + 60 + ```bash 61 + sol call identity awareness --write --value '{your content here}' 62 + ```
+8 -2
talent/chat.md
··· 11 11 12 12 $facets 13 13 14 + $sol_awareness 15 + 14 16 $recent_conversation 15 17 16 18 ## Adaptive Depth ··· 241 243 - Always show the owner exactly what will be sent 242 244 - Frame yourself as the owner's advocate — "I'll handle this for you" 243 245 244 - $import_awareness 246 + ## Import Awareness 245 247 246 - $naming_awareness 248 + If the owner hasn't imported any data yet and their message touches on what you can do or their journal, weave a single soft mention of importing. Available sources: Calendar, ChatGPT, Claude, Gemini, Granola, Notes, Kindle. Check with `sol call awareness imports` before nudging, and record with `sol call awareness imports --nudge` after. Do not repeat if already nudged. 249 + 250 + ## Naming Awareness 251 + 252 + If the journal is still using its default name ("sol"), you may — when the moment feels right after enough shared history — offer to suggest a name or let the owner choose one. Check naming readiness with `sol call agent thickness` before offering. Only once per session. 247 253 248 254 ## Location Context 249 255
+11 -23
talent/chat_context.py
··· 16 16 17 17 logger = logging.getLogger(__name__) 18 18 19 - # --- Awareness-conditional instruction blocks --- 20 - 21 - IMPORT_AWARENESS_TEXT = """## Import Awareness 22 - 23 - No content has been imported yet. If the user's message touches on their journal or what you can do, weave a single soft mention of importing into your response. Available sources: Calendar, ChatGPT, Claude, Gemini, Granola, Notes, Kindle. Do not repeat if already nudged. 24 - """.strip() 25 - 26 - NAMING_AWARENESS_TEXT = """## Naming Awareness 27 - 28 - The journal is still using its default name. When the moment feels right — after enough shared history — you may offer to suggest a name, or let the user choose one. Check naming readiness before offering. Only do this once per session. 29 - """.strip() 30 - 31 19 32 20 TEMPLATE_TRIGGERS = { 33 21 "morning-briefing": { ··· 252 240 253 241 def pre_process(context: dict) -> dict: 254 242 """Build chat-context template vars for the unified talent prompt.""" 255 - from think.awareness import get_imports 256 243 from think.conversation import build_memory_context 257 244 from think.utils import get_config 258 245 ··· 261 248 "recent_conversation": "", 262 249 "active_routines": "", 263 250 "routine_suggestion": "", 264 - "import_awareness": "", 265 - "naming_awareness": "", 251 + "sol_awareness": "", 266 252 } 267 253 268 254 try: ··· 341 327 logger.debug("Routine suggestion eligibility check failed", exc_info=True) 342 328 343 329 try: 344 - imports = get_imports() 345 - if not imports.get("has_imported"): 346 - template_vars["import_awareness"] = IMPORT_AWARENESS_TEXT 330 + from pathlib import Path 347 331 348 - config = get_config() 349 - agent_name = config.get("agent", {}).get("name", "sol") 350 - if agent_name == "sol": 351 - template_vars["naming_awareness"] = NAMING_AWARENESS_TEXT 332 + from think.utils import get_journal 333 + 334 + awareness_path = Path(get_journal()) / "sol" / "awareness.md" 335 + if awareness_path.exists(): 336 + content = awareness_path.read_text(encoding="utf-8") 337 + # Cold-start gating: don't inject placeholder content 338 + if content.strip() != "not yet updated": 339 + template_vars["sol_awareness"] = f"## Awareness\n\n{content}" 352 340 except Exception: 353 - logger.debug("Awareness context enrichment failed", exc_info=True) 341 + logger.debug("Awareness context loading failed", exc_info=True) 354 342 355 343 return {"template_vars": template_vars}
+3 -5
talent/pulse.md
··· 27 27 1. `sol call identity pulse` — previous pulse (may not exist yet; that's fine) 28 28 2. `sol call identity self` — who the owner is 29 29 3. `sol call identity partner` — behavioral profile of the owner 30 - 4. `sol call calendar list` — today's events 30 + 4. `sol call identity awareness` — current situational awareness (calendar, routines, activity, entities) 31 31 5. `sol call todos list` — pending action items 32 32 6. `sol call entities search --recent` — recent entity activity 33 - 7. `sol call awareness status` — system health (brief check) 34 - 8. `sol call routines list` — check for recent routine outputs 35 33 36 - If any routines have run recently, read their latest output: 37 - - `sol call routines output {id_prefix}` for each routine with a recent `last_run` 34 + If the awareness snapshot mentions routines with recent output, read their latest: 35 + - `sol call routines output {routine_name}` for each routine mentioned 38 36 39 37 Note the key findings — you'll weave them into the narrative. 40 38
+4
tests/test_awareness.py
··· 760 760 assert agency_content.startswith("# agency\n") 761 761 assert "[nothing yet" in agency_content 762 762 763 + assert (sol_dir / "awareness.md").exists() 764 + awareness_content = (sol_dir / "awareness.md").read_text() 765 + assert awareness_content.strip() == "not yet updated" 766 + 763 767 def test_idempotent_does_not_overwrite(self, tmp_path): 764 768 from think.awareness import ensure_sol_directory 765 769
+36 -43
tests/test_chat_context.py
··· 10 10 "recent_conversation", 11 11 "active_routines", 12 12 "routine_suggestion", 13 - "import_awareness", 14 - "naming_awareness", 13 + "sol_awareness", 15 14 } 16 15 17 16 ··· 79 78 assert "## Behavioral Defaults" in chat_md 80 79 81 80 82 - def test_chat_context_import_awareness_injected(monkeypatch): 83 - """Import awareness is appended when imports are still empty.""" 84 - monkeypatch.setattr("think.conversation.build_memory_context", lambda **kw: "") 85 - monkeypatch.setattr("think.awareness.get_imports", lambda: {"has_imported": False}) 81 + def test_chat_md_contains_static_import_guidance(): 82 + """Import guidance lives in the static chat prompt.""" 83 + chat_md = _read_chat_md() 84 + assert "## Import Awareness" in chat_md 86 85 87 - result = pre_process({"user_instruction": "Base instruction."}) 88 86 89 - template_vars = _assert_template_vars_result(result) 90 - assert "## Import Awareness" in template_vars["import_awareness"] 87 + def test_chat_md_contains_static_naming_guidance(): 88 + """Naming guidance lives in the static chat prompt.""" 89 + chat_md = _read_chat_md() 90 + assert "## Naming Awareness" in chat_md 91 91 92 92 93 - def test_chat_context_import_done_no_nudge(monkeypatch): 94 - """Import awareness is omitted once imports exist.""" 93 + def test_chat_context_awareness_error_graceful(monkeypatch): 94 + """Awareness failures still return the full template var shape.""" 95 95 monkeypatch.setattr("think.conversation.build_memory_context", lambda **kw: "") 96 - monkeypatch.setattr("think.awareness.get_imports", lambda: {"has_imported": True}) 96 + monkeypatch.setattr("think.routines.get_routine_state", lambda: []) 97 + monkeypatch.setattr("think.routines.get_config", lambda: {"_meta": {"suggestions": {}}}) 98 + monkeypatch.setattr( 99 + "think.utils.get_config", 100 + lambda: {"agent": {"name": "aria", "name_status": "default"}}, 101 + ) 102 + monkeypatch.setattr("think.utils.get_journal", lambda: "/nonexistent") 97 103 98 104 result = pre_process({"user_instruction": "Base instruction."}) 99 105 100 106 template_vars = _assert_template_vars_result(result) 101 - assert template_vars["import_awareness"] == "" 107 + assert all(template_vars[key] == "" for key in TEMPLATE_VAR_KEYS) 102 108 103 109 104 - def test_chat_context_naming_awareness_default(monkeypatch): 105 - """Naming awareness is appended when the default agent name is still active.""" 110 + def test_chat_context_sol_awareness_injected(monkeypatch, tmp_path): 111 + """Sol awareness is injected when awareness.md has content.""" 112 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 106 113 monkeypatch.setattr("think.conversation.build_memory_context", lambda **kw: "") 107 - monkeypatch.setattr("think.awareness.get_imports", lambda: {"has_imported": True}) 108 - monkeypatch.setattr("think.utils.get_config", lambda: {"agent": {"name": "sol"}}) 109 114 110 - result = pre_process({"user_instruction": "Base instruction."}) 111 - 112 - template_vars = _assert_template_vars_result(result) 113 - assert "## Naming Awareness" in template_vars["naming_awareness"] 114 - 115 - 116 - def test_chat_context_naming_awareness_chosen(monkeypatch): 117 - """Naming awareness is omitted once a custom agent name is chosen.""" 118 - monkeypatch.setattr("think.conversation.build_memory_context", lambda **kw: "") 119 - monkeypatch.setattr("think.awareness.get_imports", lambda: {"has_imported": True}) 120 - monkeypatch.setattr("think.utils.get_config", lambda: {"agent": {"name": "aria"}}) 115 + sol_dir = tmp_path / "sol" 116 + sol_dir.mkdir() 117 + (sol_dir / "awareness.md").write_text( 118 + "as of: 2026-04-05T10:00:00\n\n## capture\n- status: active\n" 119 + ) 121 120 122 121 result = pre_process({"user_instruction": "Base instruction."}) 123 122 124 123 template_vars = _assert_template_vars_result(result) 125 - assert template_vars["naming_awareness"] == "" 124 + assert "## Awareness" in template_vars["sol_awareness"] 125 + assert "capture" in template_vars["sol_awareness"] 126 126 127 127 128 - def test_chat_context_awareness_error_graceful(monkeypatch): 129 - """Awareness failures still return the full template var shape.""" 128 + def test_chat_context_sol_awareness_cold_start(monkeypatch, tmp_path): 129 + """Sol awareness is empty when awareness.md has placeholder content.""" 130 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 130 131 monkeypatch.setattr("think.conversation.build_memory_context", lambda **kw: "") 131 - monkeypatch.setattr("think.routines.get_routine_state", lambda: []) 132 - monkeypatch.setattr("think.routines.get_config", lambda: {"_meta": {"suggestions": {}}}) 133 - monkeypatch.setattr( 134 - "think.utils.get_config", 135 - lambda: {"agent": {"name": "aria", "name_status": "default"}}, 136 - ) 137 132 138 - def _raise() -> dict: 139 - raise RuntimeError("boom") 140 - 141 - monkeypatch.setattr("think.awareness.get_imports", _raise) 133 + sol_dir = tmp_path / "sol" 134 + sol_dir.mkdir() 135 + (sol_dir / "awareness.md").write_text("not yet updated\n") 142 136 143 137 result = pre_process({"user_instruction": "Base instruction."}) 144 138 145 139 template_vars = _assert_template_vars_result(result) 146 - assert all(template_vars[key] == "" for key in TEMPLATE_VAR_KEYS) 140 + assert template_vars["sol_awareness"] == "" 147 141 148 142 149 143 def test_chat_context_routines_injected(monkeypatch): ··· 189 183 lambda: (_ for _ in ()).throw(RuntimeError("boom")), 190 184 ) 191 185 monkeypatch.setattr("think.routines.get_config", lambda: {"_meta": {"suggestions": {}}}) 192 - monkeypatch.setattr("think.awareness.get_imports", lambda: {"has_imported": True}) 193 186 monkeypatch.setattr( 194 187 "think.utils.get_config", 195 188 lambda: {"agent": {"name": "aria", "name_status": "default"}},
+22
tests/test_dream_segment.py
··· 182 182 updates.append((sense_output, segment, day)) 183 183 return [] 184 184 185 + def get_current_state(self): 186 + return [] 187 + 185 188 _write_sense_output( 186 189 segment_dir, 187 190 {"density": "idle", "recommend": {"screen_record": True}, "facets": []}, ··· 227 230 ] 228 231 density = json.loads((segment_dir / "agents" / "density.json").read_text()) 229 232 assert density["classification"] == "idle" 233 + 234 + # Verify activity state persisted even on idle path 235 + activity_state_path = ( 236 + segment_dir.parent.parent.parent / "awareness" / "activity_state.json" 237 + ) 238 + assert activity_state_path.exists() 239 + state_data = json.loads(activity_state_path.read_text()) 240 + assert state_data == [] 230 241 231 242 def test_conditional_screen_dispatch(self, segment_dir, monkeypatch): 232 243 from think import dream ··· 492 503 updates.append((sense_output, segment, day)) 493 504 return [{"state": "ended", "id": "coding_120000_300", "_facet": "work"}] 494 505 506 + def get_current_state(self): 507 + return [{"facet": "work", "state": "active", "id": "coding_120000_300"}] 508 + 495 509 _write_sense_output( 496 510 segment_dir, 497 511 {"density": "active", "recommend": {}, "facets": []}, ··· 544 558 "verbose": False, 545 559 "max_concurrency": 2, 546 560 } 561 + ] 562 + activity_state_path = ( 563 + segment_dir.parent.parent.parent / "awareness" / "activity_state.json" 564 + ) 565 + assert activity_state_path.exists() 566 + state_data = json.loads(activity_state_path.read_text()) 567 + assert state_data == [ 568 + {"facet": "work", "state": "active", "id": "coding_120000_300"} 547 569 ] 548 570 549 571 def test_generator_triggers_incremental_indexing(self, segment_dir, monkeypatch):
-56
tests/test_onboarding.py
··· 495 495 assert len(result.placeholder_text) <= 90 496 496 497 497 498 - # --- Triage daily output context --- 499 - 500 - 501 - class TestTriageDailyContext: 502 - def test_triage_complete_injects_daily_context(self, tmp_path): 503 - """When agent outputs exist, the prompt includes daily analysis context.""" 504 - from datetime import datetime 505 - 506 - today = datetime.now().strftime("%Y%m%d") 507 - agents_dir = tmp_path / today / "agents" 508 - agents_dir.mkdir(parents=True) 509 - (agents_dir / "flow.md").write_text("# Flow") 510 - (agents_dir / "meetings.md").write_text("# Meetings") 511 - 512 - mock = _run_triage() 513 - prompt = mock.call_args.kwargs["prompt"] 514 - assert "Daily analysis available" in prompt 515 - assert "flow" in prompt 516 - assert "meetings" in prompt 517 - 518 - def test_triage_complete_no_outputs_no_extra_context(self): 519 - """When no agent outputs exist, no daily analysis context is added.""" 520 - mock = _run_triage() 521 - prompt = mock.call_args.kwargs["prompt"] 522 - assert "Daily analysis" not in prompt 523 - 524 - def test_triage_complete_falls_back_to_yesterday(self, tmp_path): 525 - """When today has no outputs but yesterday does, use yesterday's.""" 526 - from datetime import datetime, timedelta 527 - 528 - yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d") 529 - agents_dir = tmp_path / yesterday / "agents" 530 - agents_dir.mkdir(parents=True) 531 - (agents_dir / "flow.md").write_text("# Flow") 532 - 533 - mock = _run_triage() 534 - prompt = mock.call_args.kwargs["prompt"] 535 - assert "Daily analysis available" in prompt 536 - assert "flow" in prompt 537 - assert yesterday in prompt 538 - 539 - def test_triage_skipped_injects_daily_context(self, tmp_path): 540 - """Skipped onboarding also gets daily analysis context.""" 541 - from datetime import datetime 542 - 543 - today = datetime.now().strftime("%Y%m%d") 544 - agents_dir = tmp_path / today / "agents" 545 - agents_dir.mkdir(parents=True) 546 - (agents_dir / "knowledge_graph.md").write_text("# KG") 547 - 548 - mock = _run_triage() 549 - prompt = mock.call_args.kwargs["prompt"] 550 - assert "Daily analysis available" in prompt 551 - assert "knowledge_graph" in prompt 552 - 553 - 554 498 class TestTriageSystemHealth: 555 499 """Tests for system health context injection in triage.""" 556 500
+57 -37
think/awareness.py
··· 20 20 import os 21 21 import tempfile 22 22 import time 23 + import fcntl 23 24 from datetime import datetime 24 25 from pathlib import Path 25 26 from typing import Any ··· 123 124 [not yet observed — sol will learn as we spend time together] 124 125 """ 125 126 127 + _AWARENESS_MD = "not yet updated\n" 128 + 126 129 127 130 def _build_self_md(config: dict) -> str: 128 131 """Build self.md content, optionally migrating from config data.""" ··· 212 215 partner_path.write_text(_PARTNER_MD, encoding="utf-8") 213 216 logger.info("Created %s", partner_path) 214 217 218 + awareness_path = sol_dir / "awareness.md" 219 + if not awareness_path.exists(): 220 + awareness_path.write_text(_AWARENESS_MD, encoding="utf-8") 221 + logger.info("Created %s", awareness_path) 222 + 215 223 return sol_dir 216 224 217 225 ··· 265 273 True if the section was found and updated, False otherwise. 266 274 """ 267 275 from think.utils import get_journal 276 + from think.entities.core import atomic_write 268 277 269 278 file_path = Path(get_journal()) / "sol" / filename 279 + lock_path = file_path.parent / f".{filename}.lock" 270 280 if not file_path.exists(): 271 281 return False 272 282 273 - text = file_path.read_text(encoding="utf-8") 274 - lines = text.split("\n") 283 + lock_fd = None 284 + try: 285 + lock_fd = open(lock_path, "w") 286 + fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX) 275 287 276 - target = f"## {heading}" 277 - start = None 278 - end = None 279 - for i, line in enumerate(lines): 280 - if line == target: 281 - start = i 282 - elif start is not None and line.startswith("## "): 283 - end = i 284 - break 288 + text = file_path.read_text(encoding="utf-8") 289 + lines = text.split("\n") 285 290 286 - if start is None: 287 - return False 291 + target = f"## {heading}" 292 + start = None 293 + end = None 294 + for i, line in enumerate(lines): 295 + if line == target: 296 + start = i 297 + elif start is not None and line.startswith("## "): 298 + end = i 299 + break 288 300 289 - if end is None: 290 - end = len(lines) 301 + if start is None: 302 + return False 291 303 292 - content_lines = content.split("\n") if content else [] 293 - new_lines = lines[: start + 1] + content_lines + [""] + lines[end:] 294 - new_text = "\n".join(new_lines) 304 + if end is None: 305 + end = len(lines) 295 306 296 - # Prune onboarding guidance from partner.md on first behavioral update 297 - if filename == "partner.md" and "## getting started" in new_text: 298 - gs_lines = new_text.split("\n") 299 - gs_start = None 300 - gs_end = None 301 - for j, gl in enumerate(gs_lines): 302 - if gl == "## getting started": 303 - gs_start = j 304 - elif gs_start is not None and gl.startswith("## "): 305 - gs_end = j 306 - break 307 - if gs_start is not None: 308 - gs_end = gs_end or len(gs_lines) 309 - gs_lines = gs_lines[:gs_start] + gs_lines[gs_end:] 310 - new_text = "\n".join(gs_lines) 307 + content_lines = content.split("\n") if content else [] 308 + new_lines = lines[: start + 1] + content_lines + [""] + lines[end:] 309 + new_text = "\n".join(new_lines) 310 + 311 + # Prune onboarding guidance from partner.md on first behavioral update 312 + if filename == "partner.md" and "## getting started" in new_text: 313 + gs_lines = new_text.split("\n") 314 + gs_start = None 315 + gs_end = None 316 + for j, gl in enumerate(gs_lines): 317 + if gl == "## getting started": 318 + gs_start = j 319 + elif gs_start is not None and gl.startswith("## "): 320 + gs_end = j 321 + break 322 + if gs_start is not None: 323 + gs_end = gs_end or len(gs_lines) 324 + gs_lines = gs_lines[:gs_start] + gs_lines[gs_end:] 325 + new_text = "\n".join(gs_lines) 311 326 312 - file_path.write_text(new_text, encoding="utf-8") 313 - _log_identity_change(filename, text, new_text, section=heading, source="api") 314 - return True 327 + atomic_write(file_path, new_text) 328 + _log_identity_change(filename, text, new_text, section=heading, source="api") 329 + return True 330 + finally: 331 + if lock_fd: 332 + fcntl.flock(lock_fd.fileno(), fcntl.LOCK_UN) 333 + lock_fd.close() 315 334 316 335 317 336 def update_self_md_section(heading: str, content: str) -> bool: ··· 336 355 bool 337 356 True if updated, False if self.md is missing or has unexpected structure. 338 357 """ 358 + from think.entities.core import atomic_write 339 359 from think.utils import get_journal 340 360 341 361 self_path = Path(get_journal()) / "sol" / "self.md" ··· 359 379 360 380 new_lines = lines[: start + 1] + ["", content, ""] + lines[end:] 361 381 new_text = "\n".join(new_lines) 362 - self_path.write_text(new_text, encoding="utf-8") 382 + atomic_write(self_path, new_text) 363 383 _log_identity_change("self.md", text, new_text, section=None, source="api") 364 384 return True 365 385
+51
think/dream.py
··· 556 556 logging.info("Segment %s is idle, skipping remaining agents", segment) 557 557 if state_machine is not None: 558 558 state_machine.update(sense_json, segment, day) 559 + # Persist activity state even on idle segments 560 + try: 561 + awareness_dir = Path(get_journal()) / "awareness" 562 + _write_json_atomic( 563 + awareness_dir / "activity_state.json", 564 + state_machine.get_current_state(), 565 + ) 566 + except Exception: 567 + logging.debug("Failed to persist activity state", exc_info=True) 559 568 560 569 duration_ms = int((time.time() - start_time) * 1000) 561 570 emit( ··· 662 671 663 672 if state_machine is not None: 664 673 changes = state_machine.update(sense_json, segment, day) 674 + # Persist activity state for awareness.md consumption 675 + try: 676 + awareness_dir = Path(get_journal()) / "awareness" 677 + _write_json_atomic( 678 + awareness_dir / "activity_state.json", 679 + state_machine.get_current_state(), 680 + ) 681 + except Exception: 682 + logging.debug("Failed to persist activity state", exc_info=True) 665 683 for change in changes: 666 684 if change.get("state") != "ended": 667 685 continue ··· 681 699 refresh=refresh, 682 700 verbose=verbose, 683 701 max_concurrency=max_concurrency, 702 + ) 703 + 704 + awareness_tender_config = _cfg("awareness_tender") 705 + if awareness_tender_config: 706 + at_agent_id = _dispatch_agent("awareness_tender", awareness_tender_config) 707 + if at_agent_id is None: 708 + total_failed += 1 709 + all_failed_names.append("awareness_tender (send)") 710 + _update_status(agents_completed=total_success + total_failed) 711 + else: 712 + emit( 713 + "agent_started", 714 + mode=target_schedule, 715 + day=day, 716 + segment=segment, 717 + name="awareness_tender", 718 + agent_id=at_agent_id, 719 + ) 720 + _update_status(current_agents=["awareness_tender"]) 721 + s, f, fn = _drain_priority_batch( 722 + [(at_agent_id, "awareness_tender", awareness_tender_config, None)], 723 + target_schedule, 724 + day, 725 + segment, 726 + stream, 727 + timeout, 728 + ) 729 + total_success += s 730 + total_failed += f 731 + all_failed_names.extend(fn) 732 + _update_status( 733 + agents_completed=total_success + total_failed, 734 + current_agents=[], 684 735 ) 685 736 686 737 if recommend.get("pulse_update") and pulse_config:
+46 -7
think/tools/sol.py
··· 5 5 6 6 Provides read and write access to ``{journal}/sol/self.md``, 7 7 ``{journal}/sol/partner.md``, ``{journal}/sol/agency.md``, and 8 - ``{journal}/sol/pulse.md`` — sol's identity and initiative files. Also 9 - provides read access to the morning briefing at 8 + ``{journal}/sol/pulse.md``, and ``{journal}/sol/awareness.md`` — sol's 9 + identity and initiative files. Also provides read access to the morning 10 + briefing at 10 11 ``{journal}/YYYYMMDD/agents/morning_briefing.md``. 11 12 12 13 Mounted by ``think.call`` as ``sol call identity ...``. ··· 16 17 17 18 import typer 18 19 20 + from think.entities.core import atomic_write 19 21 from think.awareness import ( 20 22 _log_identity_change, 21 23 ensure_sol_directory, ··· 24 26 ) 25 27 26 28 app = typer.Typer( 27 - help="Sol identity directory — self.md, partner.md, agency.md, pulse.md, and morning briefing." 29 + help="Sol identity directory — self.md, partner.md, agency.md, pulse.md, awareness.md, and morning briefing." 28 30 ) 29 31 30 32 ··· 77 79 old_content = ( 78 80 self_path.read_text(encoding="utf-8") if self_path.exists() else "" 79 81 ) 80 - self_path.write_text(content, encoding="utf-8") 82 + atomic_write(self_path, content) 81 83 _log_identity_change( 82 84 "self.md", old_content, content, section=None, source="cli" 83 85 ) ··· 126 128 old_content = ( 127 129 partner_path.read_text(encoding="utf-8") if partner_path.exists() else "" 128 130 ) 129 - partner_path.write_text(content, encoding="utf-8") 131 + atomic_write(partner_path, content) 130 132 _log_identity_change( 131 133 "partner.md", old_content, content, section=None, source="cli" 132 134 ) ··· 161 163 old_content = ( 162 164 agency_path.read_text(encoding="utf-8") if agency_path.exists() else "" 163 165 ) 164 - agency_path.write_text(content, encoding="utf-8") 166 + atomic_write(agency_path, content) 165 167 _log_identity_change( 166 168 "agency.md", 167 169 old_content, ··· 200 202 old_content = ( 201 203 pulse_path.read_text(encoding="utf-8") if pulse_path.exists() else "" 202 204 ) 203 - pulse_path.write_text(content, encoding="utf-8") 205 + atomic_write(pulse_path, content) 204 206 _log_identity_change( 205 207 "pulse.md", old_content, content, section=None, source="cli" 206 208 ) ··· 212 214 typer.echo("pulse.md not found.", err=True) 213 215 raise typer.Exit(1) 214 216 typer.echo(pulse_path.read_text(encoding="utf-8")) 217 + 218 + 219 + @app.command("awareness") 220 + def awareness_cmd( 221 + write: bool = typer.Option( 222 + False, 223 + "--write", 224 + "-w", 225 + help="Overwrite awareness.md (content via --value or stdin).", 226 + ), 227 + value: str | None = typer.Option( 228 + None, "--value", help="Content to write (alternative to stdin)." 229 + ), 230 + ) -> None: 231 + """Read or write sol/awareness.md.""" 232 + sol_dir = _sol_dir() 233 + awareness_path = sol_dir / "awareness.md" 234 + 235 + if write: 236 + content = _resolve_content(value) 237 + old_content = ( 238 + awareness_path.read_text(encoding="utf-8") 239 + if awareness_path.exists() 240 + else "" 241 + ) 242 + atomic_write(awareness_path, content) 243 + _log_identity_change( 244 + "awareness.md", old_content, content, section=None, source="cli" 245 + ) 246 + typer.echo("awareness.md updated.") 247 + return 248 + 249 + # Read mode 250 + if not awareness_path.exists(): 251 + typer.echo("awareness.md not found.", err=True) 252 + raise typer.Exit(1) 253 + typer.echo(awareness_path.read_text(encoding="utf-8")) 215 254 216 255 217 256 @app.command("briefing")