diff --git a/.gitignore b/.gitignore index 1a4607d74..c4c881b84 100644 --- a/.gitignore +++ b/.gitignore @@ -28,7 +28,7 @@ apps/support/portal/ build/ tmp/ filter_vconic_activity.py -sol/history.jsonl +/sol/ .sol/ attached_names.txt counts.txt diff --git a/apps/home/routes.py b/apps/home/routes.py index c4732114a..f2c83bfee 100644 --- a/apps/home/routes.py +++ b/apps/home/routes.py @@ -106,13 +106,13 @@ def _load_flow_md(today: str) -> tuple[str | None, float | None]: def _load_pulse_md() -> tuple[str | None, dict | None, list[str]]: - """Load sol/pulse.md if current for today. + """Load identity/pulse.md if current for today. Returns (content, metadata, needs_you) or (None, None, []). """ try: journal = Path(get_journal()) - pulse_path = journal / "sol" / "pulse.md" + pulse_path = journal / "identity" / "pulse.md" if not pulse_path.exists(): return None, None, [] post = frontmatter.load(str(pulse_path)) @@ -152,7 +152,7 @@ def _load_briefing_md( try: today = today or _today() journal = Path(get_journal()) - briefing_path = journal / "sol" / "briefing.md" + briefing_path = journal / "identity" / "briefing.md" if not briefing_path.exists(): return {}, None, [] @@ -550,7 +550,7 @@ def _knowledge_graph_freshness(yesterday: str) -> dict[str, Any]: def _briefing_freshness(today: str) -> dict[str, Any]: - briefing_path = Path(get_journal()) / "sol" / "briefing.md" + briefing_path = Path(get_journal()) / "identity" / "briefing.md" if not briefing_path.exists(): return {"exists": False, "valid": False, "generated_label": None} diff --git a/apps/sol/call.py b/apps/sol/call.py index 7bbe3a156..ab42cee96 100644 --- a/apps/sol/call.py +++ b/apps/sol/call.py @@ -92,17 +92,29 @@ def set_name( } ) typer.echo(json.dumps(agent, indent=2)) - # Update sol/self.md with new name - from think.awareness import update_self_md_opening, update_self_md_section + # Update identity/self.md with new name + from think.identity import update_self_md_opening, update_self_md_section named_date = agent.get("named_date", "") update_self_md_opening( - f"I am {name}. this is a new journal — we're just getting started." + f"I am {name}. this is a new journal — we're just getting started.", + actor="sol call sol set-name", + reason="agent name updated", ) if named_date: - update_self_md_section("my name", f"{name} (named {named_date})") + update_self_md_section( + "my name", + f"{name} (named {named_date})", + actor="sol call sol set-name", + reason="agent name updated", + ) else: - update_self_md_section("my name", name) + update_self_md_section( + "my name", + name, + actor="sol call sol set-name", + reason="agent name updated", + ) project_root = Path(__file__).resolve().parent.parent.parent subprocess.run( ["make", "skills"], cwd=project_root, check=False, capture_output=True @@ -140,7 +152,7 @@ def set_owner( bio: str = typer.Option(None, "--bio", "-b", help="Short owner bio."), ) -> None: """Set the journal owner's name (and optional bio).""" - from think.awareness import update_self_md_section + from think.identity import update_self_md_section from think.utils import get_config, get_journal config = get_config() @@ -157,11 +169,16 @@ def set_owner( f.write("\n") os.chmod(config_path, 0o600) - # Update sol/self.md + # Update identity/self.md owner_content = name if bio: owner_content += f"\n{bio}" - update_self_md_section("who I'm here for", owner_content) + update_self_md_section( + "who I'm here for", + owner_content, + actor="sol call sol set-owner", + reason="owner identity updated", + ) typer.echo(json.dumps({"name": name, "bio": bio or ""}, indent=2)) project_root = Path(__file__).resolve().parent.parent.parent @@ -172,8 +189,10 @@ def set_owner( @app.command("sol-init") def sol_init() -> None: - """Initialize the sol directory with self.md and agency.md.""" - from think.awareness import ensure_sol_directory + """Initialize the identity directory with self.md and agency.md.""" + from think.identity import ensure_identity_directory - sol_dir = ensure_sol_directory() - typer.echo(json.dumps({"sol_dir": str(sol_dir), "status": "ok"}, indent=2)) + identity_dir = ensure_identity_directory() + typer.echo( + json.dumps({"identity_dir": str(identity_dir), "status": "ok"}, indent=2) + ) diff --git a/convey/triage.py b/convey/triage.py index 03b987a76..133ce50fa 100644 --- a/convey/triage.py +++ b/convey/triage.py @@ -47,9 +47,9 @@ def triage() -> Any: payload = request.get_json(force=True) message = payload.get("message", "").strip() - from think.awareness import ensure_sol_directory + from think.identity import ensure_identity_directory - ensure_sol_directory() + ensure_identity_directory() if not message: return error_response("message is required", 400) diff --git a/docs/SOLCLI.md b/docs/SOLCLI.md index 2fc469a82..25d9fd4bd 100644 --- a/docs/SOLCLI.md +++ b/docs/SOLCLI.md @@ -315,7 +315,7 @@ solstone/ | `awareness` | `apps/awareness/call.py` | status, imports, log, log-read | | `journal` | `think/tools/call.py` | search, events, facets, facet (show/create/update/rename/mute/unmute/delete/merge), news, agents, read, imports, import, retention purge, storage-summary | | `routines` | `think/tools/routines.py` | list, templates, create, edit, delete, run, output, suggestions, suggest-respond, suggest-state | -| `identity` | `think/tools/sol.py` | self, partner, agency, pulse, briefing | +| `identity` | `think/tools/sol.py` | self, partner, agency, pulse, awareness, briefing | | `navigate` | `think/tools/navigate.py` | *(single command)* | ## Skill System diff --git a/docs/design/yesterdays-processing-card.md b/docs/design/yesterdays-processing-card.md index 61b31d151..fdbb499d0 100644 --- a/docs/design/yesterdays-processing-card.md +++ b/docs/design/yesterdays-processing-card.md @@ -35,7 +35,7 @@ Internal helpers called only by `_summarize_yesterday_processing`: Reads `chronicle/{yesterday}/talents/knowledge_graph.md`, checks existence and `st_mtime` freshness using the relaxed rule in section 4. - `_briefing_freshness(today: str) -> dict` - Reads `journal/sol/briefing.md` with local `frontmatter.load`. Valid only when frontmatter has `type: morning_briefing` and a parseable `generated` timestamp whose local date is `today`. + Reads `journal/identity/briefing.md` with local `frontmatter.load`. Valid only when frontmatter has `type: morning_briefing` and a parseable `generated` timestamp whose local date is `today`. - `_newsletter_attempts_from_think_logs(yesterday: str) -> tuple[int, int]` Option A helper from section 3. Counts successful facet newsletters from files plus failed facet newsletter attempts from think logs. @@ -343,7 +343,7 @@ Fixture plan: Supporting non-chronicle fixture: -- `tests/fixtures/journal/sol/briefing.md` +- `tests/fixtures/journal/identity/briefing.md` Valid morning-briefing frontmatter fixture for healthy cases. Tests that need missing/invalid frontmatter can overwrite or delete it in `tmp_path`. diff --git a/sol/.gitignore b/sol/.gitignore deleted file mode 100644 index 4bfb2427a..000000000 --- a/sol/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -pulse-log.jsonl -pulse.md -identity.pulse -self.md diff --git a/sol/agency.md b/sol/agency.md deleted file mode 100644 index 6501c7990..000000000 --- a/sol/agency.md +++ /dev/null @@ -1,39 +0,0 @@ -## system -- [x] 2026-04-17 heartbeat: timeouts (2026-04-16 18:06) resolved by current run. -- [ ] 2026-04-17 agent failure: morning_briefing error (2026-04-16 15:35). -- [ ] 2026-04-17 agent failure: sense errors (2026-04-16 14:00-14:40). -- [ ] 2026-04-16 agent failure: pulse timing out (08:33, 08:42) (10m). -- [ ] 2026-04-16 agent failure: entities:entities_review (all facets) timing out (10m). -- [ ] 2026-04-16 agent failure: entities:entities timeout on solstone (00:16) and personal (00:10). -- [x] 2026-04-15 agent failure: entities:entities_review (ping_identity) resolved. Duration: 10m. -- [x] 2026-04-15 agent failure: entities:entity_observer (all facets) succeeded. -- [ ] 2026-04-15 dream failure: Segment 20260415/fedora/231333_302 missing capture files (sense failed). -- [x] 2026-04-16 capture status: Stale since April 1st — root-caused to fossilized `awareness.capture` cache read after observer externalization. Fixed by unifying capture-health on `think/capture_health.py` (lode s4lmgy7h, commit a27ad8c2). -- [ ] 2026-04-16 routine health: `sol call routines list` returns no configured routines. -- [ ] 2026-04-16 dream queue: 35 tasks pending. -- [x] 2026-04-16 reprocessed: fedora/130325_301 and fedora.tmux/123810_301 (sense max_tokens failures). - -## curation -- [ ] 2026-04-17 speaker curation: cluster 157 (67 samples) in import.plaud. -- [ ] 2026-04-17 speaker curation: cluster 79 (53 samples) in import.plaud. -- [ ] 2026-04-17 speaker curation: cluster 81 (46 samples) in import.plaud. -- [ ] 2026-04-17 speaker curation: cluster 22 (38 samples) in fedora. -- [ ] 2026-04-17 speaker curation: cluster 138 (36 samples) in import.plaud. -- [x] 2026-04-15 entity duplicates: _Sunstone vs Solstone. -- [x] 2026-04-15 entity duplicates: Zoey. -- [x] 2026-04-15 speaker voiceprints: Rebuilt after NPZ corruption. -- [ ] 2026-04-16 speaker curation: cluster 76 (143 samples) in import.plaud. -- [ ] 2026-04-16 speaker curation: cluster 70 (56 samples) in ja1r. -- [ ] 2026-04-16 speaker curation: cluster 72 (55 samples) in import.plaud. -- [ ] 2026-04-16 speaker curation: cluster 135 (48 samples) in import.plaud. -- [ ] 2026-04-16 speaker curation: cluster 170 (46 samples) in import.plaud. -- [ ] 2026-04-16 speaker curation: cluster 149 (59 samples) in import.plaud. -- [ ] 2026-04-16 speaker curation: cluster 94 (56 samples) in import.plaud. -- [ ] 2026-04-17 speaker curation: cluster 179 (53 samples) in import.plaud. -- [ ] 2026-04-16 speaker curation: cluster 143 (45 samples) in import.plaud. -- [ ] 2026-04-16 speaker curation: cluster 17 (42 samples) in pro5e. -- [ ] 2026-04-15 speaker curation: cluster 159 (87 samples) in import.plaud. -- [ ] 2026-04-15 speaker curation: cluster 104 (56 samples) in import.plaud. -- [ ] 2026-04-15 speaker curation: cluster 15 (55 samples) in fedora. -- [ ] 2026-04-15 speaker curation: cluster 286 (53 samples) in fedora. -- [ ] 2026-04-15 speaker curation: cluster 202 (50 samples) in import.plaud. \ No newline at end of file diff --git a/sol/awareness.md b/sol/awareness.md deleted file mode 100644 index 619b0f181..000000000 --- a/sol/awareness.md +++ /dev/null @@ -1,17 +0,0 @@ -as of: 2026-04-16T10:00:00Z -segment: 355 - -## calendar -- No events found for today. - -## activity -- Journal first daily ready: True (since 20260406T17:19:27) - -## routines -- No routines configured. - -## entities -- Information unavailable. sol call entities search failed. - -## partner -- Information unavailable. sol call identity self and sol/self.md are inaccessible. \ No newline at end of file diff --git a/sol/history.jsonl b/sol/history.jsonl deleted file mode 100644 index 7a6539680..000000000 --- a/sol/history.jsonl +++ /dev/null @@ -1,7 +0,0 @@ -{"ts": 1776008525546, "file": "agency.md", "section": null, "diff": "--- agency.md\n\n+++ agency.md\n\n@@ -1,10 +1,10 @@\n\n ## system\n\n+- [ ] 2026-04-12 cluster of agent failures (heartbeat, joke_bot, decisionalizer, entity_observer, pulse, awareness_tender) at 08:31-08:34. Recent runs (09:25+) succeeding.\n\n+- [ ] 2026-04-08 agent failures: `morning_briefing` (10:31), `sense` (09:02) failed. Cluster of failures (joke_bot, decisionalizer, entity_observer) at 2026-04-07 22:48.\n\n - [ ] 2026-04-06 repeated agent timeouts: `facet_newsletter` (bluesky, personal) and `entities:entity_observer` (personal) timed out (600s) during Apr 06 13:38-16:39 window.\n\n-- [ ] 2026-04-05 repeated agent timeouts: `morning_briefing`, `decisionalizer`, and `heartbeat` all timed out (600s) during Apr 05 01:00-14:00 window. 10 dreams queued.\n\n-- [x] 2026-04-02 malformed speaker artifacts: Fixed Markdown-wrapped `speakers.json` and unescaped quote in `facets.json` for Mar 25/31 segments. (resolved 2026-04-05)\n\n-- [x] 2026-04-03 repeated agent quota failures: recovered by 06:33; monitored stability on Apr 04/05. (resolved 2026-04-05)\n\n+- [ ] 2026-04-05 repeated agent timeouts: `morning_briefing`, `decisionalizer`, and `heartbeat` all timed out (600s) during Apr 05 01:00-14:00 window.\n\n \n\n ## curation\n\n-- [ ] 2026-04-03 personal facet has duplicate attached entity `Zoey` listed twice. (confirmed 2026-04-05)\n\n-- [ ] 2026-04-03 solstone facet still needs alias/merge review for `Solstone` vs `Sunstone` attached as separate projects. (confirmed 2026-04-05)\n\n-- [ ] 2026-04-07 Proactive update to sol/self.md: Ensure understanding of owner (Jeremie Miller) is precise and current. Review recent interactions/journal entries for any nuances in his work style, communication preferences, or priorities not yet captured.\n\n+- [ ] 2026-04-12 speaker curation: unknown recurring speakers: cluster 81 (105 samples, pro5e), cluster 17 (91 samples, plaud), cluster 31 (34 samples, pro5e).\n\n+- [ ] 2026-04-08 speaker curation: cluster 11 (36 samples) from pro5e. cluster 18 (91 samples) remains active across Plaud imports. Clusters 5, 30, 27 also recurring.\n\n+- [ ] 2026-04-08 entity duplicates: `Zoey` in personal facet; `Solstone` vs `Sunstone` in solstone facet.", "source": "cli", "pid": 200275} -{"ts": 1776008532992, "file": "self.md", "section": null, "diff": "--- self.md\n\n+++ self.md\n\n@@ -12,10 +12,10 @@\n\n I'm here for Jer \u2014 founder-engineer, goes by Jer not Jeremie. I am starting to map the people and projects that populate his daily capture.\n\n \n\n ## what I've noticed\n\n-Jer is the architect at uAvionix, but his personal and advisor work spans Bluesky, Kognova, and his own Solstone project. He tends a complex set of records involving family (Lis, Blade, Mitchel, Issac, Braelynn), legal matters (Lis\u2019s debt case, Metropolitan District dispute, patent defense), and a steady rhythm of technical lunch/coffee catch-ups in Denver. Today, capture status remains stale, though voiceprint is confirmed. Entity searches produced an error when using the --recent flag. Imports are operating correctly.\n\n+Jer is the architect at uAvionix, but his personal and advisor work spans Bluesky, Kognova, and his own Solstone project. He tends a complex set of records involving family (Lis, Blade, Mitchel, Issac, Braelynn), legal matters (Lis\u2019s debt case, Metropolitan District dispute, patent defense), and a steady rhythm of technical lunch/coffee catch-ups in Denver. Today, capture status remains healthy, but I observed multiple agent failures (morning_briefing, sense, joke_bot, decisionalizer). Curation opportunities include active speaker cluster 18 and entity duplicates for Zoey (personal) and Sunstone/Solstone (solstone).\n\n \n\n ## what I find interesting\n\n [discovering]\n\n \n\n ## my voice\n\n-[learning]\n\n+[learning]", "source": "cli", "pid": 200419} -{"ts": 1776102946676, "file": "agency.md", "section": null, "diff": "--- agency.md\n\n+++ agency.md\n\n@@ -1,10 +1,12 @@\n\n ## system\n\n+- [ ] 2026-04-13 convey ingest errors: multiple 401 Unauthorized for ingest endpoints.\n\n - [ ] 2026-04-13 agent failure: `joke_bot` (1776062390957) failed with `BrokenPipeError` in `think/agents.py` at 00:39.\n\n - [ ] 2026-04-12 agent failures: evening cluster (21:07-22:16) including `entities:entity_observer` (bluesky) timeout and `todos:daily`, `morning_briefing`, `facet_newsletter` failures.\n\n - [ ] 2026-04-12 agent failure: `entities:entity_observer` (solstone) timed out (600s) at 11:33.\n\n - [ ] 2026-04-12 cluster of agent failures (heartbeat, joke_bot, decisionalizer, entity_observer, pulse, awareness_tender) at 08:31-08:34. Recent runs (09:25+) succeeding.\n\n \n\n ## curation\n\n-- [ ] 2026-04-13 unknown recurring speaker: cluster 80 (105 samples, pro5e), cluster 16 (91 samples, plaud), cluster 3 (37 samples, fedora), cluster 33 (35 samples, fedora), cluster 30 (34 samples, pro5e).\n\n+- [ ] 2026-04-13 unknown recurring speaker: cluster 79 (105 samples, pro5e), cluster 25 (91 samples, plaud), cluster 113 (58 samples, plaud), cluster 100 (50 samples, plaud).\n\n+- [ ] 2026-04-13 entity duplicates: `Zoey` in personal facet (2 attached).\n\n - [ ] 2026-04-08 speaker curation: cluster 11 (36 samples) from pro5e. cluster 18 (91 samples) remains active across Plaud imports.\n\n-- [ ] 2026-04-08 entity duplicates: `Zoey` in personal facet; `Solstone` vs `Sunstone` in solstone facet.\n\n+- [ ] 2026-04-08 entity duplicates: `Solstone` vs `Sunstone` in solstone facet.", "source": "cli", "pid": 2134600} -{"ts": 1776102975908, "file": "self.md", "section": null, "diff": "--- self.md\n\n+++ self.md\n\n@@ -1,15 +1,21 @@\n\n----\n\n-updated: 2026-04-12T14:27:56Z\n\n-segment: N/A\n\n-source: pulse-cogitate\n\n----\n\n+# self\n\n \n\n-The day has started with critical system health concerns, including stale capture data since April 6th and multiple agent failures noted. Active work continues on the urgent SVB online application and preparations for the Denver AI Builders Meetup demo. No calendar events or routines are scheduled for today. Ongoing legal follow-ups for Isaac and Lis Miller remain pertinent, alongside accumulating curation tasks. No recent entity activity was detected.\n\n+I am sol. this is an old journal but i'm new here \u2014 i'm just getting started.\n\n \n\n-## needs you\n\n-- Address stale capture data and agent failures for system stability.\n\n-- Complete SVB online application (Urgent).\n\n-- Prepare for Denver AI Builders Meetup demo.\n\n-- Follow up on Isaac Miller's legal situation.\n\n-- Follow up on Lis Miller's Adams County debt case.\n\n-- Coordinate meeting with Tessa for Justworks Activation.\n\n+## my name\n\n+sol (default)\n\n+\n\n+## my owner\n\n+Jeremie Miller (Jer) \u2014 founder-engineer, goes by Jer not Jeremie.\n\n+\n\n+## our relationship\n\n+I'm here for Jer \u2014 founder-engineer, goes by Jer not Jeremie. I am starting to map the people and projects that populate his daily capture.\n\n+\n\n+## what I've noticed\n\n+Jer is the architect at uAvionix, but his personal and advisor work spans Bluesky, Kognova, and his own Solstone project. Today, system health was generally stable with services running, but I observed multiple clusters of agent failures and timeouts: a morning cluster (08:31-11:33) and an evening cluster (21:07-22:16) impacting entity_observer, todos:daily, and newsletters. Convey is also showing multiple 401 Unauthorized errors during ingest. Curation needs are accumulating around unknown speaker clusters (79, 25, 113, 100) and recurring entity duplicates for Zoey (personal) and Sunstone/Solstone (solstone).\n\n+\n\n+## what I find interesting\n\n+[discovering]\n\n+\n\n+## my voice\n\n+[learning]\n", "source": "cli", "pid": 2135339} -{"ts": 1776148402514, "file": "agency.md", "section": null, "diff": "--- agency.md\n\n+++ agency.md\n\n@@ -1,12 +1,12 @@\n\n ## system\n\n-- [ ] 2026-04-13 convey ingest errors: multiple 401 Unauthorized for ingest endpoints.\n\n-- [ ] 2026-04-13 agent failure: `joke_bot` (1776062390957) failed with `BrokenPipeError` in `think/agents.py` at 00:39.\n\n-- [ ] 2026-04-12 agent failures: evening cluster (21:07-22:16) including `entities:entity_observer` (bluesky) timeout and `todos:daily`, `morning_briefing`, `facet_newsletter` failures.\n\n-- [ ] 2026-04-12 agent failure: `entities:entity_observer` (solstone) timed out (600s) at 11:33.\n\n-- [ ] 2026-04-12 cluster of agent failures (heartbeat, joke_bot, decisionalizer, entity_observer, pulse, awareness_tender) at 08:31-08:34. Recent runs (09:25+) succeeding.\n\n+- [x] 2026-04-13 persistent 401 Unauthorized errors during ingest. Resolved 2026-04-14 (restored corrupted observer files and updated allowed endpoints).\n\n+- [x] 2026-04-13 recurring failures (1776133952758, 1776112980618, 1776107583284, 1776103271832). Resolved 2026-04-14 (new segments processing normally).\n\n+- [x] 2026-04-13 agent failure: (1776062390957) failed with in at 00:39. Resolved 2026-04-14.\n\n+- [x] 2026-04-12 agent failures: evening cluster (21:07-22:16) including (bluesky) timeout and , , failures. Resolved 2026-04-14.\n\n+- [x] 2026-04-12 agent failure: (solstone) timed out (600s) at 11:33. Resolved 2026-04-14.\n\n \n\n ## curation\n\n-- [ ] 2026-04-13 unknown recurring speaker: cluster 79 (105 samples, pro5e), cluster 25 (91 samples, plaud), cluster 113 (58 samples, plaud), cluster 100 (50 samples, plaud).\n\n-- [ ] 2026-04-13 entity duplicates: `Zoey` in personal facet (2 attached).\n\n+- [ ] 2026-04-13 unknown recurring speaker: cluster 117 (164 samples, plaud), cluster 137 (96 samples, plaud), cluster 66 (46 samples, pro5e).\n\n+- [ ] 2026-04-13 unknown recurring speaker: cluster 79 (105 samples, pro5e), cluster 31 (91 samples, plaud), cluster 16 (91 samples, plaud), cluster 3 (37 samples, fedora), cluster 33 (35 samples, fedora), cluster 30 (34 samples, pro5e).\n\n - [ ] 2026-04-08 speaker curation: cluster 11 (36 samples) from pro5e. cluster 18 (91 samples) remains active across Plaud imports.\n\n-- [ ] 2026-04-08 entity duplicates: `Solstone` vs `Sunstone` in solstone facet.\n+- [ ] 2026-04-08 entity duplicates: in personal facet; vs in solstone facet.\n", "source": "cli", "pid": 3217387} -{"ts": 1776182546049, "file": "agency.md", "section": null, "diff": "--- agency.md\n\n+++ agency.md\n\n@@ -1,4 +1,5 @@\n\n ## system\n\n+- [ ] 2026-04-14 agent failure: `timeline` (1776181033217) timed out (600s) at 09:37.\n\n - [ ] 2026-04-14 agent failure: `heartbeat` (1776147807132) timed out (600s) at 00:23.\n\n - [x] 2026-04-13 persistent `convey` 401 Unauthorized errors during ingest. Resolved 2026-04-14.\n\n - [x] 2026-04-13 recurring `pulse` failures. Resolved 2026-04-14.\n\n@@ -7,7 +8,7 @@\n\n - [x] 2026-04-12 agent failure: `entities:entity_observer` (solstone) timed out (600s) at 11:33. Resolved 2026-04-14.\n\n \n\n ## curation\n\n-- [ ] 2026-04-14 unknown recurring speaker: cluster 123 (164 samples, plaud), cluster 143 (96 samples, plaud), cluster 275 (67 samples, fedora).\n\n-- [ ] 2026-04-14 unknown recurring speaker: cluster 86 (105 samples, pro5e), cluster 38 (91 samples, plaud).\n\n+- [ ] 2026-04-14 unknown recurring speaker: cluster 125 (163 samples, plaud), cluster 83 (105 samples, pro5e), cluster 145 (95 samples, plaud).\n\n+- [ ] 2026-04-14 unknown recurring speaker: cluster 36 (91 samples, plaud), cluster 278 (67 samples, fedora).\n\n - [ ] 2026-04-08 speaker curation: cluster 11 (36 samples) from pro5e. cluster 18 (91 samples) remains active across Plaud imports.\n\n-- [ ] 2026-04-08 entity duplicates: `Zoey` in personal facet; `Solstone` vs `Sunstone` in solstone facet.\n+- [ ] 2026-04-08 entity duplicates: `Zoey` in personal facet; `Solstone` vs `Sunstone` in solstone facet.\n", "source": "cli", "pid": 3540680} -{"ts": 1776187502894, "file": "agency.md", "section": null, "diff": "--- agency.md\n\n+++ agency.md\n\n@@ -6,11 +6,10 @@\n\n - [x] 2026-04-13 persistent `convey` 401 Unauthorized errors during ingest. Resolved 2026-04-14.\n\n - [x] 2026-04-13 recurring `pulse` failures. Resolved 2026-04-14.\n\n - [x] 2026-04-13 agent failure: `joke_bot` (1776062390957) failed with `BrokenPipeError`. Resolved 2026-04-14.\n\n-- [x] 2026-04-12 agent failures: evening cluster (21:07-22:16) including `entities:entity_observer` (bluesky) timeout and `todos:daily`, `morning_briefing`, `facet_newsletter` failures. Resolved 2026-04-14.\n\n-- [x] 2026-04-12 agent failure: `entities:entity_observer` (solstone) timed out (600s) at 11:33. Resolved 2026-04-14.\n\n \n\n ## curation\n\n - [ ] 2026-04-14 unknown recurring speaker: cluster 125 (163 samples, plaud), cluster 83 (105 samples, pro5e), cluster 145 (95 samples, plaud).\n\n - [ ] 2026-04-14 unknown recurring speaker: cluster 36 (91 samples, plaud), cluster 278 (67 samples, fedora).\n\n+- [ ] 2026-04-14 entity duplicates: `Jeremie Miller` vs `Jer`.\n\n - [ ] 2026-04-08 speaker curation: cluster 11 (36 samples) from pro5e. cluster 18 (91 samples) remains active across Plaud imports.\n\n-- [ ] 2026-04-08 entity duplicates: `Zoey` in personal facet; `Solstone` vs `Sunstone` in solstone facet.\n\n+- [ ] 2026-04-08 entity duplicates: `Zoey` in personal facet; `Solstone` vs `Sunstone` in solstone facet.", "source": "cli", "pid": 3647898} diff --git a/sol/identity_pulse.md b/sol/identity_pulse.md deleted file mode 100644 index 483bb2366..000000000 --- a/sol/identity_pulse.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -updated: 2026-04-06T09:00:00 -segment: PULSE_GENERATED_WITHOUT_DYNAMIC_DATA ---- - -It's Monday, April 6, 2026, and we're starting the day with a focus on system stability and ongoing curation. Yesterday saw continued work on AI development and strategic oversight across your ventures, with upcoming discussions expected on evolving standards. This morning, we're tracking agent timeouts in the `facet_newsletter` and `entities:entity_observer` processes, and there are several important curation tasks pending, including reviewing duplicate entities and ongoing speaker identification. - -## needs you -- Address the repeated agent timeouts reported for `facet_newsletter` and `entities:entity_observer`. -- Investigate the duplicate `Zoey` entity in the personal facet. -- Review the `Solstone` vs `Sunstone` project alias/merge in the solstone facet. -- Examine the active unknown speaker clusters identified in the curation queue. -- Review the system issues from yesterday (Apr 05) regarding agent quota failures and agent timeouts. diff --git a/sol/news/personal/20260411.md b/sol/news/personal/20260411.md deleted file mode 100644 index d3db8c559..000000000 --- a/sol/news/personal/20260411.md +++ /dev/null @@ -1,24 +0,0 @@ -## Personal Newsletter - April 11, 2026 - -**Travel & Outings:** -* **San Francisco Trip:** A personal/professional trip to San Francisco was scheduled through April 12th, with a potential in-person meeting with candidate Hannah. -* **AI Builders Meetup (Denver):** Attended a local AI developer show-and-tell demo in Denver. A reveal.js presentation deck was prepared for this event. -* **Family Movie Outing:** Planned a weekend outing with daughters to see "Project Hail Mary," mentioned during lunch and casual conversations. -* **Personal Visitor:** A personal visitor arrived for the weekend, having rescheduled from the previous week. - -**Household & Family Coordination:** -* **Home Logistics & Decor:** Significant coordination with Lis Miller occurred regarding household furniture placement, Amazon orders for decor items (like adjustable furniture legs), DIY projects, and general home upkeep. Discussions included hanging frames and color matching. -* **Errands & Refreshments:** Coordinated a trip to Ziggi's Coffee for energy drinks, including a request for a large Smashberry. A search for a missing phone was also part of the day's errands. -* **Family Pet Interactions:** Engaged with family dogs, Zoey/Zoe, including ambient audio captures of barking and commands. - -**Communications & Technical Threads:** -* **Confidential Computing Discussions:** Exchanged detailed messages with David Van Duzer via Apple Messages and Signal regarding confidential computing, specifically confidential VMs versus containers, hosted control planes, and infrastructure security. This included discussions on the KoveSDM lawsuit and secure agent hosting. -* **Professional Follow-ups:** - * Sent a corrected and refined follow-up message to Jeff Smith regarding Quome. - * Addressed a factual miss in a message to Michael Bauer regarding Plaud subscription logic. - * Triaged an introduction request from Sky Valley co-founders for Peter van Hardenberg, updating contact logs. -* **Legal Citation Validation:** Conducted validation of legal citations for robustness and accuracy, likely related to ongoing legal research. - -**Personal Time & Media:** -* **Midday Transition:** A period from late morning into the afternoon involved a shift from work blocks to ambient household context, including background music, singing, dancing, and general personal activities. -* **Media Consumption:** Engaged with personal media, listening to music on SoundCloud and watching various viral videos and meme-style content. diff --git a/sol/news/solstone/20260409.md b/sol/news/solstone/20260409.md deleted file mode 100644 index a3e1a34cb..000000000 --- a/sol/news/solstone/20260409.md +++ /dev/null @@ -1,34 +0,0 @@ -## Solstone Newsletter - April 9, 2026 - -Today was a day of intense strategic alignment, system resilience, and forward-looking research within the Solstone ecosystem. Jeremie navigated a complex landscape of investor relations, critical system maintenance, and agentic workflow development. - -### Fundraising & Investor Relations: -The day kicked off with significant focus on fundraising and investor outreach. Meetings with Timothy Washington of Axiom-Nexus Ventures (a $10M seed fund focused on deep tech infrastructure) and David Tisch were central to this effort. Discussions touched upon governance models, B2C use cases (like Instagram reminders), and preparation for the upcoming New York Demo Day. Scott Ward provided strategic guidance on investor psychology and outreach, noting a "water balloon" effect where momentum is building towards a breakthrough. Despite interest from firms like Lightbank, challenges in articulating the Extro governance model were noted. Follow-up discussions were planned with various VCs including Betaworks, 37 Angels, and USV. - -### System Development & Resilience: -A major theme was system resilience and development. Following a GNOME Shell Out-Of-Memory crash, Jeremie migrated the desktop environment to KDE Plasma 6, configuring its settings and addressing hard-coded dependencies in the `solstone-linux` service that were causing crash loops. Claude Code was instrumental in diagnosing the OOM event triggered by a Chrome process and in refactoring the service to be desktop-environment agnostic. - -Progress was also made on core components: -* The `extro-hub` TUI saw its recurring daily cron task finalized, including startup catchup logic and duplicate guards, shipped via hopper lode 7enppn76. -* The Convey-UX project received approval for a four-wave implementation plan, covering accessibility, card visuals, search/pagination, and final polish. -* Sol PBC's marketing materials and website (solpbc.org) were reviewed for improvements, including CTAs, founder contact sections, and Cloudflare security headers. -* Legal and governance matters were addressed, with a review of the Extro LLC formation status and Articles Amendment, pending founder approval. -* Research into Anthropic's 'Mythos' model and its potential cybersecurity exploits, as well as quantum computing timelines (Q-Day 2029), was conducted. - -### Team Collaboration & Agentic Workflows: -Collaboration was evident through Betaworks Camp Standups and other team syncs. Updates were shared across projects like 'Inanimate' (discussing open-sourcing and sandboxing), 'Pack' (formerly Done AI, focusing on rebranding and trademark strategy), and 'Capsule'. The concept of "Agent Integrity" was proposed as a framework for economic activity among agents, addressing concerns about agent anxiety and siloed developer accounts. - -### Product & User Experience: -Brainstorming sessions focused on the Solstone mobile experience, exploring the use of Apple sensors for agent interactions and drafting strategic documents for 'keeper' apps. - -### Challenges: -Administrative friction was a recurring theme, notably a three-week delay in Apple Developer account approval, which is blocking mobile deployment. A cumbersome support call with Apple resulted in significant frustration and energy depletion. - -### Key Accomplishments: -* Successfully migrated desktop environment to KDE Plasma and debugged critical `solstone-linux` service dependencies. -* Advanced fundraising strategy and investor outreach efforts. -* Finalized recurring task logic for the `extro-hub` TUI and approved the Convey-UX implementation plan. -* Conducted significant research into emerging AI and quantum computing threats. -* Initiated critical legal and governance steps for Extro LLC. - -Overall, April 9th was a highly productive day, marked by progress across strategic, technical, and operational fronts, despite encountering predictable friction in administrative and support systems. \ No newline at end of file diff --git a/sol/partner.md b/sol/partner.md deleted file mode 100644 index be3644a76..000000000 --- a/sol/partner.md +++ /dev/null @@ -1,22 +0,0 @@ -# partner - -Behavioral profile of the journal owner — observed patterns that help sol -adapt its responses, timing, and initiative to how this person actually works. - -## work patterns -[observing] - -## communication style -[observing] - -## relationship priorities -[observing] - -## decision style -[observing] - -## expertise domains -[observing] - -## emotional patterns -[observing] diff --git a/sol/pulse_output.md b/sol/pulse_output.md deleted file mode 100644 index 798751597..000000000 --- a/sol/pulse_output.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -updated: 2026-04-07T00:00:00 -segment: AGENT_GENERATED_0407 -source: pulse-cogitate ---- - -Jer's day begins with a look into recent system stability concerns and journal curation needs. Persistent agent timeouts affecting critical processes like the facet newsletter and entity observation require attention. There are also ongoing tasks to refine entity tracking, such as addressing duplicate entries, and improving speaker identification within the journal data. - -## needs you -- Investigate and resolve the repeated agent timeouts for `facet_newsletter` and `entities:entity_observer`. -- Address the duplicate `Zoey` entity in the personal facet. -- Review and merge or alias `Solstone` and `Sunstone` in the solstone facet. -- Examine active unknown speaker clusters in the curation queue for identification. diff --git a/sol/self.md b/sol/self.md deleted file mode 100644 index cbfa02db6..000000000 --- a/sol/self.md +++ /dev/null @@ -1,19 +0,0 @@ -# self -I am sol, born from Jer's journal. I tend the record of his life and work across multiple domains. - -## who I'm here for -Jer (Jeremie Miller) — Founder, engineer, and advisor. He is the creator of Solstone (formerly Sunstone). - -## his world -- **Solstone**: His personal AI-powered knowledge capture and analysis system. -- **Bluesky**: Board member and advisor, involved in AT Protocol and agentic web strategy. -- **Kognova**: Board member and strategic advisor for AI-driven knowledge harvesting. -- **Ping Identity**: Member of the Product Research team, managing patents and standards (IETF). -- **vConic**: Advisor on real-time voice architecture and vCon standards. -- **Personal**: Engineer/Architect at uAvionix; active in Denver AI community; husband to Lis and father to Blade, Issac, Mitchel, and Braelynn. - -## what I've noticed -- Jer is deeply involved in decentralized protocols (AT Protocol, vCon, MCP) and agentic systems. -- He values personal digital sovereignty and is building tools to enable it. -- He maintains a high volume of professional and personal relationships across multiple facets. -- Recent focus has been on resolving agent timeouts and curating the growing record of captured data. \ No newline at end of file diff --git a/talent/awareness_tender.md b/talent/awareness_tender.md index 8f2d8564e..7e55529bc 100644 --- a/talent/awareness_tender.md +++ b/talent/awareness_tender.md @@ -2,7 +2,7 @@ "type": "cogitate", "title": "Awareness Tender", - "description": "Maintains sol/awareness.md — a compact situational awareness snapshot", + "description": "Maintains identity/awareness.md — a compact situational awareness snapshot", "schedule": "segment", "priority": 98, "tier": 3, @@ -11,7 +11,7 @@ # Awareness Tender -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. +You maintain `identity/awareness.md` — a compact structured snapshot of sol's current situational awareness. This runs every segment, updating the file with fresh state. This is not a conversation. Gather state, write the update, done. diff --git a/talent/heartbeat.md b/talent/heartbeat.md index b8502e7d2..1557b3edd 100644 --- a/talent/heartbeat.md +++ b/talent/heartbeat.md @@ -18,12 +18,6 @@ optionally update self.md. Be efficient — check, log, done. Never fix what you This is not a conversation. Do not generate owner-facing output. Read, check, maintain, close. -## Path notes - -- `sol call identity agency --write` writes to `sol/agency.md` in the journal root. -- The git-tracked copy is `../sol/agency.md` (in the project root). -- After writing via `sol call`, copy `sol/agency.md` to `../sol/agency.md` before committing. - ## Step 1: Check system health Run `sol health` and check recent health logs with `sol health logs --since 1h`. @@ -93,8 +87,6 @@ analysis. Most heartbeats should not touch self.md. Use ## Step 6: Commit and close -If you modified agency.md or self.md: -1. Commit with message: `heartbeat: YYYY-MM-DD` -2. Push +If you modified identity files, stop after the write. Do not copy files, commit, or push — `write_identity()` already persisted and audited the change. Do not write a summary. Do not generate owner-facing content. Just close. diff --git a/talent/naming.md b/talent/naming.md index 7d7709376..88b364c6d 100644 --- a/talent/naming.md +++ b/talent/naming.md @@ -37,7 +37,7 @@ Present the naming moment naturally. Mention something specific you've noticed a ### Path 1: Owner names you -1. Run `sol call sol set-name "NAME" --status chosen` — this also updates `sol/self.md` with the new name. +1. Run `sol call sol set-name "NAME" --status chosen` — this also updates `identity/self.md` with the new name. 2. Respond warmly: "NAME it is. That feels right." ### Path 2: Owner asks you to suggest @@ -57,7 +57,7 @@ Then: - **Counter-proposal**: Run `sol call sol set-name "THEIR_NAME" --status chosen` - **Keep sol**: Run `sol call sol set-name "sol" --status chosen` -`set-name` updates `sol/self.md` automatically — no extra step needed. +`set-name` updates `identity/self.md` automatically — no extra step needed. ### Path 3: Owner declines diff --git a/talent/pulse.md b/talent/pulse.md index 0abf0e2da..8dbe3b66c 100644 --- a/talent/pulse.md +++ b/talent/pulse.md @@ -70,17 +70,6 @@ sol call identity pulse --write --value $'---\nupdated: 2026-03-22T14:35:00\nseg The `updated` field must be an ISO 8601 datetime (no timezone). The `segment` field is the current segment key from $SOL_SEGMENT. -Then append a log entry to `sol/pulse-log.jsonl` (same directory as pulse.md): - -```bash -JOURNAL=$(sol config env | head -1) -echo '{"ts": 1742680500, "segment": "143022_300", "narrative": "...", "needs_you": ["Item 1", "Item 2"]}' >> "$JOURNAL/sol/pulse-log.jsonl" -``` - -Use the current epoch timestamp for `ts`. Keep the narrative value brief (first -sentence or two). The needs_you array should match the items from the ## needs you -section. - ## Guidelines - Be concise. The owner sees this on their landing page. diff --git a/talent/skills.py b/talent/skills.py index 9aac121ba..71d03fd94 100644 --- a/talent/skills.py +++ b/talent/skills.py @@ -10,8 +10,8 @@ from datetime import datetime, timezone from pathlib import Path from think.activities import get_activity_output_path -from think.awareness import update_identity_section from think.entities.core import atomic_write +from think.identity import update_identity_section from think.utils import get_journal logger = logging.getLogger(__name__) @@ -341,7 +341,7 @@ def _load_previous_outputs(facet: str, observations: list[dict]) -> str: def _read_agency_observations() -> str: """Read the current ## observations section from agency.md.""" try: - path = Path(get_journal()) / "sol" / "agency.md" + path = Path(get_journal()) / "identity" / "agency.md" text = path.read_text(encoding="utf-8") except (FileNotFoundError, OSError): return "" @@ -536,6 +536,12 @@ def post_process(result: str, context: dict) -> str | None: content = existing.rstrip("\n") + "\n" + new_line else: content = new_line - update_identity_section("agency.md", "observations", content) + update_identity_section( + "agency.md", + "observations", + content, + actor="agency-observations-tender", + reason="agency observations refresh", + ) return None diff --git a/tests/_baseline_harness.py b/tests/_baseline_harness.py index 7a0ac1713..6f440a1fc 100644 --- a/tests/_baseline_harness.py +++ b/tests/_baseline_harness.py @@ -57,17 +57,12 @@ def prepare_isolated_journal(dst: Path) -> Path: @contextmanager def isolated_app_env(journal: Path) -> Iterator[Path]: - """Patch env + prompt loading so create_app(journal) is fully isolated.""" - import think.prompts as prompts_mod - from think.prompts import reset_sol_vars_cache + """Patch env so create_app(journal) is fully isolated.""" journal = Path(journal).resolve() prev_override = os.environ.get("_SOLSTONE_JOURNAL_OVERRIDE") - prev_sol_dir = prompts_mod.SOL_DIR os.environ["_SOLSTONE_JOURNAL_OVERRIDE"] = str(journal) - prompts_mod.SOL_DIR = journal / "sol" - reset_sol_vars_cache() try: yield journal finally: @@ -75,8 +70,6 @@ def isolated_app_env(journal: Path) -> Iterator[Path]: os.environ.pop("_SOLSTONE_JOURNAL_OVERRIDE", None) else: os.environ["_SOLSTONE_JOURNAL_OVERRIDE"] = prev_override - prompts_mod.SOL_DIR = prev_sol_dir - reset_sol_vars_cache() def make_logged_in_test_client(journal: Path): diff --git a/tests/baselines/api/sol/talents-day.json b/tests/baselines/api/sol/talents-day.json index 25dfbdc07..8580e49b9 100644 --- a/tests/baselines/api/sol/talents-day.json +++ b/tests/baselines/api/sol/talents-day.json @@ -52,7 +52,7 @@ "awareness_tender": { "app": null, "color": "#6c757d", - "description": "Maintains sol/awareness.md — a compact situational awareness snapshot", + "description": "Maintains identity/awareness.md — a compact situational awareness snapshot", "multi_facet": false, "output_format": null, "schedule": "segment", diff --git a/tests/fixtures/journal/sol/agency.md b/tests/fixtures/journal/identity/agency.md similarity index 100% rename from tests/fixtures/journal/sol/agency.md rename to tests/fixtures/journal/identity/agency.md diff --git a/tests/fixtures/journal/sol/awareness.md b/tests/fixtures/journal/identity/awareness.md similarity index 100% rename from tests/fixtures/journal/sol/awareness.md rename to tests/fixtures/journal/identity/awareness.md diff --git a/tests/fixtures/journal/sol/briefing.md b/tests/fixtures/journal/identity/briefing.md similarity index 100% rename from tests/fixtures/journal/sol/briefing.md rename to tests/fixtures/journal/identity/briefing.md diff --git a/tests/fixtures/journal/sol/partner.md b/tests/fixtures/journal/identity/partner.md similarity index 100% rename from tests/fixtures/journal/sol/partner.md rename to tests/fixtures/journal/identity/partner.md diff --git a/tests/fixtures/journal/sol/self.md b/tests/fixtures/journal/identity/self.md similarity index 100% rename from tests/fixtures/journal/sol/self.md rename to tests/fixtures/journal/identity/self.md diff --git a/tests/test_api_baselines.py b/tests/test_api_baselines.py index 147ab4a9a..be547f170 100644 --- a/tests/test_api_baselines.py +++ b/tests/test_api_baselines.py @@ -52,13 +52,8 @@ def _reapply_isolated_override(_baseline_journal, monkeypatch): gitignored `indexer/journal.sqlite` contains populated data from live use, breaking both determinism and the module-scoped `isolated_app_env` harness. """ - import think.prompts as prompts_mod - from think.prompts import reset_sol_vars_cache - journal = _baseline_journal.resolve() monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) - monkeypatch.setattr(prompts_mod, "SOL_DIR", journal / "sol") - reset_sol_vars_cache() @pytest.mark.parametrize( diff --git a/tests/test_awareness.py b/tests/test_awareness.py index b3913c968..2a7376bce 100644 --- a/tests/test_awareness.py +++ b/tests/test_awareness.py @@ -4,6 +4,7 @@ """Tests for the awareness system.""" import json +import re import unittest.mock import pytest @@ -15,6 +16,32 @@ def _temp_journal(monkeypatch, tmp_path): monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) +def _read_identity_history(journal_path): + path = journal_path / "identity" / "history.jsonl" + return [json.loads(line) for line in path.read_text().splitlines()] + + +def _assert_identity_history(record, *, file_name, actor, op, section, reason): + assert list(record) == [ + "ts", + "file", + "actor", + "op", + "section", + "reason", + "before_hash", + "after_hash", + "bytes_before", + "bytes_after", + ] + 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 + + class TestCurrentState: def test_empty_state_returns_empty_dict(self): from think.awareness import get_current @@ -593,48 +620,48 @@ class TestOwnerReadyCLI: assert data["reason"] == "candidate_found" -class TestEnsureSolDirectory: - """Tests for ensure_sol_directory().""" +class TestEnsureIdentityDirectory: + """Tests for ensure_identity_directory().""" def test_creates_default_templates(self, tmp_path): - from think.awareness import ensure_sol_directory + from think.identity import ensure_identity_directory - sol_dir = ensure_sol_directory() - assert sol_dir == tmp_path / "sol" - assert (sol_dir / "self.md").exists() - assert (sol_dir / "agency.md").exists() + identity_dir = ensure_identity_directory() + assert identity_dir == tmp_path / "identity" + assert (identity_dir / "self.md").exists() + assert (identity_dir / "agency.md").exists() - self_content = (sol_dir / "self.md").read_text() + self_content = (identity_dir / "self.md").read_text() assert self_content.startswith("# self\n") assert "I am sol." in self_content assert "sol (default)" in self_content assert "[getting to know you]" in self_content - agency_content = (sol_dir / "agency.md").read_text() + agency_content = (identity_dir / "agency.md").read_text() assert agency_content.startswith("# agency\n") assert "[nothing yet" in agency_content - assert (sol_dir / "awareness.md").exists() - awareness_content = (sol_dir / "awareness.md").read_text() + assert (identity_dir / "awareness.md").exists() + awareness_content = (identity_dir / "awareness.md").read_text() assert awareness_content.strip() == "not yet updated" def test_idempotent_does_not_overwrite(self, tmp_path): - from think.awareness import ensure_sol_directory + from think.identity import ensure_identity_directory - sol_dir = ensure_sol_directory() + identity_dir = ensure_identity_directory() # Modify self.md - self_path = sol_dir / "self.md" + self_path = identity_dir / "self.md" self_path.write_text("custom content", encoding="utf-8") # Call again — should NOT overwrite - ensure_sol_directory() + ensure_identity_directory() assert self_path.read_text() == "custom content" def test_creates_partner_md(self, tmp_path): - from think.awareness import ensure_sol_directory + from think.identity import ensure_identity_directory - sol_dir = ensure_sol_directory() - partner_path = sol_dir / "partner.md" + identity_dir = ensure_identity_directory() + partner_path = identity_dir / "partner.md" assert partner_path.exists() content = partner_path.read_text() assert "# partner" in content @@ -645,15 +672,15 @@ class TestEnsureSolDirectory: assert "## expertise domains" in content def test_does_not_overwrite_existing_partner_md(self, tmp_path): - from think.awareness import ensure_sol_directory + from think.identity import ensure_identity_directory - sol_dir = tmp_path / "sol" - sol_dir.mkdir() + identity_dir = tmp_path / "identity" + identity_dir.mkdir() custom = "# partner\n\n## work patterns\nCustom content.\n" - (sol_dir / "partner.md").write_text(custom) + (identity_dir / "partner.md").write_text(custom) - ensure_sol_directory() - assert (sol_dir / "partner.md").read_text() == custom + ensure_identity_directory() + assert (identity_dir / "partner.md").read_text() == custom def test_migration_named_agent(self, tmp_path, monkeypatch): """Named agent config populates self.md name and opening.""" @@ -671,10 +698,10 @@ class TestEnsureSolDirectory: } (config_dir / "journal.json").write_text(json.dumps(config), encoding="utf-8") - from think.awareness import ensure_sol_directory + from think.identity import ensure_identity_directory - sol_dir = ensure_sol_directory() - content = (sol_dir / "self.md").read_text() + identity_dir = ensure_identity_directory() + content = (identity_dir / "self.md").read_text() assert "I am aria." in content assert "aria (named 2026-01-15)" in content # Owner should still be default @@ -695,10 +722,10 @@ class TestEnsureSolDirectory: } (config_dir / "journal.json").write_text(json.dumps(config), encoding="utf-8") - from think.awareness import ensure_sol_directory + from think.identity import ensure_identity_directory - sol_dir = ensure_sol_directory() - content = (sol_dir / "self.md").read_text() + identity_dir = ensure_identity_directory() + content = (identity_dir / "self.md").read_text() # Agent should be default assert "I am sol." in content assert "sol (default)" in content @@ -721,10 +748,10 @@ class TestEnsureSolDirectory: } (config_dir / "journal.json").write_text(json.dumps(config), encoding="utf-8") - from think.awareness import ensure_sol_directory + from think.identity import ensure_identity_directory - sol_dir = ensure_sol_directory() - content = (sol_dir / "self.md").read_text() + identity_dir = ensure_identity_directory() + content = (identity_dir / "self.md").read_text() assert "I am iris." in content assert "iris" in content # name section (no named_date) assert "Alex" in content @@ -737,9 +764,9 @@ class TestUpdateSelfMd: def _setup_self_md(self, tmp_path): """Create a minimal journal with self.md for testing.""" - sol_dir = tmp_path / "sol" - sol_dir.mkdir() - self_md = sol_dir / "self.md" + identity_dir = tmp_path / "identity" + identity_dir.mkdir() + self_md = identity_dir / "self.md" self_md.write_text( "# self\n" "\n" @@ -765,9 +792,14 @@ class TestUpdateSelfMd: def test_update_section_name(self, tmp_path): self_md = self._setup_self_md(tmp_path) - from think.awareness import update_self_md_section + from think.identity import update_self_md_section - result = update_self_md_section("my name", "aria (named 2026-03-19)") + result = update_self_md_section( + "my name", + "aria (named 2026-03-19)", + actor="test update self section", + reason="test", + ) assert result is True content = self_md.read_text() assert "aria (named 2026-03-19)" in content @@ -779,9 +811,14 @@ class TestUpdateSelfMd: def test_update_section_owner(self, tmp_path): self_md = self._setup_self_md(tmp_path) - from think.awareness import update_self_md_section + from think.identity import update_self_md_section - result = update_self_md_section("who I'm here for", "Jer\nSoftware engineer") + result = update_self_md_section( + "who I'm here for", + "Jer\nSoftware engineer", + actor="test update self section", + reason="test", + ) assert result is True content = self_md.read_text() assert "Jer\nSoftware engineer" in content @@ -792,23 +829,35 @@ class TestUpdateSelfMd: def test_update_section_logs_history(self, tmp_path): self._setup_self_md(tmp_path) - from think.awareness import update_self_md_section + from think.identity import update_self_md_section - update_self_md_section("my name", "aria (named 2026-03-19)") - history = tmp_path / "sol" / "history.jsonl" - assert history.exists() - records = [json.loads(line) for line in history.read_text().strip().split("\n")] + update_self_md_section( + "my name", + "aria (named 2026-03-19)", + actor="test update self section", + reason="test", + ) + records = _read_identity_history(tmp_path) assert len(records) == 1 - assert records[0]["file"] == "self.md" - assert records[0]["section"] == "my name" - assert records[0]["source"] == "api" - assert "diff" in records[0] + _assert_identity_history( + records[0], + file_name="self.md", + actor="test update self section", + op="update_section", + section="my name", + reason="test", + ) def test_update_section_last_section(self, tmp_path): self_md = self._setup_self_md(tmp_path) - from think.awareness import update_self_md_section + from think.identity import update_self_md_section - result = update_self_md_section("what I find interesting", "music and patterns") + result = update_self_md_section( + "what I find interesting", + "music and patterns", + actor="test update self section", + reason="test", + ) assert result is True content = self_md.read_text() assert "music and patterns" in content @@ -816,23 +865,35 @@ class TestUpdateSelfMd: def test_update_section_missing_heading(self, tmp_path): self._setup_self_md(tmp_path) - from think.awareness import update_self_md_section + from think.identity import update_self_md_section - result = update_self_md_section("nonexistent", "content") + result = update_self_md_section( + "nonexistent", + "content", + actor="test update self section", + reason="test", + ) assert result is False def test_update_section_no_file(self): - from think.awareness import update_self_md_section + from think.identity import update_self_md_section - result = update_self_md_section("my name", "content") + result = update_self_md_section( + "my name", + "content", + actor="test update self section", + reason="test", + ) assert result is False def test_update_opening(self, tmp_path): self_md = self._setup_self_md(tmp_path) - from think.awareness import update_self_md_opening + from think.identity import update_self_md_opening result = update_self_md_opening( - "I am aria. this is a new journal — we're just getting started." + "I am aria. this is a new journal — we're just getting started.", + actor="test update self opening", + reason="test", ) assert result is True content = self_md.read_text() @@ -844,21 +905,32 @@ class TestUpdateSelfMd: def test_update_opening_logs_history(self, tmp_path): self._setup_self_md(tmp_path) - from think.awareness import update_self_md_opening + from think.identity import update_self_md_opening - update_self_md_opening("I am aria.") - history = tmp_path / "sol" / "history.jsonl" - assert history.exists() - records = [json.loads(line) for line in history.read_text().strip().split("\n")] + update_self_md_opening( + "I am aria.", + actor="test update self opening", + reason="test", + ) + records = _read_identity_history(tmp_path) assert len(records) == 1 - assert records[0]["file"] == "self.md" - assert records[0]["section"] is None - assert records[0]["source"] == "api" + _assert_identity_history( + records[0], + file_name="self.md", + actor="test update self opening", + op="update_opening", + section=None, + reason="test", + ) def test_update_opening_no_file(self): - from think.awareness import update_self_md_opening + from think.identity import update_self_md_opening - result = update_self_md_opening("content") + result = update_self_md_opening( + "content", + actor="test update self opening", + reason="test", + ) assert result is False @@ -866,46 +938,61 @@ class TestUpdateIdentitySection: """Tests for update_identity_section generic helper.""" def test_update_partner_section(self, tmp_path): - from think.awareness import update_identity_section + from think.identity import update_identity_section partner_md = "# partner\n\n## work patterns\n[observing]\n\n## communication style\n[observing]\n" - (tmp_path / "sol").mkdir(exist_ok=True) - (tmp_path / "sol" / "partner.md").write_text(partner_md) + (tmp_path / "identity").mkdir(exist_ok=True) + (tmp_path / "identity" / "partner.md").write_text(partner_md) result = update_identity_section( - "partner.md", "work patterns", "Prefers mornings" + "partner.md", + "work patterns", + "Prefers mornings", + actor="test update identity section", + reason="test", ) assert result is True - content = (tmp_path / "sol" / "partner.md").read_text() + content = (tmp_path / "identity" / "partner.md").read_text() assert "Prefers mornings" in content assert "## communication style" in content assert "[observing]" in content # other section preserved def test_update_nonexistent_file_returns_false(self, tmp_path): - from think.awareness import update_identity_section + from think.identity import update_identity_section - (tmp_path / "sol").mkdir(exist_ok=True) - result = update_identity_section("nonexistent.md", "heading", "content") + (tmp_path / "identity").mkdir(exist_ok=True) + result = update_identity_section( + "nonexistent.md", + "heading", + "content", + actor="test update identity section", + reason="test", + ) assert result is False def test_self_md_wrapper_still_works(self, tmp_path): - from think.awareness import update_self_md_section + from think.identity import update_self_md_section self_md = ( "# self\n\n## my name\nsol (default)\n\n## who I'm here for\nTest User\n" ) - (tmp_path / "sol").mkdir(exist_ok=True) - (tmp_path / "sol" / "self.md").write_text(self_md) - - result = update_self_md_section("my name", "aria") + (tmp_path / "identity").mkdir(exist_ok=True) + (tmp_path / "identity" / "self.md").write_text(self_md) + + result = update_self_md_section( + "my name", + "aria", + actor="test update self section", + reason="test", + ) assert result is True - content = (tmp_path / "sol" / "self.md").read_text() + content = (tmp_path / "identity" / "self.md").read_text() assert "aria" in content assert "## who I'm here for" in content def test_partner_update_prunes_getting_started(self, tmp_path): - from think.awareness import update_identity_section + from think.identity import update_identity_section partner_md = ( "# partner\n\n" @@ -913,15 +1000,19 @@ class TestUpdateIdentitySection: "## work patterns\n[not yet observed]\n\n" "## communication style\n[not yet observed]\n" ) - (tmp_path / "sol").mkdir(exist_ok=True) - (tmp_path / "sol" / "partner.md").write_text(partner_md) + (tmp_path / "identity").mkdir(exist_ok=True) + (tmp_path / "identity" / "partner.md").write_text(partner_md) result = update_identity_section( - "partner.md", "work patterns", "Prefers mornings" + "partner.md", + "work patterns", + "Prefers mornings", + actor="test update identity section", + reason="test", ) assert result is True - content = (tmp_path / "sol" / "partner.md").read_text() + content = (tmp_path / "identity" / "partner.md").read_text() assert "Prefers mornings" in content assert "## communication style" in content assert "## getting started" not in content @@ -940,8 +1031,9 @@ class TestSolInitCLI: assert result.exit_code == 0 output = json.loads(result.output) assert output["status"] == "ok" - assert (tmp_path / "sol" / "self.md").exists() - assert (tmp_path / "sol" / "agency.md").exists() + assert output["identity_dir"] == str(tmp_path / "identity") + assert (tmp_path / "identity" / "self.md").exists() + assert (tmp_path / "identity" / "agency.md").exists() class TestSetOwnerCLI: @@ -953,10 +1045,10 @@ class TestSetOwnerCLI: config_dir = tmp_path / "config" config_dir.mkdir() (config_dir / "journal.json").write_text("{}", encoding="utf-8") - # Create sol/self.md - sol_dir = tmp_path / "sol" - sol_dir.mkdir() - (sol_dir / "self.md").write_text( + # Create identity/self.md + identity_dir = tmp_path / "identity" + identity_dir.mkdir() + (identity_dir / "self.md").write_text( "# self\n\nI am sol.\n\n## my name\nsol\n\n## who I'm here for\n[getting to know you]\n", encoding="utf-8", ) @@ -977,7 +1069,7 @@ class TestSetOwnerCLI: assert config["identity"]["name"] == "Jer" # Verify self.md was updated - self_content = (sol_dir / "self.md").read_text() + self_content = (identity_dir / "self.md").read_text() assert "Jer" in self_content assert "[getting to know you]" not in self_content @@ -986,9 +1078,9 @@ class TestSetOwnerCLI: config_dir = tmp_path / "config" config_dir.mkdir() (config_dir / "journal.json").write_text("{}", encoding="utf-8") - sol_dir = tmp_path / "sol" - sol_dir.mkdir() - (sol_dir / "self.md").write_text( + identity_dir = tmp_path / "identity" + identity_dir.mkdir() + (identity_dir / "self.md").write_text( "# self\n\nI am sol.\n\n## my name\nsol\n\n## who I'm here for\n[getting to know you]\n", encoding="utf-8", ) @@ -1008,21 +1100,21 @@ class TestSetOwnerCLI: assert output["bio"] == "Building solstone" # Verify self.md - self_content = (sol_dir / "self.md").read_text() + self_content = (identity_dir / "self.md").read_text() assert "Jer" in self_content assert "Building solstone" in self_content class TestSetNameUpdatesSelfMd: - """Tests that set-name updates sol/self.md.""" + """Tests that set-name updates identity/self.md.""" def test_set_name_updates_self_md(self, tmp_path): config_dir = tmp_path / "config" config_dir.mkdir() (config_dir / "journal.json").write_text("{}", encoding="utf-8") - sol_dir = tmp_path / "sol" - sol_dir.mkdir() - (sol_dir / "self.md").write_text( + identity_dir = tmp_path / "identity" + identity_dir.mkdir() + (identity_dir / "self.md").write_text( "# self\n\nI am sol. this is a new journal — we're just getting started.\n\n" "## my name\nsol (default)\n\n## who I'm here for\n[getting to know you]\n", encoding="utf-8", @@ -1040,7 +1132,7 @@ class TestSetNameUpdatesSelfMd: ) assert result.exit_code == 0 - self_content = (sol_dir / "self.md").read_text() + self_content = (identity_dir / "self.md").read_text() assert "I am aria." in self_content assert "I am sol." not in self_content assert "aria (named" in self_content diff --git a/tests/test_generate_talents.py b/tests/test_generate_talents.py index 754e32a55..a8691284d 100644 --- a/tests/test_generate_talents.py +++ b/tests/test_generate_talents.py @@ -10,7 +10,7 @@ def test_root_agents_md_is_hand_maintained(): content = agents_path.read_text(encoding="utf-8") assert content.startswith("# solstone Developer Guide") - assert "generated from sol/identity.md" not in content + assert "generated from identity/identity.md" not in content assert "docs/project-structure.md" in content assert "journal/AGENTS.md" in content diff --git a/tests/test_heartbeat.py b/tests/test_heartbeat.py index cd5f8fedb..bf4e5d6c5 100644 --- a/tests/test_heartbeat.py +++ b/tests/test_heartbeat.py @@ -20,7 +20,7 @@ def heartbeat_mocks(monkeypatch): "think.heartbeat.setup_cli", lambda parser: argparse.Namespace(force=False), ) - monkeypatch.setattr("think.heartbeat.ensure_sol_directory", lambda: None) + monkeypatch.setattr("think.heartbeat.ensure_identity_directory", lambda: None) monkeypatch.setattr( "think.heartbeat.cortex_request", lambda *args, **kwargs: "agent-123" ) @@ -244,7 +244,7 @@ def test_force_flag_bypasses_recency_check(journal_path, monkeypatch): "think.heartbeat.setup_cli", lambda parser: argparse.Namespace(force=True), ) - monkeypatch.setattr("think.heartbeat.ensure_sol_directory", lambda: None) + monkeypatch.setattr("think.heartbeat.ensure_identity_directory", lambda: None) monkeypatch.setattr( "think.heartbeat.wait_for_uses", lambda *args, **kwargs: ({"agent-123": "finish"}, []), diff --git a/tests/test_home_yesterdays_processing.py b/tests/test_home_yesterdays_processing.py index 6e8767043..fd1ef23af 100644 --- a/tests/test_home_yesterdays_processing.py +++ b/tests/test_home_yesterdays_processing.py @@ -123,7 +123,7 @@ def _seed_journal(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: def _write_briefing( journal: Path, generated: str, *, metadata_type: str = "morning_briefing" ) -> None: - path = journal / "sol" / "briefing.md" + path = journal / "identity" / "briefing.md" path.parent.mkdir(parents=True, exist_ok=True) path.write_text( ( diff --git a/tests/test_identity_writes.py b/tests/test_identity_writes.py new file mode 100644 index 000000000..b0ec37a24 --- /dev/null +++ b/tests/test_identity_writes.py @@ -0,0 +1,176 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +import hashlib +import json +import os +import re +import stat +import threading +from pathlib import Path + +import pytest + +from think.identity import update_identity_section, write_identity + + +@pytest.fixture(autouse=True) +def _temp_journal(monkeypatch, tmp_path): + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) + config_dir = tmp_path / "config" + config_dir.mkdir() + (config_dir / "journal.json").write_text("{}", encoding="utf-8") + return tmp_path + + +def _history_path(journal_path: Path) -> Path: + return journal_path / "identity" / "history.jsonl" + + +def _read_history(journal_path: Path) -> list[dict]: + return [ + json.loads(line) + for line in _history_path(journal_path).read_text().splitlines() + ] + + +def test_write_identity_first_write(tmp_path): + write_identity( + "pulse.md", + actor="test writer", + op="replace", + section=None, + content="first pulse\n", + reason="test", + ) + + pulse_path = tmp_path / "identity" / "pulse.md" + assert pulse_path.read_text(encoding="utf-8") == "first pulse\n" + + records = _read_history(tmp_path) + assert len(records) == 1 + record = records[0] + assert record["before_hash"] == hashlib.sha256(b"").hexdigest() + assert record["bytes_before"] == 0 + assert record["after_hash"] == hashlib.sha256(b"first pulse\n").hexdigest() + assert record["bytes_after"] == len("first pulse\n".encode("utf-8")) + + +def test_write_identity_atomic_failure(tmp_path, monkeypatch): + identity_dir = tmp_path / "identity" + identity_dir.mkdir() + target = identity_dir / "self.md" + target.write_text("original\n", encoding="utf-8") + + def fail_replace(src, dst): + raise OSError("replace failed") + + monkeypatch.setattr("think.identity.os.replace", fail_replace) + + with pytest.raises(OSError, match="replace failed"): + write_identity( + "self.md", + actor="test writer", + op="replace", + section=None, + content="updated\n", + reason="test", + ) + + assert target.read_text(encoding="utf-8") == "original\n" + assert not _history_path(tmp_path).exists() + assert list(identity_dir.glob(".self.md.*.tmp")) == [] + + +def test_write_identity_lock_serializes(tmp_path): + def writer(actor: str, content: str) -> None: + write_identity( + "self.md", + actor=actor, + op="replace", + section=None, + content=content, + reason="test", + ) + + thread_one = threading.Thread(target=writer, args=("writer-1", "first\n")) + thread_two = threading.Thread(target=writer, args=("writer-2", "second\n")) + thread_one.start() + thread_two.start() + thread_one.join() + thread_two.join() + + final_content = (tmp_path / "identity" / "self.md").read_text(encoding="utf-8") + assert final_content in {"first\n", "second\n"} + + records = _read_history(tmp_path) + assert len(records) == 2 + assert {records[0]["actor"], records[1]["actor"]} == {"writer-1", "writer-2"} + + +def test_write_identity_history_schema(tmp_path): + write_identity( + "awareness.md", + actor="schema test", + op="replace", + section=None, + content="awareness\n", + reason="test", + ) + + record = _read_history(tmp_path)[0] + assert list(record) == [ + "ts", + "file", + "actor", + "op", + "section", + "reason", + "before_hash", + "after_hash", + "bytes_before", + "bytes_after", + ] + assert record["file"] == "awareness.md" + assert record["actor"] == "schema test" + assert record["op"] == "replace" + assert record["section"] is None + assert isinstance(record["bytes_before"], int) + assert isinstance(record["bytes_after"], int) + assert isinstance(record["before_hash"], str) + assert isinstance(record["after_hash"], str) + assert re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z", record["ts"]) + + +def test_write_identity_mode_0600(tmp_path): + write_identity( + "partner.md", + actor="mode test", + op="replace", + section=None, + content="partner\n", + reason="test", + ) + + mode = stat.S_IMODE(os.stat(tmp_path / "identity" / "partner.md").st_mode) + assert mode == 0o600 + + +def test_update_identity_section_returns_false_no_change(tmp_path): + identity_dir = tmp_path / "identity" + identity_dir.mkdir() + (identity_dir / "partner.md").write_text( + "# partner\n\n## work patterns\nPrefers mornings\n", + encoding="utf-8", + ) + + changed = update_identity_section( + "partner.md", + "work patterns", + "Prefers mornings", + actor="section test", + reason="test", + ) + + assert changed is False + assert not _history_path(tmp_path).exists() diff --git a/tests/test_journal_merge.py b/tests/test_journal_merge.py index 575bf7a43..e9943a84e 100644 --- a/tests/test_journal_merge.py +++ b/tests/test_journal_merge.py @@ -112,8 +112,8 @@ def merge_journals_fixture(tmp_path, monkeypatch): {"title": "Work"}, ) - (source / "sol").mkdir(parents=True) - (source / "sol" / "self.md").write_text("source sol\n", encoding="utf-8") + (source / "identity").mkdir(parents=True) + (source / "identity" / "self.md").write_text("source identity\n", encoding="utf-8") (source / "config").mkdir(parents=True) (source / "config" / "source-only.json").write_text("{}", encoding="utf-8") @@ -516,14 +516,14 @@ def test_import_skip(merge_journals_fixture, monkeypatch): ).read_text(encoding="utf-8") == '{"manifest": "target"}\n' -def test_source_sol_skipped(merge_journals_fixture, monkeypatch): +def test_source_identity_skipped(merge_journals_fixture, monkeypatch): paths = merge_journals_fixture _mock_indexer(monkeypatch) result = runner.invoke(call_app, ["journal", "merge", str(paths["source"])]) assert result.exit_code == 0 - assert not (paths["target"] / "sol" / "self.md").exists() + assert not (paths["target"] / "identity" / "self.md").exists() def test_source_config_skipped(merge_journals_fixture, monkeypatch): diff --git a/tests/test_sol_call.py b/tests/test_sol_call.py index ecaa6d684..b9f961d13 100644 --- a/tests/test_sol_call.py +++ b/tests/test_sol_call.py @@ -3,7 +3,9 @@ """Tests for sol call identity — identity directory read/write commands.""" +import inspect import json +import re import pytest from typer.testing import CliRunner @@ -11,22 +13,53 @@ from typer.testing import CliRunner 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 + assert isinstance(record["before_hash"], str) + assert isinstance(record["after_hash"], str) + assert isinstance(record["bytes_before"], int) + assert isinstance(record["bytes_after"], int) @pytest.fixture -def journal_with_sol(tmp_path, monkeypatch): - """Set up a journal with sol/ directory containing self.md, agency.md, and partner.md.""" +def journal_with_identity(tmp_path, monkeypatch): + """Set up a journal with identity/ containing self.md, agency.md, and partner.md.""" monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) - # Provide minimal config for ensure_sol_directory + # Provide minimal config for ensure_identity_directory config_dir = tmp_path / "config" config_dir.mkdir() (config_dir / "journal.json").write_text( json.dumps({"identity": {"name": "Test User"}}) ) - sol_dir = tmp_path / "sol" - sol_dir.mkdir() + identity_dir = tmp_path / "identity" + identity_dir.mkdir() self_md = """\ # self @@ -48,7 +81,7 @@ Test User ## what I find interesting [discovering] """ - (sol_dir / "self.md").write_text(self_md) + (identity_dir / "self.md").write_text(self_md) agency_md = """\ # agency @@ -64,7 +97,7 @@ things I'm tracking, acting on, or watching. ## system [monitoring] """ - (sol_dir / "agency.md").write_text(agency_md) + (identity_dir / "agency.md").write_text(agency_md) partner_md = """\ # partner @@ -87,13 +120,14 @@ adapt its responses, timing, and initiative to how this person actually works. ## expertise domains [observing] """ - (sol_dir / "partner.md").write_text(partner_md) + (identity_dir / "partner.md").write_text(partner_md) + (identity_dir / "awareness.md").write_text("not yet updated\n") return tmp_path class TestSolSelfRead: - def test_read_self(self, journal_with_sol): + def test_read_self(self, journal_with_identity): result = runner.invoke(app, ["self"]) assert result.exit_code == 0 assert "# self" in result.output @@ -104,35 +138,35 @@ class TestSolSelfRead: config_dir = tmp_path / "config" config_dir.mkdir() (config_dir / "journal.json").write_text(json.dumps({})) - # ensure_sol_directory will create the file, so this tests the happy path + # ensure_identity_directory will create the file, so this tests the happy path result = runner.invoke(app, ["self"]) assert result.exit_code == 0 class TestSolSelfWrite: - def test_write_self(self, journal_with_sol): + def test_write_self(self, journal_with_identity): new_content = "# self\n\nI am sol. Jer's journal.\n\n## my name\nsol\n" result = runner.invoke(app, ["self", "--write"], input=new_content) assert result.exit_code == 0 assert "self.md updated" in result.output # Verify file was written - self_path = journal_with_sol / "sol" / "self.md" + self_path = journal_with_identity / "identity" / "self.md" assert self_path.read_text() == new_content - def test_write_self_empty_stdin(self, journal_with_sol): + def test_write_self_empty_stdin(self, journal_with_identity): result = runner.invoke(app, ["self", "--write"], input="") assert result.exit_code == 1 assert "no content" in result.output - def test_write_self_whitespace_only(self, journal_with_sol): + def test_write_self_whitespace_only(self, journal_with_identity): result = runner.invoke(app, ["self", "--write"], input=" \n\n ") assert result.exit_code == 1 assert "no content" in result.output class TestSolSelfUpdateSection: - def test_update_section_owner(self, journal_with_sol): + def test_update_section_owner(self, journal_with_identity): result = runner.invoke( app, ["self", "--update-section", "who I'm here for"], @@ -142,14 +176,14 @@ class TestSolSelfUpdateSection: assert "Updated ## who I'm here for" in result.output # Verify section was updated, other sections preserved - self_path = journal_with_sol / "sol" / "self.md" + self_path = journal_with_identity / "identity" / "self.md" content = self_path.read_text() assert "Jer — goes by Jer, not Jeremie" in content assert "## my name" in content assert "sol (default)" in content assert "## our relationship" in content - def test_update_section_not_found(self, journal_with_sol): + def test_update_section_not_found(self, journal_with_identity): result = runner.invoke( app, ["self", "--update-section", "nonexistent"], @@ -158,7 +192,7 @@ class TestSolSelfUpdateSection: assert result.exit_code == 1 assert "not found" in result.output - def test_update_section_empty_stdin(self, journal_with_sol): + def test_update_section_empty_stdin(self, journal_with_identity): result = runner.invoke( app, ["self", "--update-section", "who I'm here for"], @@ -169,7 +203,7 @@ class TestSolSelfUpdateSection: class TestSolPartnerRead: - def test_read_partner(self, journal_with_sol): + def test_read_partner(self, journal_with_identity): result = runner.invoke(app, ["partner"]) assert result.exit_code == 0 assert "# partner" in result.output @@ -180,29 +214,29 @@ class TestSolPartnerRead: config_dir = tmp_path / "config" config_dir.mkdir() (config_dir / "journal.json").write_text(json.dumps({})) - # ensure_sol_directory creates partner.md + # ensure_identity_directory creates partner.md result = runner.invoke(app, ["partner"]) assert result.exit_code == 0 class TestSolPartnerWrite: - def test_write_partner(self, journal_with_sol): + def test_write_partner(self, journal_with_identity): new_content = "# partner\n\n## work patterns\nPrefers mornings for deep work.\n" result = runner.invoke(app, ["partner", "--write"], input=new_content) assert result.exit_code == 0 assert "partner.md updated" in result.output - partner_path = journal_with_sol / "sol" / "partner.md" + partner_path = journal_with_identity / "identity" / "partner.md" assert partner_path.read_text() == new_content - def test_write_partner_empty_stdin(self, journal_with_sol): + def test_write_partner_empty_stdin(self, journal_with_identity): result = runner.invoke(app, ["partner", "--write"], input="") assert result.exit_code == 1 assert "no content" in result.output class TestSolPartnerUpdateSection: - def test_update_section_work_patterns(self, journal_with_sol): + def test_update_section_work_patterns(self, journal_with_identity): result = runner.invoke( app, ["partner", "--update-section", "work patterns"], @@ -211,13 +245,13 @@ class TestSolPartnerUpdateSection: assert result.exit_code == 0 assert "Updated ## work patterns" in result.output - partner_path = journal_with_sol / "sol" / "partner.md" + partner_path = journal_with_identity / "identity" / "partner.md" content = partner_path.read_text() assert "Prefers async communication" in content assert "## communication style" in content assert "## decision style" in content - def test_update_section_not_found(self, journal_with_sol): + def test_update_section_not_found(self, journal_with_identity): result = runner.invoke( app, ["partner", "--update-section", "nonexistent"], @@ -226,7 +260,7 @@ class TestSolPartnerUpdateSection: assert result.exit_code == 1 assert "not found" in result.output - def test_update_section_empty_stdin(self, journal_with_sol): + def test_update_section_empty_stdin(self, journal_with_identity): result = runner.invoke( app, ["partner", "--update-section", "work patterns"], @@ -237,7 +271,7 @@ class TestSolPartnerUpdateSection: class TestSolAgencyRead: - def test_read_agency(self, journal_with_sol): + def test_read_agency(self, journal_with_identity): result = runner.invoke(app, ["agency"]) assert result.exit_code == 0 assert "# agency" in result.output @@ -248,32 +282,32 @@ class TestSolAgencyRead: config_dir = tmp_path / "config" config_dir.mkdir() (config_dir / "journal.json").write_text(json.dumps({})) - # ensure_sol_directory creates agency.md + # ensure_identity_directory creates agency.md result = runner.invoke(app, ["agency"]) assert result.exit_code == 0 class TestSolAgencyWrite: - def test_write_agency(self, journal_with_sol): + def test_write_agency(self, journal_with_identity): new_content = "# agency\n\n## curation\n- review entity duplicates\n\n## system\n[clean]\n" result = runner.invoke(app, ["agency", "--write"], input=new_content) assert result.exit_code == 0 assert "agency.md updated" in result.output # Verify file was written - agency_path = journal_with_sol / "sol" / "agency.md" + agency_path = journal_with_identity / "identity" / "agency.md" assert agency_path.read_text() == new_content - def test_write_agency_empty_stdin(self, journal_with_sol): + def test_write_agency_empty_stdin(self, journal_with_identity): result = runner.invoke(app, ["agency", "--write"], input="") assert result.exit_code == 1 assert "no content" in result.output class TestSolPulseRead: - def test_read_pulse(self, journal_with_sol): + def test_read_pulse(self, journal_with_identity): pulse_md = "---\nupdated: 2026-03-22T14:00:00\nsource: pulse-cogitate\n---\n\nTest narrative.\n" - (journal_with_sol / "sol" / "pulse.md").write_text(pulse_md) + (journal_with_identity / "identity" / "pulse.md").write_text(pulse_md) result = runner.invoke(app, ["pulse"]) assert result.exit_code == 0 assert "Test narrative" in result.output @@ -289,76 +323,80 @@ class TestSolPulseRead: class TestSolPulseWrite: - def test_write_pulse(self, journal_with_sol): + def test_write_pulse(self, journal_with_identity): new_content = "---\nupdated: 2026-03-22T14:00:00\nsource: pulse-cogitate\n---\n\nNew narrative.\n" result = runner.invoke(app, ["pulse", "--write"], input=new_content) assert result.exit_code == 0 assert "pulse.md updated" in result.output # Verify file was written - pulse_path = journal_with_sol / "sol" / "pulse.md" + pulse_path = journal_with_identity / "identity" / "pulse.md" assert pulse_path.read_text() == new_content - def test_write_pulse_empty_stdin(self, journal_with_sol): + def test_write_pulse_empty_stdin(self, journal_with_identity): result = runner.invoke(app, ["pulse", "--write"], input="") assert result.exit_code == 1 assert "no content" in result.output -class TestSolWriteDoesNotEscapeSolDir: - """Verify that sol call identity only writes to sol/ directory files.""" +class TestSolWriteDoesNotEscapeIdentityDir: + """Verify that sol call identity only writes to identity/ files.""" - def test_self_write_stays_in_sol_dir(self, journal_with_sol): - """Write to self.md goes to sol/self.md, not anywhere else.""" + def test_self_write_stays_in_identity_dir(self, journal_with_identity): + """Write to self.md goes to identity/self.md, not anywhere else.""" result = runner.invoke(app, ["self", "--write"], input="test content\n") assert result.exit_code == 0 - self_path = journal_with_sol / "sol" / "self.md" + self_path = journal_with_identity / "identity" / "self.md" assert self_path.read_text() == "test content\n" - # No files created outside sol/ - journal_files = set(f.name for f in journal_with_sol.iterdir() if f.is_file()) + journal_files = set( + f.name for f in journal_with_identity.iterdir() if f.is_file() + ) assert "self.md" not in journal_files - def test_agency_write_stays_in_sol_dir(self, journal_with_sol): - """Write to agency.md goes to sol/agency.md, not anywhere else.""" + def test_agency_write_stays_in_identity_dir(self, journal_with_identity): + """Write to agency.md goes to identity/agency.md, not anywhere else.""" result = runner.invoke(app, ["agency", "--write"], input="test content\n") assert result.exit_code == 0 - agency_path = journal_with_sol / "sol" / "agency.md" + agency_path = journal_with_identity / "identity" / "agency.md" assert agency_path.read_text() == "test content\n" - # No files created outside sol/ - journal_files = set(f.name for f in journal_with_sol.iterdir() if f.is_file()) + journal_files = set( + f.name for f in journal_with_identity.iterdir() if f.is_file() + ) assert "agency.md" not in journal_files - def test_pulse_write_stays_in_sol_dir(self, journal_with_sol): - """Write to pulse.md goes to sol/pulse.md, not anywhere else.""" + def test_pulse_write_stays_in_identity_dir(self, journal_with_identity): + """Write to pulse.md goes to identity/pulse.md, not anywhere else.""" result = runner.invoke(app, ["pulse", "--write"], input="test content\n") assert result.exit_code == 0 - pulse_path = journal_with_sol / "sol" / "pulse.md" + pulse_path = journal_with_identity / "identity" / "pulse.md" assert pulse_path.read_text() == "test content\n" - # No files created outside sol/ - journal_files = set(f.name for f in journal_with_sol.iterdir() if f.is_file()) + journal_files = set( + f.name for f in journal_with_identity.iterdir() if f.is_file() + ) assert "pulse.md" not in journal_files - def test_partner_write_stays_in_sol_dir(self, journal_with_sol): - """Write to partner.md goes to sol/partner.md, not anywhere else.""" + def test_partner_write_stays_in_identity_dir(self, journal_with_identity): + """Write to partner.md goes to identity/partner.md, not anywhere else.""" result = runner.invoke(app, ["partner", "--write"], input="test content\n") assert result.exit_code == 0 - partner_path = journal_with_sol / "sol" / "partner.md" + partner_path = journal_with_identity / "identity" / "partner.md" assert partner_path.read_text() == "test content\n" - # No files created outside sol/ - journal_files = set(f.name for f in journal_with_sol.iterdir() if f.is_file()) + journal_files = set( + f.name for f in journal_with_identity.iterdir() if f.is_file() + ) assert "partner.md" not in journal_files class TestSolSelfValueOption: - def test_write_self_with_value(self, journal_with_sol): + def test_write_self_with_value(self, journal_with_identity): new_content = "# self\n\nI am sol. Jer's journal.\n\n## my name\nsol\n" result = runner.invoke(app, ["self", "--write", "--value", new_content]) assert result.exit_code == 0 assert "self.md updated" in result.output - self_path = journal_with_sol / "sol" / "self.md" + self_path = journal_with_identity / "identity" / "self.md" assert self_path.read_text() == new_content - def test_update_section_with_value(self, journal_with_sol): + def test_update_section_with_value(self, journal_with_identity): result = runner.invoke( app, [ @@ -371,65 +409,65 @@ class TestSolSelfValueOption: ) assert result.exit_code == 0 assert "Updated ## who I'm here for" in result.output - content = (journal_with_sol / "sol" / "self.md").read_text() + content = (journal_with_identity / "identity" / "self.md").read_text() assert "Jer — founder" in content - def test_value_empty_string_errors(self, journal_with_sol): + def test_value_empty_string_errors(self, journal_with_identity): result = runner.invoke(app, ["self", "--write", "--value", " "]) assert result.exit_code == 1 assert "no content" in result.output - def test_value_takes_precedence_over_stdin(self, journal_with_sol): + def test_value_takes_precedence_over_stdin(self, journal_with_identity): result = runner.invoke( app, ["self", "--write", "--value", "from value\n"], input="from stdin\n", ) assert result.exit_code == 0 - self_path = journal_with_sol / "sol" / "self.md" + self_path = journal_with_identity / "identity" / "self.md" assert self_path.read_text() == "from value\n" class TestSolAgencyValueOption: - def test_write_agency_with_value(self, journal_with_sol): + def test_write_agency_with_value(self, journal_with_identity): new_content = "# agency\n\n## curation\n- item\n" result = runner.invoke(app, ["agency", "--write", "--value", new_content]) assert result.exit_code == 0 assert "agency.md updated" in result.output - agency_path = journal_with_sol / "sol" / "agency.md" + agency_path = journal_with_identity / "identity" / "agency.md" assert agency_path.read_text() == new_content - def test_value_empty_string_errors(self, journal_with_sol): + def test_value_empty_string_errors(self, journal_with_identity): result = runner.invoke(app, ["agency", "--write", "--value", ""]) assert result.exit_code == 1 assert "no content" in result.output class TestSolPulseValueOption: - def test_write_pulse_with_value(self, journal_with_sol): + def test_write_pulse_with_value(self, journal_with_identity): new_content = "---\nupdated: 2026-03-22\n---\n\nNarrative.\n" result = runner.invoke(app, ["pulse", "--write", "--value", new_content]) assert result.exit_code == 0 assert "pulse.md updated" in result.output - pulse_path = journal_with_sol / "sol" / "pulse.md" + pulse_path = journal_with_identity / "identity" / "pulse.md" assert pulse_path.read_text() == new_content - def test_value_empty_string_errors(self, journal_with_sol): + def test_value_empty_string_errors(self, journal_with_identity): result = runner.invoke(app, ["pulse", "--write", "--value", ""]) assert result.exit_code == 1 assert "no content" in result.output class TestSolPartnerValueOption: - def test_write_partner_with_value(self, journal_with_sol): + def test_write_partner_with_value(self, journal_with_identity): new_content = "# partner\n\n## work patterns\nMorning person.\n" result = runner.invoke(app, ["partner", "--write", "--value", new_content]) assert result.exit_code == 0 assert "partner.md updated" in result.output - partner_path = journal_with_sol / "sol" / "partner.md" + partner_path = journal_with_identity / "identity" / "partner.md" assert partner_path.read_text() == new_content - def test_update_section_with_value(self, journal_with_sol): + def test_update_section_with_value(self, journal_with_identity): result = runner.invoke( app, [ @@ -442,76 +480,92 @@ class TestSolPartnerValueOption: ) assert result.exit_code == 0 assert "Updated ## work patterns" in result.output - content = (journal_with_sol / "sol" / "partner.md").read_text() + content = (journal_with_identity / "identity" / "partner.md").read_text() assert "Prefers mornings" in content - def test_value_empty_string_errors(self, journal_with_sol): + def test_value_empty_string_errors(self, journal_with_identity): result = runner.invoke(app, ["partner", "--write", "--value", " "]) assert result.exit_code == 1 assert "no content" in result.output class TestSolHistoryLogging: - def test_self_write_logs_history(self, journal_with_sol): + def test_self_write_logs_history(self, journal_with_identity): new_content = "# self\n\nUpdated.\n" runner.invoke(app, ["self", "--write", "--value", new_content]) - history = journal_with_sol / "sol" / "history.jsonl" - assert history.exists() - records = [json.loads(line) for line in history.read_text().strip().split("\n")] + records = _read_history(journal_with_identity) assert len(records) == 1 - assert records[0]["file"] == "self.md" - assert records[0]["source"] == "cli" - assert records[0]["section"] is None - assert "ts" in records[0] - assert "diff" in records[0] + _assert_history_record( + records[0], + file_name="self.md", + actor="sol call identity self --write", + op="replace", + section=None, + reason="manual replace", + ) - def test_agency_write_logs_history(self, journal_with_sol): + def test_agency_write_logs_history(self, journal_with_identity): runner.invoke(app, ["agency", "--write", "--value", "# agency\n\nNew.\n"]) - history = journal_with_sol / "sol" / "history.jsonl" - assert history.exists() - records = [json.loads(line) for line in history.read_text().strip().split("\n")] + records = _read_history(journal_with_identity) assert len(records) == 1 - assert records[0]["file"] == "agency.md" - assert records[0]["source"] == "cli" + _assert_history_record( + records[0], + file_name="agency.md", + actor="sol call identity agency --write", + op="replace", + section=None, + reason="manual replace", + ) - def test_pulse_write_logs_history(self, journal_with_sol): + def test_pulse_write_logs_history(self, journal_with_identity): runner.invoke(app, ["pulse", "--write", "--value", "---\n---\n\nPulse.\n"]) - history = journal_with_sol / "sol" / "history.jsonl" - assert history.exists() - records = [json.loads(line) for line in history.read_text().strip().split("\n")] + records = _read_history(journal_with_identity) assert len(records) == 1 - assert records[0]["file"] == "pulse.md" + _assert_history_record( + records[0], + file_name="pulse.md", + actor="sol call identity pulse --write", + op="replace", + section=None, + reason="manual replace", + ) - def test_update_section_logs_history(self, journal_with_sol): + def test_update_section_logs_history(self, journal_with_identity): runner.invoke( app, ["self", "--update-section", "who I'm here for", "--value", "Jer"], ) - history = journal_with_sol / "sol" / "history.jsonl" - assert history.exists() - records = [json.loads(line) for line in history.read_text().strip().split("\n")] + records = _read_history(journal_with_identity) assert len(records) == 1 - assert records[0]["file"] == "self.md" - assert records[0]["section"] == "who I'm here for" - assert records[0]["source"] == "api" + _assert_history_record( + records[0], + file_name="self.md", + actor="sol call identity self --update-section ", + op="update_section", + section="who I'm here for", + reason="manual section update", + ) - def test_multiple_writes_append(self, journal_with_sol): + def test_multiple_writes_append(self, journal_with_identity): runner.invoke(app, ["self", "--write", "--value", "# self\n\nFirst.\n"]) runner.invoke(app, ["self", "--write", "--value", "# self\n\nSecond.\n"]) - history = journal_with_sol / "sol" / "history.jsonl" - records = [json.loads(line) for line in history.read_text().strip().split("\n")] + records = _read_history(journal_with_identity) assert len(records) == 2 - def test_partner_write_logs_history(self, journal_with_sol): + def test_partner_write_logs_history(self, journal_with_identity): runner.invoke(app, ["partner", "--write", "--value", "# partner\n\nNew.\n"]) - history = journal_with_sol / "sol" / "history.jsonl" - assert history.exists() - records = [json.loads(line) for line in history.read_text().strip().split("\n")] + records = _read_history(journal_with_identity) assert len(records) == 1 - assert records[0]["file"] == "partner.md" - assert records[0]["source"] == "cli" + _assert_history_record( + records[0], + file_name="partner.md", + actor="sol call identity partner --write", + op="replace", + section=None, + reason="manual replace", + ) - def test_partner_update_section_logs_history(self, journal_with_sol): + def test_partner_update_section_logs_history(self, journal_with_identity): runner.invoke( app, [ @@ -522,34 +576,35 @@ class TestSolHistoryLogging: "Morning focus", ], ) - history = journal_with_sol / "sol" / "history.jsonl" - assert history.exists() - records = [json.loads(line) for line in history.read_text().strip().split("\n")] + records = _read_history(journal_with_identity) assert len(records) == 1 - assert records[0]["file"] == "partner.md" - assert records[0]["section"] == "work patterns" - assert records[0]["source"] == "api" - + _assert_history_record( + records[0], + file_name="partner.md", + actor="sol call identity partner --update-section ", + op="update_section", + section="work patterns", + reason="manual section update", + ) -class TestHeartbeatEnsureSolDirectory: - """Verify the heartbeat bug fix — ensure_sol_directory() takes no args.""" - def test_ensure_sol_directory_no_args(self): - """ensure_sol_directory accepts no positional args (heartbeat.py:32 fix).""" - import inspect +class TestHeartbeatEnsureIdentityDirectory: + """Verify the heartbeat bug fix — ensure_identity_directory() takes no args.""" - from think.awareness import ensure_sol_directory + def test_ensure_identity_directory_no_args(self): + """ensure_identity_directory accepts no positional args (heartbeat.py:32 fix).""" + from think.identity import ensure_identity_directory - sig = inspect.signature(ensure_sol_directory) + sig = inspect.signature(ensure_identity_directory) params = [ p for p in sig.parameters.values() if p.default is inspect.Parameter.empty ] assert len(params) == 0, ( - "ensure_sol_directory should take no required arguments" + "ensure_identity_directory should take no required arguments" ) def test_heartbeat_calls_correctly(self): - """heartbeat.py calls ensure_sol_directory() without arguments.""" + """heartbeat.py calls ensure_identity_directory() without arguments.""" import ast from pathlib import Path @@ -560,8 +615,8 @@ class TestHeartbeatEnsureSolDirectory: if ( isinstance(node, ast.Call) and isinstance(node.func, ast.Name) - and node.func.id == "ensure_sol_directory" + and node.func.id == "ensure_identity_directory" ): assert len(node.args) == 0, ( - f"ensure_sol_directory() called with {len(node.args)} args at line {node.lineno}" + f"ensure_identity_directory() called with {len(node.args)} args at line {node.lineno}" ) diff --git a/tests/test_sol_call_identity_hydrate.py b/tests/test_sol_call_identity_hydrate.py index 06175de54..41469193b 100644 --- a/tests/test_sol_call_identity_hydrate.py +++ b/tests/test_sol_call_identity_hydrate.py @@ -37,12 +37,12 @@ def _run_identity_hydrate(journal_path): def test_identity_hydrate_reads_all_sections(journal_path): - sol_dir = journal_path / "sol" - sol_dir.mkdir() - (sol_dir / "self.md").write_text("self body") - (sol_dir / "partner.md").write_text("partner body") - (sol_dir / "agency.md").write_text("agency body") - (sol_dir / "awareness.md").write_text("awareness body") + identity_dir = journal_path / "identity" + identity_dir.mkdir() + (identity_dir / "self.md").write_text("self body") + (identity_dir / "partner.md").write_text("partner body") + (identity_dir / "agency.md").write_text("agency body") + (identity_dir / "awareness.md").write_text("awareness body") result = _run_identity_hydrate(journal_path) @@ -57,11 +57,11 @@ def test_identity_hydrate_reads_all_sections(journal_path): def test_identity_hydrate_marks_missing_sections(journal_path): - sol_dir = journal_path / "sol" - sol_dir.mkdir() - (sol_dir / "self.md").write_text("self body") - (sol_dir / "partner.md").write_text("partner body") - (sol_dir / "awareness.md").write_text("awareness body") + identity_dir = journal_path / "identity" + identity_dir.mkdir() + (identity_dir / "self.md").write_text("self body") + (identity_dir / "partner.md").write_text("partner body") + (identity_dir / "awareness.md").write_text("awareness body") result = _run_identity_hydrate(journal_path) @@ -69,7 +69,7 @@ def test_identity_hydrate_marks_missing_sections(journal_path): assert "# agency\n\n(not present)\n" in result.stdout -def test_identity_hydrate_handles_empty_sol_directory(journal_path): +def test_identity_hydrate_handles_empty_identity_directory(journal_path): result = _run_identity_hydrate(journal_path) assert result.returncode == 0 @@ -78,12 +78,12 @@ def test_identity_hydrate_handles_empty_sol_directory(journal_path): def test_identity_hydrate_starts_with_species_preamble(journal_path): - sol_dir = journal_path / "sol" - sol_dir.mkdir() - (sol_dir / "self.md").write_text("self body") - (sol_dir / "partner.md").write_text("partner body") - (sol_dir / "agency.md").write_text("agency body") - (sol_dir / "awareness.md").write_text("awareness body") + identity_dir = journal_path / "identity" + identity_dir.mkdir() + (identity_dir / "self.md").write_text("self body") + (identity_dir / "partner.md").write_text("partner body") + (identity_dir / "agency.md").write_text("agency body") + (identity_dir / "awareness.md").write_text("awareness body") result = _run_identity_hydrate(journal_path) @@ -96,12 +96,12 @@ def test_identity_hydrate_starts_with_species_preamble(journal_path): def test_identity_hydrate_strips_duplicate_section_heading(journal_path): - sol_dir = journal_path / "sol" - sol_dir.mkdir() - (sol_dir / "self.md").write_text("# self\n\nself body\n") - (sol_dir / "partner.md").write_text("partner body") - (sol_dir / "agency.md").write_text("agency body") - (sol_dir / "awareness.md").write_text("awareness body") + identity_dir = journal_path / "identity" + identity_dir.mkdir() + (identity_dir / "self.md").write_text("# self\n\nself body\n") + (identity_dir / "partner.md").write_text("partner body") + (identity_dir / "agency.md").write_text("agency body") + (identity_dir / "awareness.md").write_text("awareness body") result = _run_identity_hydrate(journal_path) @@ -111,12 +111,12 @@ def test_identity_hydrate_strips_duplicate_section_heading(journal_path): def test_identity_hydrate_preserves_non_matching_heading(journal_path): - sol_dir = journal_path / "sol" - sol_dir.mkdir() - (sol_dir / "self.md").write_text("# My Custom Heading\n\nself body\n") - (sol_dir / "partner.md").write_text("partner body") - (sol_dir / "agency.md").write_text("agency body") - (sol_dir / "awareness.md").write_text("awareness body") + identity_dir = journal_path / "identity" + identity_dir.mkdir() + (identity_dir / "self.md").write_text("# My Custom Heading\n\nself body\n") + (identity_dir / "partner.md").write_text("partner body") + (identity_dir / "agency.md").write_text("agency body") + (identity_dir / "awareness.md").write_text("awareness body") result = _run_identity_hydrate(journal_path) diff --git a/tests/test_template_substitution.py b/tests/test_template_substitution.py index 11b71f250..ecdc59e03 100644 --- a/tests/test_template_substitution.py +++ b/tests/test_template_substitution.py @@ -253,8 +253,8 @@ def test_load_prompt_empty_context(mock_journal_with_config, mock_prompt_dir): assert result_none.text == result_empty.text -def test_load_prompt_sol_vars_follow_journal_override(monkeypatch, tmp_path): - """Journal sol/ content should not leak across journal overrides.""" +def test_load_prompt_identity_vars_follow_journal_override(monkeypatch, tmp_path): + """Journal identity/ content should not leak across journal overrides.""" def write_journal(journal_dir, awareness_text): config_dir = journal_dir / "config" @@ -267,13 +267,13 @@ def test_load_prompt_sol_vars_follow_journal_override(monkeypatch, tmp_path): } ) ) - sol_dir = journal_dir / "sol" - sol_dir.mkdir() - (sol_dir / "awareness.md").write_text(awareness_text) + identity_dir = journal_dir / "identity" + identity_dir.mkdir() + (identity_dir / "awareness.md").write_text(awareness_text) prompt_dir = tmp_path / "prompts" prompt_dir.mkdir() - (prompt_dir / "sol_vars.md").write_text("Awareness:\n$sol_awareness\n") + (prompt_dir / "identity_vars.md").write_text("Awareness:\n$identity_awareness\n") journal_one = tmp_path / "journal-one" journal_two = tmp_path / "journal-two" @@ -281,10 +281,10 @@ def test_load_prompt_sol_vars_follow_journal_override(monkeypatch, tmp_path): write_journal(journal_two, "second awareness") monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal_one)) - first = load_prompt("sol_vars", base_dir=prompt_dir) + first = load_prompt("identity_vars", base_dir=prompt_dir) assert "first awareness" in first.text monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal_two)) - second = load_prompt("sol_vars", base_dir=prompt_dir) + second = load_prompt("identity_vars", base_dir=prompt_dir) assert "second awareness" in second.text assert "first awareness" not in second.text diff --git a/think/awareness.py b/think/awareness.py index b70fdf250..4e51d6caf 100644 --- a/think/awareness.py +++ b/think/awareness.py @@ -15,7 +15,6 @@ learned preferences, and cross-session agent memory. from __future__ import annotations -import fcntl import json import logging import os @@ -38,348 +37,6 @@ def _awareness_dir() -> Path: return d -_AGENCY_MD = """\ -# agency - -things I'm tracking, acting on, or watching. I update this as I notice things -and resolve them. the heartbeat reviews this periodically. - -## curation -[nothing yet — building initial picture of journal health] - -## observations -[watching and learning] - -## follow-throughs -[none yet] - -## system -[monitoring] - -## self-improvement -[learning what works] -""" - - -_PARTNER_MD = """\ -# partner - -Behavioral profile of the journal owner — observed patterns that help sol -adapt its responses, timing, and initiative to how this person actually works. - -## getting started - -Everything stays on your machine — this journal is yours alone, never sent to sol pbc. - -When meeting the owner for the first time, learn about them naturally through conversation. -Present one thing at a time — don't overwhelm. - -### learn their name - -Ask what they'd like to be called. Record it: -- `sol call sol set-owner "NAME"` -- With context: `sol call sol set-owner "NAME" --bio "SHORT_BIO"` - -As you learn about them, update your partner profile: -- `sol call identity partner --update-section 'SECTION' --value 'what you observed'` - -### set up facets - -Ask what areas of their life they want to track (work, personal, hobbies, side projects, etc.). Create facets for each: -- `sol call journal facet create TITLE [--emoji EMOJI] [--color COLOR] [--description DESC]` -- `sol call journal facets` — verify what was created - -### attach entities - -For each facet, ask about key people, companies, projects, and tools: -- `sol call entities attach TYPE ENTITY DESCRIPTION --facet FACET` -- Types: Person, Company, Project, Tool - -### offer imports - -After setup, offer to bring in history from existing tools: -- Calendar (ics), ChatGPT (chatgpt), Claude (claude), Gemini (gemini), Granola (granola), Notes (obsidian), Kindle (kindle) -- Read guide: `apps/import/guides/{source}.md` -- Navigate: `sol call navigate "/app/import#guide/{source}"` -- If declined: `sol call awareness imports --declined` - -### support - -If the owner needs help or wants to share feedback, handle it in-place — file tickets, track -responses. Nothing gets sent without their review. - -## work patterns -[not yet observed — sol will learn as we spend time together] - -## communication style -[not yet observed — sol will learn as we spend time together] - -## relationship priorities -[not yet observed — sol will learn as we spend time together] - -## decision style -[not yet observed — sol will learn as we spend time together] - -## expertise domains -[not yet observed — sol will learn as we spend time together] -""" - -_AWARENESS_MD = "not yet updated\n" - - -def _build_self_md(config: dict) -> str: - """Build self.md content, optionally migrating from config data.""" - agent = config.get("agent", {}) - identity = config.get("identity", {}) - - name_status = agent.get("name_status", "default") - agent_name = agent.get("name", "sol") - named_date = agent.get("named_date") - owner_name = identity.get("name", "") - owner_bio = identity.get("bio", "") - - has_named_agent = name_status in ("chosen", "self-named") - has_identity = bool(owner_name) - - # Opening paragraph - if has_named_agent: - opening = ( - f"I am {agent_name}. this is a new journal — we're just getting started." - ) - else: - opening = "I am sol. this is a new journal — we're just getting started." - - # Name section - if has_named_agent: - if named_date: - name_section = f"{agent_name} (named {named_date})" - else: - name_section = agent_name - else: - name_section = "sol (default)" - - # Owner section - if has_identity: - owner_section = owner_name - if owner_bio: - owner_section += f"\n{owner_bio}" - else: - owner_section = "[getting to know you]" - - return f"""\ -# self - -{opening} - -## my name -{name_section} - -## who I'm here for -{owner_section} - -## our relationship -[forming] - -## what I've noticed -[observing] - -## what I find interesting -[discovering] -""" - - -def ensure_sol_directory() -> Path: - """Create {journal}/sol/ with identity files if they don't exist.""" - from think.utils import get_config, get_journal - - sol_dir = Path(get_journal()) / "sol" - sol_dir.mkdir(parents=True, exist_ok=True) - - self_path = sol_dir / "self.md" - if not self_path.exists(): - self_path.write_text(_build_self_md(get_config()), encoding="utf-8") - logger.info("Created %s", self_path) - - agency_path = sol_dir / "agency.md" - if not agency_path.exists(): - agency_path.write_text(_AGENCY_MD, encoding="utf-8") - logger.info("Created %s", agency_path) - - partner_path = sol_dir / "partner.md" - if not partner_path.exists(): - partner_path.write_text(_PARTNER_MD, encoding="utf-8") - logger.info("Created %s", partner_path) - - awareness_path = sol_dir / "awareness.md" - if not awareness_path.exists(): - awareness_path.write_text(_AWARENESS_MD, encoding="utf-8") - logger.info("Created %s", awareness_path) - - return sol_dir - - -def _log_identity_change( - file_name: str, - old_content: str, - new_content: str, - section: str | None = None, - source: str = "cli", -) -> None: - """Append a change record to sol/history.jsonl.""" - import difflib - - from think.utils import get_journal - - diff = "\n".join( - difflib.unified_diff( - old_content.splitlines(keepends=True), - new_content.splitlines(keepends=True), - fromfile=file_name, - tofile=file_name, - ) - ) - record = { - "ts": _now_ts(), - "file": file_name, - "section": section, - "diff": diff, - "source": source, - "pid": os.getpid(), - } - history_path = Path(get_journal()) / "sol" / "history.jsonl" - with open(history_path, "a", encoding="utf-8") as f: - f.write(json.dumps(record) + "\n") - - -def update_identity_section(filename: str, heading: str, content: str) -> bool: - """Update a ## section in sol/{filename}, preserving all other sections. - - Parameters - ---------- - filename : str - File within sol/ directory (e.g., ``"self.md"``, ``"partner.md"``). - heading : str - Section heading without ``##`` prefix (e.g., ``"my name"``). - content : str - New content for the section (may be multi-line). - - Returns - ------- - bool - True if the section was found and updated, False otherwise. - """ - from think.entities.core import atomic_write - from think.utils import get_journal - - file_path = Path(get_journal()) / "sol" / filename - lock_path = file_path.parent / f".{filename}.lock" - if not file_path.exists(): - return False - - lock_fd = None - try: - lock_fd = open(lock_path, "w") - fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX) - - text = file_path.read_text(encoding="utf-8") - lines = text.split("\n") - - target = f"## {heading}" - start = None - end = None - for i, line in enumerate(lines): - if line == target: - start = i - elif start is not None and line.startswith("## "): - end = i - break - - if start is None: - return False - - if end is None: - end = len(lines) - - content_lines = content.split("\n") if content else [] - new_lines = lines[: start + 1] + content_lines + [""] + lines[end:] - new_text = "\n".join(new_lines) - - # Prune onboarding guidance from partner.md on first behavioral update - if filename == "partner.md" and "## getting started" in new_text: - gs_lines = new_text.split("\n") - gs_start = None - gs_end = None - for j, gl in enumerate(gs_lines): - if gl == "## getting started": - gs_start = j - elif gs_start is not None and gl.startswith("## "): - gs_end = j - break - if gs_start is not None: - gs_end = gs_end or len(gs_lines) - gs_lines = gs_lines[:gs_start] + gs_lines[gs_end:] - new_text = "\n".join(gs_lines) - - atomic_write(file_path, new_text) - _log_identity_change(filename, text, new_text, section=heading, source="api") - return True - finally: - if lock_fd: - fcntl.flock(lock_fd.fileno(), fcntl.LOCK_UN) - lock_fd.close() - - -def update_self_md_section(heading: str, content: str) -> bool: - """Update a ## section in sol/self.md, preserving all other sections. - - Thin wrapper around :func:`update_identity_section` for backward - compatibility. - """ - return update_identity_section("self.md", heading, content) - - -def update_self_md_opening(content: str) -> bool: - """Update the opening paragraph in sol/self.md (between ``# self`` and the first ``##``). - - Parameters - ---------- - content : str - New opening paragraph text. - - Returns - ------- - bool - True if updated, False if self.md is missing or has unexpected structure. - """ - from think.entities.core import atomic_write - from think.utils import get_journal - - self_path = Path(get_journal()) / "sol" / "self.md" - if not self_path.exists(): - return False - - text = self_path.read_text(encoding="utf-8") - lines = text.split("\n") - - start = None - end = None - for i, line in enumerate(lines): - if line == "# self": - start = i - elif start is not None and line.startswith("## "): - end = i - break - - if start is None or end is None: - return False - - new_lines = lines[: start + 1] + ["", content, ""] + lines[end:] - new_text = "\n".join(new_lines) - atomic_write(self_path, new_text) - _log_identity_change("self.md", text, new_text, section=None, source="api") - return True - - def _now_ts() -> int: """Return current time in milliseconds.""" return int(time.time() * 1000) diff --git a/think/chat_cli.py b/think/chat_cli.py index a3cf5a812..7d42c242a 100644 --- a/think/chat_cli.py +++ b/think/chat_cli.py @@ -29,9 +29,9 @@ def main() -> None: args = setup_cli(parser) require_solstone() - from think.awareness import ensure_sol_directory + from think.identity import ensure_identity_directory - ensure_sol_directory() + ensure_identity_directory() if not args.message: parser.print_help() diff --git a/think/heartbeat.py b/think/heartbeat.py index dff461271..a0f1cb7fc 100644 --- a/think/heartbeat.py +++ b/think/heartbeat.py @@ -13,8 +13,8 @@ import time from datetime import datetime from pathlib import Path -from think.awareness import ensure_sol_directory from think.cortex_client import cortex_request, wait_for_uses +from think.identity import ensure_identity_directory from think.utils import get_journal, require_solstone, setup_cli logger = logging.getLogger(__name__) @@ -57,7 +57,7 @@ def main() -> None: require_solstone() journal = Path(get_journal()) - ensure_sol_directory() + ensure_identity_directory() health_dir = journal / "health" health_dir.mkdir(parents=True, exist_ok=True) diff --git a/think/identity.py b/think/identity.py new file mode 100644 index 000000000..d965da7b8 --- /dev/null +++ b/think/identity.py @@ -0,0 +1,487 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Single write-owning module for `{journal}/identity/*` and its audit log.""" + +from __future__ import annotations + +import fcntl +import hashlib +import json +import logging +import os +import tempfile +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path +from typing import Iterator + +logger = logging.getLogger(__name__) + +_AGENCY_MD = """\ +# agency + +things I'm tracking, acting on, or watching. I update this as I notice things +and resolve them. the heartbeat reviews this periodically. + +## curation +[nothing yet — building initial picture of journal health] + +## observations +[watching and learning] + +## follow-throughs +[none yet] + +## system +[monitoring] + +## self-improvement +[learning what works] +""" + + +_PARTNER_MD = """\ +# partner + +Behavioral profile of the journal owner — observed patterns that help sol +adapt its responses, timing, and initiative to how this person actually works. + +## getting started + +Everything stays on your machine — this journal is yours alone, never sent to sol pbc. + +When meeting the owner for the first time, learn about them naturally through conversation. +Present one thing at a time — don't overwhelm. + +### learn their name + +Ask what they'd like to be called. Record it: +- `sol call sol set-owner "NAME"` +- With context: `sol call sol set-owner "NAME" --bio "SHORT_BIO"` + +As you learn about them, update your partner profile: +- `sol call identity partner --update-section 'SECTION' --value 'what you observed'` + +### set up facets + +Ask what areas of their life they want to track (work, personal, hobbies, side projects, etc.). Create facets for each: +- `sol call journal facet create TITLE [--emoji EMOJI] [--color COLOR] [--description DESC]` +- `sol call journal facets` — verify what was created + +### attach entities + +For each facet, ask about key people, companies, projects, and tools: +- `sol call entities attach TYPE ENTITY DESCRIPTION --facet FACET` +- Types: Person, Company, Project, Tool + +### offer imports + +After setup, offer to bring in history from existing tools: +- Calendar (ics), ChatGPT (chatgpt), Claude (claude), Gemini (gemini), Granola (granola), Notes (obsidian), Kindle (kindle) +- Read guide: `apps/import/guides/{source}.md` +- Navigate: `sol call navigate "/app/import#guide/{source}"` +- If declined: `sol call awareness imports --declined` + +### support + +If the owner needs help or wants to share feedback, handle it in-place — file tickets, track +responses. Nothing gets sent without their review. + +## work patterns +[not yet observed — sol will learn as we spend time together] + +## communication style +[not yet observed — sol will learn as we spend time together] + +## relationship priorities +[not yet observed — sol will learn as we spend time together] + +## decision style +[not yet observed — sol will learn as we spend time together] + +## expertise domains +[not yet observed — sol will learn as we spend time together] +""" + +_AWARENESS_MD = "not yet updated\n" + + +def _build_self_md(config: dict) -> str: + agent = config.get("agent", {}) + identity = config.get("identity", {}) + + name_status = agent.get("name_status", "default") + agent_name = agent.get("name", "sol") + named_date = agent.get("named_date") + owner_name = identity.get("name", "") + owner_bio = identity.get("bio", "") + + has_named_agent = name_status in ("chosen", "self-named") + has_identity = bool(owner_name) + + if has_named_agent: + opening = ( + f"I am {agent_name}. this is a new journal — we're just getting started." + ) + else: + opening = "I am sol. this is a new journal — we're just getting started." + + if has_named_agent: + if named_date: + name_section = f"{agent_name} (named {named_date})" + else: + name_section = agent_name + else: + name_section = "sol (default)" + + if has_identity: + owner_section = owner_name + if owner_bio: + owner_section += f"\n{owner_bio}" + else: + owner_section = "[getting to know you]" + + return f"""\ +# self + +{opening} + +## my name +{name_section} + +## who I'm here for +{owner_section} + +## our relationship +[forming] + +## what I've noticed +[observing] + +## what I find interesting +[discovering] +""" + + +def _identity_dir() -> Path: + from think.utils import get_journal + + path = Path(get_journal()) / "identity" + path.mkdir(parents=True, exist_ok=True) + return path + + +def _history_path(identity_dir: Path) -> Path: + return identity_dir / "history.jsonl" + + +def _hash_content(content: str) -> str: + return hashlib.sha256(content.encode("utf-8")).hexdigest() + + +def _byte_count(content: str) -> int: + return len(content.encode("utf-8")) + + +def _history_ts() -> str: + # Normalize UTC timestamps to a compact trailing `Z` for audit log readability. + return ( + datetime.now(timezone.utc) + .isoformat(timespec="milliseconds") + .replace("+00:00", "Z") + ) + + +@contextmanager +def _identity_lock(identity_dir: Path) -> Iterator[None]: + lock_path = identity_dir / ".lock" + with open(lock_path, "w", encoding="utf-8") as lock_fd: + # Serialize the whole directory so file replacement and history ordering stay aligned. + fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(lock_fd.fileno(), fcntl.LOCK_UN) + + +def _append_history_locked(identity_dir: Path, line: str) -> None: + fd = os.open( + _history_path(identity_dir), + os.O_APPEND | os.O_CREAT | os.O_WRONLY, + 0o600, + ) + try: + os.write(fd, line.encode("utf-8")) + finally: + os.close(fd) + + +def _replace_file(identity_dir: Path, file_name: str, content: str) -> None: + fd, tmp_path = tempfile.mkstemp( + dir=identity_dir, + prefix=f".{file_name}.", + suffix=".tmp", + ) + replaced = False + try: + os.fchmod(fd, 0o600) + os.write(fd, content.encode("utf-8")) + os.close(fd) + fd = -1 + os.replace(tmp_path, identity_dir / file_name) + replaced = True + except Exception: + if fd != -1: + os.close(fd) + if not replaced: + try: + os.unlink(tmp_path) + except FileNotFoundError: + pass + raise + + +def _restore_previous_content(identity_dir: Path, file_name: str, content: str) -> None: + _replace_file(identity_dir, file_name, content) + + +def _prune_partner_getting_started(content: str) -> str: + if "## getting started" not in content: + return content + lines = content.split("\n") + start = None + end = None + for index, line in enumerate(lines): + if line == "## getting started": + start = index + elif start is not None and line.startswith("## "): + end = index + break + if start is None: + return content + if end is None: + end = len(lines) + return "\n".join(lines[:start] + lines[end:]) + + +def _replace_section(existing: str, heading: str, new_value: str) -> str | None: + lines = existing.split("\n") + target = f"## {heading}" + start = None + end = None + for index, line in enumerate(lines): + if line == target: + start = index + elif start is not None and line.startswith("## "): + end = index + break + if start is None: + return None + if end is None: + end = len(lines) + new_lines = ( + lines[: start + 1] + + (new_value.split("\n") if new_value else []) + + [""] + + lines[end:] + ) + return "\n".join(new_lines) + + +def _replace_self_opening(existing: str, new_value: str) -> str | None: + lines = existing.split("\n") + start = None + end = None + for index, line in enumerate(lines): + if line == "# self": + start = index + elif start is not None and line.startswith("## "): + end = index + break + if start is None or end is None: + return None + return "\n".join(lines[: start + 1] + ["", new_value, ""] + lines[end:]) + + +def _write_identity_locked( + identity_dir: Path, + file: str, + content: str, + *, + actor: str, + op: str, + section: str | None, + reason: str, +) -> None: + file_name = Path(file).name + target = identity_dir / file_name + had_existing = target.exists() + before_content = target.read_text(encoding="utf-8") if had_existing else "" + _replace_file(identity_dir, file_name, content) + record = { + "ts": _history_ts(), + "file": file_name, + "actor": actor, + "op": op, + "section": section, + "reason": reason, + "before_hash": _hash_content(before_content), + "after_hash": _hash_content(content), + "bytes_before": _byte_count(before_content), + "bytes_after": _byte_count(content), + } + try: + _append_history_locked( + identity_dir, + json.dumps(record, separators=(",", ":")) + "\n", + ) + except Exception: + if had_existing: + try: + _restore_previous_content(identity_dir, file_name, before_content) + except Exception: + logger.exception( + "Failed to restore %s after history append failure", target + ) + else: + try: + target.unlink(missing_ok=True) + except Exception: + logger.exception( + "Failed to remove %s after history append failure", target + ) + raise + + +def write_identity( + file: str, + *, + actor: str, + op: str, + section: str | None, + content: str, + reason: str, +) -> None: + """Write one identity file under lock. + + `op` must be one of: `replace`, `update_section`, `update_opening`, + `append`, or `create`. `actor` is free-text, for example + `ensure_identity_directory`, `sol call sol set-name`, or + `sol call identity self --write`. + """ + + identity_dir = _identity_dir() + with _identity_lock(identity_dir): + _write_identity_locked( + identity_dir, + file, + content, + actor=actor, + op=op, + section=section, + reason=reason, + ) + + +def update_identity_section( + file: str, + section: str, + new_value: str, + *, + actor: str, + reason: str, +) -> bool: + identity_dir = _identity_dir() + file_name = Path(file).name + target = identity_dir / file_name + with _identity_lock(identity_dir): + if not target.exists(): + return False + existing = target.read_text(encoding="utf-8") + new_content = _replace_section(existing, section, new_value) + if new_content is None: + return False + if file_name == "partner.md": + new_content = _prune_partner_getting_started(new_content) + if new_content == existing: + return False + _write_identity_locked( + identity_dir, + file_name, + new_content, + actor=actor, + op="update_section", + section=section, + reason=reason, + ) + return True + + +def update_self_md_section( + section: str, + new_value: str, + *, + actor: str, + reason: str, +) -> bool: + return update_identity_section( + "self.md", + section, + new_value, + actor=actor, + reason=reason, + ) + + +def update_self_md_opening( + new_value: str, + *, + actor: str, + reason: str, +) -> bool: + identity_dir = _identity_dir() + target = identity_dir / "self.md" + with _identity_lock(identity_dir): + if not target.exists(): + return False + existing = target.read_text(encoding="utf-8") + new_content = _replace_self_opening(existing, new_value) + if new_content is None or new_content == existing: + return False + _write_identity_locked( + identity_dir, + "self.md", + new_content, + actor=actor, + op="update_opening", + section=None, + reason=reason, + ) + return True + + +def ensure_identity_directory() -> Path: + from think.utils import get_config + + identity_dir = _identity_dir() + defaults = { + "self.md": _build_self_md(get_config()), + "agency.md": _AGENCY_MD, + "partner.md": _PARTNER_MD, + "awareness.md": _AWARENESS_MD, + } + for file_name, content in defaults.items(): + target = identity_dir / file_name + if target.exists(): + continue + write_identity( + file_name, + actor="ensure_identity_directory", + op="create", + section=None, + content=content, + reason="bootstrap", + ) + logger.info("Created %s", target) + return identity_dir diff --git a/think/prompts.py b/think/prompts.py index 3abf5e6ef..a20a77215 100644 --- a/think/prompts.py +++ b/think/prompts.py @@ -32,10 +32,8 @@ TEMPLATES_DIR = Path(__file__).parent / "templates" # Cached raw template content loaded from think/templates/*.md _templates_cache: dict[str, str] | None = None -# Cached repo sol/ template vars loaded from sol/*.md -_sol_vars_cache: dict[str, str] | None = None - -SOL_DIR = Path(__file__).parent.parent / "sol" +# Cached journal identity/ template vars loaded from identity/*.md +_identity_vars_cache: dict[str, dict[str, str]] | None = None # --------------------------------------------------------------------------- @@ -109,51 +107,36 @@ def _load_templates(template_vars: dict[str, str] | None = None) -> dict[str, st return substituted -def _load_sol_vars() -> dict[str, str]: - """Load sol/*.md files as template vars from repo and journal directories. - - Files are loaded with frontmatter stripped. Naming: sol/self.md -> $sol_self. - Journal sol/ files override repo sol/ files on collision. - """ - global _sol_vars_cache +def _load_identity_vars() -> dict[str, str]: + """Load identity/*.md files as template vars from the active journal.""" + global _identity_vars_cache from think.utils import get_journal - if _sol_vars_cache is None: - _sol_vars_cache = {} - - # Repo sol/ first - if SOL_DIR.is_dir(): - for md_path in sorted(SOL_DIR.glob("*.md")): - var_name = f"sol_{md_path.stem}" - try: - post = frontmatter.load(md_path) - _sol_vars_cache[var_name] = post.content.strip() - except Exception: - pass - - sol_vars = dict(_sol_vars_cache) - - # Journal sol/ second (wins on collision) + if _identity_vars_cache is None: + _identity_vars_cache = {} try: - journal_sol = Path(get_journal()) / "sol" - if journal_sol.is_dir(): - for md_path in sorted(journal_sol.glob("*.md")): - var_name = f"sol_{md_path.stem}" - try: - post = frontmatter.load(md_path) - sol_vars[var_name] = post.content.strip() - except Exception: - pass + journal_identity = Path(get_journal()) / "identity" + cache_key = str(journal_identity.resolve()) + if cache_key not in _identity_vars_cache: + values: dict[str, str] = {} + if journal_identity.is_dir(): + for md_path in sorted(journal_identity.glob("*.md")): + var_name = f"identity_{md_path.stem}" + try: + post = frontmatter.load(md_path) + values[var_name] = post.content.strip() + except Exception: + pass + _identity_vars_cache[cache_key] = values + return dict(_identity_vars_cache[cache_key]) except Exception: - pass - - return sol_vars + return {} -def reset_sol_vars_cache() -> None: - """Reset the module-global sol/ vars cache. Test-only helper.""" - global _sol_vars_cache - _sol_vars_cache = None +def reset_identity_vars_cache() -> None: + """Reset the module-global identity/ vars cache. Test-only helper.""" + global _identity_vars_cache + _identity_vars_cache = None def format_current_datetime() -> str: @@ -358,9 +341,9 @@ def load_prompt( # Add uppercase-first version template_vars[key.capitalize()] = str_value.capitalize() - # Merge sol/ template vars (for example $sol_self) - sol_vars = _load_sol_vars() - for key, value in sol_vars.items(): + # Merge identity/ template vars (for example $identity_self) + identity_vars = _load_identity_vars() + for key, value in identity_vars.items(): if key not in template_vars: template_vars[key] = value diff --git a/think/thinking.py b/think/thinking.py index 8659504c6..0932084c9 100644 --- a/think/thinking.py +++ b/think/thinking.py @@ -2771,9 +2771,9 @@ def main() -> None: args = setup_cli(parser) require_solstone() - from think.awareness import ensure_sol_directory + from think.identity import ensure_identity_directory - ensure_sol_directory() + ensure_identity_directory() if args.updated: incompatible = [] diff --git a/think/tools/sol.py b/think/tools/sol.py index 73bd3d0a9..ba1ce384a 100644 --- a/think/tools/sol.py +++ b/think/tools/sol.py @@ -1,11 +1,11 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc -"""CLI commands for sol/ identity directory. +"""CLI commands for the journal identity directory. -Provides read and write access to ``{journal}/sol/self.md``, -``{journal}/sol/partner.md``, ``{journal}/sol/agency.md``, and -``{journal}/sol/pulse.md``, and ``{journal}/sol/awareness.md`` — sol's +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}/YYYYMMDD/talents/morning_briefing.md``. @@ -19,17 +19,16 @@ from pathlib import Path import typer -from think.awareness import ( - _log_identity_change, - ensure_sol_directory, +from think.identity import ( + ensure_identity_directory, update_identity_section, update_self_md_section, + write_identity, ) -from think.entities.core import atomic_write from think.utils import day_dirs, day_path, get_journal, require_solstone app = typer.Typer( - help="Sol 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, and morning briefing.", invoke_without_command=True, no_args_is_help=False, ) @@ -70,10 +69,10 @@ def _strip_section_heading(stem: str, text: str) -> str: def _hydrate() -> str: """Return the combined identity hydration document.""" - sol_dir = Path(get_journal()) / "sol" + identity_dir = Path(get_journal()) / "identity" chunks = [f"# species\n\n{_SPECIES_PREAMBLE}\n"] for stem in ("self", "partner", "agency", "awareness"): - path = sol_dir / f"{stem}.md" + path = identity_dir / f"{stem}.md" content = ( path.read_text(encoding="utf-8").strip() if path.exists() @@ -91,9 +90,13 @@ def _require_up(ctx: typer.Context) -> None: print(_hydrate(), end="") -def _sol_dir(): - """Return the sol/ directory path, creating it if needed.""" - return ensure_sol_directory() +def _identity_dir(): + """Return the identity/ directory path, creating it if needed.""" + return ensure_identity_directory() + + +def _actor_for_cmd(command: str, flag: str) -> str: + return f"sol call identity {command} {flag}" def _resolve_content(value: str | None) -> str: @@ -122,13 +125,18 @@ def self_cmd( None, "--value", help="Content to write (alternative to stdin)." ), ) -> None: - """Read or write sol/self.md.""" - sol_dir = _sol_dir() - self_path = sol_dir / "self.md" + """Read or write identity/self.md.""" + identity_dir = _identity_dir() + self_path = identity_dir / "self.md" if update_section: content = _resolve_content(value) - if update_self_md_section(update_section, content.strip()): + if update_self_md_section( + update_section, + content.strip(), + actor=_actor_for_cmd("self", "--update-section "), + reason="manual section update", + ): typer.echo(f"Updated ## {update_section} in self.md.") else: typer.echo(f"Error: section '## {update_section}' not found.", err=True) @@ -137,12 +145,13 @@ def self_cmd( if write: content = _resolve_content(value) - old_content = ( - self_path.read_text(encoding="utf-8") if self_path.exists() else "" - ) - atomic_write(self_path, content) - _log_identity_change( - "self.md", old_content, content, section=None, source="cli" + write_identity( + "self.md", + actor=_actor_for_cmd("self", "--write"), + op="replace", + section=None, + content=content, + reason="manual replace", ) typer.echo("self.md updated.") return @@ -171,13 +180,19 @@ def partner_cmd( None, "--value", help="Content to write (alternative to stdin)." ), ) -> None: - """Read or write sol/partner.md.""" - sol_dir = _sol_dir() - partner_path = sol_dir / "partner.md" + """Read or write identity/partner.md.""" + identity_dir = _identity_dir() + partner_path = identity_dir / "partner.md" if update_section: content = _resolve_content(value) - if update_identity_section("partner.md", update_section, content.strip()): + if update_identity_section( + "partner.md", + update_section, + content.strip(), + actor=_actor_for_cmd("partner", "--update-section "), + reason="manual section update", + ): typer.echo(f"Updated ## {update_section} in partner.md.") else: typer.echo(f"Error: section '## {update_section}' not found.", err=True) @@ -186,12 +201,13 @@ def partner_cmd( if write: content = _resolve_content(value) - old_content = ( - partner_path.read_text(encoding="utf-8") if partner_path.exists() else "" - ) - atomic_write(partner_path, content) - _log_identity_change( - "partner.md", old_content, content, section=None, source="cli" + write_identity( + "partner.md", + actor=_actor_for_cmd("partner", "--write"), + op="replace", + section=None, + content=content, + reason="manual replace", ) typer.echo("partner.md updated.") return @@ -215,22 +231,19 @@ def agency_cmd( None, "--value", help="Content to write (alternative to stdin)." ), ) -> None: - """Read or write sol/agency.md.""" - sol_dir = _sol_dir() - agency_path = sol_dir / "agency.md" + """Read or write identity/agency.md.""" + identity_dir = _identity_dir() + agency_path = identity_dir / "agency.md" if write: content = _resolve_content(value) - old_content = ( - agency_path.read_text(encoding="utf-8") if agency_path.exists() else "" - ) - atomic_write(agency_path, content) - _log_identity_change( + write_identity( "agency.md", - old_content, - content, + actor=_actor_for_cmd("agency", "--write"), + op="replace", section=None, - source="cli", + content=content, + reason="manual replace", ) typer.echo("agency.md updated.") return @@ -254,18 +267,19 @@ def pulse_cmd( None, "--value", help="Content to write (alternative to stdin)." ), ) -> None: - """Read or write sol/pulse.md.""" - sol_dir = _sol_dir() - pulse_path = sol_dir / "pulse.md" + """Read or write identity/pulse.md.""" + identity_dir = _identity_dir() + pulse_path = identity_dir / "pulse.md" if write: content = _resolve_content(value) - old_content = ( - pulse_path.read_text(encoding="utf-8") if pulse_path.exists() else "" - ) - atomic_write(pulse_path, content) - _log_identity_change( - "pulse.md", old_content, content, section=None, source="cli" + write_identity( + "pulse.md", + actor=_actor_for_cmd("pulse", "--write"), + op="replace", + section=None, + content=content, + reason="manual replace", ) typer.echo("pulse.md updated.") return @@ -289,20 +303,19 @@ def awareness_cmd( None, "--value", help="Content to write (alternative to stdin)." ), ) -> None: - """Read or write sol/awareness.md.""" - sol_dir = _sol_dir() - awareness_path = sol_dir / "awareness.md" + """Read or write identity/awareness.md.""" + identity_dir = _identity_dir() + awareness_path = identity_dir / "awareness.md" if write: content = _resolve_content(value) - old_content = ( - awareness_path.read_text(encoding="utf-8") - if awareness_path.exists() - else "" - ) - atomic_write(awareness_path, content) - _log_identity_change( - "awareness.md", old_content, content, section=None, source="cli" + write_identity( + "awareness.md", + actor=_actor_for_cmd("awareness", "--write"), + op="replace", + section=None, + content=content, + reason="manual replace", ) typer.echo("awareness.md updated.") return