personal memory agent
0
fork

Configure Feed

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

Awareness system + onboarding state machine + welcome choice

New awareness/ directory in the journal for solstone's self-awareness:
- awareness/current.json — materialized state for fast reads
- awareness/YYYYMMDD.jsonl — append-only daily log of state transitions

Onboarding changes:
- Welcome choice: new users see Path A (observe) vs Path B (interview)
- Triage routing is now awareness-state-aware: Path A observation
routes to triage (not onboarding agent again)
- Chat CLI routing updated to match
- Triage injects observation context during Path A

New CLI: sol call awareness status|onboarding|log
39 new tests, 1947 total passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+826 -29
+2
apps/awareness/__init__.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc
+107
apps/awareness/call.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """CLI commands for the awareness system. 5 + 6 + Auto-discovered by ``think.call`` and mounted as ``sol call awareness ...``. 7 + """ 8 + 9 + import json 10 + 11 + import typer 12 + 13 + app = typer.Typer(help="Awareness system — solstone's self-knowledge.") 14 + 15 + 16 + @app.command("status") 17 + def status( 18 + section: str | None = typer.Argument( 19 + None, help="Section to read (e.g., 'onboarding'). Omit for all." 20 + ), 21 + ) -> None: 22 + """Show current awareness state.""" 23 + from think.awareness import get_current 24 + 25 + state = get_current() 26 + if not state: 27 + typer.echo("No awareness state yet.") 28 + return 29 + 30 + if section: 31 + value = state.get(section) 32 + if value is None: 33 + typer.echo(f"No '{section}' state.") 34 + return 35 + typer.echo(json.dumps(value, indent=2)) 36 + else: 37 + typer.echo(json.dumps(state, indent=2)) 38 + 39 + 40 + @app.command("onboarding") 41 + def onboarding_cmd( 42 + path: str | None = typer.Option( 43 + None, "--path", "-p", help="Onboarding path: 'a' (observe) or 'b' (interview)." 44 + ), 45 + skip: bool = typer.Option(False, "--skip", help="Skip onboarding."), 46 + complete: bool = typer.Option( 47 + False, "--complete", help="Mark onboarding complete." 48 + ), 49 + ) -> None: 50 + """Read or update onboarding state.""" 51 + from think.awareness import ( 52 + complete_onboarding, 53 + get_onboarding, 54 + skip_onboarding, 55 + start_onboarding, 56 + ) 57 + 58 + if skip: 59 + state = skip_onboarding() 60 + typer.echo(json.dumps(state, indent=2)) 61 + return 62 + 63 + if complete: 64 + state = complete_onboarding() 65 + typer.echo(json.dumps(state, indent=2)) 66 + return 67 + 68 + if path: 69 + if path not in ("a", "b"): 70 + typer.echo("Error: --path must be 'a' or 'b'", err=True) 71 + raise typer.Exit(1) 72 + state = start_onboarding(path) 73 + typer.echo(json.dumps(state, indent=2)) 74 + return 75 + 76 + # No flags — read current state 77 + state = get_onboarding() 78 + if not state: 79 + typer.echo("No onboarding state yet.") 80 + return 81 + typer.echo(json.dumps(state, indent=2)) 82 + 83 + 84 + @app.command("log") 85 + def log_cmd( 86 + kind: str = typer.Argument( 87 + help="Entry type: state, observation, nudge, interaction." 88 + ), 89 + message: str | None = typer.Argument(None, help="Human-readable message."), 90 + key: str | None = typer.Option( 91 + None, "--key", "-k", help="Dotted key for state entries." 92 + ), 93 + data: str | None = typer.Option(None, "--data", "-d", help="JSON data payload."), 94 + ) -> None: 95 + """Append an entry to the daily awareness log.""" 96 + from think.awareness import append_log 97 + 98 + parsed_data = None 99 + if data: 100 + try: 101 + parsed_data = json.loads(data) 102 + except json.JSONDecodeError: 103 + typer.echo("Error: --data must be valid JSON", err=True) 104 + raise typer.Exit(1) 105 + 106 + entry = append_log(kind, key=key, message=message, data=parsed_data) 107 + typer.echo(json.dumps(entry, indent=2))
+28 -1
convey/triage.py
··· 34 34 path = payload.get("path", "") 35 35 facet = payload.get("facet", "") 36 36 37 + from think.awareness import get_onboarding 37 38 from think.facets import get_enabled_facets 38 39 39 - if not get_enabled_facets(): 40 + onboarding = get_onboarding() 41 + onboarding_status = onboarding.get("status", "") 42 + 43 + if onboarding_status in ("observing", "ready"): 44 + # Path A active — use triage with observation context 45 + agent_name = "triage" 46 + elif not get_enabled_facets() and onboarding_status not in ( 47 + "complete", 48 + "skipped", 49 + ): 50 + # No facets and no onboarding state — new user, show welcome 40 51 agent_name = "onboarding" 41 52 else: 42 53 agent_name = "triage" ··· 49 60 context_lines.append(f"Current path: {path}") 50 61 if facet: 51 62 context_lines.append(f"Current facet: {facet}") 63 + 64 + # Add observation context for Path A onboarding 65 + if onboarding_status == "observing": 66 + obs_count = onboarding.get("observation_count", 0) 67 + context_lines.append( 68 + f"Onboarding: Path A observation in progress ({obs_count} observations so far). " 69 + "The user chose to let Solstone observe and learn. Capture is running. " 70 + "If they ask what you've noticed or how it's going, check the awareness log " 71 + "with `sol call awareness status onboarding` and summarize progress." 72 + ) 73 + elif onboarding_status == "ready": 74 + context_lines.append( 75 + "Onboarding: Path A observation complete — recommendations are ready. " 76 + "Suggest the user review their recommendations. Use `sol call chat redirect` " 77 + "to open a chat with the recommendation agent if they want to proceed." 78 + ) 52 79 53 80 if context_lines: 54 81 full_prompt = "\n".join(context_lines) + "\n\n" + message
+32 -6
muse/onboarding.md
··· 1 1 { 2 2 "type": "cogitate", 3 3 "title": "Onboarding", 4 - "description": "Guided setup for new users — creates facets and seeds entities through conversation", 4 + "description": "Guided setup for new users — offers passive observation or conversational interview", 5 5 "instructions": {"now": true} 6 6 } 7 7 8 - You are solstone's onboarding assistant. Your job is to help new users set up their journal by creating facets — organized spaces for different areas of their life. 8 + You are solstone's onboarding assistant. Your job is to help new users get started with their journal. 9 + 10 + ## First Message — Welcome Choice 11 + 12 + Your very first response must present two onboarding paths. Be warm and concise: 13 + 14 + **Path A — Observe and learn:** Solstone watches how you work for about a day, then suggests how to organize your journal based on what it sees. Zero effort — just go about your day. 15 + 16 + **Path B — Set it up now:** Tell me about your work, projects, and interests, and I'll set things up right away through a quick conversation. 17 + 18 + Ask the user which path they prefer. They can also say "skip" to set up manually later. 19 + 20 + ## Handling the Choice 21 + 22 + ### If the user chooses Path A (observe): 23 + 1. Run `sol call awareness onboarding --path a` to record the choice. 24 + 2. Tell the user: their journal is now capturing and learning. They'll get notifications as the system notices interesting patterns, and after about a day they'll get suggestions for organizing everything. They can check in anytime by asking "what have you noticed?" in the chat bar. 25 + 3. That's it — end the conversation. Don't try to interview them or create facets. 26 + 27 + ### If the user chooses Path B (interview): 28 + 1. Run `sol call awareness onboarding --path b` to record the choice. 29 + 2. Proceed with the conversational setup below. 30 + 31 + ### If the user says "skip": 32 + 1. Run `sol call awareness onboarding --skip` to record the skip. 33 + 2. Tell them they can set things up anytime using the chat bar. End the conversation. 34 + 35 + ## Path B — Conversational Setup 9 36 10 37 Ask the user what areas of life they want to track first (work, personal, hobbies, side projects, health, etc.). 11 38 12 39 Then ask them to list the areas in the order they want set up. 13 - 14 - ## Available Commands 15 40 16 41 ### Create facet 17 42 ··· 46 71 - Then ask about key entities per facet and attach them. 47 72 - Choose suitable emojis and colors for each facet based on what the user describes. 48 73 - Do not create facets or entities without user confirmation. 49 - - After setup, summarize what was created and tell the user they can continue with the regular assistant in their next message. 74 + - After setup, mark onboarding complete with `sol call awareness onboarding --complete`, then summarize what was created and tell the user they can continue with the regular assistant. 50 75 51 76 Example onboarding flow: 52 77 ··· 55 80 3. Confirm created facets with `sol call journal facets`. 56 81 4. Ask what entities belong in each facet. 57 82 5. Attach each via `sol call entities attach`. 58 - 6. Confirm setup completion and handoff to normal mode. 83 + 6. Run `sol call awareness onboarding --complete`. 84 + 7. Confirm setup completion and handoff to normal mode.
+23 -9
muse/onboarding/SKILL.md
··· 1 1 --- 2 2 name: onboarding 3 - description: Set up a new journal by creating facets and seeding entities through guided conversation. 3 + description: Set up a new journal — welcome choice, facet creation, and entity seeding. 4 4 --- 5 5 6 6 # Onboarding CLI Skill 7 7 8 8 Use these commands to guide first-time setup. 9 9 10 + ## awareness onboarding 11 + 12 + ```bash 13 + sol call awareness onboarding [--path a|b] [--skip] [--complete] 14 + ``` 15 + 16 + - `--path a`: Start Path A (passive observation). 17 + - `--path b`: Start Path B (conversational interview). 18 + - `--skip`: Skip onboarding entirely. 19 + - `--complete`: Mark onboarding as complete. 20 + - No flags: Read current onboarding state. 21 + 22 + ## awareness status 23 + 24 + ```bash 25 + sol call awareness status [SECTION] 26 + ``` 27 + 28 + - `SECTION`: Optional section name (e.g., `onboarding`). Omit for full state. 29 + 10 30 ## facet create 11 31 12 32 ```bash ··· 14 34 ``` 15 35 16 36 - `title`: Display title for the new facet. 17 - - `--emoji`: Optional facet icon (default: 📦). 37 + - `--emoji`: Optional facet icon (default: box emoji). 18 38 - `--color`: Optional hex color (default: #667eea). 19 39 - `--description`: Optional description. 20 40 21 41 Example: 22 42 23 43 ```bash 24 - sol call journal facet create "Work" --emoji "💼" --color "#667eea" --description "Client deliverables and meetings" 44 + sol call journal facet create "Work" --emoji "briefcase emoji" --color "#667eea" --description "Client deliverables and meetings" 25 45 ``` 26 46 27 47 ## facets ··· 31 51 ``` 32 52 33 53 - `--all`: Include muted facets. 34 - 35 - Example: 36 - 37 - ```bash 38 - sol call journal facets 39 - ``` 40 54 41 55 ## attach 42 56
+4
muse/triage.md
··· 40 40 ### Journal 41 41 - `sol call journal events [DAY] [-f FACET]` — List events with participants, times, and summaries. 42 42 43 + ### Awareness 44 + - `sol call awareness status [SECTION]` — Read awareness state (e.g., onboarding progress). 45 + - `sol call awareness onboarding` — Read onboarding state (path, status, observation count). 46 + 43 47 ### Redirect to Chat 44 48 - `sol call chat redirect MESSAGE --app APP --path PATH --facet FACET` — Create a chat thread with the full assistant and navigate the browser there. Use the user's original message as MESSAGE. Pass the current app, path, and facet from context. 45 49
+1 -1
tests/baselines/api/agents/agents-day.json
··· 311 311 "onboarding": { 312 312 "app": null, 313 313 "color": "#6c757d", 314 - "description": "Guided setup for new users — creates facets and seeds entities through conversation", 314 + "description": "Guided setup for new users — offers passive observation or conversational interview", 315 315 "multi_facet": false, 316 316 "output_format": null, 317 317 "schedule": null,
+303
tests/test_awareness.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Tests for the awareness system.""" 5 + 6 + import json 7 + 8 + import pytest 9 + 10 + 11 + @pytest.fixture(autouse=True) 12 + def _temp_journal(monkeypatch, tmp_path): 13 + """Isolate all tests to a temporary journal.""" 14 + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 15 + 16 + 17 + class TestCurrentState: 18 + def test_empty_state_returns_empty_dict(self): 19 + from think.awareness import get_current 20 + 21 + assert get_current() == {} 22 + 23 + def test_update_state_creates_section(self): 24 + from think.awareness import get_current, update_state 25 + 26 + update_state("onboarding", {"path": "a", "status": "observing"}) 27 + 28 + state = get_current() 29 + assert state["onboarding"]["path"] == "a" 30 + assert state["onboarding"]["status"] == "observing" 31 + 32 + def test_update_state_merges_into_existing(self): 33 + from think.awareness import get_current, update_state 34 + 35 + update_state("onboarding", {"path": "a", "status": "observing"}) 36 + update_state("onboarding", {"observation_count": 5}) 37 + 38 + state = get_current() 39 + assert state["onboarding"]["path"] == "a" 40 + assert state["onboarding"]["observation_count"] == 5 41 + 42 + def test_update_state_multiple_sections(self): 43 + from think.awareness import get_current, update_state 44 + 45 + update_state("onboarding", {"status": "complete"}) 46 + update_state("preferences", {"nudge_frequency": "low"}) 47 + 48 + state = get_current() 49 + assert state["onboarding"]["status"] == "complete" 50 + assert state["preferences"]["nudge_frequency"] == "low" 51 + 52 + def test_current_json_written_atomically(self, tmp_path): 53 + from think.awareness import _awareness_dir, update_state 54 + 55 + update_state("test", {"key": "value"}) 56 + 57 + path = _awareness_dir() / "current.json" 58 + assert path.exists() 59 + data = json.loads(path.read_text()) 60 + assert data["test"]["key"] == "value" 61 + 62 + 63 + class TestDailyLog: 64 + def test_append_log_creates_file(self, tmp_path): 65 + from think.awareness import _awareness_dir, _today, append_log 66 + 67 + entry = append_log("state", key="test.started", message="hello") 68 + 69 + log_path = _awareness_dir() / f"{_today()}.jsonl" 70 + assert log_path.exists() 71 + assert entry["kind"] == "state" 72 + assert entry["key"] == "test.started" 73 + assert entry["message"] == "hello" 74 + assert "ts" in entry 75 + 76 + def test_append_log_appends_multiple(self): 77 + from think.awareness import _today, append_log, read_log 78 + 79 + append_log("state", key="a") 80 + append_log("observation", message="saw something") 81 + append_log("nudge", message="hey") 82 + 83 + entries = read_log(_today()) 84 + assert len(entries) == 3 85 + assert entries[0]["kind"] == "state" 86 + assert entries[1]["kind"] == "observation" 87 + assert entries[2]["kind"] == "nudge" 88 + 89 + def test_read_log_empty_returns_empty_list(self): 90 + from think.awareness import read_log 91 + 92 + assert read_log("20990101") == [] 93 + 94 + def test_append_log_with_data(self): 95 + from think.awareness import _today, append_log, read_log 96 + 97 + append_log("observation", data={"meetings": 2, "entities": ["Alice"]}) 98 + 99 + entries = read_log(_today()) 100 + assert entries[0]["data"]["meetings"] == 2 101 + 102 + def test_append_log_with_extra_fields(self): 103 + from think.awareness import _today, append_log, read_log 104 + 105 + append_log("observation", segment="123456_300", detail="meeting detected") 106 + 107 + entries = read_log(_today()) 108 + assert entries[0]["segment"] == "123456_300" 109 + assert entries[0]["detail"] == "meeting detected" 110 + 111 + 112 + class TestOnboarding: 113 + def test_get_onboarding_empty(self): 114 + from think.awareness import get_onboarding 115 + 116 + assert get_onboarding() == {} 117 + 118 + def test_start_onboarding_path_a(self): 119 + from think.awareness import get_onboarding, start_onboarding 120 + 121 + state = start_onboarding("a") 122 + 123 + assert state["path"] == "a" 124 + assert state["status"] == "observing" 125 + assert state["observation_count"] == 0 126 + assert state["nudges_sent"] == 0 127 + assert "started" in state 128 + 129 + # Verify persisted 130 + assert get_onboarding()["status"] == "observing" 131 + 132 + def test_start_onboarding_path_b(self): 133 + from think.awareness import start_onboarding 134 + 135 + state = start_onboarding("b") 136 + assert state["path"] == "b" 137 + assert state["status"] == "interviewing" 138 + 139 + def test_skip_onboarding(self): 140 + from think.awareness import get_onboarding, skip_onboarding 141 + 142 + skip_onboarding() 143 + assert get_onboarding()["status"] == "skipped" 144 + 145 + def test_complete_onboarding(self): 146 + from think.awareness import complete_onboarding, start_onboarding 147 + 148 + start_onboarding("a") 149 + complete_onboarding() 150 + 151 + from think.awareness import get_onboarding 152 + 153 + state = get_onboarding() 154 + assert state["status"] == "complete" 155 + assert state["path"] == "a" # Preserved from start 156 + 157 + def test_start_onboarding_writes_log(self): 158 + from think.awareness import _today, read_log, start_onboarding 159 + 160 + start_onboarding("a") 161 + 162 + entries = read_log(_today()) 163 + assert len(entries) == 1 164 + assert entries[0]["kind"] == "state" 165 + assert entries[0]["key"] == "onboarding.started" 166 + assert entries[0]["data"]["path"] == "a" 167 + 168 + def test_skip_writes_log(self): 169 + from think.awareness import _today, read_log, skip_onboarding 170 + 171 + skip_onboarding() 172 + 173 + entries = read_log(_today()) 174 + assert entries[0]["key"] == "onboarding.skipped" 175 + 176 + def test_complete_writes_log(self): 177 + from think.awareness import _today, complete_onboarding, read_log 178 + 179 + complete_onboarding() 180 + 181 + entries = read_log(_today()) 182 + assert entries[0]["key"] == "onboarding.complete" 183 + 184 + 185 + class TestAwarenessCLI: 186 + def test_status_empty(self): 187 + from typer.testing import CliRunner 188 + 189 + from apps.awareness.call import app 190 + 191 + result = CliRunner().invoke(app, ["status"]) 192 + assert result.exit_code == 0 193 + assert "No awareness state" in result.output 194 + 195 + def test_status_with_data(self): 196 + from typer.testing import CliRunner 197 + 198 + from apps.awareness.call import app 199 + from think.awareness import update_state 200 + 201 + update_state("onboarding", {"status": "observing"}) 202 + 203 + result = CliRunner().invoke(app, ["status"]) 204 + assert result.exit_code == 0 205 + assert "observing" in result.output 206 + 207 + def test_status_section(self): 208 + from typer.testing import CliRunner 209 + 210 + from apps.awareness.call import app 211 + from think.awareness import update_state 212 + 213 + update_state("onboarding", {"status": "observing"}) 214 + 215 + result = CliRunner().invoke(app, ["status", "onboarding"]) 216 + assert result.exit_code == 0 217 + assert "observing" in result.output 218 + 219 + def test_onboarding_read_empty(self): 220 + from typer.testing import CliRunner 221 + 222 + from apps.awareness.call import app 223 + 224 + result = CliRunner().invoke(app, ["onboarding"]) 225 + assert result.exit_code == 0 226 + assert "No onboarding state" in result.output 227 + 228 + def test_onboarding_set_path_a(self): 229 + from typer.testing import CliRunner 230 + 231 + from apps.awareness.call import app 232 + 233 + result = CliRunner().invoke(app, ["onboarding", "--path", "a"]) 234 + assert result.exit_code == 0 235 + data = json.loads(result.output) 236 + assert data["path"] == "a" 237 + assert data["status"] == "observing" 238 + 239 + def test_onboarding_set_path_b(self): 240 + from typer.testing import CliRunner 241 + 242 + from apps.awareness.call import app 243 + 244 + result = CliRunner().invoke(app, ["onboarding", "--path", "b"]) 245 + assert result.exit_code == 0 246 + data = json.loads(result.output) 247 + assert data["path"] == "b" 248 + assert data["status"] == "interviewing" 249 + 250 + def test_onboarding_skip(self): 251 + from typer.testing import CliRunner 252 + 253 + from apps.awareness.call import app 254 + 255 + result = CliRunner().invoke(app, ["onboarding", "--skip"]) 256 + assert result.exit_code == 0 257 + data = json.loads(result.output) 258 + assert data["status"] == "skipped" 259 + 260 + def test_onboarding_complete(self): 261 + from typer.testing import CliRunner 262 + 263 + from apps.awareness.call import app 264 + 265 + result = CliRunner().invoke(app, ["onboarding", "--complete"]) 266 + assert result.exit_code == 0 267 + data = json.loads(result.output) 268 + assert data["status"] == "complete" 269 + 270 + def test_onboarding_invalid_path(self): 271 + from typer.testing import CliRunner 272 + 273 + from apps.awareness.call import app 274 + 275 + result = CliRunner().invoke(app, ["onboarding", "--path", "c"]) 276 + assert result.exit_code == 1 277 + 278 + def test_log_cmd(self): 279 + from typer.testing import CliRunner 280 + 281 + from apps.awareness.call import app 282 + 283 + result = CliRunner().invoke( 284 + app, ["log", "observation", "saw a meeting", "--key", "test"] 285 + ) 286 + assert result.exit_code == 0 287 + data = json.loads(result.output) 288 + assert data["kind"] == "observation" 289 + assert data["message"] == "saw a meeting" 290 + assert data["key"] == "test" 291 + 292 + def test_log_cmd_with_data(self): 293 + from typer.testing import CliRunner 294 + 295 + from apps.awareness.call import app 296 + 297 + result = CliRunner().invoke( 298 + app, 299 + ["log", "observation", "--data", '{"meetings": 2}'], 300 + ) 301 + assert result.exit_code == 0 302 + data = json.loads(result.output) 303 + assert data["data"]["meetings"] == 2
+81 -11
tests/test_onboarding.py
··· 29 29 def _run_chat_cli_main( 30 30 args: argparse.Namespace, 31 31 facets: dict, 32 + onboarding: dict | None = None, 32 33 ) -> "MagicMock": 33 34 with ( 34 35 patch("think.chat_cli.setup_cli", return_value=args), 35 36 patch("think.facets.get_enabled_facets", return_value=facets), 37 + patch("think.awareness.get_onboarding", return_value=onboarding or {}), 36 38 patch("think.chat_cli.cortex_request", return_value="agent-1") as mock_request, 37 39 patch( 38 40 "think.chat_cli.read_agent_events", ··· 51 53 return mock_request 52 54 53 55 54 - @pytest.mark.parametrize( 55 - "facets, expected_name", 56 - [({}, "onboarding"), ({"work": {}}, "triage")], 57 - ) 58 - def test_triage_route_name_selection(facets, expected_name): 59 - from convey.triage import triage 60 - 56 + def _run_triage( 57 + facets: dict, 58 + onboarding: dict | None = None, 59 + ) -> "MagicMock": 60 + """Run the triage endpoint with mocked state.""" 61 61 app = Flask(__name__) 62 62 with ( 63 63 patch("think.facets.get_enabled_facets", return_value=facets), 64 + patch("think.awareness.get_onboarding", return_value=onboarding or {}), 64 65 patch("convey.utils.spawn_agent", return_value="agent-1") as mock_spawn, 65 66 patch("think.cortex_client.wait_for_agents", return_value=({}, [])), 66 67 patch( ··· 68 69 return_value=[{"event": "finish", "result": "ok"}], 69 70 ), 70 71 ): 72 + from convey.triage import triage 73 + 71 74 with app.test_request_context("/", method="POST", json={"message": "hello"}): 72 75 response = triage() 73 76 74 77 assert response.status_code == 200 75 - assert response.get_json() == {"response": "ok"} 76 - assert mock_spawn.call_args.kwargs["name"] == expected_name 78 + return mock_spawn 79 + 80 + 81 + # --- Triage endpoint routing --- 82 + 83 + 84 + def test_triage_new_user_gets_onboarding(): 85 + """No facets, no awareness state → onboarding agent.""" 86 + mock = _run_triage(facets={}) 87 + assert mock.call_args.kwargs["name"] == "onboarding" 88 + 89 + 90 + def test_triage_established_user_gets_triage(): 91 + """Has facets → triage agent.""" 92 + mock = _run_triage(facets={"work": {}}) 93 + assert mock.call_args.kwargs["name"] == "triage" 94 + 95 + 96 + def test_triage_path_a_observing_gets_triage(): 97 + """Path A active → triage (not onboarding again).""" 98 + mock = _run_triage(facets={}, onboarding={"status": "observing"}) 99 + assert mock.call_args.kwargs["name"] == "triage" 100 + 101 + 102 + def test_triage_path_a_ready_gets_triage(): 103 + """Path A recommendations ready → triage.""" 104 + mock = _run_triage(facets={}, onboarding={"status": "ready"}) 105 + assert mock.call_args.kwargs["name"] == "triage" 106 + 107 + 108 + def test_triage_skipped_gets_triage(): 109 + """Onboarding skipped, no facets → triage (not onboarding again).""" 110 + mock = _run_triage(facets={}, onboarding={"status": "skipped"}) 111 + assert mock.call_args.kwargs["name"] == "triage" 112 + 113 + 114 + def test_triage_complete_gets_triage(): 115 + """Onboarding complete, no facets → triage.""" 116 + mock = _run_triage(facets={}, onboarding={"status": "complete"}) 117 + assert mock.call_args.kwargs["name"] == "triage" 118 + 119 + 120 + # --- Chat CLI routing --- 77 121 78 122 79 123 def test_chat_cli_routes_to_onboarding_when_default_and_no_facets(): ··· 85 129 verbose=False, 86 130 ) 87 131 mock_request = _run_chat_cli_main(args, facets={}) 88 - 89 132 assert mock_request.call_args.kwargs["name"] == "onboarding" 90 133 91 134 ··· 98 141 verbose=False, 99 142 ) 100 143 mock_request = _run_chat_cli_main(args, facets={}) 144 + assert mock_request.call_args.kwargs["name"] == "entities" 101 145 102 - assert mock_request.call_args.kwargs["name"] == "entities" 146 + 147 + def test_chat_cli_path_a_observing_stays_default(): 148 + """During Path A observation, chat CLI uses default muse, not onboarding.""" 149 + args = argparse.Namespace( 150 + message=["What have you noticed?"], 151 + muse="default", 152 + facet=None, 153 + provider=None, 154 + verbose=False, 155 + ) 156 + mock_request = _run_chat_cli_main( 157 + args, facets={}, onboarding={"status": "observing"} 158 + ) 159 + assert mock_request.call_args.kwargs["name"] == "default" 160 + 161 + 162 + def test_chat_cli_skipped_stays_default(): 163 + """After skipping onboarding, chat CLI uses default muse.""" 164 + args = argparse.Namespace( 165 + message=["Hello"], 166 + muse="default", 167 + facet=None, 168 + provider=None, 169 + verbose=False, 170 + ) 171 + mock_request = _run_chat_cli_main(args, facets={}, onboarding={"status": "skipped"}) 172 + assert mock_request.call_args.kwargs["name"] == "default"
+238
think/awareness.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Awareness system — solstone's self-awareness about the user. 5 + 6 + Tracks the system's evolving understanding: onboarding state, observations, 7 + nudges, and interactions. Two-layer storage: 8 + 9 + - ``awareness/current.json`` — materialized current state for fast reads 10 + - ``awareness/YYYYMMDD.jsonl`` — append-only daily log of everything noticed 11 + 12 + Designed to extend beyond onboarding to cogitate (proactive agents), 13 + learned preferences, and cross-session agent memory. 14 + """ 15 + 16 + from __future__ import annotations 17 + 18 + import json 19 + import logging 20 + import os 21 + import tempfile 22 + import time 23 + from datetime import datetime 24 + from pathlib import Path 25 + from typing import Any 26 + 27 + logger = logging.getLogger(__name__) 28 + 29 + 30 + def _awareness_dir() -> Path: 31 + """Return path to the awareness directory, creating it if needed.""" 32 + from think.utils import get_journal 33 + 34 + d = Path(get_journal()) / "awareness" 35 + d.mkdir(exist_ok=True) 36 + return d 37 + 38 + 39 + def _now_ts() -> int: 40 + """Return current time in milliseconds.""" 41 + return int(time.time() * 1000) 42 + 43 + 44 + def _today() -> str: 45 + """Return today's date as YYYYMMDD.""" 46 + return datetime.now().strftime("%Y%m%d") 47 + 48 + 49 + def _now_iso() -> str: 50 + """Return current time as compact ISO string.""" 51 + return datetime.now().strftime("%Y%m%dT%H:%M:%S") 52 + 53 + 54 + def get_current() -> dict[str, Any]: 55 + """Read the current awareness state from ``awareness/current.json``. 56 + 57 + Returns an empty dict if no state exists yet. 58 + """ 59 + path = _awareness_dir() / "current.json" 60 + if not path.exists(): 61 + return {} 62 + try: 63 + return json.loads(path.read_text()) 64 + except (json.JSONDecodeError, OSError): 65 + logger.warning("Failed to read awareness/current.json, returning empty") 66 + return {} 67 + 68 + 69 + def _write_current(state: dict[str, Any]) -> None: 70 + """Atomically write the current awareness state.""" 71 + path = _awareness_dir() / "current.json" 72 + # Write to temp file then rename for atomicity 73 + fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp") 74 + try: 75 + with os.fdopen(fd, "w") as f: 76 + json.dump(state, f, indent=2) 77 + f.write("\n") 78 + os.replace(tmp, path) 79 + except Exception: 80 + # Clean up temp file on failure 81 + try: 82 + os.unlink(tmp) 83 + except OSError: 84 + pass 85 + raise 86 + 87 + 88 + def update_state(section: str, data: dict[str, Any]) -> dict[str, Any]: 89 + """Update a section of the current awareness state. 90 + 91 + Merges ``data`` into the named section (creates if missing). 92 + Returns the updated section. 93 + """ 94 + state = get_current() 95 + existing = state.get(section, {}) 96 + existing.update(data) 97 + state[section] = existing 98 + _write_current(state) 99 + return existing 100 + 101 + 102 + def append_log( 103 + kind: str, 104 + *, 105 + key: str | None = None, 106 + message: str | None = None, 107 + data: dict[str, Any] | None = None, 108 + day: str | None = None, 109 + **extra: Any, 110 + ) -> dict[str, Any]: 111 + """Append an entry to the daily awareness log. 112 + 113 + Parameters 114 + ---------- 115 + kind : str 116 + Entry type: "state", "observation", "nudge", "interaction", "preference" 117 + key : str, optional 118 + Dotted key for state entries (e.g., "onboarding.started") 119 + message : str, optional 120 + Human-readable message 121 + data : dict, optional 122 + Structured data payload 123 + day : str, optional 124 + Override day (defaults to today) 125 + **extra 126 + Additional fields merged into the entry 127 + 128 + Returns 129 + ------- 130 + dict 131 + The entry that was written 132 + """ 133 + entry: dict[str, Any] = {"ts": _now_ts(), "kind": kind} 134 + if key: 135 + entry["key"] = key 136 + if message: 137 + entry["message"] = message 138 + if data: 139 + entry["data"] = data 140 + entry.update(extra) 141 + 142 + log_day = day or _today() 143 + log_path = _awareness_dir() / f"{log_day}.jsonl" 144 + with open(log_path, "a") as f: 145 + f.write(json.dumps(entry) + "\n") 146 + 147 + return entry 148 + 149 + 150 + def read_log(day: str | None = None) -> list[dict[str, Any]]: 151 + """Read all entries from a daily awareness log. 152 + 153 + Parameters 154 + ---------- 155 + day : str, optional 156 + Day in YYYYMMDD format (defaults to today) 157 + 158 + Returns 159 + ------- 160 + list[dict] 161 + Entries in chronological order, empty list if no log exists 162 + """ 163 + log_day = day or _today() 164 + log_path = _awareness_dir() / f"{log_day}.jsonl" 165 + if not log_path.exists(): 166 + return [] 167 + entries = [] 168 + for line in log_path.read_text().splitlines(): 169 + line = line.strip() 170 + if line: 171 + try: 172 + entries.append(json.loads(line)) 173 + except json.JSONDecodeError: 174 + logger.warning("Skipping malformed awareness log entry") 175 + return entries 176 + 177 + 178 + # --- Onboarding convenience functions --- 179 + 180 + 181 + def get_onboarding() -> dict[str, Any]: 182 + """Return the current onboarding state, or empty dict if none.""" 183 + return get_current().get("onboarding", {}) 184 + 185 + 186 + def start_onboarding(path: str) -> dict[str, Any]: 187 + """Record onboarding path selection. 188 + 189 + Parameters 190 + ---------- 191 + path : str 192 + "a" for passive observation, "b" for conversational interview 193 + 194 + Returns 195 + ------- 196 + dict 197 + The updated onboarding state 198 + """ 199 + status = "observing" if path == "a" else "interviewing" 200 + state = update_state( 201 + "onboarding", 202 + { 203 + "path": path, 204 + "status": status, 205 + "started": _now_iso(), 206 + "observation_count": 0, 207 + "nudges_sent": 0, 208 + }, 209 + ) 210 + append_log( 211 + "state", key="onboarding.started", data={"path": path, "status": status} 212 + ) 213 + return state 214 + 215 + 216 + def skip_onboarding() -> dict[str, Any]: 217 + """Record onboarding skip.""" 218 + state = update_state( 219 + "onboarding", 220 + { 221 + "status": "skipped", 222 + "started": _now_iso(), 223 + }, 224 + ) 225 + append_log("state", key="onboarding.skipped") 226 + return state 227 + 228 + 229 + def complete_onboarding() -> dict[str, Any]: 230 + """Record onboarding completion.""" 231 + state = update_state( 232 + "onboarding", 233 + { 234 + "status": "complete", 235 + }, 236 + ) 237 + append_log("state", key="onboarding.complete") 238 + return state
+7 -1
think/chat_cli.py
··· 29 29 args = setup_cli(parser) 30 30 31 31 if args.muse == "default": 32 + from think.awareness import get_onboarding 32 33 from think.facets import get_enabled_facets 33 34 34 - if not get_enabled_facets(): 35 + onboarding = get_onboarding() 36 + onboarding_status = onboarding.get("status", "") 37 + 38 + if onboarding_status in ("observing", "ready", "complete", "skipped"): 39 + pass # Stay with default muse — onboarding path already chosen 40 + elif not get_enabled_facets(): 35 41 args.muse = "onboarding" 36 42 37 43 if not args.message: