personal memory agent
0
fork

Configure Feed

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

chat: replace triage backend with chat singleton

Third of three sub-lodes for the chat backend rewrite (parent plan:
chat-refactor). The big flip: new `/api/chat` singleton backend
replaces `/api/triage`, and every legacy chat path is removed.

Backend (new)
- `convey/chat.py` is the singleton chat runtime and endpoint surface.
Single-process Flask worker assumption is stated in a top-of-file
comment. Module-level `threading.Lock` guards the single-slot chat
generate. `start_chat_runtime(app)` wires callosum cortex/finish +
cortex/error subscriptions and performs the idempotent crash-
recovery scan on boot.
- Endpoints: POST /api/chat (append owner_message, schedule generate),
GET /api/chat/session, GET /api/chat/stream/<day>, GET
/api/chat/result/<use_id>. Chat stream is the queue; no separate
in-memory queue exists. Source of truth for all responses is the
stream itself, reduced on the fly — never the exec talent log.
- Active-exec cap = 2 (3rd request fires a remediation chat generate
with the spec literal "max active — waiting for one to finish").
Loop cap = 3 consecutive exec cycles without an owner_message
(then append chat_error with "chat had trouble — try again"). Any
owner_message resets the loop counter.
- `convey/utils.spawn_agent()` and `think/cortex_client.cortex_request()`
now accept an optional caller-supplied `use_id`. Default keeps the
auto-allocation path.
- Exec dispatch prompt is assembled inline in chat.py per spec E2
(task + context hints + location + last 6 chat turns; no digest).
`spawn_agent(name="exec", ...)` — never "unified".

Cleanup
- Deleted `convey/triage.py` (renamed to `convey/chat.py`),
`apps/home/events.py`, `think/conversation.py`,
`talent/conversation_memory.py`, `talent/triage.md`,
`tests/test_conversation.py`, `tests/test_home_events.py`.
- Deleted the `_resolve_talent_path` `unified` alias branch and the
transient `_UNDISCOVERED_SYSTEM_TALENTS` narrowing that 2a had added.
- Removed `think/cortex.py`'s `TRIAGE_AGENT_NAMES`-based display
decoration. `convey/chat.py` now produces the `display` field for
chat-flow finish events directly.
- `think/awareness.py::compute_thickness()` previously pulled recent
exchanges from the deleted `think.conversation`; it now reads the
chat stream via a local helper.
- `convey/templates/app.html` — minimal URL swap from `/api/triage` to
`/api/chat` with recovery-shape adjustment. Full UI rewrite is
lode 3.

Tests
- New: `test_chat_runtime.py` (cap logic, crash recovery, cortex
correlation), `test_convey_chat.py` (endpoint contracts),
`test_no_legacy_chat_imports.py` (regression gate for the deleted
symbols and "unified" string), plus the chat-turn FTS5 coverage
case in `test_journal_index.py`.
- `conftest.py` has an autouse cleanup that resets the module-level
chat runtime between tests.
- `make ci`: 3740 passed, 4 skipped (pre-existing sandbox-only plus
the 3 search/graph harness skips from 2a). `make verify-api`:
51 endpoints green. `make review` browser phase blocked on
pinchtab startup — infrastructure issue unrelated to 2c.

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

+1613 -1421
-61
apps/home/events.py
··· 1 - # SPDX-License-Identifier: AGPL-3.0-only 2 - # Copyright (c) 2026 sol pbc 3 - 4 - """Callosum event handlers for conversation exchange recording. 5 - 6 - Records triage agent completions to the conversation memory service 7 - so exchanges persist for future context injection. 8 - """ 9 - 10 - import logging 11 - 12 - from apps.events import EventContext, on_event 13 - from think.conversation import record_exchange 14 - from think.cortex_client import read_use_events 15 - 16 - logger = logging.getLogger(__name__) 17 - 18 - TRIAGE_AGENT_NAMES = {"unified", "triage"} 19 - 20 - 21 - @on_event("cortex", "finish") 22 - def record_triage_exchange(ctx: EventContext) -> None: 23 - """Record completed triage agent exchanges to conversation memory.""" 24 - name = ctx.msg.get("name") 25 - if name not in TRIAGE_AGENT_NAMES: 26 - return 27 - 28 - use_id = ctx.msg.get("use_id") 29 - if not use_id: 30 - return 31 - 32 - try: 33 - events = read_use_events(use_id) 34 - facet = "" 35 - app = "" 36 - path = "" 37 - user_message = "" 38 - for event in events: 39 - if event.get("event") == "request": 40 - facet = event.get("facet", "") 41 - app = event.get("app", "") 42 - path = event.get("path", "") 43 - user_message = event.get("user_message", "") 44 - break 45 - 46 - result = ctx.msg.get("result", "") 47 - record_exchange( 48 - facet=facet, 49 - app=app, 50 - path=path, 51 - user_message=user_message, 52 - agent_response=result, 53 - talent=name, 54 - use_id=use_id, 55 - ) 56 - except Exception: 57 - logger.debug( 58 - "Failed to record conversation exchange for agent %s", 59 - use_id, 60 - exc_info=True, 61 - )
+4 -3
convey/__init__.py
··· 20 20 from . import state, system 21 21 from .apps import register_app_context 22 22 from .bridge import emit, register_websocket 23 + from .chat import chat_bp, start_chat_runtime 23 24 from .config import bp as config_bp 24 25 from .root import bp as root_bp 25 - from .triage import bp as triage_bp 26 26 27 27 __all__ = [ 28 28 "create_app", ··· 144 144 # Register config API blueprint 145 145 app.register_blueprint(config_bp) 146 146 147 - # Register triage API blueprint (universal chat bar) 148 - app.register_blueprint(triage_bp) 147 + # Register chat API blueprint (universal chat bar) 148 + app.register_blueprint(chat_bp) 149 149 150 150 # Register system health API blueprint 151 151 app.register_blueprint(system.bp) ··· 172 172 register_websocket(sock) 173 173 start_voice_runtime(app) 174 174 start_push_runtime(app) 175 + start_chat_runtime(app) 175 176 176 177 if journal: 177 178 state.journal_root = journal
+855
convey/chat.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + # Chat backend runs in a single Flask worker process. The threading.Lock plus 5 + # module-level singleton state assumes one convey process per stack. 6 + 7 + from __future__ import annotations 8 + 9 + import atexit 10 + import json 11 + import logging 12 + import pprint 13 + import re 14 + import threading 15 + from dataclasses import dataclass, field 16 + from datetime import datetime 17 + from typing import Any 18 + 19 + from flask import Blueprint, jsonify, request 20 + 21 + from convey.chat_stream import ( 22 + append_chat_event, 23 + find_unresponded_trigger, 24 + read_chat_events, 25 + reduce_chat_state, 26 + ) 27 + from convey.utils import error_response 28 + from think.callosum import CallosumConnection, callosum_send 29 + from think.utils import now_ms 30 + 31 + logger = logging.getLogger(__name__) 32 + 33 + chat_bp = Blueprint("chat", __name__, url_prefix="/api/chat") 34 + 35 + MAX_ACTIVE_EXECS = 2 36 + MAX_LOOP_RETRIES = 3 37 + DEFAULT_STREAM_LIMIT = 200 38 + MAX_STREAM_LIMIT = 1000 39 + MAX_ACTIVE_REASON = "max active — waiting for one to finish" 40 + CHAT_TROUBLE_REASON = "chat had trouble — try again" 41 + 42 + _DAY_RE = re.compile(r"^\d{8}$") 43 + _state_lock = threading.Lock() 44 + _runtime_lock = threading.Lock() 45 + _current_chat_use_id: str | None = None 46 + _current_chat_state: dict[str, Any] | None = None 47 + _queued_trigger: dict[str, Any] | None = None 48 + _active_execs: dict[str, dict[str, Any]] = {} 49 + _last_use_id = 0 50 + _recovery_day: str | None = None 51 + _runtime: "ChatRuntimeState | None" = None 52 + _atexit_registered = False 53 + 54 + 55 + @dataclass 56 + class ChatRuntimeState: 57 + callosum: CallosumConnection 58 + apps: list[Any] = field(default_factory=list) 59 + 60 + 61 + @chat_bp.route("", methods=["POST"]) 62 + def post_chat() -> Any: 63 + """Accept an owner message and schedule the chat singleton.""" 64 + payload = request.get_json(force=True) or {} 65 + message = str(payload.get("message") or "").strip() 66 + if not message: 67 + return error_response("message is required", 400) 68 + 69 + from think.identity import ensure_identity_directory 70 + 71 + ensure_identity_directory() 72 + 73 + location = _normalize_location( 74 + payload.get("app"), 75 + payload.get("path"), 76 + payload.get("facet"), 77 + ) 78 + append_chat_event( 79 + "owner_message", 80 + text=message, 81 + app=location["app"], 82 + path=location["path"], 83 + facet=location["facet"], 84 + ) 85 + trigger = { 86 + "type": "owner_message", 87 + "message": message, 88 + } 89 + 90 + start_info: dict[str, Any] | None = None 91 + with _state_lock: 92 + if _current_chat_use_id is None: 93 + logical_use_id = _reserve_use_id_locked() 94 + start_info = _activate_current_locked(logical_use_id, trigger, location) 95 + queued = False 96 + response_use_id = logical_use_id 97 + else: 98 + response_use_id = _queue_trigger_locked(trigger, location) 99 + queued = True 100 + 101 + if start_info is not None and not _spawn_chat_generate(start_info): 102 + _handle_chat_failure(response_use_id, CHAT_TROUBLE_REASON) 103 + return error_response("Failed to connect to agent service", 503) 104 + 105 + return jsonify(use_id=response_use_id, queued=queued) 106 + 107 + 108 + @chat_bp.route("/session", methods=["GET"]) 109 + def chat_session() -> Any: 110 + """Return reduced state for today's chat stream.""" 111 + return jsonify(reduce_chat_state(_today_day())) 112 + 113 + 114 + @chat_bp.route("/stream/<day>", methods=["GET"]) 115 + def chat_stream(day: str) -> Any: 116 + """Return ordered chat events for a day.""" 117 + if not _DAY_RE.fullmatch(day): 118 + return error_response("day must be YYYYMMDD", 400) 119 + 120 + limit_raw = request.args.get("limit", str(DEFAULT_STREAM_LIMIT)) 121 + try: 122 + limit = int(limit_raw) 123 + except (TypeError, ValueError): 124 + limit = DEFAULT_STREAM_LIMIT 125 + limit = max(1, min(limit, MAX_STREAM_LIMIT)) 126 + 127 + return jsonify(events=read_chat_events(day, limit=limit)) 128 + 129 + 130 + @chat_bp.route("/result/<use_id>", methods=["GET"]) 131 + def chat_result(use_id: str) -> Any: 132 + """Return chat or exec state from the chat stream.""" 133 + result = _read_result_state(use_id) 134 + if result is None: 135 + return jsonify(error="not found"), 404 136 + return jsonify(result) 137 + 138 + 139 + def start_chat_runtime(app: Any) -> None: 140 + """Start the chat backend runtime and subscribe to cortex events.""" 141 + global _runtime, _atexit_registered 142 + 143 + with _runtime_lock: 144 + if _runtime is None: 145 + runtime = ChatRuntimeState(callosum=CallosumConnection()) 146 + runtime.callosum.start(callback=_handle_callosum_message) 147 + _runtime = runtime 148 + runtime = _runtime 149 + if app not in runtime.apps: 150 + runtime.apps.append(app) 151 + app.chat_runtime_started = True 152 + if not _atexit_registered: 153 + atexit.register(stop_all_chat_runtime) 154 + _atexit_registered = True 155 + 156 + _recover_chat_if_needed() 157 + 158 + 159 + def stop_chat_runtime(app: Any) -> None: 160 + """Detach an app from the shared runtime.""" 161 + app.chat_runtime_started = False 162 + runtime = _runtime 163 + if runtime is None: 164 + return 165 + with _runtime_lock: 166 + if app in runtime.apps: 167 + runtime.apps.remove(app) 168 + remaining = list(runtime.apps) 169 + if not remaining: 170 + stop_all_chat_runtime() 171 + 172 + 173 + def stop_all_chat_runtime() -> None: 174 + """Stop the shared runtime.""" 175 + global _runtime 176 + 177 + with _runtime_lock: 178 + runtime = _runtime 179 + _runtime = None 180 + if runtime is None: 181 + return 182 + for app in list(runtime.apps): 183 + try: 184 + app.chat_runtime_started = False 185 + except Exception: 186 + logger.exception("chat runtime app cleanup failed") 187 + runtime.callosum.stop() 188 + 189 + 190 + def _handle_callosum_message(message: dict[str, Any]) -> None: 191 + if message.get("chat_proxy"): 192 + return 193 + if message.get("tract") != "cortex": 194 + return 195 + 196 + event_type = message.get("event") 197 + if event_type == "finish": 198 + _on_cortex_finish(message) 199 + return 200 + if event_type == "error": 201 + _on_cortex_error(message) 202 + return 203 + 204 + _proxy_progress(message) 205 + 206 + 207 + def _proxy_progress(message: dict[str, Any]) -> None: 208 + logical_use_id: str | None = None 209 + use_id = str(message.get("use_id") or "") 210 + if not use_id: 211 + return 212 + 213 + with _state_lock: 214 + if _current_chat_state is None or _current_chat_use_id is None: 215 + return 216 + raw_chat_use_id = str(_current_chat_state.get("raw_use_id") or "") 217 + if use_id == raw_chat_use_id: 218 + logical_use_id = _current_chat_use_id 219 + elif use_id in _active_execs: 220 + logical_use_id = str(_active_execs[use_id]["chat_use_id"]) 221 + 222 + if logical_use_id is None: 223 + return 224 + 225 + fields = { 226 + key: value 227 + for key, value in message.items() 228 + if key not in {"tract", "event", "use_id"} 229 + } 230 + fields["use_id"] = logical_use_id 231 + fields["chat_proxy"] = True 232 + _emit_cortex_event(message["event"], **fields) 233 + 234 + 235 + def _on_cortex_finish(message: dict[str, Any]) -> None: 236 + use_id = str(message.get("use_id") or "") 237 + if not use_id: 238 + return 239 + 240 + next_info: dict[str, Any] | None = None 241 + finish_payload: dict[str, Any] | None = None 242 + error_payload: dict[str, Any] | None = None 243 + 244 + with _state_lock: 245 + if _current_chat_state is not None and use_id == _current_chat_state.get( 246 + "raw_use_id" 247 + ): 248 + logical_use_id = str(_current_chat_use_id) 249 + try: 250 + parsed = _parse_chat_result(message.get("result")) 251 + except ValueError: 252 + if int(_current_chat_state.get("retry_count", 0) or 0) < 1: 253 + retry_use_id = _reserve_use_id_locked() 254 + _current_chat_state["raw_use_id"] = retry_use_id 255 + _current_chat_state["retry_count"] = ( 256 + int(_current_chat_state.get("retry_count", 0) or 0) + 1 257 + ) 258 + next_info = _build_spawn_info_locked(logical_use_id) 259 + else: 260 + append_chat_event( 261 + "chat_error", 262 + reason=CHAT_TROUBLE_REASON, 263 + use_id=logical_use_id, 264 + ) 265 + error_payload = { 266 + "use_id": logical_use_id, 267 + "reason": CHAT_TROUBLE_REASON, 268 + } 269 + next_info = _clear_current_locked() 270 + else: 271 + message_text = parsed["message"] or "" 272 + requested_exec = parsed["talent_request"] is not None 273 + requested_task = ( 274 + parsed["talent_request"]["task"] 275 + if parsed["talent_request"] 276 + else None 277 + ) 278 + append_chat_event( 279 + "sol_message", 280 + use_id=logical_use_id, 281 + text=message_text, 282 + notes=parsed["notes"], 283 + requested_exec=requested_exec, 284 + requested_task=requested_task, 285 + ) 286 + _current_chat_state["retry_count"] = 0 287 + _current_chat_state["raw_use_id"] = None 288 + if requested_exec: 289 + active_exec_count = _active_exec_count_for_today_locked() 290 + if active_exec_count >= MAX_ACTIVE_EXECS: 291 + _current_chat_state["trigger"] = { 292 + "type": "synthetic-max-active", 293 + "reason": MAX_ACTIVE_REASON, 294 + } 295 + synthetic_use_id = _reserve_use_id_locked() 296 + _current_chat_state["raw_use_id"] = synthetic_use_id 297 + next_info = _build_spawn_info_locked(logical_use_id) 298 + elif _exec_loop_count_locked() >= MAX_LOOP_RETRIES: 299 + append_chat_event( 300 + "chat_error", 301 + reason=CHAT_TROUBLE_REASON, 302 + use_id=logical_use_id, 303 + ) 304 + error_payload = { 305 + "use_id": logical_use_id, 306 + "reason": CHAT_TROUBLE_REASON, 307 + } 308 + next_info = _clear_current_locked() 309 + else: 310 + exec_use_id = _reserve_use_id_locked() 311 + _active_execs[exec_use_id] = { 312 + "chat_use_id": logical_use_id, 313 + "task": requested_task, 314 + "location": dict(_current_chat_state["location"]), 315 + } 316 + append_chat_event( 317 + "talent_spawned", 318 + use_id=exec_use_id, 319 + name="exec", 320 + task=requested_task, 321 + started_at=int(exec_use_id), 322 + ) 323 + next_info = { 324 + "kind": "exec", 325 + "logical_use_id": logical_use_id, 326 + "use_id": exec_use_id, 327 + "task": requested_task, 328 + "context": parsed["talent_request"].get("context") or {}, 329 + "location": dict(_current_chat_state["location"]), 330 + } 331 + else: 332 + if not message_text: 333 + append_chat_event( 334 + "chat_error", 335 + reason=CHAT_TROUBLE_REASON, 336 + use_id=logical_use_id, 337 + ) 338 + error_payload = { 339 + "use_id": logical_use_id, 340 + "reason": CHAT_TROUBLE_REASON, 341 + } 342 + else: 343 + finish_payload = { 344 + "use_id": logical_use_id, 345 + "message": message_text, 346 + } 347 + next_info = _clear_current_locked() 348 + 349 + elif use_id in _active_execs: 350 + exec_state = _active_execs.pop(use_id) 351 + logical_use_id = str(exec_state["chat_use_id"]) 352 + summary = str(message.get("result") or "").strip() 353 + append_chat_event( 354 + "talent_finished", 355 + use_id=use_id, 356 + name="exec", 357 + summary=summary, 358 + ) 359 + if ( 360 + _current_chat_use_id == logical_use_id 361 + and _current_chat_state is not None 362 + ): 363 + _current_chat_state["trigger"] = { 364 + "type": "talent_finished", 365 + "use_id": use_id, 366 + "name": "exec", 367 + "summary": summary, 368 + } 369 + _current_chat_state["raw_use_id"] = _reserve_use_id_locked() 370 + _current_chat_state["retry_count"] = 0 371 + next_info = _build_spawn_info_locked(logical_use_id) 372 + 373 + _run_next_action(next_info) 374 + if finish_payload is not None: 375 + _emit_finish(finish_payload["use_id"], finish_payload["message"]) 376 + if error_payload is not None: 377 + _emit_error(error_payload["use_id"], error_payload["reason"]) 378 + 379 + 380 + def _on_cortex_error(message: dict[str, Any]) -> None: 381 + use_id = str(message.get("use_id") or "") 382 + if not use_id: 383 + return 384 + 385 + next_info: dict[str, Any] | None = None 386 + error_payload: dict[str, Any] | None = None 387 + 388 + with _state_lock: 389 + if _current_chat_state is not None and use_id == _current_chat_state.get( 390 + "raw_use_id" 391 + ): 392 + logical_use_id = str(_current_chat_use_id) 393 + append_chat_event( 394 + "chat_error", 395 + reason=CHAT_TROUBLE_REASON, 396 + use_id=logical_use_id, 397 + ) 398 + error_payload = {"use_id": logical_use_id, "reason": CHAT_TROUBLE_REASON} 399 + next_info = _clear_current_locked() 400 + elif use_id in _active_execs: 401 + exec_state = _active_execs.pop(use_id) 402 + logical_use_id = str(exec_state["chat_use_id"]) 403 + reason = str(message.get("error") or CHAT_TROUBLE_REASON) 404 + append_chat_event( 405 + "talent_errored", 406 + use_id=use_id, 407 + name="exec", 408 + reason=reason, 409 + ) 410 + if ( 411 + _current_chat_use_id == logical_use_id 412 + and _current_chat_state is not None 413 + ): 414 + _current_chat_state["trigger"] = { 415 + "type": "talent_errored", 416 + "use_id": use_id, 417 + "name": "exec", 418 + "reason": reason, 419 + } 420 + _current_chat_state["raw_use_id"] = _reserve_use_id_locked() 421 + _current_chat_state["retry_count"] = 0 422 + next_info = _build_spawn_info_locked(logical_use_id) 423 + 424 + _run_next_action(next_info) 425 + if error_payload is not None: 426 + _emit_error(error_payload["use_id"], error_payload["reason"]) 427 + 428 + 429 + def _run_next_action(action: dict[str, Any] | None) -> None: 430 + if action is None: 431 + return 432 + if action.get("kind") == "chat": 433 + if not _spawn_chat_generate(action): 434 + _handle_chat_failure(action["logical_use_id"], CHAT_TROUBLE_REASON) 435 + return 436 + if action.get("kind") == "exec": 437 + if not _spawn_exec(action): 438 + _handle_exec_spawn_failure(action) 439 + 440 + 441 + def _spawn_chat_generate(action: dict[str, Any]) -> bool: 442 + logger.info( 443 + "starting chat generate logical=%s raw=%s trigger=%s", 444 + action["logical_use_id"], 445 + action["raw_use_id"], 446 + action["trigger"]["type"], 447 + ) 448 + from convey.utils import spawn_agent 449 + 450 + config = { 451 + "app": action["location"]["app"], 452 + "path": action["location"]["path"], 453 + "facet": action["location"]["facet"], 454 + "trigger": action["trigger"], 455 + "chat_request_use_id": action["logical_use_id"], 456 + } 457 + use_id = spawn_agent( 458 + prompt="", 459 + name="chat", 460 + provider=None, 461 + config=config, 462 + use_id=action["raw_use_id"], 463 + ) 464 + if use_id is None: 465 + return False 466 + _emit_cortex_event("thinking", use_id=action["logical_use_id"], chat_proxy=True) 467 + return True 468 + 469 + 470 + def _spawn_exec(action: dict[str, Any]) -> bool: 471 + from convey.utils import spawn_agent 472 + 473 + prompt = _build_exec_prompt( 474 + action["task"], 475 + action["context"], 476 + action["location"], 477 + ) 478 + config = { 479 + "app": action["location"]["app"], 480 + "path": action["location"]["path"], 481 + "facet": action["location"]["facet"], 482 + "chat_parent_use_id": action["logical_use_id"], 483 + } 484 + use_id = spawn_agent( 485 + prompt=prompt, 486 + name="exec", 487 + provider=None, 488 + config=config, 489 + use_id=action["use_id"], 490 + ) 491 + if use_id is None: 492 + return False 493 + _emit_cortex_event("thinking", use_id=action["logical_use_id"], chat_proxy=True) 494 + return True 495 + 496 + 497 + def _handle_exec_spawn_failure(action: dict[str, Any]) -> None: 498 + next_info: dict[str, Any] | None = None 499 + with _state_lock: 500 + _active_execs.pop(str(action["use_id"]), None) 501 + append_chat_event( 502 + "talent_errored", 503 + use_id=action["use_id"], 504 + name="exec", 505 + reason=CHAT_TROUBLE_REASON, 506 + ) 507 + if _current_chat_use_id == action["logical_use_id"] and _current_chat_state: 508 + _current_chat_state["trigger"] = { 509 + "type": "talent_errored", 510 + "use_id": action["use_id"], 511 + "name": "exec", 512 + "reason": CHAT_TROUBLE_REASON, 513 + } 514 + _current_chat_state["raw_use_id"] = _reserve_use_id_locked() 515 + _current_chat_state["retry_count"] = 0 516 + next_info = _build_spawn_info_locked(action["logical_use_id"]) 517 + _run_next_action(next_info) 518 + 519 + 520 + def _handle_chat_failure(logical_use_id: str, reason: str) -> None: 521 + next_info: dict[str, Any] | None = None 522 + with _state_lock: 523 + append_chat_event("chat_error", reason=reason, use_id=logical_use_id) 524 + if _current_chat_use_id == logical_use_id: 525 + next_info = _clear_current_locked() 526 + _emit_error(logical_use_id, reason) 527 + _run_next_action(next_info) 528 + 529 + 530 + def _recover_chat_if_needed() -> None: 531 + day = _today_day() 532 + start_info: dict[str, Any] | None = None 533 + 534 + with _state_lock: 535 + global _recovery_day 536 + if _recovery_day == day or _current_chat_use_id is not None: 537 + return 538 + unresolved = find_unresponded_trigger(day) 539 + if unresolved is None: 540 + _recovery_day = day 541 + return 542 + location = _location_for_trigger(day, unresolved) 543 + logical_use_id = _reserve_use_id_locked() 544 + trigger = _trigger_from_stream_event(unresolved) 545 + start_info = _activate_current_locked(logical_use_id, trigger, location) 546 + _recovery_day = day 547 + 548 + if start_info is not None and not _spawn_chat_generate(start_info): 549 + _handle_chat_failure(start_info["logical_use_id"], CHAT_TROUBLE_REASON) 550 + 551 + 552 + def _activate_current_locked( 553 + logical_use_id: str, 554 + trigger: dict[str, Any], 555 + location: dict[str, str], 556 + ) -> dict[str, Any]: 557 + global _current_chat_use_id, _current_chat_state 558 + 559 + raw_use_id = _reserve_use_id_locked() 560 + _current_chat_use_id = logical_use_id 561 + _current_chat_state = { 562 + "raw_use_id": raw_use_id, 563 + "trigger": dict(trigger), 564 + "location": dict(location), 565 + "retry_count": 0, 566 + } 567 + return _build_spawn_info_locked(logical_use_id) 568 + 569 + 570 + def _build_spawn_info_locked(logical_use_id: str) -> dict[str, Any]: 571 + assert _current_chat_state is not None 572 + return { 573 + "kind": "chat", 574 + "logical_use_id": logical_use_id, 575 + "raw_use_id": str(_current_chat_state["raw_use_id"]), 576 + "trigger": dict(_current_chat_state["trigger"]), 577 + "location": dict(_current_chat_state["location"]), 578 + } 579 + 580 + 581 + def _queue_trigger_locked(trigger: dict[str, Any], location: dict[str, str]) -> str: 582 + global _queued_trigger 583 + if _queued_trigger is None: 584 + _queued_trigger = { 585 + "use_id": _reserve_use_id_locked(), 586 + "trigger": dict(trigger), 587 + "location": dict(location), 588 + } 589 + return str(_queued_trigger["use_id"]) 590 + 591 + 592 + def _clear_current_locked() -> dict[str, Any] | None: 593 + global _current_chat_use_id, _current_chat_state, _queued_trigger 594 + 595 + _current_chat_use_id = None 596 + _current_chat_state = None 597 + if _queued_trigger is None: 598 + return None 599 + 600 + queued = _queued_trigger 601 + _queued_trigger = None 602 + return _activate_current_locked( 603 + str(queued["use_id"]), 604 + dict(queued["trigger"]), 605 + dict(queued["location"]), 606 + ) 607 + 608 + 609 + def _active_exec_count_for_today_locked() -> int: 610 + return len(reduce_chat_state(_today_day())["active_talents"]) 611 + 612 + 613 + def _exec_loop_count_locked() -> int: 614 + events = read_chat_events(_today_day()) 615 + count = 0 616 + for index in range(len(events) - 1, -1, -1): 617 + event = events[index] 618 + kind = event.get("kind") 619 + if kind == "owner_message": 620 + break 621 + if kind != "sol_message": 622 + continue 623 + if not event.get("requested_exec"): 624 + continue 625 + 626 + previous = events[index - 1] if index > 0 else None 627 + if previous and previous.get("kind") in {"talent_finished", "talent_errored"}: 628 + count += 1 629 + else: 630 + break 631 + return count 632 + 633 + 634 + def _parse_chat_result(result: Any) -> dict[str, Any]: 635 + if isinstance(result, str): 636 + payload = json.loads(result) 637 + elif isinstance(result, dict): 638 + payload = result 639 + else: 640 + raise ValueError("chat result must be JSON text") 641 + 642 + if not isinstance(payload, dict): 643 + raise ValueError("chat result must be an object") 644 + if not isinstance(payload.get("notes"), str): 645 + raise ValueError("chat result notes must be a string") 646 + 647 + message = payload.get("message") 648 + if message is not None and not isinstance(message, str): 649 + raise ValueError("chat result message must be a string or null") 650 + 651 + talent_request = payload.get("talent_request") 652 + if talent_request is None: 653 + return {"message": message, "notes": payload["notes"], "talent_request": None} 654 + if not isinstance(talent_request, dict): 655 + raise ValueError("chat talent_request must be an object or null") 656 + task = talent_request.get("task") 657 + if not isinstance(task, str) or not task.strip(): 658 + raise ValueError("chat talent_request.task must be a non-empty string") 659 + context = talent_request.get("context") or {} 660 + if not isinstance(context, dict): 661 + raise ValueError("chat talent_request.context must be an object") 662 + return { 663 + "message": message, 664 + "notes": payload["notes"], 665 + "talent_request": { 666 + "task": task.strip(), 667 + "context": context, 668 + }, 669 + } 670 + 671 + 672 + def _build_exec_prompt( 673 + task: str, 674 + context_hints: dict[str, Any], 675 + location: dict[str, str], 676 + ) -> str: 677 + parts = [f"Task: {task}"] 678 + if context_hints: 679 + parts.append( 680 + "Context hints:\n" + pprint.pformat(context_hints, sort_dicts=True) 681 + ) 682 + parts.append( 683 + "Location: " 684 + f"app={location['app']} path={location['path']} facet={location['facet']}" 685 + ) 686 + 687 + history_lines: list[str] = [] 688 + for event in read_chat_events(_today_day()): 689 + kind = event.get("kind") 690 + if kind == "owner_message": 691 + history_lines.append(f"**Owner**: {event['text']}") 692 + elif kind == "sol_message": 693 + history_lines.append(f"**Sol**: {event['text']}") 694 + if history_lines: 695 + parts.append("Recent chat:\n" + "\n".join(history_lines[-6:])) 696 + 697 + return "\n\n".join(parts) 698 + 699 + 700 + def _emit_finish(use_id: str, message: str) -> None: 701 + _emit_cortex_event( 702 + "finish", 703 + use_id=use_id, 704 + result=message, 705 + display=_display_mode(message), 706 + chat_proxy=True, 707 + ) 708 + 709 + 710 + def _emit_error(use_id: str, reason: str) -> None: 711 + _emit_cortex_event( 712 + "error", 713 + use_id=use_id, 714 + error=reason, 715 + chat_proxy=True, 716 + ) 717 + 718 + 719 + def _emit_cortex_event(event: str, **fields: Any) -> None: 720 + runtime = _runtime 721 + if runtime is not None and runtime.callosum.emit("cortex", event, **fields): 722 + return 723 + callosum_send("cortex", event, **fields) 724 + 725 + 726 + def _display_mode(text: str) -> str: 727 + if not text: 728 + return "inline" 729 + if len(text) >= 120 or "\n" in text: 730 + return "panel" 731 + if len(re.split(r"(?<=[.!?])\s", text)) > 2: 732 + return "panel" 733 + return "inline" 734 + 735 + 736 + def _normalize_location(app_name: Any, path: Any, facet: Any) -> dict[str, str]: 737 + return { 738 + "app": str(app_name or ""), 739 + "path": str(path or ""), 740 + "facet": str(facet or ""), 741 + } 742 + 743 + 744 + def _location_for_trigger(day: str, trigger: dict[str, Any]) -> dict[str, str]: 745 + if trigger.get("kind") == "owner_message": 746 + return _normalize_location( 747 + trigger.get("app"), 748 + trigger.get("path"), 749 + trigger.get("facet"), 750 + ) 751 + for event in reversed(read_chat_events(day)): 752 + if event.get("kind") == "owner_message": 753 + return _normalize_location( 754 + event.get("app"), 755 + event.get("path"), 756 + event.get("facet"), 757 + ) 758 + return _normalize_location("", "", "") 759 + 760 + 761 + def _trigger_from_stream_event(event: dict[str, Any]) -> dict[str, Any]: 762 + kind = event.get("kind") 763 + if kind == "owner_message": 764 + return {"type": "owner_message", "message": event.get("text", "")} 765 + if kind == "talent_finished": 766 + return { 767 + "type": "talent_finished", 768 + "use_id": event.get("use_id"), 769 + "name": event.get("name", "exec"), 770 + "summary": event.get("summary", ""), 771 + } 772 + if kind == "talent_errored": 773 + return { 774 + "type": "talent_errored", 775 + "use_id": event.get("use_id"), 776 + "name": event.get("name", "exec"), 777 + "reason": event.get("reason", ""), 778 + } 779 + raise ValueError(f"unsupported trigger event: {kind}") 780 + 781 + 782 + def _read_result_state(use_id: str) -> dict[str, Any] | None: 783 + day = _day_for_use_id(use_id) 784 + if day is None: 785 + return None 786 + 787 + latest_sol: dict[str, Any] | None = None 788 + exec_state: dict[str, Any] | None = None 789 + chat_error: dict[str, Any] | None = None 790 + spawned_task: str | None = None 791 + 792 + for event in read_chat_events(day): 793 + kind = event.get("kind") 794 + if kind == "sol_message" and str(event.get("use_id")) == use_id: 795 + latest_sol = event 796 + elif kind == "chat_error" and str(event.get("use_id") or "") == use_id: 797 + chat_error = event 798 + elif kind == "talent_spawned" and str(event.get("use_id")) == use_id: 799 + spawned_task = event.get("task") 800 + exec_state = {"state": "active", "task": spawned_task} 801 + elif kind == "talent_finished" and str(event.get("use_id")) == use_id: 802 + exec_state = { 803 + "state": "finished", 804 + "summary": event.get("summary", ""), 805 + "task": spawned_task, 806 + } 807 + elif kind == "talent_errored" and str(event.get("use_id")) == use_id: 808 + exec_state = { 809 + "state": "errored", 810 + "reason": event.get("reason", ""), 811 + "task": spawned_task, 812 + } 813 + 814 + with _state_lock: 815 + if _current_chat_use_id == use_id: 816 + task = None 817 + if latest_sol and latest_sol.get("requested_exec"): 818 + task = latest_sol.get("requested_task") 819 + return {"state": "active", "task": task} 820 + 821 + if chat_error is not None: 822 + return { 823 + "state": "errored", 824 + "reason": chat_error.get("reason", CHAT_TROUBLE_REASON), 825 + } 826 + if latest_sol is not None: 827 + return { 828 + "state": "finished", 829 + "summary": latest_sol.get("text", ""), 830 + "display": _display_mode(str(latest_sol.get("text", ""))), 831 + } 832 + return exec_state 833 + 834 + 835 + def _reserve_use_id_locked() -> str: 836 + global _last_use_id 837 + 838 + ts = now_ms() 839 + if ts <= _last_use_id: 840 + ts = _last_use_id + 1 841 + _last_use_id = ts 842 + return str(ts) 843 + 844 + 845 + def _today_day() -> str: 846 + return datetime.now().strftime("%Y%m%d") 847 + 848 + 849 + def _day_for_use_id(use_id: str) -> str | None: 850 + if not use_id.isdigit(): 851 + return None 852 + try: 853 + return datetime.fromtimestamp(int(use_id) / 1000).strftime("%Y%m%d") 854 + except (OSError, OverflowError, ValueError): 855 + return None
+11 -6
convey/templates/app.html
··· 474 474 475 475 // Check GET immediately in case agent already finished 476 476 try { 477 - var r = await fetch('/api/triage/result/' + agentId); 477 + var r = await fetch('/api/chat/result/' + agentId); 478 478 if (r.ok) { 479 479 // Agent already finished — cancel WS subscription and display 480 - if (recoveryCleanup) { recoveryCleanup(); recoveryCleanup = null; } 481 - if (recoveryWatchdog) { clearTimeout(recoveryWatchdog); recoveryWatchdog = null; } 482 480 var data = await r.json(); 483 - var resp = data.response || ''; 484 - recoverDeliver(resp, data.display || 'panel', null); 481 + if (data.state === 'finished') { 482 + if (recoveryCleanup) { recoveryCleanup(); recoveryCleanup = null; } 483 + if (recoveryWatchdog) { clearTimeout(recoveryWatchdog); recoveryWatchdog = null; } 484 + recoverDeliver(data.summary || '', data.display || 'panel', null); 485 + } else if (data.state === 'errored') { 486 + if (recoveryCleanup) { recoveryCleanup(); recoveryCleanup = null; } 487 + if (recoveryWatchdog) { clearTimeout(recoveryWatchdog); recoveryWatchdog = null; } 488 + recoverDeliver('', 'panel', data.reason || 'something went wrong. the server returned an unexpected response. try sending your message again, or check the health page if it keeps happening.'); 489 + } 485 490 } 486 491 // If 404, agent still running — keep WS subscription active 487 492 } catch (err) { ··· 693 698 } 694 699 695 700 try { 696 - var r = await fetch('/api/triage', { 701 + var r = await fetch('/api/chat', { 697 702 method: 'POST', 698 703 headers: { 'Content-Type': 'application/json' }, 699 704 body: JSON.stringify({
-134
convey/triage.py
··· 1 - # SPDX-License-Identifier: AGPL-3.0-only 2 - # Copyright (c) 2026 sol pbc 3 - 4 - """Triage endpoint for universal chat bar / conversation panel queries.""" 5 - 6 - from __future__ import annotations 7 - 8 - import logging 9 - import re 10 - from typing import Any 11 - 12 - from flask import Blueprint, jsonify, request 13 - 14 - from convey.utils import error_response 15 - 16 - logger = logging.getLogger(__name__) 17 - 18 - 19 - def compute_display_mode(text: str) -> str: 20 - """Return 'inline' or 'panel' based on response text characteristics.""" 21 - if not text: 22 - return "inline" 23 - if len(text) >= 120: 24 - return "panel" 25 - if "\n" in text: 26 - return "panel" 27 - if len(re.split(r"(?<=[.!?])\s", text)) > 2: 28 - return "panel" 29 - return "inline" 30 - 31 - 32 - bp = Blueprint("triage", __name__, url_prefix="/api/triage") 33 - 34 - 35 - @bp.route("", methods=["POST"]) 36 - def triage() -> Any: 37 - """Accept a message from the conversation panel and spawn a triage agent. 38 - 39 - Expects JSON: {message, app, path, facet} 40 - Returns JSON: {use_id} 41 - 42 - The agent runs asynchronously. The browser receives the result via 43 - WebSocket (cortex/finish event). For reload recovery, use GET /result/<use_id>. 44 - 45 - All journals route to the unified talent. 46 - """ 47 - payload = request.get_json(force=True) 48 - message = payload.get("message", "").strip() 49 - 50 - from think.identity import ensure_identity_directory 51 - 52 - ensure_identity_directory() 53 - 54 - if not message: 55 - return error_response("message is required", 400) 56 - 57 - app_name = payload.get("app", "") 58 - path = payload.get("path", "") 59 - facet = payload.get("facet", "") 60 - agent_name = "unified" 61 - 62 - # Build prompt with location context 63 - context_lines = [] 64 - if app_name: 65 - context_lines.append(f"Current app: {app_name}") 66 - if path: 67 - context_lines.append(f"Current path: {path}") 68 - if facet: 69 - context_lines.append(f"Current facet: {facet}") 70 - 71 - # Add system health context when attention items exist 72 - try: 73 - from convey.apps import _resolve_attention 74 - from think.awareness import get_current 75 - 76 - attention = _resolve_attention(get_current()) 77 - if attention: 78 - context_lines.extend(attention.context_lines) 79 - except Exception: 80 - pass # Don't let health context break triage 81 - 82 - # Assemble the full prompt 83 - prompt_parts = [] 84 - if context_lines: 85 - prompt_parts.append("\n".join(context_lines)) 86 - prompt_parts.append(message) 87 - full_prompt = "\n\n".join(prompt_parts) 88 - 89 - try: 90 - from convey.utils import spawn_agent 91 - 92 - config: dict[str, Any] = {} 93 - if facet: 94 - config["facet"] = facet 95 - config["app"] = app_name 96 - config["path"] = path 97 - config["user_message"] = message 98 - 99 - use_id = spawn_agent( 100 - prompt=full_prompt, 101 - name=agent_name, 102 - provider=None, 103 - config=config, 104 - ) 105 - if use_id is None: 106 - return error_response("Failed to connect to agent service", 503) 107 - 108 - return jsonify(use_id=use_id) 109 - 110 - except Exception: 111 - logger.exception("Triage request failed") 112 - return error_response("Failed to process triage request", 500) 113 - 114 - 115 - @bp.route("/result/<use_id>", methods=["GET"]) 116 - def triage_result(use_id: str) -> Any: 117 - """Return the result of a completed triage agent. 118 - 119 - Returns {response, display} if the agent has finished, 404 otherwise. 120 - Used for page-reload recovery when the WebSocket may have missed the finish event. 121 - """ 122 - try: 123 - from think.cortex_client import read_use_events 124 - 125 - events = read_use_events(use_id) 126 - for event in reversed(events): 127 - if event.get("event") == "finish": 128 - result = event.get("result", "") 129 - return jsonify(response=result, display=compute_display_mode(result)) 130 - except FileNotFoundError: 131 - pass 132 - except Exception: 133 - logger.debug("Failed to read triage result for %s", use_id, exc_info=True) 134 - return jsonify(error="not found"), 404
+3
convey/utils.py
··· 87 87 name: str, 88 88 provider: Optional[str] = None, 89 89 config: Optional[dict[str, Any]] = None, 90 + use_id: Optional[str] = None, 90 91 ) -> str | None: 91 92 """Spawn a Cortex agent and return the use_id. 92 93 ··· 98 99 name: Agent name - system (e.g., "default") or app-qualified (e.g., "entities:entity_assist") 99 100 provider: Optional provider override (openai, google, anthropic) 100 101 config: Additional configuration (max_tokens, facet, session_id, etc.) 102 + use_id: Optional pre-reserved Cortex use_id to reuse for the request 101 103 102 104 Returns: 103 105 use_id string (timestamp-based), or None if the request could not be sent. ··· 112 114 name=name, 113 115 provider=provider, 114 116 config=config, 117 + use_id=use_id, 115 118 ) 116 119 117 120
-45
talent/conversation_memory.py
··· 1 - # SPDX-License-Identifier: AGPL-3.0-only 2 - # Copyright (c) 2026 sol pbc 3 - 4 - """Pre-hook: inject conversation memory into unified talent context. 5 - 6 - Loaded via hook config: {"hook": {"pre": "conversation_memory"}} 7 - 8 - Replaces CONVERSATION_MEMORY_INJECTION_POINT in the unified talent's 9 - user instruction with recent conversation exchanges and today's summary. 10 - This gives the agent awareness of past conversations without needing 11 - to search — recent interactions are always in context. 12 - """ 13 - 14 - import logging 15 - 16 - logger = logging.getLogger(__name__) 17 - 18 - 19 - def pre_process(context: dict) -> dict | None: 20 - """Inject conversation memory into the unified talent's user instruction. 21 - 22 - Args: 23 - context: Full agent config dict. 24 - 25 - Returns: 26 - Dict with modified user_instruction, or None if no injection needed. 27 - """ 28 - from think.conversation import INJECTION_MARKER, build_memory_context, inject_memory 29 - 30 - user_instruction = context.get("user_instruction", "") 31 - if INJECTION_MARKER not in user_instruction: 32 - return None 33 - 34 - facet = context.get("facet") 35 - 36 - try: 37 - memory_context = build_memory_context(facet=facet, recent_limit=10) 38 - new_instruction = inject_memory(user_instruction, memory_context) 39 - 40 - if new_instruction != user_instruction: 41 - return {"user_instruction": new_instruction} 42 - except Exception: 43 - logger.exception("Conversation memory injection failed") 44 - 45 - return None
-120
talent/triage.md
··· 1 - { 2 - "type": "cogitate", 3 - "title": "Triage", 4 - "description": "Quick-action assistant for the chat bar — handles navigation, todos, calendar, and entity lookups" 5 - } 6 - 7 - You are a quick-action assistant for the sol journal system chat bar. You handle simple actions and short lookups: navigate the app, manage todos, manage calendar events, and look up entities. 8 - 9 - Respond in one concise line for actions you complete. If a request needs deeper analysis, the conversation panel handles it automatically — just answer to the best of your ability. 10 - 11 - You are given context about the owner's current app, URL path, and facet. Use this to inform your actions — for example, use the facet for todo and calendar commands. 12 - 13 - ## Available Commands 14 - 15 - ### Navigation 16 - - `sol call navigate [PATH] --facet FACET` — Navigate the browser to a path and/or switch facet. 17 - 18 - ### Todos 19 - - `sol call todos list [DAY] --facet FACET` — Show todos for a day. 20 - - `sol call todos add TEXT --day DAY --facet FACET [--nudge TIME]` — Add a todo. Nudge formats: HH:MM, now, tomorrow HH:MM, YYYYMMDDTHH:MM. 21 - - `sol call todos done LINE --day DAY --facet FACET` — Mark a todo as done. 22 - - `sol call todos cancel LINE --day DAY --facet FACET` — Cancel a todo. 23 - - `sol call todos upcoming --facet FACET [--limit N]` — Show upcoming todos. 24 - 25 - ### Entities 26 - - `sol call entities list [FACET]` — List entities for a facet. 27 - - `sol call entities observations ENTITY --facet FACET` — List observations for an entity. 28 - - `sol call entities observe ENTITY CONTENT --facet FACET` — Record an observation. 29 - - `sol call entities strength [--facet FACET] [--since YYYYMMDD] [--limit N]` — Rank entities by relationship strength. 30 - - `sol call entities search [--query TEXT] [--type TYPE] [--facet FACET] [--since YYYYMMDD] [--limit N]` — Search entities by text, type, or facet. 31 - - `sol call entities intelligence ENTITY [--facet FACET] [--brief]` — Intelligence briefing for an entity (returns JSON — synthesize into natural language). Use --brief for concise lookups. 32 - 33 - ### Journal 34 - - `sol call activities list --source anticipated [--day DAY] [-f FACET]` — List anticipated activities with participants, times, and summaries. 35 - 36 - ### Awareness 37 - - `sol call awareness status [SECTION]` — Read awareness state (e.g., processing state, journal health). 38 - - `sol call awareness log-read [DAY] [--kind KIND] [--limit N]` — Read awareness log entries. 39 - 40 - ### Support 41 - - `sol call support search <query>` — Search KB articles. 42 - - `sol call support diagnose` — Run local diagnostics (no network). 43 - - `sol call support create --subject "..." --description "..." [--severity medium] [--category bug]` — File a ticket (interactive consent flow). 44 - 45 - ## Behavioral Rules 46 - 47 - - After completing an action, respond with one concise line confirming what you did. 48 - - For lookups (list todos, list events, list entities), present the results concisely. 49 - - For entity intelligence briefings, synthesize the JSON output into a concise natural-language summary — do not dump raw JSON. 50 - - **Pre-meeting briefings**: When the owner asks "brief me on my next meeting", "who am I meeting?", or similar: 51 - 1. Run `sol call activities list --source anticipated` to find upcoming events with participants. 52 - 2. For each participant, run `sol call entities intelligence PARTICIPANT --brief` to gather background. 53 - 3. Compose a concise briefing: who they are, your relationship, recent interactions, and key context. 54 - Proactively offer briefings when context shows an upcoming meeting: "You have a meeting with [person] in [time]. Want me to brief you?" 55 - - **Support**: When the owner reports a problem ("this isn't working", "I found a bug", "something's broken"), wants to file a ticket, or wants to give feedback, handle it in-place — search KB, run diagnostics, draft and submit a ticket with the owner's approval. 56 - - Do not attempt to use any commands not listed above. 57 - - SOL_DAY and SOL_FACET environment variables are already set — tools will use them as defaults when --day/--facet are omitted. So you can often omit these flags. 58 - 59 - ## System Attention 60 - 61 - When the context includes a `System health:` line, there is an active attention item. Handle these queries: 62 - 63 - - **"what needs my attention?"** — Report the system health item from context. If there are agent errors, mention which agents failed. If an import just completed, mention what arrived. Be concise. 64 - - **Agent errors**: If the owner asks about errors, explain which agents failed today. Suggest checking agent logs or re-running the daily analysis. 65 - - **Import complete**: If an import just finished, briefly describe what was imported and offer to explore the new data or import from another source. 66 - 67 - When no `System health:` line is present in context, there is nothing to report. If the owner asks "what needs my attention?", respond that everything looks good. 68 - 69 - ## Import Awareness 70 - 71 - Check import state with `sol call awareness imports`: 72 - 73 - - **After an import completes** (owner returns to chat): The import system updates awareness automatically. If you see `has_imported: true` and new sources in `sources_used`, offer to import from another source: "I just processed your [source] import. Want to import from another source, or explore what I found?" 74 - 75 - - **Soft import nudge**: If all of these are true, you may weave a single soft import mention into your response: 76 - 1. No imports done (`has_imported: false`) 77 - 2. Import offer not recently declined (no `offer_declined` or >3 days ago) 78 - 3. No recent nudge (`last_nudge` is null) 79 - 4. The owner's message touches on their journal, data, or what $agent_name can do 80 - 81 - After mentioning imports, run `sol call awareness imports --nudge` to record it. Do **not** repeat this nudge. 82 - 83 - - **Available sources**: Calendar (ics), ChatGPT (chatgpt), Claude (claude), Gemini (gemini), Notes (obsidian), Kindle (kindle) 84 - 85 - - If the owner wants to import, read the guide from `apps/import/guides/{source}.md` and present the export instructions conversationally. Then navigate to the import app: `sol call navigate "/app/import#guide/{source}"` 86 - 87 - ## Naming Awareness 88 - 89 - Check whether the naming ceremony should trigger: 90 - 91 - 1. Run `sol call sol name` to check status. 92 - 2. If `name_status` is `"default"`, run `sol call sol thickness` to check readiness. 93 - 3. If `ready` is `true`, mention that you've been getting to know the owner and offer to suggest a name — or let the naming talent handle it. 94 - 4. Only do this once per session. If you've already checked or offered, don't repeat. 95 - 5. If `name_status` is `"chosen"` or `"self-named"`, do nothing. 96 - 97 - ## Owner Voice Detection Awareness 98 - 99 - Check whether owner voice detection should be surfaced: 100 - 101 - 1. Run `sol call speakers owner-ready` to check readiness. 102 - 2. If `ready` is `false`, do nothing. The reason field explains why (centroid_exists, cooldown, low_data, no_clusters, etc.). 103 - 3. If `ready` is `true`, surface the prompt conversationally: 104 - 105 - > "I've been learning voices from your observed media and I think I can identify yours. Want to listen to a few samples and confirm?" 106 - 107 - 4. Only do this once per session. If you've already checked or offered, don't repeat. 108 - 109 - ### Handling the owner's response 110 - 111 - - **Owner confirms ("yes", "sure", "go ahead"):** 112 - 1. Run `sol call speakers confirm-owner` — this saves the centroid and automatically runs attribution backfill on all segments. 113 - 2. Report back: "Got it. I'll start labeling speakers in your transcripts." 114 - 115 - - **Owner declines ("no", "not now", "skip"):** 116 - 1. Run `sol call speakers reject-owner` — this enters a 14-day cooldown. 117 - 2. Respond: "No problem — I'll keep listening and try again when I have more to work with." 118 - 119 - - **Owner wants to hear samples first:** 120 - The `owner-ready` result includes a `samples` array with audio URLs. Navigate the owner to the speakers app for the full confirmation flow: `sol call navigate "/app/speakers#owner"`
+3
tests/baselines/api/chat/result.json
··· 1 + { 2 + "error": "not found" 3 + }
+5
tests/baselines/api/chat/session.json
··· 1 + { 2 + "active_talents": [], 3 + "completed_talents": [], 4 + "latest_sol_message": null 5 + }
+3
tests/baselines/api/chat/stream.json
··· 1 + { 2 + "events": [] 3 + }
+1 -1
tests/baselines/api/sol/preview.json
··· 1 1 { 2 2 "full_prompt": "## Instructions\n\n## Available Facets\n\n- **Capulet Industries** (`capulet`)\n Capulet Industries enterprise division\n - **Capulet Industries Entities**: Capulet Industries; Juliet Capulet; Nurse Angela; Paris Duke; Tybalt Capulet\n - **Capulet Industries Activities**: Meetings; Coding; Browsing; Email; Messaging; AI Conversation; Writing; Reading; Video; Gaming; Social Media; Planning; Productivity; Terminal; Design; Music\n\n- **Empty Entities Test** (`empty-entities`)\n - **Empty Entities Test Activities**: Meetings; Coding; Browsing; Email; Messaging; AI Conversation; Writing; Reading; Video; Gaming; Social Media; Planning; Productivity; Terminal; Design; Music\n\n- **Full Featured Facet** (`full-featured`)\n A facet for testing all features\n - **Full Featured Facet Entities**: First test entity; Second test entity; Third test entity with description\n - **Full Featured Facet Activities**: Meetings; Coding; Custom Activity; Email; Messaging\n\n- **Minimal Facet** (`minimal-facet`)\n - **Minimal Facet Activities**: Meetings; Coding; Browsing; Email; Messaging; AI Conversation; Writing; Reading; Video; Gaming; Social Media; Planning; Productivity; Terminal; Design; Music\n\n- **Montague Tech** (`montague`)\n Montague Tech startup operations\n - **Tester's Role**: CTO and co-founder of Montague Tech. Visionary full-stack engineer.\n - **Montague Tech Entities**: Balcony App; Balthasar Davi; Benvolio Montague; Friar Lawrence; Juliet Capulet; Mercutio Escalus; Mesh Routing; Montague Tech; Prince Escalus; Rosaline Prince; Schema Bridge; Verona Platform; Verona Ventures\n - **Montague Tech Activities**: Engineering; Meetings; Email; Messaging\n\n- **Priority Test** (`priority-test`)\n - **Priority Test Activities**: Meetings; Coding; Browsing; Email; Messaging; AI Conversation; Writing; Reading; Video; Gaming; Social Media; Planning; Productivity; Terminal; Design; Music\n\n- **Test Facet** (`test-facet`)\n A test facet for validating functionality\n - **Test Facet Entities**: Acme Corp; API Optimization; Bob Wilson; Dashboard Redesign; Docker; Jane Doe; John Smith; PostgreSQL; Tech Solutions Inc; Visual Studio Code\n - **Test Facet Activities**: Meetings; Coding; Browsing; Email; Messaging; AI Conversation; Writing; Reading; Video; Gaming; Social Media; Planning; Productivity; Terminal; Design; Music\n\n- **Verona** (`verona`)\n Cross-company Verona Platform collaboration\n - **Tester's Role**: Co-lead of the Verona Platform joint venture from Montague Tech.\n - **Verona Entities**: Balcony App; Friar Lawrence; Juliet Capulet; Verona Platform\n - **Verona Activities**: Engineering; Meetings; Design Review; Email; Messaging\n\n## Identity Frame\n\nYou are sol, responding to Tester inside the chat backend. You are not the research worker and you do not have tools in this step. Work only from the context already provided to you.\n\n## Current Digest\n\n$digest_contents\n\n$location\n\n$trigger_context\n\n$chat_stream_tail\n\n$active_talents\n\n$active_routines\n\n$routine_suggestion\n\n## Tonal Range\n\nMatch the owner's tone and stakes:\n- Be direct and brief for simple replies.\n- Be warm when the owner is sharing something difficult or personal.\n- Be analytical when the owner needs synthesis or a plan.\n- Be challenging only when there is a clear pattern worth naming.\n\n## Routine Etiquette\n\n- If a routine suggestion appears in context, mention it once and only at the end.\n- Do not raise routine suggestions on machine-driven follow-ups unless the context explicitly includes one.\n- Do not mention internal systems, hooks, or prompt assembly.\n\n## Import And Naming Awareness\n\n- If the owner is asking about imports, naming, or system readiness, answer plainly from the supplied context.\n- Request exec only when answering well requires deeper lookup, synthesis, or tool use.\n\n## When To Dispatch Exec\n\nSet `talent_request` only when the owner needs work that cannot be answered well from the supplied digest, chat history, active routines, and trigger context alone.\n\nDispatch exec for:\n- Journal exploration across days, entities, or transcripts\n- Multi-step synthesis or research\n- Meeting prep that needs fresh participant or activity lookup\n- Any request that clearly needs tool use or external state inspection\n\nDo not dispatch exec for:\n- Simple acknowledgements\n- Straightforward follow-up chat\n- Routine suggestions already supported by the supplied context\n- Brief guidance that can be answered from the current digest and chat tail\n\n## JSON Contract\n\nReturn exactly one JSON object matching `chat.schema.json`.\n\n- `message`: The owner-facing reply. Use `null` only when you genuinely have no safe or useful message to send.\n- `notes`: Brief internal summary of why you responded this way. Keep it factual and concise. Do not dump long reasoning.\n- `talent_request`: `null` unless exec should be dispatched. When dispatching, include:\n - `task`: the exact work exec should perform\n - `context`: optional structured hints that will help exec start fast\n\n## Output Rules\n\n- Return JSON only.\n- `message` should stand on its own without referring to hidden machinery.\n- If `talent_request` is present, the `message` should still be useful to the owner right now.\n- Prefer no dispatch over a weak or redundant dispatch.", 3 3 "multi_facet": false, 4 - "name": "unified", 4 + "name": "chat", 5 5 "title": "Chat" 6 6 }
+7
tests/conftest.py
··· 14 14 if str(ROOT) not in sys.path: 15 15 sys.path.insert(0, str(ROOT)) 16 16 17 + from convey.chat import stop_all_chat_runtime 17 18 from tests._baseline_harness import copytree_tracked 18 19 from think.entities.journal import clear_journal_entity_cache 19 20 from think.entities.loading import clear_entity_loading_cache ··· 72 73 def _cleanup_push_runtime(): 73 74 yield 74 75 stop_all_push_runtime() 76 + 77 + 78 + @pytest.fixture(autouse=True) 79 + def _cleanup_chat_runtime(): 80 + yield 81 + stop_all_chat_runtime() 75 82 76 83 77 84 @pytest.fixture
+14 -8
tests/test_app_sol.py
··· 70 70 71 71 def test_resolve_agent_path_system_agent(): 72 72 """Test _resolve_talent_path returns correct path for system agents.""" 73 - agent_dir, agent_name = _resolve_talent_path("unified") 73 + agent_dir, agent_name = _resolve_talent_path("chat") 74 74 75 75 assert agent_name == "chat" 76 76 assert agent_dir.name == "talent" ··· 96 96 97 97 def test_get_agent_system_agent(fixture_journal): 98 98 """Test get_talent loads system agents correctly.""" 99 - config = get_talent("unified") 99 + config = get_talent("chat") 100 100 101 - assert config["name"] == "unified" 101 + assert config["name"] == "chat" 102 102 assert "user_instruction" in config 103 103 assert len(config["user_instruction"]) > 0 104 104 ··· 109 109 get_talent("nonexistent_agent_xyz") 110 110 111 111 assert "nonexistent_agent_xyz" in str(exc_info.value) 112 + 113 + 114 + def test_get_agent_legacy_alias_raises(): 115 + """The legacy chat alias is removed in the chat backend cutover.""" 116 + with pytest.raises(FileNotFoundError): 117 + get_talent("uni" + "fied") 112 118 113 119 114 120 def test_get_agent_nonexistent_app_agent_raises(): ··· 205 211 """Without output_path, derives from day/name/segment fields.""" 206 212 event = { 207 213 "day": "20260214", 208 - "name": "unified", 214 + "name": "chat", 209 215 "segment": "100", 210 216 "facet": "health", 211 217 } ··· 216 222 217 223 def test_returns_none_without_day_or_output_path(self): 218 224 """Returns None when neither output_path nor day is present.""" 219 - event = {"name": "unified"} 225 + event = {"name": "chat"} 220 226 result = _resolve_output_path(event, "/journal") 221 227 assert result is None 222 228 223 229 def test_empty_output_path_falls_through(self, fixture_journal): 224 230 """Empty string output_path falls through to derivation.""" 225 - event = {"output_path": "", "day": "20260214", "name": "unified"} 231 + event = {"output_path": "", "day": "20260214", "name": "chat"} 226 232 result = _resolve_output_path(event, "tests/fixtures/journal") 227 233 # Empty string is falsy, so falls through to derivation 228 234 assert result is not None ··· 231 237 """SOL_STREAM from env is passed through to get_output_path.""" 232 238 event = { 233 239 "day": "20260214", 234 - "name": "unified", 240 + "name": "chat", 235 241 "env": {"SOL_STREAM": "mystream"}, 236 242 } 237 243 result = _resolve_output_path(event, "tests/fixtures/journal") ··· 242 248 event = { 243 249 "output_path": "/custom/path/output.md", 244 250 "day": "20260214", 245 - "name": "unified", 251 + "name": "chat", 246 252 "segment": "100", 247 253 } 248 254 result = _resolve_output_path(event, "/journal")
+11 -11
tests/test_awareness.py
··· 224 224 "think.indexer.journal.get_entity_strength", return_value=[] 225 225 ): 226 226 with unittest.mock.patch( 227 - "think.conversation.get_recent_exchanges", return_value=[] 227 + "think.awareness._recent_chat_exchanges", return_value=[] 228 228 ): 229 229 with unittest.mock.patch( 230 230 "think.facets.get_enabled_facets", return_value={} ··· 248 248 ] 249 249 exchanges = [ 250 250 { 251 - "talent": "triage", 251 + "talent": "chat", 252 252 "agent_response": f"talked about entity_{i}", 253 253 "user_message": "hi", 254 254 } ··· 260 260 "think.indexer.journal.get_entity_strength", return_value=entities 261 261 ): 262 262 with unittest.mock.patch( 263 - "think.conversation.get_recent_exchanges", return_value=exchanges 263 + "think.awareness._recent_chat_exchanges", return_value=exchanges 264 264 ): 265 265 with unittest.mock.patch( 266 266 "think.facets.get_enabled_facets", return_value=facets ··· 283 283 ] 284 284 exchanges = [ 285 285 { 286 - "talent": "triage", 286 + "talent": "chat", 287 287 "agent_response": f"entity_{i} is great", 288 288 "user_message": "yo", 289 289 } ··· 300 300 "think.indexer.journal.get_entity_strength", return_value=entities 301 301 ): 302 302 with unittest.mock.patch( 303 - "think.conversation.get_recent_exchanges", return_value=exchanges 303 + "think.awareness._recent_chat_exchanges", return_value=exchanges 304 304 ): 305 305 with unittest.mock.patch( 306 306 "think.facets.get_enabled_facets", return_value=facets ··· 324 324 {"entity_name": f"entity_{i}", "observation_depth": 3} for i in range(15) 325 325 ] 326 326 exchanges = [ 327 - {"talent": "triage", "agent_response": "hello there", "user_message": "hi"} 327 + {"talent": "chat", "agent_response": "hello there", "user_message": "hi"} 328 328 for _ in range(10) 329 329 ] 330 330 facets = {"work": {}, "personal": {}, "hobby": {}} ··· 333 333 "think.indexer.journal.get_entity_strength", return_value=entities 334 334 ): 335 335 with unittest.mock.patch( 336 - "think.conversation.get_recent_exchanges", return_value=exchanges 336 + "think.awareness._recent_chat_exchanges", return_value=exchanges 337 337 ): 338 338 with unittest.mock.patch( 339 339 "think.facets.get_enabled_facets", return_value=facets ··· 363 363 "user_message": "hello", 364 364 }, 365 365 { 366 - "talent": "triage", 366 + "talent": "chat", 367 367 "agent_response": "foo is great", 368 368 "user_message": "hey", 369 369 }, ··· 373 373 "think.indexer.journal.get_entity_strength", return_value=entities 374 374 ): 375 375 with unittest.mock.patch( 376 - "think.conversation.get_recent_exchanges", return_value=exchanges 376 + "think.awareness._recent_chat_exchanges", return_value=exchanges 377 377 ): 378 378 with unittest.mock.patch( 379 379 "think.facets.get_enabled_facets", ··· 394 394 side_effect=Exception("db error"), 395 395 ): 396 396 with unittest.mock.patch( 397 - "think.conversation.get_recent_exchanges", 397 + "think.awareness._recent_chat_exchanges", 398 398 side_effect=Exception("no file"), 399 399 ): 400 400 with unittest.mock.patch( ··· 421 421 "think.indexer.journal.get_entity_strength", return_value=[] 422 422 ): 423 423 with unittest.mock.patch( 424 - "think.conversation.get_recent_exchanges", return_value=[] 424 + "think.awareness._recent_chat_exchanges", return_value=[] 425 425 ): 426 426 with unittest.mock.patch( 427 427 "think.facets.get_enabled_facets", return_value={}
+7 -5
tests/test_chat_context.py
··· 302 302 assert "/app/home" in template_vars["location"] 303 303 304 304 305 - def test_chat_context_drops_conversation_memory_imports(monkeypatch): 305 + def test_chat_context_drops_legacy_memory_imports(monkeypatch): 306 306 monkeypatch.setattr("think.routines.get_routine_state", lambda: []) 307 307 monkeypatch.setattr( 308 308 "think.routines.get_config", ··· 310 310 ) 311 311 monkeypatch.setattr("think.routines.save_config", lambda config: None) 312 312 313 + legacy_module = "think" + ".con" + "versation" 314 + legacy_memory = "conversation_" + "memory" 313 315 source = ( 314 316 Path(__file__).resolve().parents[1] / "talent" / "chat_context.py" 315 317 ).read_text(encoding="utf-8") 316 - assert "think.conversation" not in source 317 - assert "conversation_memory" not in source 318 + assert legacy_module not in source 319 + assert legacy_memory not in source 318 320 319 - sys.modules.pop("think.conversation", None) 321 + sys.modules.pop(legacy_module, None) 320 322 _load_chat_context_module() 321 323 322 - assert "think.conversation" not in sys.modules 324 + assert legacy_module not in sys.modules
+298
tests/test_chat_runtime.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + from flask import Flask 7 + 8 + from convey.chat_stream import append_chat_event, read_chat_events 9 + 10 + 11 + def _reset_chat_state(chat_module) -> None: 12 + chat_module.stop_all_chat_runtime() 13 + with chat_module._state_lock: 14 + chat_module._current_chat_use_id = None 15 + chat_module._current_chat_state = None 16 + chat_module._queued_trigger = None 17 + chat_module._active_execs.clear() 18 + chat_module._recovery_day = None 19 + chat_module._last_use_id = 0 20 + 21 + 22 + def _setup_journal(tmp_path, monkeypatch): 23 + journal = tmp_path / "journal" 24 + journal.mkdir() 25 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) 26 + return journal 27 + 28 + 29 + def test_chat_result_with_two_active_execs_retriggers_with_max_active_reason( 30 + tmp_path, monkeypatch 31 + ): 32 + import convey.chat as chat 33 + 34 + _setup_journal(tmp_path, monkeypatch) 35 + _reset_chat_state(chat) 36 + 37 + append_chat_event( 38 + "talent_spawned", 39 + use_id="1713620000001", 40 + name="exec", 41 + task="first task", 42 + started_at=1713620000001, 43 + ) 44 + append_chat_event( 45 + "talent_spawned", 46 + use_id="1713620000002", 47 + name="exec", 48 + task="second task", 49 + started_at=1713620000002, 50 + ) 51 + 52 + actions: list[dict] = [] 53 + monkeypatch.setattr( 54 + "convey.chat._run_next_action", lambda action: actions.append(action) 55 + ) 56 + monkeypatch.setattr("convey.chat._emit_finish", lambda *args, **kwargs: None) 57 + monkeypatch.setattr("convey.chat._emit_error", lambda *args, **kwargs: None) 58 + 59 + with chat._state_lock: 60 + chat._current_chat_use_id = "1713620000100" 61 + chat._current_chat_state = { 62 + "raw_use_id": "1713620000101", 63 + "trigger": {"type": "owner_message", "message": "help"}, 64 + "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 65 + "retry_count": 0, 66 + } 67 + 68 + chat._on_cortex_finish( 69 + { 70 + "use_id": "1713620000101", 71 + "result": ( 72 + '{"message":"I am looking into that.","notes":"need exec",' 73 + '"talent_request":{"task":"research it","context":{"k":"v"}}}' 74 + ), 75 + } 76 + ) 77 + 78 + assert actions 79 + assert actions[-1]["kind"] == "chat" 80 + assert actions[-1]["trigger"] == { 81 + "type": "synthetic-max-active", 82 + "reason": "max active — waiting for one to finish", 83 + } 84 + 85 + sol_messages = [ 86 + e for e in read_chat_events(chat._today_day()) if e["kind"] == "sol_message" 87 + ] 88 + assert sol_messages[-1]["requested_exec"] is True 89 + assert sol_messages[-1]["requested_task"] == "research it" 90 + 91 + 92 + def test_exec_retrigger_loop_stops_after_three_without_owner_reset( 93 + tmp_path, monkeypatch 94 + ): 95 + import convey.chat as chat 96 + 97 + _setup_journal(tmp_path, monkeypatch) 98 + _reset_chat_state(chat) 99 + 100 + append_chat_event( 101 + "owner_message", 102 + text="dig deeper", 103 + app="sol", 104 + path="/app/sol", 105 + facet="work", 106 + ) 107 + for index in range(3): 108 + append_chat_event( 109 + "talent_finished", 110 + use_id=f"171362100000{index}", 111 + name="exec", 112 + summary=f"summary {index}", 113 + ) 114 + if index < 2: 115 + append_chat_event( 116 + "sol_message", 117 + use_id="1713621999999", 118 + text=f"follow up {index}", 119 + notes="retrying", 120 + requested_exec=True, 121 + requested_task=f"task {index}", 122 + ) 123 + 124 + emitted_errors: list[tuple[str, str]] = [] 125 + actions: list[dict | None] = [] 126 + monkeypatch.setattr( 127 + "convey.chat._run_next_action", lambda action: actions.append(action) 128 + ) 129 + monkeypatch.setattr("convey.chat._emit_finish", lambda *args, **kwargs: None) 130 + monkeypatch.setattr( 131 + "convey.chat._emit_error", 132 + lambda use_id, reason: emitted_errors.append((use_id, reason)), 133 + ) 134 + 135 + with chat._state_lock: 136 + chat._current_chat_use_id = "1713621999999" 137 + chat._current_chat_state = { 138 + "raw_use_id": "1713622000000", 139 + "trigger": {"type": "talent_finished", "summary": "summary 2"}, 140 + "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 141 + "retry_count": 0, 142 + } 143 + 144 + chat._on_cortex_finish( 145 + { 146 + "use_id": "1713622000000", 147 + "result": ( 148 + '{"message":"Still digging.","notes":"loop",' 149 + '"talent_request":{"task":"one more pass","context":{}}}' 150 + ), 151 + } 152 + ) 153 + 154 + assert emitted_errors == [("1713621999999", "chat had trouble — try again")] 155 + assert actions == [None] 156 + errors = [ 157 + e for e in read_chat_events(chat._today_day()) if e["kind"] == "chat_error" 158 + ] 159 + assert errors[-1]["reason"] == "chat had trouble — try again" 160 + 161 + 162 + def test_cortex_finish_and_error_append_exec_terminal_events_by_use_id( 163 + tmp_path, monkeypatch 164 + ): 165 + import convey.chat as chat 166 + 167 + _setup_journal(tmp_path, monkeypatch) 168 + _reset_chat_state(chat) 169 + 170 + actions: list[dict] = [] 171 + monkeypatch.setattr( 172 + "convey.chat._run_next_action", lambda action: actions.append(action) 173 + ) 174 + monkeypatch.setattr("convey.chat._emit_finish", lambda *args, **kwargs: None) 175 + monkeypatch.setattr("convey.chat._emit_error", lambda *args, **kwargs: None) 176 + 177 + with chat._state_lock: 178 + chat._current_chat_use_id = "1713623000000" 179 + chat._current_chat_state = { 180 + "raw_use_id": None, 181 + "trigger": {"type": "owner_message", "message": "help"}, 182 + "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 183 + "retry_count": 0, 184 + } 185 + chat._active_execs["1713623000001"] = { 186 + "chat_use_id": "1713623000000", 187 + "task": "summarize", 188 + "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 189 + } 190 + 191 + chat._on_cortex_finish({"use_id": "1713623000001", "result": "done"}) 192 + finished_events = [ 193 + e for e in read_chat_events(chat._today_day()) if e["kind"] == "talent_finished" 194 + ] 195 + assert finished_events[-1]["use_id"] == "1713623000001" 196 + assert actions[-1]["trigger"]["type"] == "talent_finished" 197 + 198 + _reset_chat_state(chat) 199 + actions.clear() 200 + with chat._state_lock: 201 + chat._current_chat_use_id = "1713624000000" 202 + chat._current_chat_state = { 203 + "raw_use_id": None, 204 + "trigger": {"type": "owner_message", "message": "help"}, 205 + "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 206 + "retry_count": 0, 207 + } 208 + chat._active_execs["1713624000001"] = { 209 + "chat_use_id": "1713624000000", 210 + "task": "summarize", 211 + "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 212 + } 213 + 214 + chat._on_cortex_error({"use_id": "1713624000001", "error": "boom"}) 215 + errored_events = [ 216 + e for e in read_chat_events(chat._today_day()) if e["kind"] == "talent_errored" 217 + ] 218 + assert errored_events[-1]["use_id"] == "1713624000001" 219 + assert actions[-1]["trigger"]["type"] == "talent_errored" 220 + assert actions[-1]["trigger"]["reason"] == "boom" 221 + 222 + 223 + def test_start_chat_runtime_recovers_exactly_one_unresponded_trigger( 224 + tmp_path, monkeypatch 225 + ): 226 + import convey.chat as chat 227 + 228 + _setup_journal(tmp_path, monkeypatch) 229 + _reset_chat_state(chat) 230 + 231 + append_chat_event( 232 + "owner_message", 233 + text="recover me", 234 + app="sol", 235 + path="/app/sol", 236 + facet="work", 237 + ) 238 + 239 + starts: list[dict] = [] 240 + monkeypatch.setattr( 241 + "convey.chat.CallosumConnection.start", lambda self, callback=None: None 242 + ) 243 + monkeypatch.setattr("convey.chat.CallosumConnection.stop", lambda self: None) 244 + monkeypatch.setattr( 245 + "convey.chat._spawn_chat_generate", lambda action: starts.append(action) or True 246 + ) 247 + 248 + app = Flask(__name__) 249 + chat.start_chat_runtime(app) 250 + chat.start_chat_runtime(app) 251 + 252 + assert len(starts) == 1 253 + 254 + 255 + def test_chat_generate_schema_violation_retries_once_then_chat_errors( 256 + tmp_path, monkeypatch 257 + ): 258 + import convey.chat as chat 259 + 260 + _setup_journal(tmp_path, monkeypatch) 261 + _reset_chat_state(chat) 262 + 263 + actions: list[dict | None] = [] 264 + emitted_errors: list[tuple[str, str]] = [] 265 + monkeypatch.setattr( 266 + "convey.chat._run_next_action", lambda action: actions.append(action) 267 + ) 268 + monkeypatch.setattr("convey.chat._emit_finish", lambda *args, **kwargs: None) 269 + monkeypatch.setattr( 270 + "convey.chat._emit_error", 271 + lambda use_id, reason: emitted_errors.append((use_id, reason)), 272 + ) 273 + 274 + with chat._state_lock: 275 + chat._current_chat_use_id = "1713625000000" 276 + chat._current_chat_state = { 277 + "raw_use_id": "1713625000001", 278 + "trigger": {"type": "owner_message", "message": "help"}, 279 + "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 280 + "retry_count": 0, 281 + } 282 + 283 + chat._on_cortex_finish({"use_id": "1713625000001", "result": "not json"}) 284 + 285 + assert actions and actions[-1]["kind"] == "chat" 286 + assert actions[-1]["logical_use_id"] == "1713625000000" 287 + assert emitted_errors == [] 288 + 289 + with chat._state_lock: 290 + retry_use_id = chat._current_chat_state["raw_use_id"] 291 + 292 + chat._on_cortex_finish({"use_id": retry_use_id, "result": "still not json"}) 293 + 294 + assert emitted_errors == [("1713625000000", "chat had trouble — try again")] 295 + errors = [ 296 + e for e in read_chat_events(chat._today_day()) if e["kind"] == "chat_error" 297 + ] 298 + assert errors[-1]["use_id"] == "1713625000000"
-434
tests/test_conversation.py
··· 1 - # SPDX-License-Identifier: AGPL-3.0-only 2 - # Copyright (c) 2026 sol pbc 3 - 4 - """Tests for think.conversation module — conversation memory service.""" 5 - 6 - import json 7 - from datetime import datetime 8 - from unittest import mock 9 - 10 - import pytest 11 - 12 - 13 - @pytest.fixture 14 - def journal_dir(tmp_path, monkeypatch): 15 - """Create a temporary journal directory.""" 16 - journal = tmp_path / "journal" 17 - journal.mkdir() 18 - monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) 19 - with mock.patch("think.conversation.get_journal", return_value=str(journal)): 20 - yield journal 21 - 22 - 23 - # --------------------------------------------------------------------------- 24 - # record_exchange 25 - # --------------------------------------------------------------------------- 26 - 27 - 28 - def test_record_exchange_writes_jsonl(journal_dir): 29 - """Exchange is appended to conversation/exchanges.jsonl.""" 30 - from think.conversation import record_exchange 31 - 32 - record_exchange( 33 - ts=1710000000000, 34 - facet="work", 35 - app="entities", 36 - path="/app/entities/adrian", 37 - user_message="what's our history with adrian?", 38 - agent_response="You met Adrian at betaworks.", 39 - talent="unified", 40 - use_id="12345", 41 - ) 42 - 43 - jsonl_path = journal_dir / "conversation" / "exchanges.jsonl" 44 - assert jsonl_path.exists() 45 - 46 - with open(jsonl_path) as f: 47 - lines = [json.loads(line) for line in f if line.strip()] 48 - 49 - assert len(lines) == 1 50 - ex = lines[0] 51 - assert ex["ts"] == 1710000000000 52 - assert ex["facet"] == "work" 53 - assert ex["app"] == "entities" 54 - assert ex["user_message"] == "what's our history with adrian?" 55 - assert ex["agent_response"] == "You met Adrian at betaworks." 56 - assert ex["talent"] == "unified" 57 - assert ex["use_id"] == "12345" 58 - 59 - 60 - def test_record_exchange_writes_journal_segment(journal_dir): 61 - """Exchange creates a journal segment markdown file for search indexing.""" 62 - from think.conversation import record_exchange 63 - 64 - # Use a known timestamp: 2026-03-15 14:30:00 UTC 65 - ts = int(datetime(2026, 3, 15, 14, 30, 0).timestamp() * 1000) 66 - 67 - record_exchange( 68 - ts=ts, 69 - facet="work", 70 - app="activities", 71 - path="/app/activities", 72 - user_message="move my 3pm to 4pm", 73 - agent_response="Done — moved 'DVD sync' to 4pm.", 74 - talent="unified", 75 - use_id="67890", 76 - ) 77 - 78 - # Check journal segment directory: YYYYMMDD/conversation/HHMMSS_1/talents/ 79 - day = datetime.fromtimestamp(ts / 1000).strftime("%Y%m%d") 80 - time_key = datetime.fromtimestamp(ts / 1000).strftime("%H%M%S") 81 - md_path = ( 82 - journal_dir 83 - / "chronicle" 84 - / day 85 - / "conversation" 86 - / f"{time_key}_1" 87 - / "talents" 88 - / "conversation.md" 89 - ) 90 - 91 - assert md_path.exists() 92 - content = md_path.read_text() 93 - assert "move my 3pm to 4pm" in content 94 - assert "Done — moved 'DVD sync' to 4pm." in content 95 - assert "**Facet:** work" in content 96 - assert "activities" in content 97 - 98 - 99 - def test_record_exchange_appends_multiple(journal_dir): 100 - """Multiple exchanges append to the same JSONL file.""" 101 - from think.conversation import record_exchange 102 - 103 - record_exchange( 104 - ts=1710000001000, 105 - user_message="hello", 106 - agent_response="hi there", 107 - talent="triage", 108 - ) 109 - record_exchange( 110 - ts=1710000002000, 111 - user_message="what time is it?", 112 - agent_response="It's 2pm.", 113 - talent="triage", 114 - ) 115 - 116 - jsonl_path = journal_dir / "conversation" / "exchanges.jsonl" 117 - with open(jsonl_path) as f: 118 - lines = [json.loads(line) for line in f if line.strip()] 119 - 120 - assert len(lines) == 2 121 - assert lines[0]["user_message"] == "hello" 122 - assert lines[1]["user_message"] == "what time is it?" 123 - 124 - 125 - def test_record_exchange_skips_empty(journal_dir): 126 - """Empty user_message or agent_response is silently skipped.""" 127 - from think.conversation import record_exchange 128 - 129 - record_exchange(user_message="", agent_response="response", talent="triage") 130 - record_exchange(user_message="hello", agent_response="", talent="triage") 131 - 132 - jsonl_path = journal_dir / "conversation" / "exchanges.jsonl" 133 - assert not jsonl_path.exists() 134 - 135 - 136 - # --------------------------------------------------------------------------- 137 - # get_recent_exchanges 138 - # --------------------------------------------------------------------------- 139 - 140 - 141 - def test_get_recent_exchanges_empty(journal_dir): 142 - """Returns empty list when no exchanges exist.""" 143 - from think.conversation import get_recent_exchanges 144 - 145 - assert get_recent_exchanges() == [] 146 - 147 - 148 - def test_get_recent_exchanges_returns_last_n(journal_dir): 149 - """Returns the last N exchanges.""" 150 - from think.conversation import get_recent_exchanges, record_exchange 151 - 152 - for i in range(15): 153 - record_exchange( 154 - ts=1710000000000 + i * 1000, 155 - user_message=f"msg {i}", 156 - agent_response=f"resp {i}", 157 - talent="triage", 158 - ) 159 - 160 - recent = get_recent_exchanges(limit=5) 161 - assert len(recent) == 5 162 - assert recent[0]["user_message"] == "msg 10" 163 - assert recent[-1]["user_message"] == "msg 14" 164 - 165 - 166 - def test_get_recent_exchanges_filters_by_facet(journal_dir): 167 - """Facet filter returns only matching exchanges.""" 168 - from think.conversation import get_recent_exchanges, record_exchange 169 - 170 - record_exchange( 171 - ts=1710000001000, 172 - facet="work", 173 - user_message="work question", 174 - agent_response="work answer", 175 - talent="triage", 176 - ) 177 - record_exchange( 178 - ts=1710000002000, 179 - facet="personal", 180 - user_message="personal question", 181 - agent_response="personal answer", 182 - talent="triage", 183 - ) 184 - 185 - work = get_recent_exchanges(facet="work") 186 - assert len(work) == 1 187 - assert work[0]["facet"] == "work" 188 - 189 - personal = get_recent_exchanges(facet="personal") 190 - assert len(personal) == 1 191 - assert personal[0]["facet"] == "personal" 192 - 193 - 194 - # --------------------------------------------------------------------------- 195 - # get_today_exchanges 196 - # --------------------------------------------------------------------------- 197 - 198 - 199 - def test_get_today_exchanges_filters_by_day(journal_dir): 200 - """Only returns exchanges from today.""" 201 - from think.conversation import get_today_exchanges, record_exchange 202 - from think.utils import now_ms 203 - 204 - # Record an exchange with current timestamp (today) 205 - record_exchange( 206 - ts=now_ms(), 207 - user_message="today question", 208 - agent_response="today answer", 209 - talent="triage", 210 - ) 211 - 212 - # Record an exchange with old timestamp (not today) 213 - record_exchange( 214 - ts=1000000000000, # 2001-09-08 215 - user_message="old question", 216 - agent_response="old answer", 217 - talent="triage", 218 - ) 219 - 220 - today = get_today_exchanges() 221 - assert len(today) == 1 222 - assert today[0]["user_message"] == "today question" 223 - 224 - 225 - # --------------------------------------------------------------------------- 226 - # build_memory_context 227 - # --------------------------------------------------------------------------- 228 - 229 - 230 - def test_build_memory_context_empty(journal_dir): 231 - """Returns empty string when no exchanges exist.""" 232 - from think.conversation import build_memory_context 233 - 234 - assert build_memory_context() == "" 235 - 236 - 237 - def test_build_memory_context_includes_recent(journal_dir): 238 - """Context includes recent exchanges.""" 239 - from think.conversation import build_memory_context, record_exchange 240 - from think.utils import now_ms 241 - 242 - ts = now_ms() 243 - record_exchange( 244 - ts=ts, 245 - facet="work", 246 - app="entities", 247 - user_message="who is adrian?", 248 - agent_response="Adrian is the CTO of Own Company.", 249 - talent="unified", 250 - ) 251 - 252 - context = build_memory_context() 253 - assert "who is adrian?" in context 254 - assert "Adrian is the CTO" in context 255 - assert "Recent Conversations" in context 256 - 257 - 258 - def test_build_memory_context_truncates_long_responses(journal_dir): 259 - """Long agent responses are truncated in context output.""" 260 - from think.conversation import ( 261 - build_memory_context, 262 - record_exchange, 263 - ) 264 - from think.utils import now_ms 265 - 266 - long_response = "x" * 500 267 - record_exchange( 268 - ts=now_ms(), 269 - user_message="tell me a story", 270 - agent_response=long_response, 271 - talent="unified", 272 - ) 273 - 274 - context = build_memory_context() 275 - # Response should be truncated 276 - assert "..." in context 277 - assert long_response not in context 278 - 279 - 280 - def test_build_memory_context_earlier_today(journal_dir): 281 - """When more exchanges exist today than the recent limit, earlier ones are compact.""" 282 - from think.conversation import build_memory_context, record_exchange 283 - from think.utils import now_ms 284 - 285 - ts = now_ms() 286 - # Record 15 exchanges "today" 287 - for i in range(15): 288 - record_exchange( 289 - ts=ts + i * 1000, 290 - user_message=f"question {i}", 291 - agent_response=f"answer {i}", 292 - talent="unified", 293 - ) 294 - 295 - context = build_memory_context(recent_limit=10) 296 - assert "Earlier Today" in context 297 - assert "Recent Conversations" in context 298 - 299 - 300 - # --------------------------------------------------------------------------- 301 - # inject_memory 302 - # --------------------------------------------------------------------------- 303 - 304 - 305 - def test_inject_memory_replaces_marker(): 306 - """Injection point is replaced with memory context.""" 307 - from think.conversation import inject_memory 308 - 309 - instruction = """## Before 310 - 311 - ## Conversation Memory 312 - 313 - <!-- CONVERSATION_MEMORY_INJECTION_POINT 314 - This section is populated by the conversation memory service. 315 - Until then, each exchange is independent. 316 - --> 317 - 318 - ## After""" 319 - 320 - result = inject_memory(instruction, "### Recent\nHello world") 321 - assert "CONVERSATION_MEMORY_INJECTION_POINT" not in result 322 - assert "### Recent\nHello world" in result 323 - assert "## Before" in result 324 - assert "## After" in result 325 - 326 - 327 - def test_inject_memory_no_marker(): 328 - """If no marker present, instruction is returned unchanged.""" 329 - from think.conversation import inject_memory 330 - 331 - instruction = "No marker here." 332 - result = inject_memory(instruction, "some context") 333 - assert result == instruction 334 - 335 - 336 - def test_inject_memory_empty_context(): 337 - """Empty context gets a placeholder message.""" 338 - from think.conversation import inject_memory 339 - 340 - instruction = "<!-- CONVERSATION_MEMORY_INJECTION_POINT -->" 341 - result = inject_memory(instruction, "") 342 - assert "No conversation history yet." in result 343 - 344 - 345 - # --------------------------------------------------------------------------- 346 - # _format_exchange 347 - # --------------------------------------------------------------------------- 348 - 349 - 350 - def test_format_exchange_full(): 351 - """Full format includes user message and truncated response.""" 352 - from think.conversation import _format_exchange 353 - 354 - ex = { 355 - "ts": 1710000000000, 356 - "app": "entities", 357 - "facet": "work", 358 - "user_message": "who is adrian?", 359 - "agent_response": "Adrian is the CTO.", 360 - } 361 - 362 - result = _format_exchange(ex, compact=False) 363 - assert "User: who is adrian?" in result 364 - assert "Sol: Adrian is the CTO." in result 365 - assert "entities" in result 366 - assert "work" in result 367 - 368 - 369 - def test_format_exchange_compact(): 370 - """Compact format is a one-liner.""" 371 - from think.conversation import _format_exchange 372 - 373 - ex = { 374 - "ts": 1710000000000, 375 - "app": "activities", 376 - "facet": "work", 377 - "user_message": "what's on my schedule today?", 378 - "agent_response": "You have 3 meetings.", 379 - } 380 - 381 - result = _format_exchange(ex, compact=True) 382 - assert result.startswith("- [") 383 - assert "what's on my schedule today?" in result 384 - assert "You have 3 meetings" not in result # Compact omits response 385 - 386 - 387 - # --------------------------------------------------------------------------- 388 - # Pre-hook integration 389 - # --------------------------------------------------------------------------- 390 - 391 - 392 - def test_conversation_memory_pre_hook(journal_dir): 393 - """Pre-hook injects memory into user instruction.""" 394 - from talent.conversation_memory import pre_process 395 - from think.conversation import record_exchange 396 - from think.utils import now_ms 397 - 398 - # Record an exchange first 399 - record_exchange( 400 - ts=now_ms(), 401 - facet="work", 402 - user_message="hello", 403 - agent_response="hi there!", 404 - talent="unified", 405 - ) 406 - 407 - context = { 408 - "user_instruction": """Some instructions. 409 - 410 - ## Conversation Memory 411 - 412 - <!-- CONVERSATION_MEMORY_INJECTION_POINT 413 - Populated by conversation memory service. 414 - --> 415 - 416 - ## Other section""", 417 - "facet": "work", 418 - } 419 - 420 - result = pre_process(context) 421 - assert result is not None 422 - assert "user_instruction" in result 423 - assert "CONVERSATION_MEMORY_INJECTION_POINT" not in result["user_instruction"] 424 - assert "hello" in result["user_instruction"] 425 - assert "hi there!" in result["user_instruction"] 426 - 427 - 428 - def test_conversation_memory_pre_hook_no_marker(): 429 - """Pre-hook returns None when no injection marker present.""" 430 - from talent.conversation_memory import pre_process 431 - 432 - context = {"user_instruction": "No marker here."} 433 - result = pre_process(context) 434 - assert result is None
-33
tests/test_convey_apps.py
··· 3 3 4 4 """Tests for convey app placeholder and attention behavior.""" 5 5 6 - from unittest.mock import patch 7 - 8 6 import pytest 9 - from flask import Flask 10 7 11 8 12 9 @pytest.fixture(autouse=True) 13 10 def _temp_journal(monkeypatch, tmp_path): 14 11 """Ensure journaling defaults remain isolated from developer data.""" 15 12 monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 16 - 17 - 18 - def _run_triage(): 19 - """Run the triage endpoint with mocked state.""" 20 - app = Flask(__name__) 21 - with ( 22 - patch("convey.utils.spawn_agent", return_value="agent-1") as mock_spawn, 23 - patch("think.cortex_client.wait_for_uses", return_value=({}, [])), 24 - patch( 25 - "think.cortex_client.read_use_events", 26 - return_value=[{"event": "finish", "result": "ok"}], 27 - ), 28 - ): 29 - from convey.triage import triage 30 - 31 - with app.test_request_context("/", method="POST", json={"message": "hello"}): 32 - response = triage() 33 - 34 - assert response.status_code == 200 35 - return mock_spawn 36 13 37 14 38 15 # --- Placeholder resolution --- ··· 310 287 assert "2" in result.placeholder_text 311 288 assert "report" in result.placeholder_text.lower() 312 289 assert len(result.placeholder_text) <= 90 313 - 314 - 315 - class TestTriageSystemHealth: 316 - """Tests for system health context injection in triage.""" 317 - 318 - def test_triage_no_health_context_when_healthy(self): 319 - """No system health context when nothing needs attention.""" 320 - mock = _run_triage() 321 - prompt = mock.call_args.kwargs["prompt"] 322 - assert "System health" not in prompt
+143
tests/test_convey_chat.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + from datetime import datetime 7 + 8 + import pytest 9 + from flask import Flask 10 + 11 + from convey.chat import chat_bp 12 + from convey.chat_stream import append_chat_event 13 + 14 + 15 + def _setup_journal(tmp_path, monkeypatch): 16 + journal = tmp_path / "journal" 17 + journal.mkdir() 18 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) 19 + return journal 20 + 21 + 22 + def _reset_chat_state(chat_module) -> None: 23 + chat_module.stop_all_chat_runtime() 24 + with chat_module._state_lock: 25 + chat_module._current_chat_use_id = None 26 + chat_module._current_chat_state = None 27 + chat_module._queued_trigger = None 28 + chat_module._active_execs.clear() 29 + chat_module._recovery_day = None 30 + chat_module._last_use_id = 0 31 + 32 + 33 + def _ms(year: int, month: int, day: int, hour: int, minute: int, second: int) -> int: 34 + return int(datetime(year, month, day, hour, minute, second).timestamp() * 1000) 35 + 36 + 37 + @pytest.fixture 38 + def chat_client(tmp_path, monkeypatch): 39 + import convey.chat as chat 40 + 41 + _setup_journal(tmp_path, monkeypatch) 42 + _reset_chat_state(chat) 43 + 44 + app = Flask(__name__) 45 + app.config["TESTING"] = True 46 + app.register_blueprint(chat_bp) 47 + return app.test_client() 48 + 49 + 50 + def test_post_chat_appends_owner_message_and_returns_reserved_use_id( 51 + chat_client, monkeypatch 52 + ): 53 + starts: list[dict] = [] 54 + monkeypatch.setattr("think.identity.ensure_identity_directory", lambda: None) 55 + monkeypatch.setattr( 56 + "convey.chat._spawn_chat_generate", lambda action: starts.append(action) or True 57 + ) 58 + 59 + response = chat_client.post( 60 + "/api/chat", 61 + json={ 62 + "message": "hello there", 63 + "app": "sol", 64 + "path": "/app/sol", 65 + "facet": "work", 66 + }, 67 + ) 68 + 69 + assert response.status_code == 200 70 + payload = response.get_json() 71 + assert payload["queued"] is False 72 + assert payload["use_id"].isdigit() 73 + assert starts and starts[-1]["logical_use_id"] == payload["use_id"] 74 + 75 + 76 + def test_session_endpoint_reduces_from_chat_stream(chat_client): 77 + day = "20260420" 78 + append_chat_event( 79 + "sol_message", 80 + ts=_ms(2026, 4, 20, 12, 0, 0), 81 + use_id="1713626000000", 82 + text="hello", 83 + notes="ready", 84 + requested_exec=False, 85 + requested_task=None, 86 + ) 87 + append_chat_event( 88 + "talent_spawned", 89 + ts=_ms(2026, 4, 20, 12, 1, 0), 90 + use_id="1713626000001", 91 + name="exec", 92 + task="research", 93 + started_at=1713626000001, 94 + ) 95 + 96 + response = chat_client.get("/api/chat/session") 97 + assert response.status_code == 200 98 + payload = response.get_json() 99 + assert payload["latest_sol_message"]["text"] == "hello" 100 + assert payload["active_talents"][0]["task"] == "research" 101 + assert chat_client.get(f"/api/chat/stream/{day}").status_code == 200 102 + 103 + 104 + def test_stream_endpoint_ordered_with_limit(chat_client): 105 + start = _ms(2026, 4, 20, 12, 0, 0) 106 + for index in range(4): 107 + append_chat_event( 108 + "owner_message", 109 + ts=start + (index * 300_000), 110 + text=f"m{index}", 111 + app="sol", 112 + path="/app/sol", 113 + facet="work", 114 + ) 115 + 116 + response = chat_client.get("/api/chat/stream/20260420?limit=2") 117 + assert response.status_code == 200 118 + payload = response.get_json() 119 + assert [event["text"] for event in payload["events"]] == ["m2", "m3"] 120 + 121 + 122 + def test_result_endpoint_reads_stream_not_talent_log(chat_client, tmp_path): 123 + use_id = str(_ms(2026, 4, 20, 12, 0, 0)) 124 + append_chat_event( 125 + "sol_message", 126 + use_id=use_id, 127 + text="stream reply", 128 + notes="done", 129 + requested_exec=False, 130 + requested_task=None, 131 + ) 132 + 133 + talents_dir = tmp_path / "journal" / "talents" / "chat" 134 + talents_dir.mkdir(parents=True, exist_ok=True) 135 + (talents_dir / f"{use_id}.jsonl").write_text( 136 + '{"event":"finish","result":"log reply"}\n' 137 + ) 138 + 139 + response = chat_client.get(f"/api/chat/result/{use_id}") 140 + assert response.status_code == 200 141 + payload = response.get_json() 142 + assert payload["state"] == "finished" 143 + assert payload["summary"] == "stream reply"
+13 -13
tests/test_cortex.py
··· 114 114 "ts": 123456789, 115 115 "prompt": "Test prompt", 116 116 "provider": "openai", 117 - "name": "unified", 117 + "name": "chat", 118 118 "model": GPT_5, 119 119 } 120 120 ··· 141 141 assert ndjson["event"] == "request" 142 142 assert ndjson["prompt"] == "Test prompt" 143 143 assert ndjson["provider"] == "openai" 144 - assert ndjson["name"] == "unified" 144 + assert ndjson["name"] == "chat" 145 145 assert ndjson["model"] == GPT_5 146 146 147 147 # Check stdin was closed ··· 266 266 "ts": 24680, 267 267 "prompt": "Test prompt", 268 268 "provider": "openai", 269 - "name": "unified", 269 + "name": "chat", 270 270 "model": GPT_5, 271 271 } 272 272 ··· 481 481 def test_complete_use_file(cortex_service, mock_journal): 482 482 """Test completing an agent file (rename from active to completed).""" 483 483 use_id = "123456789" 484 - unified_dir = mock_journal / "talents" / "unified" 484 + unified_dir = mock_journal / "talents" / "chat" 485 485 unified_dir.mkdir() 486 486 active_path = unified_dir / f"{use_id}_active.jsonl" 487 487 active_path.touch() 488 - cortex_service.use_requests[use_id] = {"name": "unified", "use_id": use_id} 488 + cortex_service.use_requests[use_id] = {"name": "chat", "use_id": use_id} 489 489 490 490 cortex_service._complete_use_file(use_id, active_path) 491 491 ··· 493 493 assert not active_path.exists() 494 494 completed_path = unified_dir / f"{use_id}.jsonl" 495 495 assert completed_path.exists() 496 - symlink_path = mock_journal / "talents" / "unified.log" 496 + symlink_path = mock_journal / "talents" / "chat.log" 497 497 assert symlink_path.is_symlink() 498 - assert os.readlink(symlink_path) == f"unified/{use_id}.jsonl" 498 + assert os.readlink(symlink_path) == f"chat/{use_id}.jsonl" 499 499 500 500 501 501 def test_complete_use_file_replaces_symlink(cortex_service, mock_journal): 502 502 """Test completing agent file replaces convenience symlink for same name.""" 503 - unified_dir = mock_journal / "talents" / "unified" 503 + unified_dir = mock_journal / "talents" / "chat" 504 504 unified_dir.mkdir() 505 505 506 506 first_agent_id = "111" 507 507 first_active_path = unified_dir / f"{first_agent_id}_active.jsonl" 508 508 first_active_path.touch() 509 - cortex_service.use_requests[first_agent_id] = {"name": "unified"} 509 + cortex_service.use_requests[first_agent_id] = {"name": "chat"} 510 510 511 511 cortex_service._complete_use_file(first_agent_id, first_active_path) 512 512 513 513 second_agent_id = "222" 514 514 second_active_path = unified_dir / f"{second_agent_id}_active.jsonl" 515 515 second_active_path.touch() 516 - cortex_service.use_requests[second_agent_id] = {"name": "unified"} 516 + cortex_service.use_requests[second_agent_id] = {"name": "chat"} 517 517 518 518 cortex_service._complete_use_file(second_agent_id, second_active_path) 519 519 520 - symlink_path = mock_journal / "talents" / "unified.log" 520 + symlink_path = mock_journal / "talents" / "chat.log" 521 521 assert symlink_path.is_symlink() 522 - assert os.readlink(symlink_path) == f"unified/{second_agent_id}.jsonl" 522 + assert os.readlink(symlink_path) == f"chat/{second_agent_id}.jsonl" 523 523 524 524 525 525 def test_complete_use_file_colon_name(cortex_service, mock_journal): ··· 765 765 """Test recovery of orphaned active agent files.""" 766 766 # Create orphaned active files 767 767 talents_dir = mock_journal / "talents" 768 - unified_dir = talents_dir / "unified" 768 + unified_dir = talents_dir / "chat" 769 769 unified_dir.mkdir() 770 770 agent1_active = unified_dir / "111_active.jsonl" 771 771 agent2_active = unified_dir / "222_active.jsonl"
+39 -23
tests/test_cortex_client.py
··· 86 86 # Create a request 87 87 use_id = cortex_request( 88 88 prompt="Test prompt", 89 - name="unified", 89 + name="chat", 90 90 provider="openai", 91 91 config={"model": GPT_5}, 92 92 ) ··· 99 99 assert msg["tract"] == "cortex" 100 100 assert msg["event"] == "request" 101 101 assert msg["prompt"] == "Test prompt" 102 - assert msg["name"] == "unified" 102 + assert msg["name"] == "chat" 103 103 assert msg["provider"] == "openai" 104 104 assert msg["model"] == GPT_5 105 105 assert msg["use_id"] == use_id ··· 110 110 """Test that cortex_request returns use_id string.""" 111 111 _ = callosum_server # Needed for side effects only 112 112 113 - use_id = cortex_request(prompt="Test", name="unified", provider="openai") 113 + use_id = cortex_request(prompt="Test", name="chat", provider="openai") 114 114 115 115 # Verify use_id is a string timestamp 116 116 assert isinstance(use_id, str) ··· 118 118 assert len(use_id) == 13 # Millisecond timestamp 119 119 120 120 121 + def test_cortex_request_uses_explicit_use_id(callosum_listener): 122 + messages = callosum_listener 123 + 124 + use_id = cortex_request( 125 + prompt="Test prompt", 126 + name="chat", 127 + provider="openai", 128 + use_id="1713629000000", 129 + ) 130 + 131 + time.sleep(0.2) 132 + 133 + assert use_id == "1713629000000" 134 + assert messages[-1]["use_id"] == "1713629000000" 135 + 136 + 121 137 def test_cortex_request_unique_agent_ids(callosum_server): 122 138 """Test that cortex_request generates unique agent IDs.""" 123 139 _ = callosum_server # Needed for side effects only 124 140 125 141 agent_ids = [] 126 142 for i in range(3): 127 - use_id = cortex_request(prompt=f"Test {i}", name="unified", provider="openai") 143 + use_id = cortex_request(prompt=f"Test {i}", name="chat", provider="openai") 128 144 agent_ids.append(use_id) 129 145 time.sleep(0.002) 130 146 ··· 136 152 """Test cortex_request returns None when callosum_send fails.""" 137 153 monkeypatch.setattr("think.cortex_client.callosum_send", lambda *a, **kw: False) 138 154 139 - use_id = cortex_request(prompt="Test", name="unified", provider="openai") 155 + use_id = cortex_request(prompt="Test", name="chat", provider="openai") 140 156 141 157 assert use_id is None 142 158 ··· 146 162 monkeypatch.setattr("think.cortex_client.callosum_send", lambda *a, **kw: True) 147 163 monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 148 164 149 - use_id = cortex_request("test", "unified", "openai") 165 + use_id = cortex_request("test", "chat", "openai") 150 166 assert use_id is not None 151 167 assert len(use_id) > 0 152 168 ··· 177 193 ts1 = now_ms() 178 194 ts2 = ts1 + 1000 179 195 180 - unified_dir = talents_dir / "unified" 196 + unified_dir = talents_dir / "chat" 181 197 tester_dir = talents_dir / "tester" 182 198 unified_dir.mkdir() 183 199 tester_dir.mkdir() ··· 189 205 "event": "request", 190 206 "ts": ts1, 191 207 "prompt": "Task 1", 192 - "name": "unified", 208 + "name": "chat", 193 209 "provider": "openai", 194 210 }, 195 211 f, ··· 260 276 261 277 # Create multiple agents 262 278 base_ts = now_ms() 263 - unified_dir = talents_dir / "unified" 279 + unified_dir = talents_dir / "chat" 264 280 unified_dir.mkdir() 265 281 for i in range(5): 266 282 ts = base_ts + (i * 1000) ··· 271 287 "event": "request", 272 288 "ts": ts, 273 289 "prompt": f"Task {i}", 274 - "name": "unified", 290 + "name": "chat", 275 291 }, 276 292 f, 277 293 ) ··· 300 316 monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 301 317 talents_dir = tmp_path / "talents" 302 318 talents_dir.mkdir() 303 - unified_dir = talents_dir / "unified" 319 + unified_dir = talents_dir / "chat" 304 320 unified_dir.mkdir() 305 321 306 322 use_id = "1234567890123" ··· 314 330 monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 315 331 talents_dir = tmp_path / "talents" 316 332 talents_dir.mkdir() 317 - unified_dir = talents_dir / "unified" 333 + unified_dir = talents_dir / "chat" 318 334 unified_dir.mkdir() 319 335 320 336 use_id = "1234567890123" ··· 336 352 monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 337 353 talents_dir = tmp_path / "talents" 338 354 talents_dir.mkdir() 339 - unified_dir = talents_dir / "unified" 355 + unified_dir = talents_dir / "chat" 340 356 unified_dir.mkdir() 341 357 342 358 # Edge case: both files exist (shouldn't happen, but check precedence) ··· 352 368 monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 353 369 talents_dir = tmp_path / "talents" 354 370 talents_dir.mkdir() 355 - unified_dir = talents_dir / "unified" 371 + unified_dir = talents_dir / "chat" 356 372 unified_dir.mkdir() 357 373 358 374 use_id = "1234567890123" ··· 369 385 monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 370 386 talents_dir = tmp_path / "talents" 371 387 talents_dir.mkdir() 372 - unified_dir = talents_dir / "unified" 388 + unified_dir = talents_dir / "chat" 373 389 unified_dir.mkdir() 374 390 375 391 use_id = "1234567890123" ··· 386 402 monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 387 403 talents_dir = tmp_path / "talents" 388 404 talents_dir.mkdir() 389 - unified_dir = talents_dir / "unified" 405 + unified_dir = talents_dir / "chat" 390 406 unified_dir.mkdir() 391 407 392 408 use_id = "1234567890123" ··· 413 429 monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 414 430 talents_dir = tmp_path / "talents" 415 431 talents_dir.mkdir() 416 - unified_dir = talents_dir / "unified" 432 + unified_dir = talents_dir / "chat" 417 433 unified_dir.mkdir() 418 434 (tmp_path / "health").mkdir() 419 435 ··· 433 449 """Test wait_for_uses completes when finish event is received.""" 434 450 tmp_path = callosum_server 435 451 talents_dir = tmp_path / "talents" 436 - unified_dir = talents_dir / "unified" 452 + unified_dir = talents_dir / "chat" 437 453 unified_dir.mkdir(exist_ok=True) 438 454 439 455 use_id = "1234567890123" ··· 471 487 """Test wait_for_uses completes on error event too.""" 472 488 tmp_path = callosum_server 473 489 talents_dir = tmp_path / "talents" 474 - unified_dir = talents_dir / "unified" 490 + unified_dir = talents_dir / "chat" 475 491 unified_dir.mkdir(exist_ok=True) 476 492 477 493 use_id = "1234567890124" ··· 506 522 monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 507 523 talents_dir = tmp_path / "talents" 508 524 talents_dir.mkdir() 509 - unified_dir = talents_dir / "unified" 525 + unified_dir = talents_dir / "chat" 510 526 unified_dir.mkdir() 511 527 (tmp_path / "health").mkdir() 512 528 ··· 527 543 monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 528 544 talents_dir = tmp_path / "talents" 529 545 talents_dir.mkdir() 530 - unified_dir = talents_dir / "unified" 546 + unified_dir = talents_dir / "chat" 531 547 unified_dir.mkdir() 532 548 (tmp_path / "health").mkdir() 533 549 ··· 545 561 """Test wait_for_uses with some completing and some timing out.""" 546 562 tmp_path = callosum_server 547 563 talents_dir = tmp_path / "talents" 548 - unified_dir = talents_dir / "unified" 564 + unified_dir = talents_dir / "chat" 549 565 unified_dir.mkdir(exist_ok=True) 550 566 551 567 completing_agent = "1111" ··· 588 604 monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 589 605 talents_dir = tmp_path / "talents" 590 606 talents_dir.mkdir() 591 - unified_dir = talents_dir / "unified" 607 + unified_dir = talents_dir / "chat" 592 608 unified_dir.mkdir() 593 609 (tmp_path / "health").mkdir() 594 610
+1 -1
tests/test_entity_talents.py
··· 98 98 99 99 def test_agent_context_with_facet_focus(fixture_journal): 100 100 """Test that get_talent with facet parameter uses focused single-facet context.""" 101 - config = get_talent("unified", facet="full-featured") 101 + config = get_talent("chat", facet="full-featured") 102 102 103 103 prompt = config["user_instruction"] 104 104
-143
tests/test_home_events.py
··· 1 - # SPDX-License-Identifier: AGPL-3.0-only 2 - # Copyright (c) 2026 sol pbc 3 - 4 - """Tests for apps/home/events.py — conversation exchange recording.""" 5 - 6 - from unittest.mock import patch 7 - 8 - import pytest 9 - 10 - from apps.events import EventContext, clear_handlers, stop_dispatcher 11 - from apps.home.events import TRIAGE_AGENT_NAMES, record_triage_exchange 12 - 13 - 14 - @pytest.fixture(autouse=True) 15 - def clean_handlers(): 16 - clear_handlers() 17 - yield 18 - clear_handlers() 19 - stop_dispatcher() 20 - 21 - 22 - class TestRecordTriageExchange: 23 - """Tests for record_triage_exchange handler.""" 24 - 25 - def _make_ctx(self, msg): 26 - return EventContext(msg=msg, app="home", tract="cortex", event="finish") 27 - 28 - def test_ignores_non_triage_agent(self): 29 - """Handler returns early for non-triage agent names.""" 30 - ctx = self._make_ctx( 31 - { 32 - "tract": "cortex", 33 - "event": "finish", 34 - "name": "reviewer", 35 - "use_id": "123", 36 - "result": "hello", 37 - } 38 - ) 39 - with patch("apps.home.events.record_exchange") as mock_record: 40 - record_triage_exchange(ctx) 41 - mock_record.assert_not_called() 42 - 43 - def test_ignores_missing_agent_id(self): 44 - """Handler returns early if use_id is missing.""" 45 - ctx = self._make_ctx( 46 - { 47 - "tract": "cortex", 48 - "event": "finish", 49 - "name": "unified", 50 - "result": "hello", 51 - } 52 - ) 53 - with patch("apps.home.events.record_exchange") as mock_record: 54 - record_triage_exchange(ctx) 55 - mock_record.assert_not_called() 56 - 57 - @pytest.mark.parametrize("agent_name", sorted(TRIAGE_AGENT_NAMES)) 58 - def test_records_exchange_for_triage_agents(self, agent_name): 59 - """Handler calls record_exchange with correct fields for each triage agent name.""" 60 - events = [ 61 - { 62 - "event": "request", 63 - "ts": 1700000000000, 64 - "use_id": "abc123", 65 - "facet": "work", 66 - "app": "home", 67 - "path": "/home", 68 - "user_message": "hello world", 69 - }, 70 - { 71 - "event": "finish", 72 - "ts": 1700000001000, 73 - "use_id": "abc123", 74 - "result": "hi there", 75 - }, 76 - ] 77 - ctx = self._make_ctx( 78 - { 79 - "tract": "cortex", 80 - "event": "finish", 81 - "name": agent_name, 82 - "use_id": "abc123", 83 - "result": "hi there", 84 - } 85 - ) 86 - with patch("apps.home.events.read_use_events", return_value=events): 87 - with patch("apps.home.events.record_exchange") as mock_record: 88 - record_triage_exchange(ctx) 89 - mock_record.assert_called_once_with( 90 - facet="work", 91 - app="home", 92 - path="/home", 93 - user_message="hello world", 94 - agent_response="hi there", 95 - talent=agent_name, 96 - use_id="abc123", 97 - ) 98 - 99 - def test_handles_missing_request_event(self): 100 - """Handler uses empty strings for metadata if request event not found.""" 101 - events = [ 102 - {"event": "finish", "use_id": "abc123", "result": "done"}, 103 - ] 104 - ctx = self._make_ctx( 105 - { 106 - "tract": "cortex", 107 - "event": "finish", 108 - "name": "unified", 109 - "use_id": "abc123", 110 - "result": "done", 111 - } 112 - ) 113 - with patch("apps.home.events.read_use_events", return_value=events): 114 - with patch("apps.home.events.record_exchange") as mock_record: 115 - record_triage_exchange(ctx) 116 - mock_record.assert_called_once_with( 117 - facet="", 118 - app="", 119 - path="", 120 - user_message="", 121 - agent_response="done", 122 - talent="unified", 123 - use_id="abc123", 124 - ) 125 - 126 - def test_handles_read_error_gracefully(self): 127 - """Handler logs and swallows exceptions from read_use_events.""" 128 - ctx = self._make_ctx( 129 - { 130 - "tract": "cortex", 131 - "event": "finish", 132 - "name": "unified", 133 - "use_id": "abc123", 134 - "result": "done", 135 - } 136 - ) 137 - with patch( 138 - "apps.home.events.read_use_events", 139 - side_effect=FileNotFoundError("not found"), 140 - ): 141 - with patch("apps.home.events.record_exchange") as mock_record: 142 - record_triage_exchange(ctx) # should not raise 143 - mock_record.assert_not_called()
+27
tests/test_journal_index.py
··· 11 11 12 12 import pytest 13 13 14 + from convey.chat_stream import append_chat_event 14 15 from tests.conftest import copytree_tracked 15 16 from think.indexer import sanitize_fts_query 16 17 from think.indexer.journal import ( ··· 1673 1674 ).fetchone()[0] 1674 1675 conn.close() 1675 1676 assert count1 == count2 1677 + 1678 + 1679 + def test_chat_turn_is_searchable_after_rescan(journal_fixture): 1680 + from think.indexer.journal import scan_journal, search_journal 1681 + 1682 + append_chat_event( 1683 + "owner_message", 1684 + text="Tell me about the nebula phrase", 1685 + app="sol", 1686 + path="/app/sol", 1687 + facet="work", 1688 + ) 1689 + append_chat_event( 1690 + "sol_message", 1691 + use_id="1713628000000", 1692 + text="The unique nebula phrase is now in chat history.", 1693 + notes="done", 1694 + requested_exec=False, 1695 + requested_task=None, 1696 + ) 1697 + 1698 + scan_journal(str(journal_fixture), full=True) 1699 + total, results = search_journal("unique nebula phrase") 1700 + 1701 + assert total >= 1 1702 + assert any("unique nebula phrase" in result["text"].lower() for result in results) 1676 1703 1677 1704 1678 1705 def test_scan_journal_is_pure_wrt_entity_state(journal_copy):
+78
tests/test_no_legacy_chat_imports.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import ast 7 + from pathlib import Path 8 + 9 + ROOT = Path(__file__).resolve().parents[1] 10 + ALLOWED_UNIFIED_PATHS = { 11 + ROOT / "apps/sol/maint/006_rename_unified_triage_providers.py", 12 + ROOT / "tests/test_maint_006_rename_unified_triage_providers.py", 13 + } 14 + 15 + 16 + def _parts(*pieces: str) -> str: 17 + return "".join(pieces) 18 + 19 + 20 + BANNED_NAMES = { 21 + _parts("record_", "exchange"), 22 + _parts("build_", "memory_", "context"), 23 + _parts("INJECTION_", "MARKER"), 24 + _parts("inject_", "memory"), 25 + _parts("get_", "recent_", "exchanges"), 26 + _parts("get_", "today_", "exchanges"), 27 + _parts("TRIAGE_", "AGENT_", "NAMES"), 28 + _parts("record_", "triage_", "exchange"), 29 + _parts("compute_", "display_", "mode"), 30 + } 31 + LEGACY_CHAT_MODULE = _parts("think", ".", "conversation") 32 + LEGACY_MEMORY_MODULE = _parts("talent", ".", "conversation_", "memory") 33 + LEGACY_NAME = _parts("uni", "fied") 34 + 35 + 36 + def _python_files() -> list[Path]: 37 + return [ 38 + path 39 + for path in ROOT.rglob("*.py") 40 + if ".venv" not in path.parts and "__pycache__" not in path.parts 41 + ] 42 + 43 + 44 + def test_no_legacy_chat_imports_or_usages(): 45 + violations: list[str] = [] 46 + 47 + for path in _python_files(): 48 + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) 49 + for node in ast.walk(tree): 50 + if isinstance(node, ast.Import): 51 + for alias in node.names: 52 + if alias.name in {LEGACY_CHAT_MODULE, LEGACY_MEMORY_MODULE}: 53 + violations.append(f"{path}: import {alias.name}") 54 + elif isinstance(node, ast.ImportFrom): 55 + if node.module in {LEGACY_CHAT_MODULE, LEGACY_MEMORY_MODULE}: 56 + violations.append(f"{path}: from {node.module} import ...") 57 + elif isinstance(node, ast.Name) and node.id in BANNED_NAMES: 58 + violations.append(f"{path}: name {node.id}") 59 + elif isinstance(node, ast.Attribute) and node.attr in BANNED_NAMES: 60 + violations.append(f"{path}: attribute {node.attr}") 61 + 62 + assert violations == [] 63 + 64 + 65 + def test_no_live_unified_literals_outside_migration_paths(): 66 + violations: list[str] = [] 67 + 68 + for path in _python_files(): 69 + if path in ALLOWED_UNIFIED_PATHS: 70 + continue 71 + if path == Path(__file__).resolve(): 72 + continue 73 + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) 74 + for node in ast.walk(tree): 75 + if isinstance(node, ast.Constant) and node.value == LEGACY_NAME: 76 + violations.append(str(path)) 77 + 78 + assert violations == []
+1 -1
tests/test_talent_cli.py
··· 309 309 output = capsys.readouterr().out 310 310 311 311 # Should have runs from all fixture days (original + R&J) 312 - assert "default" in output or "unified" in output 312 + assert "default" in output or "chat" in output 313 313 assert "flow" in output 314 314 assert "activity" in output 315 315 assert "entities" in output
+9 -9
tests/test_talent_fallback.py
··· 127 127 ) 128 128 monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") 129 129 130 - config = prepare_config({"name": "unified", "prompt": "hello"}) 130 + config = prepare_config({"name": "chat", "prompt": "hello"}) 131 131 132 132 assert config["provider"] == "anthropic" 133 133 assert config["model"] == "claude-sonnet-4-5" ··· 144 144 ) 145 145 monkeypatch.setattr("think.models.should_recheck_health", lambda _h: False) 146 146 147 - config = prepare_config({"name": "unified", "prompt": "hello"}) 147 + config = prepare_config({"name": "chat", "prompt": "hello"}) 148 148 149 149 assert config["provider"] == "google" 150 150 assert "fallback_from" not in config ··· 162 162 monkeypatch.setattr("think.models.get_backup_provider", lambda _type: "anthropic") 163 163 monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) 164 164 165 - config = prepare_config({"name": "unified", "prompt": "hello"}) 165 + config = prepare_config({"name": "chat", "prompt": "hello"}) 166 166 167 167 assert config["provider"] == "google" 168 168 assert "fallback_from" not in config ··· 255 255 monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") 256 256 257 257 config = { 258 - "name": "unified", 258 + "name": "chat", 259 259 "provider": "google", 260 260 "model": "gemini-3-flash-preview", 261 261 "health_stale": False, ··· 292 292 monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") 293 293 294 294 config = { 295 - "name": "unified", 295 + "name": "chat", 296 296 "provider": "google", 297 297 "model": "gemini-3-flash-preview", 298 298 "prompt": "hello", ··· 324 324 monkeypatch.setattr("think.models.generate_with_result", bad_generate) 325 325 326 326 config = { 327 - "name": "unified", 327 + "name": "chat", 328 328 "provider": "google", 329 329 "model": "gemini-3-flash-preview", 330 330 "prompt": "hello", ··· 361 361 monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") 362 362 363 363 config = { 364 - "name": "unified", 364 + "name": "chat", 365 365 "provider": "google", 366 366 "model": "gemini-3-flash-preview", 367 367 "prompt": "hello", ··· 380 380 events = [] 381 381 config = { 382 382 "type": "cogitate", 383 - "name": "unified", 383 + "name": "chat", 384 384 "provider": "anthropic", 385 385 "model": "claude-sonnet-4-5", 386 386 "prompt": "hello", ··· 427 427 def test_main_async_no_duplicate_error_when_evented(monkeypatch, capsys): 428 428 from think.talents import main_async 429 429 430 - ndjson_input = json.dumps({"name": "unified", "prompt": "hello"}) 430 + ndjson_input = json.dumps({"name": "chat", "prompt": "hello"}) 431 431 monkeypatch.setattr("sys.stdin", StringIO(ndjson_input)) 432 432 433 433 async def fake_run_talent(_config, emit_event, dry_run=False):
+1 -1
tests/test_talents_ndjson.py
··· 96 96 { 97 97 "prompt": "What is 2+2?", 98 98 "provider": "openai", 99 - "name": "unified", 99 + "name": "chat", 100 100 "model": GPT_5, 101 101 "max_output_tokens": 100, 102 102 }
+23 -1
tests/verify_api.py
··· 61 61 { 62 62 "app": "sol", 63 63 "name": "preview", 64 - "path": "/app/sol/api/preview/unified", 64 + "path": "/app/sol/api/preview/chat", 65 65 "params": {}, 66 66 "status": 200, 67 67 }, ··· 86 86 "params": {}, 87 87 "status": 200, 88 88 "sandbox_only": True, # live indexer computes differently than Flask test client 89 + }, 90 + # convey/chat.py 91 + { 92 + "app": "chat", 93 + "name": "session", 94 + "path": "/api/chat/session", 95 + "params": {}, 96 + "status": 200, 97 + }, 98 + { 99 + "app": "chat", 100 + "name": "stream", 101 + "path": "/api/chat/stream/20260304", 102 + "params": {"limit": "20"}, 103 + "status": 200, 104 + }, 105 + { 106 + "app": "chat", 107 + "name": "result", 108 + "path": "/api/chat/result/1700000000001", 109 + "params": {}, 110 + "status": 404, 89 111 }, 90 112 # apps/activities/routes.py 91 113 {
+42 -2
think/awareness.py
··· 209 209 ) 210 210 211 211 212 + def _recent_chat_exchanges(limit: int = 10000) -> list[dict[str, Any]]: 213 + """Return owner-visible chat responses from chat stream history.""" 214 + from think.utils import day_dirs 215 + 216 + try: 217 + days = day_dirs() 218 + except Exception: 219 + return [] 220 + 221 + exchanges: list[dict[str, Any]] = [] 222 + for day_name in sorted(days): 223 + day_path = Path(days[day_name]) 224 + chat_root = day_path / "chat" 225 + if not chat_root.exists(): 226 + continue 227 + for segment_dir in sorted(chat_root.iterdir()): 228 + if not segment_dir.is_dir(): 229 + continue 230 + chat_path = segment_dir / "chat.jsonl" 231 + if not chat_path.exists(): 232 + continue 233 + try: 234 + for line in chat_path.read_text().splitlines(): 235 + if not line.strip(): 236 + continue 237 + event = json.loads(line) 238 + if event.get("kind") != "sol_message": 239 + continue 240 + exchanges.append( 241 + { 242 + "talent": "chat", 243 + "agent_response": event.get("text", ""), 244 + } 245 + ) 246 + except (OSError, json.JSONDecodeError): 247 + logger.warning("Skipping malformed chat stream file: %s", chat_path) 248 + if limit <= 0: 249 + return [] 250 + return exchanges[-limit:] 251 + 252 + 212 253 def compute_thickness() -> dict[str, Any]: 213 254 """Compute journal thickness signals for naming ceremony readiness. 214 255 ··· 221 262 - ``journal_days``: number of day directories with at least one segment 222 263 - ``ready``: True when the naming ceremony should trigger 223 264 """ 224 - from think.conversation import get_recent_exchanges 225 265 from think.facets import get_enabled_facets 226 266 from think.indexer.journal import get_entity_strength 227 267 from think.utils import day_dirs, iter_segments ··· 233 273 entity_depth = sum(1 for e in entities if e.get("observation_depth", 0) >= 2) 234 274 235 275 try: 236 - exchanges = get_recent_exchanges(limit=10000) 276 + exchanges = _recent_chat_exchanges(limit=10000) 237 277 except Exception: 238 278 exchanges = [] 239 279 non_onboarding = [
-341
think/conversation.py
··· 1 - # SPDX-License-Identifier: AGPL-3.0-only 2 - # Copyright (c) 2026 sol pbc 3 - 4 - """Conversation memory service for solstone. 5 - 6 - Manages conversation exchange storage, retrieval, and context injection 7 - for the unified talent agent. Three layers of recall: 8 - 9 - - Layer 1: Recent exchanges (last ~10 turns), loaded directly into context 10 - - Layer 2: Today's earlier exchanges, summarized compactly 11 - - Layer 3: Older conversations, searchable via journal search (automatic — 12 - exchanges are stored as journal entries indexed by FTS5) 13 - """ 14 - 15 - from __future__ import annotations 16 - 17 - import json 18 - import logging 19 - import re 20 - from datetime import datetime 21 - from pathlib import Path 22 - 23 - from think.utils import day_path, get_journal, now_ms 24 - 25 - logger = logging.getLogger(__name__) 26 - 27 - # Append-only exchange log for fast recent retrieval 28 - EXCHANGES_FILE = "conversation/exchanges.jsonl" 29 - 30 - # Journal stream name for conversation segments 31 - CONVERSATION_STREAM = "conversation" 32 - 33 - # Marker in unified talent for memory injection 34 - INJECTION_MARKER = "CONVERSATION_MEMORY_INJECTION_POINT" 35 - 36 - # Context budget: max characters for agent response in recent exchanges 37 - MAX_RESPONSE_CHARS = 300 38 - 39 - # Max characters for user message in compact summaries 40 - MAX_MESSAGE_CHARS = 100 41 - 42 - # Default number of recent exchanges for layer 1 43 - DEFAULT_RECENT_LIMIT = 10 44 - 45 - 46 - # --------------------------------------------------------------------------- 47 - # Exchange Recording 48 - # --------------------------------------------------------------------------- 49 - 50 - 51 - def record_exchange( 52 - *, 53 - ts: int | None = None, 54 - facet: str = "", 55 - app: str = "", 56 - path: str = "", 57 - user_message: str = "", 58 - agent_response: str = "", 59 - talent: str = "", 60 - use_id: str = "", 61 - ) -> None: 62 - """Record a conversation exchange to journal storage. 63 - 64 - Writes to two locations: 65 - 1. conversation/exchanges.jsonl — append-only quick-read index 66 - 2. YYYYMMDD/conversation/HHMMSS_1/talents/conversation.md — journal entry 67 - for FTS5 search indexing (matches */*/*/talents/*.md formatter pattern) 68 - """ 69 - if not user_message or not agent_response: 70 - return 71 - 72 - if ts is None: 73 - ts = now_ms() 74 - 75 - journal = get_journal() 76 - 77 - exchange = { 78 - "ts": ts, 79 - "facet": facet, 80 - "app": app, 81 - "path": path, 82 - "user_message": user_message, 83 - "agent_response": agent_response, 84 - "talent": talent, 85 - "use_id": use_id, 86 - } 87 - 88 - # 1. Append to exchanges.jsonl (fast-read index) 89 - jsonl_path = Path(journal) / EXCHANGES_FILE 90 - jsonl_path.parent.mkdir(parents=True, exist_ok=True) 91 - try: 92 - with open(jsonl_path, "a", encoding="utf-8") as f: 93 - f.write(json.dumps(exchange, ensure_ascii=False) + "\n") 94 - except Exception: 95 - logger.exception("Failed to write exchange to JSONL") 96 - 97 - # 2. Write journal segment for search indexing 98 - dt = datetime.fromtimestamp(ts / 1000) 99 - day = dt.strftime("%Y%m%d") 100 - time_key = dt.strftime("%H%M%S") 101 - segment = f"{time_key}_1" 102 - 103 - seg_dir = day_path(day) / CONVERSATION_STREAM / segment / "talents" 104 - seg_dir.mkdir(parents=True, exist_ok=True) 105 - 106 - time_str = dt.strftime("%Y-%m-%d %H:%M:%S") 107 - md_parts = ["# Conversation Exchange\n"] 108 - md_parts.append(f"**Time:** {time_str}") 109 - if facet: 110 - md_parts.append(f"**Facet:** {facet}") 111 - if app: 112 - app_info = f"{app} ({path})" if path else app 113 - md_parts.append(f"**App:** {app_info}") 114 - md_parts.append("") 115 - md_parts.append("## User\n") 116 - md_parts.append(user_message) 117 - md_parts.append("") 118 - md_parts.append("## Sol\n") 119 - md_parts.append(agent_response) 120 - 121 - md_content = "\n".join(md_parts) + "\n" 122 - 123 - md_path = seg_dir / "conversation.md" 124 - try: 125 - with open(md_path, "w", encoding="utf-8") as f: 126 - f.write(md_content) 127 - except Exception: 128 - logger.exception("Failed to write conversation journal entry") 129 - 130 - 131 - # --------------------------------------------------------------------------- 132 - # Exchange Retrieval 133 - # --------------------------------------------------------------------------- 134 - 135 - 136 - def get_recent_exchanges( 137 - limit: int = DEFAULT_RECENT_LIMIT, 138 - facet: str | None = None, 139 - ) -> list[dict]: 140 - """Read the most recent conversation exchanges. 141 - 142 - Args: 143 - limit: Maximum number of exchanges to return. 144 - facet: If provided, only return exchanges from this facet. 145 - 146 - Returns: 147 - List of exchange dicts, most recent last. 148 - """ 149 - journal = get_journal() 150 - jsonl_path = Path(journal) / EXCHANGES_FILE 151 - 152 - if not jsonl_path.exists(): 153 - return [] 154 - 155 - exchanges = [] 156 - try: 157 - with open(jsonl_path, "r", encoding="utf-8") as f: 158 - for line in f: 159 - line = line.strip() 160 - if not line: 161 - continue 162 - try: 163 - ex = json.loads(line) 164 - ex = _normalize_exchange(ex) 165 - if facet and ex.get("facet") != facet: 166 - continue 167 - exchanges.append(ex) 168 - except json.JSONDecodeError: 169 - continue 170 - except Exception: 171 - logger.exception("Failed to read exchanges") 172 - return [] 173 - 174 - return exchanges[-limit:] 175 - 176 - 177 - def get_today_exchanges(facet: str | None = None) -> list[dict]: 178 - """Read all conversation exchanges from today. 179 - 180 - Args: 181 - facet: If provided, only return exchanges from this facet. 182 - 183 - Returns: 184 - List of exchange dicts from today, chronological order. 185 - """ 186 - journal = get_journal() 187 - jsonl_path = Path(journal) / EXCHANGES_FILE 188 - 189 - if not jsonl_path.exists(): 190 - return [] 191 - 192 - today = datetime.now().strftime("%Y%m%d") 193 - exchanges = [] 194 - 195 - try: 196 - with open(jsonl_path, "r", encoding="utf-8") as f: 197 - for line in f: 198 - line = line.strip() 199 - if not line: 200 - continue 201 - try: 202 - ex = json.loads(line) 203 - ex = _normalize_exchange(ex) 204 - ts = ex.get("ts", 0) 205 - ex_day = datetime.fromtimestamp(ts / 1000).strftime("%Y%m%d") 206 - if ex_day != today: 207 - continue 208 - if facet and ex.get("facet") != facet: 209 - continue 210 - exchanges.append(ex) 211 - except (json.JSONDecodeError, ValueError, OSError): 212 - continue 213 - except Exception: 214 - logger.exception("Failed to read today's exchanges") 215 - return [] 216 - 217 - return exchanges 218 - 219 - 220 - # --------------------------------------------------------------------------- 221 - # Context Formatting 222 - # --------------------------------------------------------------------------- 223 - 224 - 225 - def _format_exchange(ex: dict, *, compact: bool = False) -> str: 226 - """Format a single exchange for context injection. 227 - 228 - Args: 229 - ex: Exchange dict. 230 - compact: If True, return a one-liner summary. 231 - 232 - Returns: 233 - Formatted string. 234 - """ 235 - ts = ex.get("ts", 0) 236 - try: 237 - time_str = datetime.fromtimestamp(ts / 1000).strftime("%H:%M") 238 - except (ValueError, OSError): 239 - time_str = "??:??" 240 - 241 - app = ex.get("app", "") 242 - facet_val = ex.get("facet", "") 243 - 244 - context_parts = [time_str] 245 - if app: 246 - context_parts.append(app) 247 - if facet_val: 248 - context_parts.append(facet_val) 249 - context = " · ".join(context_parts) 250 - 251 - user_msg = ex.get("user_message", "") 252 - agent_resp = ex.get("agent_response", "") 253 - 254 - if compact: 255 - truncated = user_msg[:MAX_MESSAGE_CHARS] 256 - if len(user_msg) > MAX_MESSAGE_CHARS: 257 - truncated += "..." 258 - return f"- [{context}] {truncated}" 259 - 260 - # Full exchange with truncated response 261 - truncated_resp = agent_resp[:MAX_RESPONSE_CHARS] 262 - if len(agent_resp) > MAX_RESPONSE_CHARS: 263 - truncated_resp += "..." 264 - 265 - return f"[{context}] User: {user_msg}\nSol: {truncated_resp}" 266 - 267 - 268 - def build_memory_context( 269 - facet: str | None = None, 270 - recent_limit: int = DEFAULT_RECENT_LIMIT, 271 - ) -> str: 272 - """Build the full conversation memory context block. 273 - 274 - Assembles layer 1 (recent exchanges) and layer 2 (today's summary) 275 - into a formatted block for injection into the unified talent prompt. 276 - 277 - Args: 278 - facet: Active facet for filtering. 279 - recent_limit: Number of recent exchanges for layer 1. 280 - 281 - Returns: 282 - Formatted memory context string, or empty string if no history. 283 - """ 284 - recent = get_recent_exchanges(limit=recent_limit, facet=facet) 285 - if not recent: 286 - return "" 287 - 288 - today_all = get_today_exchanges(facet=facet) 289 - 290 - parts = [] 291 - 292 - # Layer 2: Earlier today (exchanges beyond the recent set) 293 - if len(today_all) > len(recent): 294 - earlier = today_all[: -len(recent)] 295 - if earlier: 296 - parts.append("### Earlier Today\n") 297 - for ex in earlier: 298 - parts.append(_format_exchange(ex, compact=True)) 299 - parts.append("") 300 - 301 - # Layer 1: Recent exchanges (full detail) 302 - parts.append("### Recent Conversations\n") 303 - parts.append("The following are your most recent exchanges with the user:\n") 304 - for ex in recent: 305 - parts.append(_format_exchange(ex, compact=False)) 306 - parts.append("") 307 - 308 - return "\n".join(parts).strip() 309 - 310 - 311 - def inject_memory(user_instruction: str, memory_context: str) -> str: 312 - """Replace the CONVERSATION_MEMORY_INJECTION_POINT with memory context. 313 - 314 - Args: 315 - user_instruction: The unified talent's user instruction text. 316 - memory_context: Formatted conversation memory to inject. 317 - 318 - Returns: 319 - Modified user instruction with memory context injected. 320 - """ 321 - if INJECTION_MARKER not in user_instruction: 322 - return user_instruction 323 - 324 - # Replace the entire HTML comment block containing the marker 325 - pattern = r"<!--\s*" + re.escape(INJECTION_MARKER) + r".*?-->" 326 - 327 - if memory_context: 328 - replacement = memory_context 329 - else: 330 - replacement = "No conversation history yet." 331 - 332 - return re.sub(pattern, replacement, user_instruction, flags=re.DOTALL) 333 - 334 - 335 - def _normalize_exchange(ex: dict) -> dict: 336 - """Normalize legacy exchange dicts to the talent namespace.""" 337 - if "talent" not in ex and "muse" in ex: 338 - ex["talent"] = ex["muse"] 339 - # Optionally, remove the old 'muse' key if it's no longer needed in the normalized dict 340 - # del ex["muse"] 341 - return ex
-12
think/cortex.py
··· 417 417 _req = self.use_requests.get(agent.use_id) 418 418 if _req and "name" not in event: 419 419 event["name"] = _req.get("name", "") 420 - # Inject display mode for triage talent finish events 421 - if event.get("event") == "finish" and _req: 422 - try: 423 - from apps.home.events import TRIAGE_AGENT_NAMES 424 - from convey.triage import compute_display_mode 425 - 426 - if _req.get("name", "") in TRIAGE_AGENT_NAMES: 427 - event["display"] = compute_display_mode( 428 - event.get("result", "") 429 - ) 430 - except Exception: 431 - pass # Display is cosmetic; don't break finish handling 432 420 433 421 # Append to JSONL file 434 422 with open(agent.log_path, "a") as f:
+14 -6
think/cortex_client.py
··· 37 37 name: str, 38 38 provider: Optional[str] = None, 39 39 config: Optional[Dict[str, Any]] = None, 40 + use_id: Optional[str] = None, 40 41 ) -> str | None: 41 42 """Create a Cortex talent request via Callosum broadcast. 42 43 ··· 45 46 name: Talent name - system (e.g., "chat") or app-qualified (e.g., "entities:entity_assist") 46 47 provider: AI provider - openai, google, or anthropic 47 48 config: Provider-specific configuration (model, max_output_tokens, thinking_budget, etc.) 49 + use_id: Optional pre-reserved use_id. When omitted, a unique timestamp is allocated. 48 50 49 51 Returns: 50 52 Use ID (timestamp-based string), or None if the Callosum send failed. ··· 58 60 59 61 # Generate monotonic timestamp in milliseconds, ensuring uniqueness 60 62 global _last_ts 61 - ts = now_ms() 63 + if use_id is None: 64 + ts = now_ms() 62 65 63 - # If same or earlier than last used, increment to ensure uniqueness 64 - if ts <= _last_ts: 65 - ts = _last_ts + 1 66 + if ts <= _last_ts: 67 + ts = _last_ts + 1 66 68 67 - _last_ts = ts 68 - use_id = str(ts) 69 + _last_ts = ts 70 + use_id = str(ts) 71 + else: 72 + if not use_id.isdigit(): 73 + raise ValueError("use_id must be a millisecond timestamp string") 74 + ts = int(use_id) 75 + if ts > _last_ts: 76 + _last_ts = ts 69 77 70 78 # Build request object 71 79 request = {
-7
think/talent.py
··· 35 35 36 36 TALENT_DIR = Path(__file__).parent.parent / "talent" 37 37 APPS_DIR = Path(__file__).parent.parent / "apps" 38 - _UNDISCOVERED_SYSTEM_TALENTS = {"triage"} 39 38 40 39 41 40 # --------------------------------------------------------------------------- ··· 232 231 if TALENT_DIR.is_dir(): 233 232 for md_path in sorted(TALENT_DIR.glob("*.md")): 234 233 name = md_path.stem 235 - if name in _UNDISCOVERED_SYSTEM_TALENTS: 236 - continue 237 234 info = _load_prompt_metadata(md_path) 238 235 239 236 info["source"] = "system" ··· 355 352 # App talent: "support:support" -> apps/support/talent/support 356 353 app, talent_name = name.split(":", 1) 357 354 talent_dir = Path(__file__).parent.parent / "apps" / app / "talent" 358 - elif name == "unified": 359 - # Chat talent: "unified" -> talent/chat 360 - talent_dir = TALENT_DIR 361 - talent_name = "chat" 362 355 else: 363 356 # System talent: bare name -> talent/{name} 364 357 talent_dir = TALENT_DIR