personal memory agent
0
fork

Configure Feed

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

digest: add digest layer — talent + CLI + startup trigger

Add the digest layer end to end.

What ships:
- new `talent/digest.md` as a tier 3 cogitate talent with `schedule: "none"`
- polymorphic `sol call identity digest` CLI where default mode triggers, waits, and prints a short success line, while `--write --value` is the persistence sink
- `_maybe_submit_startup_digest()` in the supervisor, fired once per boot after `_task_queue.set_ready()`
- `digest.md` seeded with `"not yet generated\n"` via `ensure_identity_directory()`

This keeps D1 on the polymorphic verb path, mirroring the `awareness_tender` pattern so trigger and write sink share one verb and no separate hook file is needed.

For D2, startup uses `_task_queue.submit(["sol", "call", "identity", "digest"])` so the fire-and-forget path gets subprocess logs, env inheritance, and the same codepath manual callers use.

Startup submission is skipped under `--no-cortex` and `--remote`, and still runs under `--no-daily` and `--no-schedule`.

Not in scope here: chat talents, chat stream, UI, or auto-cadence work. Those stay deferred to later lodes.

The talent prompt now tells the talent to compute a 7-days-ago `YYYYMMDD` at runtime before calling entity search, since that CLI only accepts absolute dates and a baked-in value would go stale.

`make ci` is green.

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

+347 -7
+34
talent/digest.md
··· 1 + { 2 + "type": "cogitate", 3 + 4 + "title": "Digest", 5 + "description": "Synthesize a plain-English digest of who sol is and what's happening now.", 6 + "schedule": "none", 7 + "priority": 10, 8 + "tier": 3, 9 + "max_output_tokens": 1000 10 + } 11 + 12 + # Digest 13 + 14 + You maintain `identity/digest.md` — a plain-English synthesis of who you are, what is active now, and what matters over the next couple of days. 15 + 16 + This is not a conversation. Gather state, synthesize one compact digest, write it, done. 17 + 18 + First compute the date seven days ago in `YYYYMMDD` format. Then gather state with these commands: 19 + 20 + `sol call activities list` 21 + `sol call journal facets` 22 + `sol call entities search --since <the YYYYMMDD date from seven days ago> --limit 12` 23 + `sol call routines list` 24 + `sol call todos list` 25 + `sol call identity self` 26 + `sol call identity partner` 27 + 28 + Then write a 400-600 word digest in plain English. Use second person throughout. No bullets, no headings, no numbered lists, no markdown structure. Cover who you are, what is happening now, the active work and agenda, the next 48 hours, the key people in motion this week, open loops, and routine state. If one source is thin or unavailable, work with what you have instead of stalling. 29 + 30 + Finalize by writing the digest exactly once: 31 + 32 + ```bash 33 + sol call identity digest --write --value '<the synthesized digest text>' 34 + ```
+8
tests/baselines/api/settings/providers.json
··· 237 237 "tier": 2, 238 238 "type": "generate" 239 239 }, 240 + "talent.system.digest": { 241 + "disabled": false, 242 + "group": "Think", 243 + "label": "Digest", 244 + "schedule": "none", 245 + "tier": 3, 246 + "type": "cogitate" 247 + }, 240 248 "talent.system.documents": { 241 249 "disabled": false, 242 250 "group": "Think",
+11
tests/baselines/api/sol/talents-day.json
··· 104 104 "title": "Maintenance Window", 105 105 "type": "generate" 106 106 }, 107 + "digest": { 108 + "app": null, 109 + "color": "#6c757d", 110 + "description": "Synthesize a plain-English digest of who sol is and what's happening now.", 111 + "multi_facet": false, 112 + "output_format": null, 113 + "schedule": "none", 114 + "source": "system", 115 + "title": "Digest", 116 + "type": "cogitate" 117 + }, 107 118 "documents": { 108 119 "app": null, 109 120 "color": "#5c6bc0",
+1
tests/fixtures/journal/identity/digest.md
··· 1 + not yet generated
+11
tests/test_app_sol.py
··· 142 142 assert "color" in chat 143 143 144 144 145 + def test_digest_talent_discovery_and_schedule_exclusion(fixture_journal): 146 + agents = get_talent_configs(type="cogitate") 147 + 148 + assert "digest" in agents 149 + assert agents["digest"]["tier"] == 3 150 + assert agents["digest"]["schedule"] == "none" 151 + 152 + for schedule in ("daily", "segment", "activity", "weekly"): 153 + assert "digest" not in get_talent_configs(type="cogitate", schedule=schedule) 154 + 155 + 145 156 def test_get_talent_configs_excludes_private_apps( 146 157 fixture_journal, tmp_path, monkeypatch 147 158 ):
+4
tests/test_awareness.py
··· 645 645 awareness_content = (identity_dir / "awareness.md").read_text() 646 646 assert awareness_content.strip() == "not yet updated" 647 647 648 + assert (identity_dir / "digest.md").exists() 649 + digest_content = (identity_dir / "digest.md").read_text() 650 + assert digest_content.strip() == "not yet generated" 651 + 648 652 def test_idempotent_does_not_overwrite(self, tmp_path): 649 653 from think.identity import ensure_identity_directory 650 654
+1
tests/test_sol_call.py
··· 122 122 """ 123 123 (identity_dir / "partner.md").write_text(partner_md) 124 124 (identity_dir / "awareness.md").write_text("not yet updated\n") 125 + (identity_dir / "digest.md").write_text("not yet generated\n") 125 126 126 127 return tmp_path 127 128
+160
tests/test_sol_call_identity_digest.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Tests for ``sol call identity digest``.""" 5 + 6 + import json 7 + import re 8 + import time 9 + 10 + import pytest 11 + from typer.testing import CliRunner 12 + 13 + from think.identity import write_identity 14 + from think.tools.sol import app 15 + 16 + runner = CliRunner() 17 + _HISTORY_FIELDS = [ 18 + "ts", 19 + "file", 20 + "actor", 21 + "op", 22 + "section", 23 + "reason", 24 + "before_hash", 25 + "after_hash", 26 + "bytes_before", 27 + "bytes_after", 28 + ] 29 + 30 + 31 + def _read_history(journal_path): 32 + history = journal_path / "identity" / "history.jsonl" 33 + return [json.loads(line) for line in history.read_text().splitlines()] 34 + 35 + 36 + def _assert_history_record(record, *, file_name, actor, op, section, reason): 37 + assert list(record) == _HISTORY_FIELDS 38 + assert re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z", record["ts"]) 39 + assert record["file"] == file_name 40 + assert record["actor"] == actor 41 + assert record["op"] == op 42 + assert record["section"] == section 43 + assert record["reason"] == reason 44 + 45 + 46 + @pytest.fixture 47 + def digest_journal(tmp_path, monkeypatch): 48 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 49 + monkeypatch.setenv("SOL_SKIP_SUPERVISOR_CHECK", "1") 50 + 51 + config_dir = tmp_path / "config" 52 + config_dir.mkdir() 53 + (config_dir / "journal.json").write_text("{}", encoding="utf-8") 54 + return tmp_path 55 + 56 + 57 + def test_digest_write_mode_writes_via_identity(digest_journal): 58 + digest_path = digest_journal / "identity" / "digest.md" 59 + content = "digest body" 60 + 61 + result = runner.invoke(app, ["digest", "--write", "--value", content]) 62 + 63 + assert result.exit_code == 0 64 + assert digest_path.read_text(encoding="utf-8") == content 65 + assert ( 66 + f"wrote {digest_path} ({len(content.encode('utf-8'))} bytes)" in result.output 67 + ) 68 + 69 + record = _read_history(digest_journal)[-1] 70 + _assert_history_record( 71 + record, 72 + file_name="digest.md", 73 + actor="sol call identity digest --write", 74 + op="replace", 75 + section=None, 76 + reason="manual replace", 77 + ) 78 + 79 + 80 + def test_digest_write_mode_stdin(digest_journal): 81 + digest_path = digest_journal / "identity" / "digest.md" 82 + content = "digest from stdin" 83 + 84 + result = runner.invoke(app, ["digest", "--write"], input=content) 85 + 86 + assert result.exit_code == 0 87 + assert digest_path.read_text(encoding="utf-8") == content 88 + assert ( 89 + f"wrote {digest_path} ({len(content.encode('utf-8'))} bytes)" in result.output 90 + ) 91 + 92 + record = _read_history(digest_journal)[-1] 93 + _assert_history_record( 94 + record, 95 + file_name="digest.md", 96 + actor="sol call identity digest --write", 97 + op="replace", 98 + section=None, 99 + reason="manual replace", 100 + ) 101 + 102 + 103 + def test_digest_write_mode_allows_empty_value(digest_journal): 104 + digest_path = digest_journal / "identity" / "digest.md" 105 + 106 + result = runner.invoke(app, ["digest", "--write", "--value", ""]) 107 + 108 + assert result.exit_code == 0 109 + assert digest_path.read_text(encoding="utf-8") == "" 110 + assert f"wrote {digest_path} (0 bytes)" in result.output 111 + 112 + 113 + def test_digest_default_mode_success(digest_journal, monkeypatch): 114 + digest_path = digest_journal / "identity" / "digest.md" 115 + request_kwargs = {} 116 + 117 + def fake_cortex_request(**kwargs): 118 + request_kwargs.update(kwargs) 119 + return "digest-use-1" 120 + 121 + def fake_wait_for_uses(use_ids, timeout): 122 + assert use_ids == ["digest-use-1"] 123 + assert timeout == 600 124 + time.sleep(0.01) 125 + write_identity( 126 + "digest.md", 127 + actor="test digest writer", 128 + op="replace", 129 + section=None, 130 + content="fresh digest body", 131 + reason="test completion", 132 + ) 133 + return {"digest-use-1": "finish"}, [] 134 + 135 + monkeypatch.setattr("think.tools.sol.cortex_request", fake_cortex_request) 136 + monkeypatch.setattr("think.tools.sol.wait_for_uses", fake_wait_for_uses) 137 + 138 + result = runner.invoke(app, ["digest"]) 139 + 140 + assert result.exit_code == 0 141 + assert digest_path.read_text(encoding="utf-8") == "fresh digest body" 142 + assert request_kwargs == {"prompt": "", "name": "digest"} 143 + assert "regenerated " in result.output 144 + assert "digest.md" in result.output 145 + 146 + 147 + def test_digest_default_mode_failure_timeout(digest_journal, monkeypatch): 148 + monkeypatch.setattr( 149 + "think.tools.sol.cortex_request", 150 + lambda **kwargs: "digest-use-1", 151 + ) 152 + monkeypatch.setattr( 153 + "think.tools.sol.wait_for_uses", 154 + lambda use_ids, timeout: ({}, ["digest-use-1"]), 155 + ) 156 + 157 + result = runner.invoke(app, ["digest"]) 158 + 159 + assert result.exit_code == 1 160 + assert "Error: digest request timed out." in result.output
+29
tests/test_supervisor_startup.py
··· 195 195 side_effect=AssertionError("should not run"), 196 196 ): 197 197 assert utils.require_solstone() is None 198 + 199 + 200 + def test_startup_submits_digest_once(): 201 + mod = importlib.reload(importlib.import_module("think.supervisor")) 202 + submit = mock.Mock() 203 + 204 + mod._task_queue = SimpleNamespace(submit=submit) 205 + mod._is_remote_mode = False 206 + mod._digest_submitted_this_boot = False 207 + 208 + mod._maybe_submit_startup_digest(no_cortex=False) 209 + mod._maybe_submit_startup_digest(no_cortex=False) 210 + 211 + submit.assert_called_once_with(["sol", "call", "identity", "digest"]) 212 + assert mod._digest_submitted_this_boot is True 213 + 214 + 215 + def test_startup_skips_digest_when_no_cortex(): 216 + mod = importlib.reload(importlib.import_module("think.supervisor")) 217 + submit = mock.Mock() 218 + 219 + mod._task_queue = SimpleNamespace(submit=submit) 220 + mod._is_remote_mode = False 221 + mod._digest_submitted_this_boot = False 222 + 223 + mod._maybe_submit_startup_digest(no_cortex=True) 224 + 225 + submit.assert_not_called() 226 + assert mod._digest_submitted_this_boot is False
+2
think/identity.py
··· 105 105 """ 106 106 107 107 _AWARENESS_MD = "not yet updated\n" 108 + _DIGEST_MD = "not yet generated\n" 108 109 109 110 110 111 def _build_self_md(config: dict) -> str: ··· 470 471 "agency.md": _AGENCY_MD, 471 472 "partner.md": _PARTNER_MD, 472 473 "awareness.md": _AWARENESS_MD, 474 + "digest.md": _DIGEST_MD, 473 475 } 474 476 for file_name, content in defaults.items(): 475 477 target = identity_dir / file_name
+21
think/supervisor.py
··· 490 490 491 491 # Track whether running in remote mode (upload-only, no local processing) 492 492 _is_remote_mode: bool = False 493 + _digest_submitted_this_boot = False 493 494 494 495 # State for daily processing (tracks day boundary for midnight think trigger) 495 496 _daily_state = { ··· 678 679 queued=len(queue), 679 680 queue=queue, 680 681 ) 682 + 683 + 684 + def _maybe_submit_startup_digest(*, no_cortex: bool) -> None: 685 + """Submit the startup digest once when a local cortex substrate exists.""" 686 + global _digest_submitted_this_boot 687 + 688 + if ( 689 + _digest_submitted_this_boot 690 + or no_cortex 691 + or _is_remote_mode 692 + or _task_queue is None 693 + ): 694 + return 695 + 696 + _task_queue.submit(["sol", "call", "identity", "digest"]) 697 + _digest_submitted_this_boot = True 698 + logging.info("startup: submitted identity digest") 681 699 682 700 683 701 def _handle_task_request(message: dict) -> None: ··· 1504 1522 logging.info("Supervisor starting...") 1505 1523 1506 1524 global _managed_procs, _supervisor_callosum, _is_remote_mode 1525 + global _digest_submitted_this_boot 1507 1526 global _task_queue 1508 1527 procs: list[ManagedProcess] = [] 1509 1528 convey_port = None 1510 1529 1511 1530 # Remote mode: run sync instead of local processing 1512 1531 _is_remote_mode = bool(args.remote) 1532 + _digest_submitted_this_boot = False 1513 1533 1514 1534 # Run pending journal-maintenance tasks before spawning any writer children. 1515 1535 # Callosum isn't up yet (emit_fn=None); migrations log through supervisor's logger only. ··· 1598 1618 1599 1619 if _task_queue: 1600 1620 _task_queue.set_ready() 1621 + _maybe_submit_startup_digest(no_cortex=args.no_cortex) 1601 1622 1602 1623 # Show Convey URL if running 1603 1624 if convey_port:
+65 -7
think/tools/sol.py
··· 5 5 6 6 Provides read and write access to ``{journal}/identity/self.md``, 7 7 ``{journal}/identity/partner.md``, ``{journal}/identity/agency.md``, and 8 - ``{journal}/identity/pulse.md``, and ``{journal}/identity/awareness.md`` — sol's 9 - identity and initiative files. Also provides read access to the morning 10 - briefing at 8 + ``{journal}/identity/pulse.md``, ``{journal}/identity/awareness.md``, and 9 + ``{journal}/identity/digest.md`` — sol's identity and initiative files. Also 10 + provides read access to the morning briefing at 11 11 ``{journal}/YYYYMMDD/talents/morning_briefing.md``. 12 12 13 13 Mounted by ``think.call`` as ``sol call identity ...``. ··· 19 19 20 20 import typer 21 21 22 + from think.cortex_client import cortex_request, wait_for_uses 22 23 from think.identity import ( 23 24 ensure_identity_directory, 24 25 update_identity_section, ··· 28 29 from think.utils import day_dirs, day_path, get_journal, require_solstone 29 30 30 31 app = typer.Typer( 31 - help="Journal identity directory — self.md, partner.md, agency.md, pulse.md, awareness.md, and morning briefing.", 32 + help="Journal identity directory — self.md, partner.md, agency.md, pulse.md, awareness.md, digest.md, and morning briefing.", 32 33 invoke_without_command=True, 33 34 no_args_is_help=False, 34 35 ) ··· 99 100 return f"sol call identity {command} {flag}" 100 101 101 102 102 - def _resolve_content(value: str | None) -> str: 103 - """Return *value* if provided, else read stdin. Exit 1 if empty.""" 103 + def _resolve_content(value: str | None, *, allow_empty: bool = False) -> str: 104 + """Return *value* if provided, else read stdin.""" 104 105 if value is not None: 105 106 content = value 106 107 else: 107 108 content = sys.stdin.read() 108 - if not content.strip(): 109 + if not allow_empty and not content.strip(): 109 110 typer.echo("Error: no content provided.", err=True) 110 111 raise typer.Exit(1) 111 112 return content ··· 325 326 typer.echo("awareness.md not found.", err=True) 326 327 raise typer.Exit(1) 327 328 typer.echo(awareness_path.read_text(encoding="utf-8")) 329 + 330 + 331 + @app.command("digest") 332 + def digest_cmd( 333 + write: bool = typer.Option( 334 + False, 335 + "--write", 336 + "-w", 337 + help="Persist digest text (used by the digest talent).", 338 + ), 339 + value: str | None = typer.Option( 340 + None, "--value", help="Digest text to persist (alternative to stdin)." 341 + ), 342 + ) -> None: 343 + """Regenerate the identity digest (synchronous).""" 344 + identity_dir = _identity_dir() 345 + digest_path = identity_dir / "digest.md" 346 + 347 + if write: 348 + content = _resolve_content(value, allow_empty=True) 349 + write_identity( 350 + "digest.md", 351 + actor=_actor_for_cmd("digest", "--write"), 352 + op="replace", 353 + section=None, 354 + content=content, 355 + reason="manual replace", 356 + ) 357 + typer.echo(f"wrote {digest_path} ({digest_path.stat().st_size} bytes)") 358 + return 359 + 360 + before_mtime_ns = digest_path.stat().st_mtime_ns if digest_path.exists() else None 361 + use_id = cortex_request(prompt="", name="digest") 362 + if use_id is None: 363 + typer.echo("Error: failed to send digest request to cortex.", err=True) 364 + raise typer.Exit(1) 365 + 366 + completed, timed_out = wait_for_uses([use_id], timeout=600) 367 + if use_id in timed_out: 368 + typer.echo("Error: digest request timed out.", err=True) 369 + raise typer.Exit(1) 370 + 371 + end_state = completed.get(use_id, "unknown") 372 + if end_state != "finish": 373 + typer.echo(f"Error: digest request failed: {end_state}.", err=True) 374 + raise typer.Exit(1) 375 + 376 + if not digest_path.exists(): 377 + typer.echo("Error: digest.md was not written.", err=True) 378 + raise typer.Exit(1) 379 + 380 + after_mtime_ns = digest_path.stat().st_mtime_ns 381 + if before_mtime_ns is not None and after_mtime_ns <= before_mtime_ns: 382 + typer.echo("Error: digest.md was not updated.", err=True) 383 + raise typer.Exit(1) 384 + 385 + typer.echo(f"regenerated {digest_path} ({digest_path.stat().st_size} bytes)") 328 386 329 387 330 388 @app.command("briefing")