diff --git a/talent/digest.md b/talent/digest.md new file mode 100644 index 000000000..29d2e7a1c --- /dev/null +++ b/talent/digest.md @@ -0,0 +1,34 @@ +{ + "type": "cogitate", + + "title": "Digest", + "description": "Synthesize a plain-English digest of who sol is and what's happening now.", + "schedule": "none", + "priority": 10, + "tier": 3, + "max_output_tokens": 1000 +} + +# Digest + +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. + +This is not a conversation. Gather state, synthesize one compact digest, write it, done. + +First compute the date seven days ago in `YYYYMMDD` format. Then gather state with these commands: + +`sol call activities list` +`sol call journal facets` +`sol call entities search --since --limit 12` +`sol call routines list` +`sol call todos list` +`sol call identity self` +`sol call identity partner` + +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. + +Finalize by writing the digest exactly once: + +```bash +sol call identity digest --write --value '' +``` diff --git a/tests/baselines/api/settings/providers.json b/tests/baselines/api/settings/providers.json index eccc02bb8..389a1b3d3 100644 --- a/tests/baselines/api/settings/providers.json +++ b/tests/baselines/api/settings/providers.json @@ -237,6 +237,14 @@ "tier": 2, "type": "generate" }, + "talent.system.digest": { + "disabled": false, + "group": "Think", + "label": "Digest", + "schedule": "none", + "tier": 3, + "type": "cogitate" + }, "talent.system.documents": { "disabled": false, "group": "Think", diff --git a/tests/baselines/api/sol/talents-day.json b/tests/baselines/api/sol/talents-day.json index e2df7f2af..9eccf30ee 100644 --- a/tests/baselines/api/sol/talents-day.json +++ b/tests/baselines/api/sol/talents-day.json @@ -104,6 +104,17 @@ "title": "Maintenance Window", "type": "generate" }, + "digest": { + "app": null, + "color": "#6c757d", + "description": "Synthesize a plain-English digest of who sol is and what's happening now.", + "multi_facet": false, + "output_format": null, + "schedule": "none", + "source": "system", + "title": "Digest", + "type": "cogitate" + }, "documents": { "app": null, "color": "#5c6bc0", diff --git a/tests/fixtures/journal/identity/digest.md b/tests/fixtures/journal/identity/digest.md new file mode 100644 index 000000000..6c6497d4e --- /dev/null +++ b/tests/fixtures/journal/identity/digest.md @@ -0,0 +1 @@ +not yet generated diff --git a/tests/test_app_sol.py b/tests/test_app_sol.py index aaff47c78..1e184b2d3 100644 --- a/tests/test_app_sol.py +++ b/tests/test_app_sol.py @@ -142,6 +142,17 @@ def test_get_talent_configs_system_agents_have_metadata(fixture_journal): assert "color" in chat +def test_digest_talent_discovery_and_schedule_exclusion(fixture_journal): + agents = get_talent_configs(type="cogitate") + + assert "digest" in agents + assert agents["digest"]["tier"] == 3 + assert agents["digest"]["schedule"] == "none" + + for schedule in ("daily", "segment", "activity", "weekly"): + assert "digest" not in get_talent_configs(type="cogitate", schedule=schedule) + + def test_get_talent_configs_excludes_private_apps( fixture_journal, tmp_path, monkeypatch ): diff --git a/tests/test_awareness.py b/tests/test_awareness.py index 2a7376bce..715e65087 100644 --- a/tests/test_awareness.py +++ b/tests/test_awareness.py @@ -645,6 +645,10 @@ class TestEnsureIdentityDirectory: awareness_content = (identity_dir / "awareness.md").read_text() assert awareness_content.strip() == "not yet updated" + assert (identity_dir / "digest.md").exists() + digest_content = (identity_dir / "digest.md").read_text() + assert digest_content.strip() == "not yet generated" + def test_idempotent_does_not_overwrite(self, tmp_path): from think.identity import ensure_identity_directory diff --git a/tests/test_sol_call.py b/tests/test_sol_call.py index b9f961d13..9591516fd 100644 --- a/tests/test_sol_call.py +++ b/tests/test_sol_call.py @@ -122,6 +122,7 @@ adapt its responses, timing, and initiative to how this person actually works. """ (identity_dir / "partner.md").write_text(partner_md) (identity_dir / "awareness.md").write_text("not yet updated\n") + (identity_dir / "digest.md").write_text("not yet generated\n") return tmp_path diff --git a/tests/test_sol_call_identity_digest.py b/tests/test_sol_call_identity_digest.py new file mode 100644 index 000000000..1036fec93 --- /dev/null +++ b/tests/test_sol_call_identity_digest.py @@ -0,0 +1,160 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Tests for ``sol call identity digest``.""" + +import json +import re +import time + +import pytest +from typer.testing import CliRunner + +from think.identity import write_identity +from think.tools.sol import app + +runner = CliRunner() +_HISTORY_FIELDS = [ + "ts", + "file", + "actor", + "op", + "section", + "reason", + "before_hash", + "after_hash", + "bytes_before", + "bytes_after", +] + + +def _read_history(journal_path): + history = journal_path / "identity" / "history.jsonl" + return [json.loads(line) for line in history.read_text().splitlines()] + + +def _assert_history_record(record, *, file_name, actor, op, section, reason): + assert list(record) == _HISTORY_FIELDS + assert re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z", record["ts"]) + assert record["file"] == file_name + assert record["actor"] == actor + assert record["op"] == op + assert record["section"] == section + assert record["reason"] == reason + + +@pytest.fixture +def digest_journal(tmp_path, monkeypatch): + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) + monkeypatch.setenv("SOL_SKIP_SUPERVISOR_CHECK", "1") + + config_dir = tmp_path / "config" + config_dir.mkdir() + (config_dir / "journal.json").write_text("{}", encoding="utf-8") + return tmp_path + + +def test_digest_write_mode_writes_via_identity(digest_journal): + digest_path = digest_journal / "identity" / "digest.md" + content = "digest body" + + result = runner.invoke(app, ["digest", "--write", "--value", content]) + + assert result.exit_code == 0 + assert digest_path.read_text(encoding="utf-8") == content + assert ( + f"wrote {digest_path} ({len(content.encode('utf-8'))} bytes)" in result.output + ) + + record = _read_history(digest_journal)[-1] + _assert_history_record( + record, + file_name="digest.md", + actor="sol call identity digest --write", + op="replace", + section=None, + reason="manual replace", + ) + + +def test_digest_write_mode_stdin(digest_journal): + digest_path = digest_journal / "identity" / "digest.md" + content = "digest from stdin" + + result = runner.invoke(app, ["digest", "--write"], input=content) + + assert result.exit_code == 0 + assert digest_path.read_text(encoding="utf-8") == content + assert ( + f"wrote {digest_path} ({len(content.encode('utf-8'))} bytes)" in result.output + ) + + record = _read_history(digest_journal)[-1] + _assert_history_record( + record, + file_name="digest.md", + actor="sol call identity digest --write", + op="replace", + section=None, + reason="manual replace", + ) + + +def test_digest_write_mode_allows_empty_value(digest_journal): + digest_path = digest_journal / "identity" / "digest.md" + + result = runner.invoke(app, ["digest", "--write", "--value", ""]) + + assert result.exit_code == 0 + assert digest_path.read_text(encoding="utf-8") == "" + assert f"wrote {digest_path} (0 bytes)" in result.output + + +def test_digest_default_mode_success(digest_journal, monkeypatch): + digest_path = digest_journal / "identity" / "digest.md" + request_kwargs = {} + + def fake_cortex_request(**kwargs): + request_kwargs.update(kwargs) + return "digest-use-1" + + def fake_wait_for_uses(use_ids, timeout): + assert use_ids == ["digest-use-1"] + assert timeout == 600 + time.sleep(0.01) + write_identity( + "digest.md", + actor="test digest writer", + op="replace", + section=None, + content="fresh digest body", + reason="test completion", + ) + return {"digest-use-1": "finish"}, [] + + monkeypatch.setattr("think.tools.sol.cortex_request", fake_cortex_request) + monkeypatch.setattr("think.tools.sol.wait_for_uses", fake_wait_for_uses) + + result = runner.invoke(app, ["digest"]) + + assert result.exit_code == 0 + assert digest_path.read_text(encoding="utf-8") == "fresh digest body" + assert request_kwargs == {"prompt": "", "name": "digest"} + assert "regenerated " in result.output + assert "digest.md" in result.output + + +def test_digest_default_mode_failure_timeout(digest_journal, monkeypatch): + monkeypatch.setattr( + "think.tools.sol.cortex_request", + lambda **kwargs: "digest-use-1", + ) + monkeypatch.setattr( + "think.tools.sol.wait_for_uses", + lambda use_ids, timeout: ({}, ["digest-use-1"]), + ) + + result = runner.invoke(app, ["digest"]) + + assert result.exit_code == 1 + assert "Error: digest request timed out." in result.output diff --git a/tests/test_supervisor_startup.py b/tests/test_supervisor_startup.py index d97c05394..09aa0d3c1 100644 --- a/tests/test_supervisor_startup.py +++ b/tests/test_supervisor_startup.py @@ -195,3 +195,32 @@ def test_require_solstone_skip_env_still_honored(monkeypatch): side_effect=AssertionError("should not run"), ): assert utils.require_solstone() is None + + +def test_startup_submits_digest_once(): + mod = importlib.reload(importlib.import_module("think.supervisor")) + submit = mock.Mock() + + mod._task_queue = SimpleNamespace(submit=submit) + mod._is_remote_mode = False + mod._digest_submitted_this_boot = False + + mod._maybe_submit_startup_digest(no_cortex=False) + mod._maybe_submit_startup_digest(no_cortex=False) + + submit.assert_called_once_with(["sol", "call", "identity", "digest"]) + assert mod._digest_submitted_this_boot is True + + +def test_startup_skips_digest_when_no_cortex(): + mod = importlib.reload(importlib.import_module("think.supervisor")) + submit = mock.Mock() + + mod._task_queue = SimpleNamespace(submit=submit) + mod._is_remote_mode = False + mod._digest_submitted_this_boot = False + + mod._maybe_submit_startup_digest(no_cortex=True) + + submit.assert_not_called() + assert mod._digest_submitted_this_boot is False diff --git a/think/identity.py b/think/identity.py index d965da7b8..fb78c3cec 100644 --- a/think/identity.py +++ b/think/identity.py @@ -105,6 +105,7 @@ responses. Nothing gets sent without their review. """ _AWARENESS_MD = "not yet updated\n" +_DIGEST_MD = "not yet generated\n" def _build_self_md(config: dict) -> str: @@ -470,6 +471,7 @@ def ensure_identity_directory() -> Path: "agency.md": _AGENCY_MD, "partner.md": _PARTNER_MD, "awareness.md": _AWARENESS_MD, + "digest.md": _DIGEST_MD, } for file_name, content in defaults.items(): target = identity_dir / file_name diff --git a/think/supervisor.py b/think/supervisor.py index 5ff91ae3f..225ad52b3 100644 --- a/think/supervisor.py +++ b/think/supervisor.py @@ -490,6 +490,7 @@ _restart_requests: dict[str, tuple[float, subprocess.Popen]] = {} # Track whether running in remote mode (upload-only, no local processing) _is_remote_mode: bool = False +_digest_submitted_this_boot = False # State for daily processing (tracks day boundary for midnight think trigger) _daily_state = { @@ -680,6 +681,23 @@ def _emit_queue_event(cmd_name: str, running_ref: str, queue: list) -> None: ) +def _maybe_submit_startup_digest(*, no_cortex: bool) -> None: + """Submit the startup digest once when a local cortex substrate exists.""" + global _digest_submitted_this_boot + + if ( + _digest_submitted_this_boot + or no_cortex + or _is_remote_mode + or _task_queue is None + ): + return + + _task_queue.submit(["sol", "call", "identity", "digest"]) + _digest_submitted_this_boot = True + logging.info("startup: submitted identity digest") + + def _handle_task_request(message: dict) -> None: """Handle incoming task request from Callosum.""" if message.get("tract") != "supervisor" or message.get("event") != "request": @@ -1504,12 +1522,14 @@ def main() -> None: logging.info("Supervisor starting...") global _managed_procs, _supervisor_callosum, _is_remote_mode + global _digest_submitted_this_boot global _task_queue procs: list[ManagedProcess] = [] convey_port = None # Remote mode: run sync instead of local processing _is_remote_mode = bool(args.remote) + _digest_submitted_this_boot = False # Run pending journal-maintenance tasks before spawning any writer children. # Callosum isn't up yet (emit_fn=None); migrations log through supervisor's logger only. @@ -1598,6 +1618,7 @@ def main() -> None: if _task_queue: _task_queue.set_ready() + _maybe_submit_startup_digest(no_cortex=args.no_cortex) # Show Convey URL if running if convey_port: diff --git a/think/tools/sol.py b/think/tools/sol.py index ba1ce384a..e8b889186 100644 --- a/think/tools/sol.py +++ b/think/tools/sol.py @@ -5,9 +5,9 @@ Provides read and write access to ``{journal}/identity/self.md``, ``{journal}/identity/partner.md``, ``{journal}/identity/agency.md``, and -``{journal}/identity/pulse.md``, and ``{journal}/identity/awareness.md`` — sol's -identity and initiative files. Also provides read access to the morning -briefing at +``{journal}/identity/pulse.md``, ``{journal}/identity/awareness.md``, and +``{journal}/identity/digest.md`` — sol's identity and initiative files. Also +provides read access to the morning briefing at ``{journal}/YYYYMMDD/talents/morning_briefing.md``. Mounted by ``think.call`` as ``sol call identity ...``. @@ -19,6 +19,7 @@ from pathlib import Path import typer +from think.cortex_client import cortex_request, wait_for_uses from think.identity import ( ensure_identity_directory, update_identity_section, @@ -28,7 +29,7 @@ from think.identity import ( from think.utils import day_dirs, day_path, get_journal, require_solstone app = typer.Typer( - help="Journal identity directory — self.md, partner.md, agency.md, pulse.md, awareness.md, and morning briefing.", + help="Journal identity directory — self.md, partner.md, agency.md, pulse.md, awareness.md, digest.md, and morning briefing.", invoke_without_command=True, no_args_is_help=False, ) @@ -99,13 +100,13 @@ def _actor_for_cmd(command: str, flag: str) -> str: return f"sol call identity {command} {flag}" -def _resolve_content(value: str | None) -> str: - """Return *value* if provided, else read stdin. Exit 1 if empty.""" +def _resolve_content(value: str | None, *, allow_empty: bool = False) -> str: + """Return *value* if provided, else read stdin.""" if value is not None: content = value else: content = sys.stdin.read() - if not content.strip(): + if not allow_empty and not content.strip(): typer.echo("Error: no content provided.", err=True) raise typer.Exit(1) return content @@ -327,6 +328,63 @@ def awareness_cmd( typer.echo(awareness_path.read_text(encoding="utf-8")) +@app.command("digest") +def digest_cmd( + write: bool = typer.Option( + False, + "--write", + "-w", + help="Persist digest text (used by the digest talent).", + ), + value: str | None = typer.Option( + None, "--value", help="Digest text to persist (alternative to stdin)." + ), +) -> None: + """Regenerate the identity digest (synchronous).""" + identity_dir = _identity_dir() + digest_path = identity_dir / "digest.md" + + if write: + content = _resolve_content(value, allow_empty=True) + write_identity( + "digest.md", + actor=_actor_for_cmd("digest", "--write"), + op="replace", + section=None, + content=content, + reason="manual replace", + ) + typer.echo(f"wrote {digest_path} ({digest_path.stat().st_size} bytes)") + return + + before_mtime_ns = digest_path.stat().st_mtime_ns if digest_path.exists() else None + use_id = cortex_request(prompt="", name="digest") + if use_id is None: + typer.echo("Error: failed to send digest request to cortex.", err=True) + raise typer.Exit(1) + + completed, timed_out = wait_for_uses([use_id], timeout=600) + if use_id in timed_out: + typer.echo("Error: digest request timed out.", err=True) + raise typer.Exit(1) + + end_state = completed.get(use_id, "unknown") + if end_state != "finish": + typer.echo(f"Error: digest request failed: {end_state}.", err=True) + raise typer.Exit(1) + + if not digest_path.exists(): + typer.echo("Error: digest.md was not written.", err=True) + raise typer.Exit(1) + + after_mtime_ns = digest_path.stat().st_mtime_ns + if before_mtime_ns is not None and after_mtime_ns <= before_mtime_ns: + typer.echo("Error: digest.md was not updated.", err=True) + raise typer.Exit(1) + + typer.echo(f"regenerated {digest_path} ({digest_path.stat().st_size} bytes)") + + @app.command("briefing") def briefing_cmd( day: str | None = typer.Option(None, "--day", "-d", help="Specific day YYYYMMDD."),