personal memory agent
0
fork

Configure Feed

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

feat(talents): add cwd frontmatter, default cogitate agents to journal

Adds an optional `cwd` field on talent frontmatter with two legal values (`"journal"` default, `"repo"` escape hatch) for `type: cogitate` prompts. Threads the resolved absolute path through `prepare_config` and each provider's `CLIRunner`, and also through the cortex-side `Popen` for `sol agents`, so every cogitate subprocess lands in the correct working directory. Validation is centralized in `_validate_cwd` (`think/talent.py`) and runs at both `get_talent_configs` and `get_agent`. `talent/coder.md` opts into `cwd: "repo"`; all other cogitate talents default to `"journal"`. Generators reject `cwd` entirely. Fixes repo-relative path notes in `talent/heartbeat.md` so the agent runs correctly under journal cwd. Known gap: Google provider's `--sandbox=none` is unchanged and deferred to a follow-up lode.

+312 -10
+2 -2
docs/APPS.md
··· 283 283 - Keys are namespaced as `{app}:{agent}` (e.g., `my_app:weekly_summary`) 284 284 - Outputs go to `JOURNAL/YYYYMMDD/agents/_<app>_<agent>.md` (or `.json` if `output: "json"`) 285 285 286 - **Metadata format:** Same schema as system generators in `talent/*.md` - JSON frontmatter includes `title`, `description`, `color`, `schedule` (required), `priority` (required for scheduled prompts), `hook`, `output`, `max_output_tokens`, and `thinking_budget` fields. The `schedule` field must be `"segment"` or `"daily"`. The `priority` field is required for all scheduled prompts - prompts without explicit priority will fail validation. Set `output: "json"` for structured JSON output instead of markdown. Optional `max_output_tokens` sets the maximum response length; `thinking_budget` sets the model's thinking token budget (provider-specific defaults apply if omitted). 286 + **Metadata format:** Same schema as system generators in `talent/*.md` - JSON frontmatter includes `title`, `description`, `color`, `schedule` (required), `priority` (required for scheduled prompts), `hook`, `output`, `max_output_tokens`, and `thinking_budget` fields. The `schedule` field must be `"segment"` or `"daily"`. The `priority` field is required for all scheduled prompts - prompts without explicit priority will fail validation. Set `output: "json"` for structured JSON output instead of markdown. Optional `max_output_tokens` sets the maximum response length; `thinking_budget` sets the model's thinking token budget (provider-specific defaults apply if omitted). Generators reject a `cwd` field entirely; working-directory control is only available for `type: "cogitate"` prompts. 287 287 288 288 **Priority bands:** Prompts run in priority order (lowest first). Recommended bands: 289 289 - 10-30: Generators (content-producing prompts) ··· 365 365 - Keys are namespaced as `{app}:{name}` (e.g., `my_app:helper`) 366 366 - Agents inherit all system agent capabilities (tools, scheduling, multi-facet) 367 367 368 - **Metadata format:** Same schema as system agents in `talent/*.md` - JSON frontmatter includes `title`, `provider`, `model`, `tools`, `schedule`, `priority`, `multi_facet`, `max_output_tokens`, and `thinking_budget` fields. The `priority` field is **required** for all scheduled prompts - prompts without explicit priority will fail validation. See the priority bands documentation in [THINK.md](THINK.md#unified-priority-execution). Optional `max_output_tokens` sets the maximum response length; `thinking_budget` sets the model's thinking token budget (provider-specific defaults apply if omitted; OpenAI uses fixed reasoning and ignores this field). See [CORTEX.md](CORTEX.md) for agent configuration details. 368 + **Metadata format:** Same schema as system agents in `talent/*.md` - JSON frontmatter includes `title`, `provider`, `model`, `tools`, `schedule`, `priority`, `multi_facet`, `max_output_tokens`, and `thinking_budget` fields. The `priority` field is **required** for all scheduled prompts - prompts without explicit priority will fail validation. See the priority bands documentation in [THINK.md](THINK.md#unified-priority-execution). Optional `max_output_tokens` sets the maximum response length; `thinking_budget` sets the model's thinking token budget (provider-specific defaults apply if omitted; OpenAI uses fixed reasoning and ignores this field). Cogitate agents may also declare `cwd: "journal"` or `cwd: "repo"`; when omitted they default to `journal`, and repo-oriented prompts like `coder` should opt into `repo`. See [CORTEX.md](CORTEX.md) for agent configuration details. 369 369 370 370 **Template variables:** Agent prompts can use template variables like `$name`, `$preferred`, and pronoun variables. See [PROMPT_TEMPLATES.md](PROMPT_TEMPLATES.md) for the complete template system documentation. 371 371
+1 -1
docs/THINK.md
··· 254 254 255 255 System prompts in `talent/*.md` (markdown with JSON frontmatter). Apps can add custom agents in `apps/{app}/talent/`. 256 256 257 - JSON metadata supports `title`, `provider`, `model`, `tools`, `schedule`, `priority`, `multi_facet`, and `load` keys. 257 + JSON metadata supports `title`, `provider`, `model`, `tools`, `schedule`, `priority`, `multi_facet`, and `load` keys. Cogitate prompts may also set `cwd: "journal"` or `cwd: "repo"`; when omitted they default to `journal`, while repo-root agents such as `coder` should set `repo`. Generators reject `cwd`. 258 258 259 259 **Important:** The `priority` field is **required** for all prompts with a `schedule`. Prompts without explicit priority will fail validation. See the [Unified Priority Execution](#unified-priority-execution) section for priority bands. 260 260
+1
talent/coder.md
··· 1 1 { 2 2 "type": "cogitate", 3 3 "write": true, 4 + "cwd": "repo", 4 5 "title": "Coder", 5 6 "description": "Developer agent with full repo read/write access" 6 7 }
+3 -3
talent/heartbeat.md
··· 20 20 21 21 ## Path notes 22 22 23 - - `sol call identity agency --write` writes to `journal/sol/agency.md`. 24 - - The git-tracked copy is `sol/agency.md` in the project root. 25 - - After writing via `sol call`, copy `journal/sol/agency.md` to `sol/agency.md` before committing. 23 + - `sol call identity agency --write` writes to `sol/agency.md` in the journal root. 24 + - The git-tracked copy is `../sol/agency.md` (in the project root). 25 + - After writing via `sol call`, copy `sol/agency.md` to `../sol/agency.md` before committing. 26 26 27 27 ## Step 1: Check system health 28 28
+31
tests/test_cli_provider.py
··· 365 365 assert captured_env is provided_env 366 366 assert sentinel_key not in captured_env 367 367 368 + def test_cwd_passed_to_create_subprocess_exec(self): 369 + events = [] 370 + callback = JSONEventCallback(events.append) 371 + aggregator = ThinkingAggregator(callback, model="test-model") 372 + captured_cwd = None 373 + 374 + async def create_subprocess_exec(*args, **kwargs): 375 + nonlocal captured_cwd 376 + captured_cwd = kwargs["cwd"] 377 + return _make_process([], [], 0) 378 + 379 + runner = CLIRunner( 380 + cmd=["fakecli", "--json"], 381 + prompt_text="test", 382 + translate=lambda _e, _a, _c: None, 383 + callback=callback, 384 + aggregator=aggregator, 385 + cwd=Path("/tmp"), 386 + ) 387 + 388 + with ( 389 + patch( 390 + "think.providers.cli.asyncio.create_subprocess_exec", 391 + AsyncMock(side_effect=create_subprocess_exec), 392 + ), 393 + patch("think.providers.cli.shutil.which", return_value="/usr/bin/fakecli"), 394 + ): 395 + asyncio.run(runner.run()) 396 + 397 + assert captured_cwd == "/tmp" 398 + 368 399 369 400 class TestCLIRunnerFirstEventTimeout: 370 401 def test_first_event_timeout_includes_stderr(self):
+93 -2
tests/test_cortex.py
··· 186 186 config = { 187 187 "event": "request", 188 188 "ts": 987654321, 189 - "name": "activity", 189 + "name": "decisions", 190 190 "day": "20240101", 191 191 "output": "md", 192 192 } ··· 213 213 written_data = mock_process.stdin.write.call_args[0][0] 214 214 ndjson = json.loads(written_data.strip()) 215 215 assert ndjson["event"] == "request" 216 - assert ndjson["name"] == "activity" 216 + assert ndjson["name"] == "decisions" 217 217 assert ndjson["day"] == "20240101" 218 218 assert ndjson["output"] == "md" 219 219 ··· 232 232 # Check timer was created and started 233 233 mock_timer.assert_called_once() 234 234 mock_timer_instance.start.assert_called_once() 235 + 236 + 237 + @patch("think.talent.get_agent") 238 + @patch("think.cortex.subprocess.Popen") 239 + @patch("think.cortex.threading.Thread") 240 + @patch("think.cortex.threading.Timer") 241 + def test_spawn_subprocess_uses_cwd_from_talent( 242 + mock_timer, 243 + mock_thread, 244 + mock_popen, 245 + mock_get_agent, 246 + cortex_service, 247 + mock_journal, 248 + ): 249 + mock_process = MagicMock() 250 + mock_process.pid = 24680 251 + mock_process.poll.return_value = None 252 + mock_process.stdin = MagicMock() 253 + mock_process.stdout = MagicMock() 254 + mock_process.stderr = MagicMock() 255 + mock_popen.return_value = mock_process 256 + mock_get_agent.return_value = {"type": "cogitate", "cwd": "journal"} 257 + 258 + mock_timer_instance = MagicMock() 259 + mock_timer.return_value = mock_timer_instance 260 + 261 + agent_id = "24680" 262 + file_path = mock_journal / "agents" / f"{agent_id}_active.jsonl" 263 + request = { 264 + "event": "request", 265 + "ts": 24680, 266 + "prompt": "Test prompt", 267 + "provider": "openai", 268 + "name": "unified", 269 + "model": GPT_5, 270 + } 271 + 272 + cortex_service._spawn_subprocess( 273 + agent_id, 274 + file_path, 275 + request, 276 + ["sol", "agents"], 277 + "agent", 278 + ) 279 + 280 + assert mock_popen.call_args.kwargs["cwd"] == str(mock_journal) 281 + 282 + 283 + @patch("think.talent.get_agent") 284 + @patch("think.cortex.subprocess.Popen") 285 + @patch("think.cortex.threading.Thread") 286 + @patch("think.cortex.threading.Timer") 287 + def test_spawn_subprocess_skips_cwd_for_generate( 288 + mock_timer, 289 + mock_thread, 290 + mock_popen, 291 + mock_get_agent, 292 + cortex_service, 293 + mock_journal, 294 + ): 295 + mock_process = MagicMock() 296 + mock_process.pid = 13579 297 + mock_process.poll.return_value = None 298 + mock_process.stdin = MagicMock() 299 + mock_process.stdout = MagicMock() 300 + mock_process.stderr = MagicMock() 301 + mock_popen.return_value = mock_process 302 + mock_get_agent.return_value = {"type": "generate"} 303 + 304 + mock_timer_instance = MagicMock() 305 + mock_timer.return_value = mock_timer_instance 306 + 307 + agent_id = "13579" 308 + file_path = mock_journal / "agents" / f"{agent_id}_active.jsonl" 309 + request = { 310 + "event": "request", 311 + "ts": 13579, 312 + "name": "decisions", 313 + "day": "20240101", 314 + "output": "md", 315 + } 316 + 317 + cortex_service._spawn_subprocess( 318 + agent_id, 319 + file_path, 320 + request, 321 + ["sol", "agents"], 322 + "agent", 323 + ) 324 + 325 + assert mock_popen.call_args.kwargs["cwd"] is None 235 326 236 327 237 328 def test_monitor_stdout_json_events(cortex_service, mock_journal):
+31
tests/test_openai.py
··· 4 4 import asyncio 5 5 import functools 6 6 import importlib 7 + from pathlib import Path 7 8 from unittest.mock import AsyncMock, MagicMock, patch 8 9 9 10 from think.models import GPT_5 ··· 346 347 assert MockCLIRunner.last_instance is not None 347 348 assert "resume" in MockCLIRunner.last_instance.cmd 348 349 assert "thread-abc" in MockCLIRunner.last_instance.cmd 350 + 351 + def test_run_cogitate_passes_cwd_to_cli_runner(self): 352 + provider = _openai_provider() 353 + events = [] 354 + 355 + class MockCLIRunner: 356 + last_instance = None 357 + 358 + def __init__(self, **kwargs): 359 + self.kwargs = kwargs 360 + self.cmd = kwargs["cmd"] 361 + self.prompt_text = kwargs["prompt_text"] 362 + self.cli_session_id = "test-session-id" 363 + self.run = AsyncMock(return_value="test result") 364 + MockCLIRunner.last_instance = self 365 + 366 + with patch("think.providers.openai.CLIRunner", MockCLIRunner): 367 + asyncio.run( 368 + provider.run_cogitate( 369 + { 370 + "prompt": "hello", 371 + "model": GPT_5, 372 + "cwd": "/fake/journal", 373 + }, 374 + events.append, 375 + ) 376 + ) 377 + 378 + assert MockCLIRunner.last_instance is not None 379 + assert MockCLIRunner.last_instance.kwargs["cwd"] == Path("/fake/journal") 349 380 350 381 def test_system_instruction_prepended(self): 351 382 provider = _openai_provider()
+47 -1
tests/test_talent.py
··· 3 3 4 4 """Tests for think.talent module.""" 5 5 6 - from think.talent import get_agent_filter, source_is_enabled, source_is_required 6 + import pytest 7 + 8 + from think.talent import ( 9 + _validate_cwd, 10 + get_agent, 11 + get_agent_filter, 12 + source_is_enabled, 13 + source_is_required, 14 + ) 7 15 8 16 9 17 def test_source_is_enabled_bool(): ··· 59 67 filter_dict = {"entities": True, "meetings": "required", "flow": False} 60 68 assert get_agent_filter(filter_dict) == filter_dict 61 69 assert get_agent_filter({}) == {} 70 + 71 + 72 + def test_validate_cwd_defaults_cogitate_to_journal(): 73 + assert _validate_cwd(None, "cogitate", "test-agent") == "journal" 74 + 75 + 76 + def test_validate_cwd_accepts_repo(): 77 + assert _validate_cwd("repo", "cogitate", "test-agent") == "repo" 78 + 79 + 80 + def test_validate_cwd_accepts_journal(): 81 + assert _validate_cwd("journal", "cogitate", "test-agent") == "journal" 82 + 83 + 84 + def test_validate_cwd_rejects_generate_with_cwd(): 85 + with pytest.raises( 86 + ValueError, 87 + match="Prompt 'test-agent' sets 'cwd' but cwd is only valid for type: cogitate", 88 + ): 89 + _validate_cwd("journal", "generate", "test-agent") 90 + 91 + 92 + def test_validate_cwd_rejects_invalid_value(): 93 + with pytest.raises( 94 + ValueError, 95 + match="Prompt 'test-agent' has invalid 'cwd' value 'home'", 96 + ): 97 + _validate_cwd("home", "cogitate", "test-agent") 98 + 99 + 100 + def test_get_agent_normalizes_cwd_for_cogitate(): 101 + config = get_agent("chat") 102 + assert config["cwd"] == "journal" 103 + 104 + 105 + def test_get_agent_preserves_repo_cwd_for_coder(): 106 + config = get_agent("coder") 107 + assert config["cwd"] == "repo"
+24
think/agents.py
··· 45 45 format_day, 46 46 format_segment_times, 47 47 get_journal, 48 + get_project_root, 48 49 now_ms, 49 50 require_solstone, 50 51 segment_parse, ··· 460 461 # Convert path string to Path object for convenience 461 462 agent_path = Path(config["path"]) if config.get("path") else None 462 463 sources = config.get("sources", {}) 464 + talent_cwd = config.get("cwd") 463 465 464 466 # Merge request values (request overrides agent defaults) 465 467 config.update({k: v for k, v in request.items() if v is not None}) 468 + request_cwd = request.get("cwd") 469 + if request_cwd is not None and request_cwd != talent_cwd: 470 + raise ValueError( 471 + f"Request overrides 'cwd' for talent '{name}' are not allowed " 472 + f"({talent_cwd!r} != {request_cwd!r})" 473 + ) 474 + 475 + cwd_value = config.get("cwd") 476 + if cwd_value == "journal": 477 + try: 478 + journal_path = Path(get_journal()) 479 + except Exception as exc: 480 + raise RuntimeError( 481 + f"Cannot resolve cwd for talent '{name}' — journal path unavailable" 482 + ) from exc 483 + if not journal_path.exists(): 484 + raise RuntimeError( 485 + f"Cannot resolve cwd for talent '{name}' — journal path unavailable" 486 + ) 487 + config["cwd"] = str(journal_path) 488 + elif cwd_value == "repo": 489 + config["cwd"] = get_project_root() 466 490 467 491 # Populate stream from env if not already in config (dream passes it as 468 492 # SOL_STREAM env var but not as a top-level request key — hooks need it)
+26 -1
think/cortex.py
··· 29 29 30 30 from think.callosum import CallosumConnection 31 31 from think.runner import _atomic_symlink 32 - from think.utils import get_journal, get_rev, now_ms 32 + from think.utils import get_journal, get_project_root, get_rev, now_ms 33 33 34 34 35 35 class AgentProcess: ··· 193 193 - Event relay to Callosum 194 194 195 195 All config loading, validation, and hydration is done by agents.py. 196 + Cortex only resolves talent cwd early so the child process starts in 197 + the correct working directory. 196 198 """ 197 199 agent_id = request.get("agent_id") 198 200 if not agent_id: ··· 280 282 # Spawn the subprocess 281 283 self.logger.info(f"Spawning {process_type} {agent_id}: {cmd}") 282 284 self.logger.debug(f"NDJSON input: {ndjson_input}") 285 + subprocess_cwd = None 286 + if process_type == "agent": 287 + from think.talent import get_agent 288 + 289 + talent_key = str(config.get("name", "unified")) 290 + talent_config = get_agent(talent_key) 291 + if talent_config.get("type") == "cogitate": 292 + # Resolve here because prepare_config() runs inside sol agents. 293 + cwd_value = talent_config.get("cwd") 294 + if cwd_value == "journal": 295 + try: 296 + subprocess_cwd = str(Path(get_journal())) 297 + except Exception as exc: 298 + raise RuntimeError( 299 + f"Cannot resolve cwd for talent '{talent_key}'" 300 + ) from exc 301 + elif cwd_value == "repo": 302 + subprocess_cwd = get_project_root() 303 + else: 304 + raise RuntimeError( 305 + f"Cannot resolve cwd for talent '{talent_key}'" 306 + ) 283 307 284 308 process = subprocess.Popen( 285 309 cmd, ··· 289 313 text=True, 290 314 env=env, 291 315 bufsize=1, 316 + cwd=subprocess_cwd, 292 317 ) 293 318 294 319 # Send input and close stdin
+3
think/providers/anthropic.py
··· 34 34 import logging 35 35 import os 36 36 import traceback 37 + from pathlib import Path 37 38 from typing import Any, Callable 38 39 39 40 from anthropic import AsyncAnthropic ··· 273 274 ) -> str | None: 274 275 return _translate_claude(event, agg, cb, pending_tools, result_meta) 275 276 277 + cwd_value = config.get("cwd") 276 278 runner = CLIRunner( 277 279 cmd=cmd, 278 280 prompt_text=prompt_body, 279 281 translate=translate, 280 282 callback=callback, 281 283 aggregator=aggregator, 284 + cwd=Path(cwd_value) if cwd_value else None, 282 285 env=build_cogitate_env("ANTHROPIC_API_KEY"), 283 286 ) 284 287
+3
think/providers/google.py
··· 34 34 import logging 35 35 import os 36 36 import traceback 37 + from pathlib import Path 37 38 from typing import Any, Callable 38 39 39 40 from google import genai ··· 726 727 return _translate_gemini(event, agg, cb, usage, pending_tools) 727 728 728 729 aggregator = ThinkingAggregator(callback, model=model) 730 + cwd_value = config.get("cwd") 729 731 runner = CLIRunner( 730 732 cmd=cmd, 731 733 prompt_text=prompt_body, 732 734 translate=translate, 733 735 callback=callback, 734 736 aggregator=aggregator, 737 + cwd=Path(cwd_value) if cwd_value else None, 735 738 env=build_cogitate_env("GOOGLE_API_KEY"), 736 739 ) 737 740
+3
think/providers/ollama.py
··· 50 50 import logging 51 51 import os 52 52 import traceback 53 + from pathlib import Path 53 54 from typing import Any, Callable 54 55 55 56 import httpx ··· 523 524 return _translate_opencode(event, agg, cb, usage) 524 525 525 526 aggregator = ThinkingAggregator(callback, model=model) 527 + cwd_value = config.get("cwd") 526 528 runner = CLIRunner( 527 529 cmd=cmd, 528 530 prompt_text=prompt_body, 529 531 translate=translate, 530 532 callback=callback, 531 533 aggregator=aggregator, 534 + cwd=Path(cwd_value) if cwd_value else None, 532 535 env=_build_opencode_env(), 533 536 # Local models are slower than cloud APIs; allow more time for 534 537 # the first event (model loading + initial inference).
+3
think/providers/openai.py
··· 36 36 import logging 37 37 import os 38 38 import traceback 39 + from pathlib import Path 39 40 from typing import Any, Callable 40 41 41 42 from think.models import GPT_5, OPENAI_EFFORT_SUFFIXES ··· 205 206 usage_holder: list[dict[str, Any]] = [{}] 206 207 aggregator = ThinkingAggregator(cb, model) 207 208 translate = functools.partial(_translate_codex, usage_holder=usage_holder) 209 + cwd_value = config.get("cwd") 208 210 runner = CLIRunner( 209 211 cmd=cmd, 210 212 prompt_text=prompt_text, 211 213 translate=translate, 212 214 callback=cb, 213 215 aggregator=aggregator, 216 + cwd=Path(cwd_value) if cwd_value else None, 214 217 env=build_cogitate_env("OPENAI_API_KEY"), 215 218 ) 216 219
+41
think/talent.py
··· 40 40 # --------------------------------------------------------------------------- 41 41 42 42 43 + def _validate_cwd(raw_cwd: Any, talent_type: Any, key: str) -> str | None: 44 + """Validate and normalize the optional talent cwd setting.""" 45 + if talent_type == "cogitate": 46 + if raw_cwd is None: 47 + return "journal" 48 + if raw_cwd in {"journal", "repo"}: 49 + return raw_cwd 50 + raise ValueError( 51 + f"Prompt '{key}' has invalid 'cwd' value '{raw_cwd}' " 52 + "(must be 'journal' or 'repo')" 53 + ) 54 + 55 + if talent_type == "generate": 56 + if raw_cwd is not None: 57 + raise ValueError( 58 + f"Prompt '{key}' sets 'cwd' but cwd is only valid for type: cogitate" 59 + ) 60 + return None 61 + 62 + if raw_cwd is None: 63 + return None 64 + 65 + raise ValueError( 66 + f"Prompt '{key}' has invalid 'cwd' value '{raw_cwd}' " 67 + "(must be 'journal' or 'repo')" 68 + ) 69 + 70 + 43 71 def key_to_context(key: str) -> str: 44 72 """Convert talent config key to context pattern. 45 73 ··· 288 316 f'(activity types to match, or ["*"] for all types).' 289 317 ) 290 318 319 + # Validate: cwd is only valid for cogitate prompts and defaults there 320 + for key, info in configs.items(): 321 + normalized_cwd = _validate_cwd(info.get("cwd"), info.get("type"), key) 322 + if normalized_cwd is None: 323 + info.pop("cwd", None) 324 + else: 325 + info["cwd"] = normalized_cwd 326 + 291 327 return {key: info for key, info in configs.items() if matches_filter(info)} 292 328 293 329 ··· 456 492 # Load config from frontmatter - preserve all fields 457 493 post = frontmatter.load(md_path) 458 494 config = dict(post.metadata) if post.metadata else {} 495 + normalized_cwd = _validate_cwd(config.get("cwd"), config.get("type"), name) 496 + if normalized_cwd is None: 497 + config.pop("cwd", None) 498 + else: 499 + config["cwd"] = normalized_cwd 459 500 460 501 # Store path for later use 461 502 config["path"] = str(md_path)