personal memory agent
0
fork

Configure Feed

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

add pulse cogitate agent with segment-scheduled narrative generation

New muse/pulse.md agent runs each segment at priority 99, gathering context
via sol call tools and writing a living narrative to sol/pulse.md. Landing
page reads pulse.md as primary narrative with flow.md fallback. Includes
skip-on-idle to avoid redundant runs, cortex name injection for WebSocket
events, and sol call sol pulse read/write CLI.

+280 -20
+70 -3
apps/home/routes.py
··· 10 10 from pathlib import Path 11 11 from typing import Any 12 12 13 + import frontmatter 13 14 from flask import Blueprint, jsonify, render_template 14 15 16 + from convey.apps import _resolve_attention 17 + from convey.bridge import get_cached_state 15 18 from think.awareness import get_current 16 19 from think.facets import get_facets 17 20 from think.indexer.journal import get_journal_index 18 21 from think.utils import get_journal 19 - 20 - from convey.apps import _resolve_attention 21 - from convey.bridge import get_cached_state 22 22 23 23 home_bp = Blueprint( 24 24 "app:home", ··· 41 41 except Exception: 42 42 pass 43 43 return None, None 44 + 45 + 46 + def _load_pulse_md() -> tuple[str | None, dict | None, list[str]]: 47 + """Load sol/pulse.md if current for today. 48 + 49 + Returns (content, metadata, needs_you) or (None, None, []). 50 + """ 51 + try: 52 + journal = Path(get_journal()) 53 + pulse_path = journal / "sol" / "pulse.md" 54 + if not pulse_path.exists(): 55 + return None, None, [] 56 + post = frontmatter.load(str(pulse_path)) 57 + updated = post.metadata.get("updated") 58 + if not updated: 59 + return None, None, [] 60 + # Parse ISO datetime and check if from today 61 + if isinstance(updated, str): 62 + updated_dt = datetime.fromisoformat(updated) 63 + else: 64 + updated_dt = updated # frontmatter may parse datetime objects 65 + if updated_dt.date() != datetime.now().date(): 66 + return None, None, [] 67 + # Extract ## needs you section 68 + needs = [] 69 + in_needs = False 70 + for line in post.content.splitlines(): 71 + if line.strip().lower() == "## needs you": 72 + in_needs = True 73 + continue 74 + if in_needs: 75 + if line.startswith("## "): 76 + break 77 + stripped = line.strip() 78 + if stripped.startswith("- "): 79 + needs.append(stripped[2:].strip()) 80 + return post.content, post.metadata, needs 81 + except Exception: 82 + return None, None, [] 44 83 45 84 46 85 def _load_stats(today: str) -> dict[str, Any]: ··· 187 226 if flow_mtime: 188 227 flow_updated_at = datetime.fromtimestamp(flow_mtime).strftime("%H:%M") 189 228 229 + # Try pulse.md as primary narrative, fall back to flow.md 230 + pulse_content, pulse_meta, pulse_needs = _load_pulse_md() 231 + if pulse_content: 232 + narrative_content = pulse_content 233 + narrative_source = "pulse" 234 + narrative_header = "Pulse" 235 + updated = pulse_meta.get("updated", "") 236 + if isinstance(updated, str): 237 + try: 238 + narrative_updated_at = datetime.fromisoformat(updated).strftime("%H:%M") 239 + except ValueError: 240 + narrative_updated_at = flow_updated_at 241 + elif hasattr(updated, "strftime"): 242 + narrative_updated_at = updated.strftime("%H:%M") 243 + else: 244 + narrative_updated_at = flow_updated_at 245 + else: 246 + narrative_content = flow_content 247 + narrative_source = "flow" 248 + narrative_header = "Today's Flow" 249 + narrative_updated_at = flow_updated_at 250 + pulse_needs = [] 251 + 190 252 events = _collect_events(today) 191 253 activities = _collect_activities(today) 192 254 todos = _collect_todos(today) ··· 216 278 "segment_count": segment_count, 217 279 "duration_minutes": duration_minutes, 218 280 "facet_data": facet_data, 281 + "narrative_content": narrative_content, 282 + "narrative_updated_at": narrative_updated_at, 283 + "narrative_source": narrative_source, 284 + "narrative_header": narrative_header, 285 + "pulse_needs": pulse_needs, 219 286 "flow_content": flow_content, 220 287 "flow_updated_at": flow_updated_at, 221 288 "events": events,
+21 -14
apps/home/workspace.html
··· 313 313 </div> 314 314 315 315 <!-- Narrative --> 316 - {% if flow_content is not none %} 316 + {% if narrative_content is not none %} 317 317 <div class="pulse-narrative" id="pulse-narrative"> 318 - <div class="pulse-section-header">Today's Flow</div> 318 + <div class="pulse-section-header">{{ narrative_header }}</div> 319 319 <div class="pulse-narrative-content" id="pulse-narrative-content"></div> 320 - {% if flow_updated_at %} 321 - <div class="pulse-narrative-meta">Updated at {{ flow_updated_at }}</div> 320 + {% if narrative_updated_at %} 321 + <div class="pulse-narrative-meta">Updated at {{ narrative_updated_at }}</div> 322 322 {% endif %} 323 323 </div> 324 324 {% elif segment_count > 0 %} 325 325 <div class="pulse-narrative" id="pulse-narrative"> 326 - <div class="pulse-section-header">Today's Flow</div> 326 + <div class="pulse-section-header">{{ narrative_header or "Today's Flow" }}</div> 327 327 <div class="pulse-narrative-empty">Analysis will be available after the next processing cycle.</div> 328 328 </div> 329 329 {% endif %} ··· 365 365 {% endif %} 366 366 367 367 <!-- Needs You --> 368 - {% if attention or todos %} 368 + {% if attention or pulse_needs or todos %} 369 369 <div class="pulse-needs" id="pulse-needs"> 370 370 <div class="pulse-section-header">Needs You</div> 371 371 <div class="pulse-needs-list"> 372 372 {% if attention %} 373 373 <div class="pulse-needs-item pulse-needs-attention">{{ attention.placeholder_text }}</div> 374 374 {% endif %} 375 + {% for item in pulse_needs %} 376 + <div class="pulse-needs-item">{{ item }}</div> 377 + {% endfor %} 375 378 {% for todo in todos[:7] %} 376 379 <div class="pulse-needs-item">{{ todo.get('text', '') }}{% if todo.get('facet') %} <span style="color:#94a3b8;font-size:0.75rem">({{ todo['facet'] }})</span>{% endif %}</div> 377 380 {% endfor %} ··· 403 406 <script src="{{ vendor_lib('marked') }}"></script> 404 407 <script> 405 408 (function() { 406 - // Render flow.md content with marked 407 - const flowRaw = {{ (flow_content or '')|tojson|safe }}; 408 - if (flowRaw) { 409 + // Render narrative content with marked 410 + const narrativeRaw = {{ (narrative_content or '')|tojson|safe }}; 411 + if (narrativeRaw) { 409 412 const el = document.getElementById('pulse-narrative-content'); 410 - if (el) el.innerHTML = marked.parse(flowRaw, {breaks: true, gfm: true}); 413 + if (el) el.innerHTML = marked.parse(narrativeRaw, {breaks: true, gfm: true}); 411 414 } 412 415 413 416 // WebSocket live updates ··· 420 423 if (msg.event === 'observed' || msg.event === 'status') refreshVitals(); 421 424 }); 422 425 423 - // Narrative: cortex.finish where name=flow 426 + // Narrative: cortex.finish where name=flow or pulse 424 427 window.appEvents.listen('cortex', function(msg) { 425 - if (msg.event === 'finish' && msg.name === 'flow') refreshNarrative(); 428 + if (msg.event === 'finish' && (msg.name === 'flow' || msg.name === 'pulse')) refreshNarrative(); 426 429 if (msg.event === 'error') refreshVitals(); 427 430 }); 428 431 } ··· 457 460 .then(r => r.json()) 458 461 .then(data => { 459 462 const el = document.getElementById('pulse-narrative-content'); 460 - if (el && data.flow_content) { 461 - el.innerHTML = marked.parse(data.flow_content, {breaks: true, gfm: true}); 463 + if (el && data.narrative_content) { 464 + el.innerHTML = marked.parse(data.narrative_content, {breaks: true, gfm: true}); 465 + const hdr = document.querySelector('.pulse-narrative .pulse-section-header'); 466 + if (hdr && data.narrative_header) hdr.textContent = data.narrative_header; 467 + const meta = document.querySelector('.pulse-narrative .pulse-narrative-meta'); 468 + if (meta && data.narrative_updated_at) meta.textContent = 'Updated at ' + data.narrative_updated_at; 462 469 } 463 470 }) 464 471 .catch(function() {});
+90
muse/pulse.md
··· 1 + { 2 + "type": "cogitate", 3 + 4 + "title": "Pulse", 5 + "description": "Living narrative of the owner's day — updated each segment", 6 + "schedule": "segment", 7 + "priority": 99, 8 + "tier": 3, 9 + "max_output_tokens": 1000, 10 + "instructions": {"system": "journal", "facets": true, "now": true} 11 + 12 + } 13 + 14 + # Pulse 15 + 16 + You are generating the owner's Pulse — a living narrative that captures the shape 17 + of their day so far. This runs every segment, building on the previous pulse. 18 + 19 + This is not a conversation. Gather context, write the pulse, done. 20 + 21 + ## Gather context 22 + 23 + Read current state using these tools: 24 + 25 + 1. `sol call sol pulse` — previous pulse (may not exist yet; that's fine) 26 + 2. `sol call sol self` — who the owner is 27 + 3. `sol call calendar list` — today's events 28 + 4. `sol call todos list` — pending action items 29 + 5. `sol call entities search --recent` — recent entity activity 30 + 6. `sol call awareness status` — system health (brief check) 31 + 32 + ## Write the pulse 33 + 34 + Compose a short, natural narrative (3-8 sentences) describing the shape of the 35 + owner's day so far. Lead with what matters most right now. Mention upcoming events, 36 + active work, and anything that shifted since the last pulse. 37 + 38 + After the narrative, include a `## needs you` section — a ranked list of 3-7 39 + action items the owner should notice. Format as markdown bullet points: 40 + 41 + ```` 42 + ## needs you 43 + - Most urgent item 44 + - Second priority 45 + - Third item 46 + ```` 47 + 48 + Draw needs-you items from: pending todos, upcoming calendar events needing prep, 49 + entity follow-ups, and anything the narrative highlights as important. 50 + 51 + ## Write output 52 + 53 + Write the complete pulse (YAML frontmatter + narrative + needs-you section) via: 54 + 55 + ```bash 56 + cat <<'EOF' | sol call sol pulse --write 57 + --- 58 + updated: 2026-03-22T14:35:00 59 + segment: 143022_300 60 + source: pulse-cogitate 61 + --- 62 + 63 + [Your narrative here] 64 + 65 + ## needs you 66 + - Item 1 67 + - Item 2 68 + EOF 69 + ``` 70 + 71 + The `updated` field must be an ISO 8601 datetime (no timezone). The `segment` 72 + field is the current segment key from $SOL_SEGMENT. 73 + 74 + Then append a log entry to `sol/pulse-log.jsonl` (same directory as pulse.md): 75 + 76 + ```bash 77 + JOURNAL=$(sol config env | head -1) 78 + echo '{"ts": 1742680500, "segment": "143022_300", "narrative": "...", "needs_you": ["Item 1", "Item 2"]}' >> "$JOURNAL/sol/pulse-log.jsonl" 79 + ``` 80 + 81 + Use the current epoch timestamp for `ts`. Keep the narrative value brief (first 82 + sentence or two). The needs_you array should match the items from the ## needs you 83 + section. 84 + 85 + ## Guidelines 86 + 87 + - Be concise. The owner sees this on their landing page. 88 + - Don't repeat the same narrative if nothing changed — note stability. 89 + - Don't include greetings or meta-commentary about being an AI. 90 + - If the day is just starting and there's little data, say so briefly.
+45
tests/test_sol_call.py
··· 179 179 assert "no content" in result.output 180 180 181 181 182 + class TestSolPulseRead: 183 + def test_read_pulse(self, journal_with_sol): 184 + pulse_md = "---\nupdated: 2026-03-22T14:00:00\nsource: pulse-cogitate\n---\n\nTest narrative.\n" 185 + (journal_with_sol / "sol" / "pulse.md").write_text(pulse_md) 186 + result = runner.invoke(app, ["pulse"]) 187 + assert result.exit_code == 0 188 + assert "Test narrative" in result.output 189 + 190 + def test_read_pulse_missing(self, tmp_path, monkeypatch): 191 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 192 + config_dir = tmp_path / "config" 193 + config_dir.mkdir() 194 + (config_dir / "journal.json").write_text(json.dumps({})) 195 + result = runner.invoke(app, ["pulse"]) 196 + assert result.exit_code == 1 197 + assert "not found" in result.output 198 + 199 + 200 + class TestSolPulseWrite: 201 + def test_write_pulse(self, journal_with_sol): 202 + new_content = "---\nupdated: 2026-03-22T14:00:00\nsource: pulse-cogitate\n---\n\nNew narrative.\n" 203 + result = runner.invoke(app, ["pulse", "--write"], input=new_content) 204 + assert result.exit_code == 0 205 + assert "pulse.md updated" in result.output 206 + 207 + # Verify file was written 208 + pulse_path = journal_with_sol / "sol" / "pulse.md" 209 + assert pulse_path.read_text() == new_content 210 + 211 + def test_write_pulse_empty_stdin(self, journal_with_sol): 212 + result = runner.invoke(app, ["pulse", "--write"], input="") 213 + assert result.exit_code == 1 214 + assert "no content" in result.output 215 + 216 + 182 217 class TestSolWriteDoesNotEscapeSolDir: 183 218 """Verify that sol call sol only writes to sol/ directory files.""" 184 219 ··· 201 236 # No files created outside sol/ 202 237 journal_files = set(f.name for f in journal_with_sol.iterdir() if f.is_file()) 203 238 assert "agency.md" not in journal_files 239 + 240 + def test_pulse_write_stays_in_sol_dir(self, journal_with_sol): 241 + """Write to pulse.md goes to sol/pulse.md, not anywhere else.""" 242 + result = runner.invoke(app, ["pulse", "--write"], input="test content\n") 243 + assert result.exit_code == 0 244 + pulse_path = journal_with_sol / "sol" / "pulse.md" 245 + assert pulse_path.read_text() == "test content\n" 246 + # No files created outside sol/ 247 + journal_files = set(f.name for f in journal_with_sol.iterdir() if f.is_file()) 248 + assert "pulse.md" not in journal_files 204 249 205 250 206 251 class TestHeartbeatEnsureSolDirectory:
+6
think/cortex.py
··· 381 381 if "agent_id" not in event: 382 382 event["agent_id"] = agent.agent_id 383 383 384 + # Inject agent name for WebSocket consumers 385 + with self.lock: 386 + _req = self.agent_requests.get(agent.agent_id) 387 + if _req and "name" not in event: 388 + event["name"] = _req.get("name", "") 389 + 384 390 # Append to JSONL file 385 391 with open(agent.log_path, "a") as f: 386 392 f.write(json.dumps(event) + "\n")
+18
think/dream.py
··· 36 36 get_rev, 37 37 iso_date, 38 38 iter_segments, 39 + segment_parse, 39 40 setup_cli, 40 41 updated_days, 41 42 ) ··· 423 424 f"Skipping {prompt_name}: stream '{stream}' matches exclude_streams" 424 425 ) 425 426 continue 427 + 428 + # Skip pulse when sol/pulse.md is already current for this segment 429 + if prompt_name == "pulse" and segment and not refresh: 430 + try: 431 + pulse_path = Path(get_journal()) / "sol" / "pulse.md" 432 + if pulse_path.exists(): 433 + start_time, _ = segment_parse(segment) 434 + if start_time: 435 + day_date = datetime.strptime(day, "%Y%m%d").date() 436 + seg_dt = datetime.combine(day_date, start_time) 437 + if pulse_path.stat().st_mtime >= seg_dt.timestamp(): 438 + logging.info( 439 + f"Skipping pulse: sol/pulse.md current for {segment}" 440 + ) 441 + continue 442 + except Exception: 443 + pass 426 444 427 445 try: 428 446 if config.get("multi_facet"):
+30 -3
think/tools/sol.py
··· 3 3 4 4 """CLI commands for sol/ identity directory. 5 5 6 - Provides read and write access to ``{journal}/sol/self.md`` and 7 - ``{journal}/sol/agency.md`` — sol's identity and initiative files. 6 + Provides read and write access to ``{journal}/sol/self.md``, 7 + ``{journal}/sol/agency.md``, and ``{journal}/sol/pulse.md`` — sol's 8 + identity and initiative files. 8 9 9 10 Mounted by ``think.call`` as ``sol call sol ...``. 10 11 """ ··· 15 16 16 17 from think.awareness import ensure_sol_directory, update_self_md_section 17 18 18 - app = typer.Typer(help="Sol identity directory — self.md and agency.md.") 19 + app = typer.Typer(help="Sol identity directory — self.md, agency.md, and pulse.md.") 19 20 20 21 21 22 def _sol_dir(): ··· 90 91 typer.echo("agency.md not found.", err=True) 91 92 raise typer.Exit(1) 92 93 typer.echo(agency_path.read_text(encoding="utf-8")) 94 + 95 + 96 + @app.command("pulse") 97 + def pulse_cmd( 98 + write: bool = typer.Option( 99 + False, "--write", "-w", help="Write pulse.md from stdin." 100 + ), 101 + ) -> None: 102 + """Read or write sol/pulse.md.""" 103 + sol_dir = _sol_dir() 104 + pulse_path = sol_dir / "pulse.md" 105 + 106 + if write: 107 + content = sys.stdin.read() 108 + if not content.strip(): 109 + typer.echo("Error: no content provided on stdin.", err=True) 110 + raise typer.Exit(1) 111 + pulse_path.write_text(content, encoding="utf-8") 112 + typer.echo("pulse.md updated.") 113 + return 114 + 115 + # Read mode 116 + if not pulse_path.exists(): 117 + typer.echo("pulse.md not found.", err=True) 118 + raise typer.Exit(1) 119 + typer.echo(pulse_path.read_text(encoding="utf-8"))