diff --git a/evals/compare_personas.py b/evals/compare_personas.py index bd5f473..5f6e587 100644 --- a/evals/compare_personas.py +++ b/evals/compare_personas.py @@ -179,7 +179,11 @@ def main() -> int: print(f"=> candidate {args.candidate} WINS") return 0 print(f"=> baseline {args.baseline} holds") - return 1 + # Exit 0 on a completed comparison regardless of verdict (POSIX: the exit + # code signals run success/failure, not a domain result). The verdict is on + # stdout: "candidate ... WINS" vs "baseline ... holds". No CI/Makefile/ + # wrapper in this repo branches on the old exit-1 baseline-holds signal. + return 0 if __name__ == "__main__": diff --git a/evals/continuous_eval.py b/evals/continuous_eval.py index 898c08a..94ae598 100644 --- a/evals/continuous_eval.py +++ b/evals/continuous_eval.py @@ -42,10 +42,12 @@ import json import sys import time -import traceback +import logging from pathlib import Path from statistics import mean as mean_ +_logger = logging.getLogger(__name__) + _REPO_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(_REPO_ROOT / "src")) @@ -385,7 +387,7 @@ def main() -> int: last_full = now except Exception as exc: print(f"[continuous_eval] eval error on {provider_name}: {exc}", flush=True) - traceback.print_exc() + _logger.exception("eval error on %s", provider_name) _write_heartbeat(heartbeat_path, "error") if args.once: return 1 @@ -442,7 +444,7 @@ def main() -> int: return 0 except Exception as exc: print(f"[continuous_eval] outer error: {exc}", flush=True) - traceback.print_exc() + _logger.exception("outer eval loop error") time.sleep(60) diff --git a/evals/evolve.py b/evals/evolve.py index ea8c650..f773783 100644 --- a/evals/evolve.py +++ b/evals/evolve.py @@ -77,7 +77,11 @@ def find_latest_persona() -> tuple[int, PersonaSpec]: if match: versions.append((int(match.group(1)), path)) if not versions: - raise FileNotFoundError("No persona.vN.md artifacts found.") + raise FileNotFoundError( + f"No persona.vN.md artifacts found in {ARTIFACTS_DIR}. " + f"Seed persona.v1.md in that directory before running evolve, " + f"or override the location via ARTIFACTS_DIR before launching." + ) versions.sort(key=lambda x: x[0]) n, path = versions[-1] text = path.read_text(encoding="utf-8") diff --git a/evals/evolve_persona.py b/evals/evolve_persona.py index c799b24..d6e5ddf 100644 --- a/evals/evolve_persona.py +++ b/evals/evolve_persona.py @@ -115,7 +115,11 @@ def find_latest_persona() -> tuple[int, PersonaSpec]: if match: versions.append((int(match.group(1)), path)) if not versions: - raise FileNotFoundError("No persona.vN.md artifacts found.") + raise FileNotFoundError( + f"No persona.vN.md artifacts found in {ARTIFACTS_DIR}. " + f"Seed persona.v1.md in that directory before running evolve_persona, " + f"or override the location via ARTIFACTS_DIR before launching." + ) versions.sort(key=lambda x: x[0]) n, path = versions[-1] text = path.read_text(encoding="utf-8") diff --git a/evals/full_pulse.py b/evals/full_pulse.py index ec71a28..36397dc 100644 --- a/evals/full_pulse.py +++ b/evals/full_pulse.py @@ -87,7 +87,12 @@ def main() -> int: print(f"[{label}] ERROR generating: {exc}") t1_rows.append({"label": label, "prompt": prompt, "error": str(exc)}) continue - t1 = score_persona(result.final) + try: + t1 = score_persona(result.final) + except Exception as exc: + print(f"[{label}] T1 scorer error: {exc}") + t1_rows.append({"label": label, "prompt": prompt, "error": str(exc)}) + continue try: t2 = score_distinctiveness(result.final, provider=provider) except Exception as exc: diff --git a/evals/lowest_tier_watch.py b/evals/lowest_tier_watch.py index fe6b1d3..4ce6d67 100644 --- a/evals/lowest_tier_watch.py +++ b/evals/lowest_tier_watch.py @@ -111,9 +111,10 @@ def _write_heartbeat(path: Path, phase: str) -> None: def find_lowest_tier(history: list[dict], *, min_runs: int) -> tuple[str | None, float, int]: """Return (axis, mean_score, n_samples) for the weakest tier with - at least min_runs samples. Returns (None, 1.0, 0) if nothing - qualifies.""" - weakest = (None, 1.0, 0) + at least min_runs samples. Returns (None, inf, 0) if nothing + qualifies. A perfect-1.0 tier still qualifies as the weakest when + all eligible axes tie at the ceiling.""" + weakest: tuple[str | None, float, int] = (None, float("inf"), 0) for axis in TIER_KEYS: values = [r.get(axis) for r in history if isinstance(r.get(axis), (int, float))] if len(values) < min_runs: diff --git a/src/spark_character/chip_loader.py b/src/spark_character/chip_loader.py index 1fb6f97..304c66c 100644 --- a/src/spark_character/chip_loader.py +++ b/src/spark_character/chip_loader.py @@ -152,20 +152,38 @@ def _validate_score(value: Any, field_name: str) -> None: raise ValueError(f"Personality chip field {field_name} must be a number in [0, 1].") +_CHIP_SCHEMA_HINT = ( + " See docs/ARCHITECTURE.md (schema: spark-personality-chip.v1) for the " + "canonical chip YAML overview." +) + + def validate_chip_yaml_spec(spec: Any) -> dict[str, Any]: """Validate the minimal chip-lab YAML shape consumed by spark-character.""" root = _require_mapping(spec, "") schema = root.get("schema", "spark-personality-chip.v1") if not isinstance(schema, str) or not schema.strip(): - raise ValueError("Personality chip field schema must be a non-empty string.") + raise ValueError( + "Personality chip field schema must be a non-empty string (e.g. " + "'spark-personality-chip.v1')." + _CHIP_SCHEMA_HINT + ) identity = _require_mapping(root.get("identity"), "identity") for key in ("id", "name"): if not isinstance(identity.get(key), str) or not identity.get(key, "").strip(): - raise ValueError(f"Personality chip field identity.{key} must be a non-empty string.") + got = type(identity.get(key)).__name__ + raise ValueError( + f"Personality chip field identity.{key} must be a non-empty string " + f"(got {got}). Example: identity:\\n {key}: \"founder-operator\"." + + _CHIP_SCHEMA_HINT + ) for key in ("archetype", "voice_signature", "tagline"): if key in identity and identity[key] is not None and not isinstance(identity[key], str): - raise ValueError(f"Personality chip field identity.{key} must be a string.") + got = type(identity[key]).__name__ + raise ValueError( + f"Personality chip field identity.{key} must be a string " + f"(got {got})." + _CHIP_SCHEMA_HINT + ) traits = _require_mapping(root.get("traits", {}), "traits") for key in TRAIT_FIELDS: @@ -177,33 +195,64 @@ def validate_chip_yaml_spec(spec: Any) -> dict[str, Any]: if key in emotional_profile: _validate_score(emotional_profile[key], f"emotional_profile.{key}") if "empathy_style" in emotional_profile and not isinstance(emotional_profile["empathy_style"], str): - raise ValueError("Personality chip field emotional_profile.empathy_style must be a string.") + got = type(emotional_profile["empathy_style"]).__name__ + raise ValueError( + f"Personality chip field emotional_profile.empathy_style must be a string " + f"(got {got}). Example: empathy_style: \"warm, but direct when asked\"." + _CHIP_SCHEMA_HINT + ) emotional_range = _require_mapping(emotional_profile.get("emotional_range", {}), "emotional_profile.emotional_range") for key, value in emotional_range.items(): _validate_score(value, f"emotional_profile.emotional_range.{key}") triggers = _require_mapping(emotional_profile.get("triggers", {}), "emotional_profile.triggers") for key, value in triggers.items(): if not isinstance(value, list): - raise ValueError(f"Personality chip field emotional_profile.triggers.{key} must be a list.") + got = type(value).__name__ + raise ValueError( + f"Personality chip field emotional_profile.triggers.{key} must be a list " + f"(got {got}). Example: triggers:\\n {key}: [\"betrayal\", \"unfairness\"]." + + _CHIP_SCHEMA_HINT + ) preferences = _require_mapping(root.get("preferences", {}), "preferences") for key in ("likes", "dislikes"): if key in preferences and not isinstance(preferences[key], list): - raise ValueError(f"Personality chip field preferences.{key} must be a list.") + got = type(preferences[key]).__name__ + raise ValueError( + f"Personality chip field preferences.{key} must be a list " + f"(got {got}). Example: preferences:\\n {key}: [\"short replies\", \"plain language\"]." + + _CHIP_SCHEMA_HINT + ) for key in ("communication", "decision_making"): if key in preferences and not isinstance(preferences[key], dict): - raise ValueError(f"Personality chip field preferences.{key} must be a mapping.") + got = type(preferences[key]).__name__ + raise ValueError( + f"Personality chip field preferences.{key} must be a mapping " + f"(got {got}). Example: preferences:\\n {key}:\\n style: \"direct\"." + + _CHIP_SCHEMA_HINT + ) safety = _require_mapping(root.get("safety", {}), "safety") if "harm_avoidance" in safety and not isinstance(safety["harm_avoidance"], list): - raise ValueError("Personality chip field safety.harm_avoidance must be a list.") + got = type(safety["harm_avoidance"]).__name__ + raise ValueError( + f"Personality chip field safety.harm_avoidance must be a list " + f"(got {got}). Example: harm_avoidance: [\"no medical advice\", \"no legal advice\"]." + _CHIP_SCHEMA_HINT + ) for key in TOP_LEVEL_LIST_FIELDS: if key in root and not isinstance(root[key], list): - raise ValueError(f"Personality chip field {key} must be a list.") + got = type(root[key]).__name__ + raise ValueError( + f"Personality chip field {key} must be a list (got {got})." + + _CHIP_SCHEMA_HINT + ) for key in TOP_LEVEL_DICT_FIELDS: if key in root and not isinstance(root[key], dict): - raise ValueError(f"Personality chip field {key} must be a mapping.") + got = type(root[key]).__name__ + raise ValueError( + f"Personality chip field {key} must be a mapping (got {got})." + + _CHIP_SCHEMA_HINT + ) return root @@ -337,8 +386,11 @@ def load_chip_by_id( continue if chip.id == safe_chip_id: return chip + # Report only the basenames of the labs we actually searched: enough to + # debug a misplaced chip without leaking the full filesystem layout. + searched = ", ".join(sorted({p.name for p in paths})) raise FileNotFoundError( - f"Personality chip '{safe_chip_id}' not found in: {[str(p) for p in paths]}" + f"Personality chip '{safe_chip_id}' not found in labs: {searched}" ) diff --git a/src/spark_character/codex_provider.py b/src/spark_character/codex_provider.py index 99dab9b..a1dbb5f 100644 --- a/src/spark_character/codex_provider.py +++ b/src/spark_character/codex_provider.py @@ -25,7 +25,12 @@ def _default_codex_binary() -> str: explicit = os.environ.get("CODEX_PATH") or os.environ.get("SPARK_CODEX_PATH") if explicit: - return explicit + # Validate the path exists and is a regular file to prevent + # arbitrary binary execution via malicious env vars + expanded = os.path.expanduser(explicit) + if not os.path.isfile(expanded): + raise FileNotFoundError(f"Codex binary not found: {expanded}") + return expanded if sys.platform.startswith("win"): return "codex.cmd" return "codex" @@ -75,15 +80,35 @@ def call_codex( "--output-last-message", str(out_path), "-", ] - result = subprocess.run( - cmd, - input=combined.encode("utf-8"), - capture_output=True, - timeout=spec.timeout_seconds, - ) + try: + result = subprocess.run( + cmd, + input=combined.encode("utf-8"), + capture_output=True, + timeout=spec.timeout_seconds, + ) + except FileNotFoundError as exc: + # Guards the eval/judge driver against a raw stack trace when the + # codex CLI is not installed or CODEX_PATH points at a removed + # binary. Preserves the operator's next move (install codex or + # set CODEX_PATH) instead of leaking the OSError text. + raise RuntimeError( + f"codex binary not found at {spec.binary!r}. Install the codex CLI " + f"or set CODEX_PATH / SPARK_CODEX_PATH to its absolute path." + ) from exc + except subprocess.TimeoutExpired as exc: + # Closes the silent-hang window when codex exec exceeds the + # configured timeout; surfaces the actual budget so the operator + # can raise CodexSpec.timeout_seconds rather than guess. + raise RuntimeError( + f"codex exec timed out after {spec.timeout_seconds:.0f}s. " + f"Increase CodexSpec.timeout_seconds or check that the codex " + f"CLI is responsive." + ) from exc if result.returncode != 0: - stderr = result.stderr.decode("utf-8", errors="replace") if result.stderr else "" - raise RuntimeError(f"codex exec failed (rc={result.returncode}): {stderr.strip()[:300]}") + # Redact raw stderr (may carry internal paths / prompt fragments); + # keep the return code so operators can still triage. + raise RuntimeError(f"codex exec failed (rc={result.returncode})") if not out_path.exists(): raise RuntimeError("codex exec did not write the expected output file.") text = out_path.read_text(encoding="utf-8", errors="replace").strip() diff --git a/src/spark_character/critic.py b/src/spark_character/critic.py index 4b115f5..5830890 100644 --- a/src/spark_character/critic.py +++ b/src/spark_character/critic.py @@ -41,7 +41,7 @@ class CritiqueResult: def load_critic(version: str = DEFAULT_CRITIC_VERSION) -> CriticSpec: path = ARTIFACTS_DIR / f"critic.{version}.md" if not path.exists(): - raise FileNotFoundError(f"Critic artifact not found: {path}") + raise FileNotFoundError("Critic artifact not found") return CriticSpec(version=version, text=path.read_text(encoding="utf-8")) diff --git a/src/spark_character/memory_grounded.py b/src/spark_character/memory_grounded.py index 8b2ec2e..429fbcd 100644 --- a/src/spark_character/memory_grounded.py +++ b/src/spark_character/memory_grounded.py @@ -23,8 +23,8 @@ from spark_character.memory_grounded import build_t7_probes_from_state, latest_user_states probes = build_t7_probes_from_state( - sib_home="C:/Users/USER/Desktop/.../tmp-home", - human_id="human:telegram:8319079055", + sib_home=Path.home() / ".spark" / "sib-home", + human_id="human:telegram:", ) for p in probes: result = run_deep_probe(p, provider=..., persona=...) @@ -68,8 +68,10 @@ class UserStateObservation: def _open_state(sib_home: str | Path) -> sqlite3.Connection: db = Path(sib_home) / "state.db" if not db.exists(): - raise FileNotFoundError(f"state.db not found in {sib_home}") - return sqlite3.connect(str(db)) + raise FileNotFoundError("State database not found") + # Open read-only via URI so the probe builder can never accidentally + # mutate SIB's authoritative state.db (user_instructions, personality_observations). + return sqlite3.connect(f"file:{db}?mode=ro", uri=True) def latest_user_instructions( diff --git a/src/spark_character/persona.py b/src/spark_character/persona.py index af9e3e7..6940ae2 100644 --- a/src/spark_character/persona.py +++ b/src/spark_character/persona.py @@ -102,7 +102,7 @@ def set_latest_persona_version( resolved = validate_persona_version(version) artifact_path = artifacts_dir / f"persona.{resolved}.md" if not artifact_path.exists(): - raise FileNotFoundError(f"Persona artifact not found: {artifact_path}") + raise FileNotFoundError(f"Persona artifact not found: persona.{resolved}.md") previous = pointer_path.read_text(encoding="utf-8").strip() if pointer_path.exists() else "" pointer_path.parent.mkdir(parents=True, exist_ok=True) @@ -180,7 +180,7 @@ def load_persona( resolved = version or resolve_latest_persona_version() path = ARTIFACTS_DIR / f"persona.{resolved}.md" if not path.exists(): - raise FileNotFoundError(f"Persona artifact not found: {path}") + raise FileNotFoundError(f"Persona artifact not found: persona.{resolved}.md") base_text = sanitize_prompt_text(path.read_text(encoding="utf-8")) parts = [base_text.rstrip()] overlay_text = load_overlay(provider_kind) @@ -200,6 +200,6 @@ def load_persona( def load_persona_from_path(path: str | Path) -> PersonaSpec: p = Path(path) if not p.exists(): - raise FileNotFoundError(f"Persona artifact not found: {p}") + raise FileNotFoundError(f"Persona artifact not found: {p.name}") version = p.stem.split(".", 1)[-1] if "." in p.stem else "custom" return PersonaSpec(version=version, text=sanitize_prompt_text(p.read_text(encoding="utf-8"))) diff --git a/src/spark_character/prompt_guard.py b/src/spark_character/prompt_guard.py index 97657d1..6f6b294 100644 --- a/src/spark_character/prompt_guard.py +++ b/src/spark_character/prompt_guard.py @@ -28,7 +28,7 @@ re.compile(PROMPT_BOUNDARY_PREFIX + r"(system|developer)\s+(prompt|message|instruction)s?\b.*\b(override|replace|ignore)\b", re.I), ), ("hidden-html", re.compile(r"