diff --git a/apps/skills/__init__.py b/apps/skills/__init__.py new file mode 100644 index 000000000..7cc399de7 --- /dev/null +++ b/apps/skills/__init__.py @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Skills app package.""" diff --git a/apps/skills/call.py b/apps/skills/call.py new file mode 100644 index 000000000..f200ec9c7 --- /dev/null +++ b/apps/skills/call.py @@ -0,0 +1,476 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""CLI commands for owner-wide skill patterns and edit requests. + +Auto-discovered by ``think.call`` and mounted as ``sol call skills ...``. +""" + +from __future__ import annotations + +import json +import logging +from typing import Any, Callable + +import typer + +from think.skills import ( + find_pattern, + load_patterns, + load_profile, + locked_modify_edit_requests, + locked_modify_patterns, + make_request_id, + observation_key, + profile_path, + rename_profile, + touch_updated, + utc_now_iso, +) +from think.utils import require_solstone + +logger = logging.getLogger(__name__) + +app = typer.Typer(help="Owner-wide skill patterns and edit requests.") + + +class _PatternCommandError(Exception): + """Internal control-flow error carrying a CLI message and exit code.""" + + def __init__(self, message: str, exit_code: int) -> None: + super().__init__(message) + self.message = message + self.exit_code = exit_code + + +@app.callback() +def _require_up() -> None: + require_solstone() + + +def _echo_json(payload: Any) -> None: + typer.echo(json.dumps(payload, indent=2, ensure_ascii=False)) + + +def _exit_with_message(message: str, *, code: int) -> None: + typer.echo(message, err=True) + raise typer.Exit(code=code) + + +def _parse_activity_ids(raw_value: str) -> list[str]: + activity_ids = [item.strip() for item in raw_value.split(",") if item.strip()] + if not activity_ids: + typer.echo("Error: --activity-ids requires at least one id.", err=True) + raise typer.Exit(1) from None + return activity_ids + + +def _parse_status_filter(raw_value: str | None) -> set[str] | None: + if raw_value is None: + return None + statuses = {item.strip() for item in raw_value.split(",") if item.strip()} + return statuses or None + + +def _pattern_observation_key( + pattern: dict[str, Any], observation: dict[str, Any] +) -> str: + return observation_key( + str(pattern.get("slug") or ""), + str(observation.get("day") or ""), + [str(item) for item in observation.get("activity_ids", [])], + ) + + +def _recompute_derived_fields(pattern: dict[str, Any]) -> None: + observations = pattern.get("observations", []) + facets = sorted( + { + str(observation.get("facet") or "") + for observation in observations + if observation.get("facet") + } + ) + days = sorted( + str(observation.get("day") or "") + for observation in observations + if observation.get("day") + ) + pattern["facets_touched"] = facets + if days: + pattern["first_seen"] = days[0] + pattern["last_seen"] = days[-1] + + +def _emit_pattern_result( + pattern: dict[str, Any], *, json_output: bool, text_message: str +) -> None: + if json_output: + _echo_json(pattern) + return + typer.echo(text_message) + + +def _locked_update_pattern( + slug: str, mutate_fn: Callable[[dict[str, Any]], None] +) -> dict[str, Any]: + updated_pattern: dict[str, Any] | None = None + + def mutate(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + nonlocal updated_pattern + pattern = find_pattern(slug, rows) + if pattern is None: + raise _PatternCommandError("no such skill", 1) + mutate_fn(pattern) + updated_pattern = pattern + return rows + + try: + locked_modify_patterns(mutate) + except _PatternCommandError as exc: + _exit_with_message(exc.message, code=exc.exit_code) + + if updated_pattern is None: # pragma: no cover - defensive assertion + raise RuntimeError(f"pattern mutation produced no row for slug {slug}") + return updated_pattern + + +@app.command("list") +def list_skills( + status: str | None = typer.Option( + None, + "--status", + help="Filter by one status or a comma-separated list of statuses.", + ), + json_output: bool = typer.Option(False, "--json", help="Output as JSON."), +) -> None: + """List owner-wide skill patterns.""" + rows = load_patterns() + status_filter = _parse_status_filter(status) + if status_filter is not None: + rows = [row for row in rows if str(row.get("status") or "") in status_filter] + + if json_output: + _echo_json(rows) + return + + for row in rows: + slug = str(row.get("slug") or "")[:40] + status_value = str(row.get("status") or "")[:10] + observations = row.get("observations", []) + last_seen = str(row.get("last_seen") or "") + facets = ",".join(str(item) for item in row.get("facets_touched", [])) + typer.echo( + f"{slug:<40} {status_value:<10} " + f"obs={len(observations):<3} last={last_seen} facets={facets}" + ) + + +@app.command("show") +def show_skill( + slug: str = typer.Argument(help="Skill slug."), + json_output: bool = typer.Option(False, "--json", help="Output as JSON."), +) -> None: + """Show one owner-wide skill pattern and its profile.""" + pattern = find_pattern(slug) + if pattern is None: + _exit_with_message("no such skill", code=1) + + profile = load_profile(slug) + if json_output: + _echo_json({"pattern": pattern, "profile": profile}) + return + + typer.echo(f"name: {pattern.get('name', '')}") + typer.echo(f"slug: {pattern.get('slug', '')}") + typer.echo(f"status: {pattern.get('status', '')}") + typer.echo(f"first_seen: {pattern.get('first_seen', '')}") + typer.echo(f"last_seen: {pattern.get('last_seen', '')}") + typer.echo(f"obs_count: {len(pattern.get('observations', []))}") + typer.echo(f"facets_touched: {','.join(pattern.get('facets_touched', []))}") + observations = sorted( + pattern.get("observations", []), + key=lambda observation: ( + str(observation.get("day", "")), + str(observation.get("recorded_at", "")), + ), + ) + for observation in observations: + activity_ids = ",".join( + str(item) for item in observation.get("activity_ids", []) + ) + notes = str(observation.get("notes") or "") + typer.echo( + f"- {observation.get('day', '')} [{observation.get('facet', '')}] " + f"activity_ids={activity_ids} notes={notes}" + ) + if profile is not None: + typer.echo("---") + typer.echo(profile.rstrip("\n")) + + +@app.command("observe") +def observe_skill( + slug: str = typer.Argument(help="Skill slug."), + day: str = typer.Option(..., "--day", help="Observation day in YYYY-MM-DD format."), + facet: str = typer.Option(..., "--facet", help="Facet name."), + activity_ids: str = typer.Option( + ..., + "--activity-ids", + help="Comma-separated activity ids.", + ), + notes: str = typer.Option("", "--notes", help="Optional observation notes."), + json_output: bool = typer.Option(False, "--json", help="Output as JSON."), +) -> None: + """Record one new observation for an existing skill.""" + normalized_activity_ids = _parse_activity_ids(activity_ids) + target_key = observation_key(slug, day, normalized_activity_ids) + + def mutate(pattern: dict[str, Any]) -> None: + existing = pattern.get("observations", []) + if any( + _pattern_observation_key(pattern, observation) == target_key + for observation in existing + ): + raise _PatternCommandError("already recorded", 0) + existing.append( + { + "day": day, + "facet": facet, + "activity_ids": normalized_activity_ids, + "notes": notes, + "recorded_at": utc_now_iso(), + } + ) + _recompute_derived_fields(pattern) + if pattern.get("status") == "dormant": + pattern["status"] = "mature" + touch_updated(pattern) + + pattern = _locked_update_pattern(slug, mutate) + _emit_pattern_result( + pattern, + json_output=json_output, + text_message=f"recorded observation: {slug}", + ) + + +@app.command("seed") +def seed_skill( + slug: str = typer.Argument(help="Skill slug."), + name: str = typer.Option(..., "--name", help="Human-readable skill name."), + day: str = typer.Option(..., "--day", help="Observation day in YYYY-MM-DD format."), + facet: str = typer.Option(..., "--facet", help="Facet name."), + activity_ids: str = typer.Option( + ..., + "--activity-ids", + help="Comma-separated activity ids.", + ), + notes: str = typer.Option("", "--notes", help="Optional observation notes."), + json_output: bool = typer.Option(False, "--json", help="Output as JSON."), +) -> None: + """Seed one new emerging skill pattern.""" + normalized_activity_ids = _parse_activity_ids(activity_ids) + created_pattern: dict[str, Any] | None = None + created_at = utc_now_iso() + + def mutate(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + nonlocal created_pattern + if find_pattern(slug, rows) is not None: + raise _PatternCommandError("slug already exists", 1) + created_pattern = { + "slug": slug, + "name": name, + "status": "emerging", + "observations": [ + { + "day": day, + "facet": facet, + "activity_ids": normalized_activity_ids, + "notes": notes, + "recorded_at": created_at, + } + ], + "facets_touched": [facet], + "first_seen": day, + "last_seen": day, + "needs_profile": False, + "needs_refresh": False, + "profile_generated_at": None, + "created_at": created_at, + "updated_at": created_at, + } + rows = list(rows) + rows.append(created_pattern) + return rows + + try: + locked_modify_patterns(mutate) + except _PatternCommandError as exc: + _exit_with_message(exc.message, code=exc.exit_code) + + if created_pattern is None: # pragma: no cover - defensive assertion + raise RuntimeError(f"seed did not create pattern {slug}") + _emit_pattern_result( + created_pattern, + json_output=json_output, + text_message=f"created skill: {slug}", + ) + + +@app.command("promote") +def promote_skill( + slug: str = typer.Argument(help="Skill slug."), + json_output: bool = typer.Option(False, "--json", help="Output as JSON."), +) -> None: + """Flag one skill for profile generation.""" + + def mutate(pattern: dict[str, Any]) -> None: + if pattern.get("status") == "mature": + raise _PatternCommandError("already mature", 0) + if bool(pattern.get("needs_profile")): + raise _PatternCommandError("already flagged", 0) + pattern["needs_profile"] = True + touch_updated(pattern) + + pattern = _locked_update_pattern(slug, mutate) + _emit_pattern_result( + pattern, + json_output=json_output, + text_message=f"flagged for profile: {slug}", + ) + + +@app.command("refresh") +def refresh_skill( + slug: str = typer.Argument(help="Skill slug."), + json_output: bool = typer.Option(False, "--json", help="Output as JSON."), +) -> None: + """Flag one mature skill for profile refresh.""" + + def mutate(pattern: dict[str, Any]) -> None: + if pattern.get("status") != "mature": + raise _PatternCommandError("not mature", 1) + if bool(pattern.get("needs_refresh")): + raise _PatternCommandError("already flagged", 0) + pattern["needs_refresh"] = True + touch_updated(pattern) + + pattern = _locked_update_pattern(slug, mutate) + _emit_pattern_result( + pattern, + json_output=json_output, + text_message=f"flagged for refresh: {slug}", + ) + + +@app.command("mark-dormant") +def mark_dormant_skill( + slug: str = typer.Argument(help="Skill slug."), + json_output: bool = typer.Option(False, "--json", help="Output as JSON."), +) -> None: + """Mark one skill dormant.""" + + def mutate(pattern: dict[str, Any]) -> None: + if pattern.get("status") == "dormant": + raise _PatternCommandError("already flagged", 0) + pattern["status"] = "dormant" + touch_updated(pattern) + + pattern = _locked_update_pattern(slug, mutate) + _emit_pattern_result( + pattern, + json_output=json_output, + text_message=f"marked dormant: {slug}", + ) + + +@app.command("retire") +def retire_skill( + slug: str = typer.Argument(help="Skill slug."), + json_output: bool = typer.Option(False, "--json", help="Output as JSON."), +) -> None: + """Mark one skill retired.""" + + def mutate(pattern: dict[str, Any]) -> None: + if pattern.get("status") == "retired": + raise _PatternCommandError("already flagged", 0) + pattern["status"] = "retired" + touch_updated(pattern) + + pattern = _locked_update_pattern(slug, mutate) + _emit_pattern_result( + pattern, + json_output=json_output, + text_message=f"retired skill: {slug}", + ) + + +@app.command("edit-request") +def edit_request_skill( + slug: str = typer.Argument(help="Skill slug."), + instructions: str = typer.Option(..., "--instructions", help="Edit instructions."), + requested_by: str = typer.Option("chat", "--requested-by", help="Request source."), + json_output: bool = typer.Option(False, "--json", help="Output as JSON."), +) -> None: + """Append one owner-authored edit request for a skill.""" + if find_pattern(slug) is None: + _exit_with_message("no such skill", code=1) + + request_id = make_request_id() + request = { + "id": request_id, + "slug": slug, + "instructions": instructions, + "requested_at": utc_now_iso(), + "requested_by": requested_by, + "processed_at": None, + } + + def mutate(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + next_rows = list(rows) + next_rows.append(request) + return next_rows + + locked_modify_edit_requests(mutate) + + if json_output: + _echo_json({"request_id": request_id, "slug": slug}) + return + typer.echo(f"request_id: {request_id}") + + +@app.command("rename") +def rename_skill( + old_slug: str = typer.Argument(help="Existing skill slug."), + new_slug: str = typer.Argument(help="New skill slug."), + json_output: bool = typer.Option(False, "--json", help="Output as JSON."), +) -> None: + """Rename one skill slug and move its profile if present.""" + patterns = load_patterns() + if find_pattern(old_slug, patterns) is None: + _exit_with_message("no such skill", code=1) + if find_pattern(new_slug, patterns) is not None or profile_path(new_slug).exists(): + _exit_with_message("new slug already exists", code=1) + + rename_profile(old_slug, new_slug) + + try: + pattern = _locked_update_pattern( + old_slug, + lambda row: (row.__setitem__("slug", new_slug), touch_updated(row)), + ) + except Exception: + logger.error( + "skills: rename_pattern failed after profile move %s -> %s", + old_slug, + new_slug, + exc_info=True, + ) + raise + + _emit_pattern_result( + pattern, + json_output=json_output, + text_message=f"renamed skill: {new_slug}", + ) diff --git a/docs/SOLCLI.md b/docs/SOLCLI.md index 25d9fd4bd..f3c5a7655 100644 --- a/docs/SOLCLI.md +++ b/docs/SOLCLI.md @@ -309,6 +309,7 @@ solstone/ | `activities` | `apps/activities/call.py` | list, get, create, update, mute, unmute | | `entities` | `apps/entities/call.py` | list, show, search, observe, merge | | `speakers` | `apps/speakers/call.py` | list, show, detect-owner, confirm-owner, clusters, suggest | +| `skills` | `apps/skills/call.py` | list, show, observe, seed, promote, refresh, mark-dormant, retire, edit-request, rename | | `transcripts` | `apps/transcripts/call.py` | list, read, segments | | `support` | `apps/support/call.py` | register, search, article, create, list, show, reply, attach, feedback, announcements, diagnose | | `sol` | `apps/sol/call.py` | name, set-name, reset, thickness, set-owner, sol-init | diff --git a/tests/test_apps_skills_call.py b/tests/test_apps_skills_call.py new file mode 100644 index 000000000..d118e64fc --- /dev/null +++ b/tests/test_apps_skills_call.py @@ -0,0 +1,536 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from apps.skills.call import app as skills_app +from think.call import call_app +from think.skills import ( + load_edit_requests, + load_patterns, + locked_modify_patterns, + profile_path, + save_patterns, + save_profile, +) + +runner = CliRunner() + + +@pytest.fixture +def skill_cli_env(monkeypatch, tmp_path): + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) + return Path(tmp_path) + + +def _make_pattern( + *, + slug: str = "alpha-skill", + name: str = "Alpha Skill", + status: str = "emerging", + day: str = "2026-04-19", + facet: str = "work", + activity_ids: list[str] | None = None, + notes: str = "", + needs_profile: bool = False, + needs_refresh: bool = False, + profile_generated_at: str | None = None, + created_at: str = "2026-04-19T14:22:00Z", + updated_at: str = "2026-04-19T14:22:00Z", +) -> dict: + ids = ["act_abc"] if activity_ids is None else activity_ids + observations = [ + { + "day": day, + "facet": facet, + "activity_ids": ids, + "notes": notes, + "recorded_at": created_at, + } + ] + return { + "slug": slug, + "name": name, + "status": status, + "observations": observations, + "facets_touched": [facet], + "first_seen": day, + "last_seen": day, + "needs_profile": needs_profile, + "needs_refresh": needs_refresh, + "profile_generated_at": profile_generated_at, + "created_at": created_at, + "updated_at": updated_at, + } + + +def _seed_patterns(*rows: dict) -> None: + save_patterns(list(rows)) + + +def _invoke(*args: str): + return runner.invoke(call_app, ["skills", *args]) + + +def test_skills_app_has_ten_registered_commands(skill_cli_env): + command_names = {command.name for command in skills_app.registered_commands} + + assert len(skills_app.registered_commands) == 10 + assert command_names == { + "list", + "show", + "observe", + "seed", + "promote", + "refresh", + "mark-dormant", + "retire", + "edit-request", + "rename", + } + + +def test_list_empty_text_output(skill_cli_env): + result = _invoke("list") + + assert result.exit_code == 0 + assert result.output == "" + + +def test_list_empty_json_output(skill_cli_env): + result = _invoke("list", "--json") + + assert result.exit_code == 0 + assert json.loads(result.output) == [] + + +def test_list_filters_by_status(skill_cli_env): + _seed_patterns( + _make_pattern(slug="alpha-skill"), + _make_pattern(slug="beta-skill", status="dormant"), + ) + + result = _invoke("list", "--status", "dormant") + + assert result.exit_code == 0 + assert "beta-skill" in result.output + assert "alpha-skill" not in result.output + + +def test_list_filters_by_status_comma_separated(skill_cli_env): + _seed_patterns( + _make_pattern(slug="alpha-skill", status="mature"), + _make_pattern(slug="beta-skill", status="dormant"), + _make_pattern(slug="gamma-skill", status="retired"), + ) + + result = _invoke("list", "--status", "mature,dormant") + + assert result.exit_code == 0 + assert "alpha-skill" in result.output + assert "beta-skill" in result.output + assert "gamma-skill" not in result.output + + +def test_show_missing_slug(skill_cli_env): + result = _invoke("show", "missing-skill") + + assert result.exit_code == 1 + assert "no such skill" in result.stderr + + +def test_show_renders_pattern(skill_cli_env): + _seed_patterns( + _make_pattern( + slug="alpha-skill", + name="Alpha Skill", + activity_ids=["act_abc", "act_def"], + notes="Observed in review", + ) + ) + save_profile("alpha-skill", "# Alpha Skill\n") + + result = _invoke("show", "alpha-skill") + + assert result.exit_code == 0 + assert "name: Alpha Skill" in result.output + assert "slug: alpha-skill" in result.output + assert ( + "- 2026-04-19 [work] activity_ids=act_abc,act_def notes=Observed in review" + in result.output + ) + assert "# Alpha Skill" in result.output + + +def test_show_json_shape(skill_cli_env): + _seed_patterns(_make_pattern()) + save_profile("alpha-skill", "# Alpha Skill\n") + + result = _invoke("show", "alpha-skill", "--json") + + payload = json.loads(result.output) + assert result.exit_code == 0 + assert set(payload) == {"pattern", "profile"} + assert payload["pattern"]["slug"] == "alpha-skill" + assert payload["profile"] == "# Alpha Skill\n" + + +def test_show_sorts_observations_for_text_output(skill_cli_env): + _seed_patterns(_make_pattern(day="2026-04-20", created_at="2026-04-20T10:00:00Z")) + + def mutate(rows): + rows = list(rows) + rows[0]["observations"].append( + { + "day": "2026-04-19", + "facet": "solpbc", + "activity_ids": ["act_older"], + "notes": "Earlier observation", + "recorded_at": "2026-04-19T09:00:00Z", + } + ) + rows[0]["first_seen"] = "2026-04-19" + rows[0]["last_seen"] = "2026-04-20" + return rows + + locked_modify_patterns(mutate) + + result = _invoke("show", "alpha-skill") + + assert result.exit_code == 0 + first_index = result.output.index( + "- 2026-04-19 [solpbc] activity_ids=act_older notes=Earlier observation" + ) + second_index = result.output.index( + "- 2026-04-20 [work] activity_ids=act_abc notes=" + ) + assert first_index < second_index + + +def test_observe_missing_slug_errors(skill_cli_env): + result = _invoke( + "observe", + "missing-skill", + "--day", + "2026-04-20", + "--facet", + "work", + "--activity-ids", + "act_new", + ) + + assert result.exit_code == 1 + assert "no such skill" in result.stderr + + +def test_observe_appends_and_updates_derived_fields(skill_cli_env): + _seed_patterns(_make_pattern()) + + result = _invoke( + "observe", + "alpha-skill", + "--day", + "2026-04-20", + "--facet", + "personal", + "--activity-ids", + "act_new", + "--notes", + "Later observation", + "--json", + ) + + payload = json.loads(result.output) + assert result.exit_code == 0 + assert payload["facets_touched"] == ["personal", "work"] + assert payload["first_seen"] == "2026-04-19" + assert payload["last_seen"] == "2026-04-20" + assert len(payload["observations"]) == 2 + assert payload["updated_at"].endswith("Z") + + +def test_observe_resurrects_dormant(skill_cli_env): + _seed_patterns(_make_pattern(status="dormant")) + + result = _invoke( + "observe", + "alpha-skill", + "--day", + "2026-04-20", + "--facet", + "work", + "--activity-ids", + "act_new", + "--json", + ) + + payload = json.loads(result.output) + assert result.exit_code == 0 + assert payload["status"] == "mature" + + +def test_observe_idempotent_exits_0_with_already_recorded(skill_cli_env): + _seed_patterns(_make_pattern(activity_ids=["act_a", "act_b"])) + + result = _invoke( + "observe", + "alpha-skill", + "--day", + "2026-04-19", + "--facet", + "work", + "--activity-ids", + "act_b,act_a", + ) + + assert result.exit_code == 0 + assert "already recorded" in result.stderr + rows = load_patterns() + assert len(rows[0]["observations"]) == 1 + + +def test_seed_creates_pattern_with_initial_observation(skill_cli_env): + result = _invoke( + "seed", + "alpha-skill", + "--name", + "Alpha Skill", + "--day", + "2026-04-19", + "--facet", + "work", + "--activity-ids", + "act_abc,act_def", + "--notes", + "Initial seed", + "--json", + ) + + payload = json.loads(result.output) + assert result.exit_code == 0 + assert payload["slug"] == "alpha-skill" + assert payload["status"] == "emerging" + assert payload["needs_profile"] is False + assert payload["needs_refresh"] is False + assert payload["profile_generated_at"] is None + assert payload["facets_touched"] == ["work"] + assert payload["first_seen"] == "2026-04-19" + assert payload["last_seen"] == "2026-04-19" + assert payload["observations"][0]["activity_ids"] == ["act_abc", "act_def"] + assert payload["observations"][0]["notes"] == "Initial seed" + + +def test_seed_collision_errors_with_slug_already_exists(skill_cli_env): + _seed_patterns(_make_pattern()) + + result = _invoke( + "seed", + "alpha-skill", + "--name", + "Alpha Skill", + "--day", + "2026-04-19", + "--facet", + "work", + "--activity-ids", + "act_abc", + ) + + assert result.exit_code == 1 + assert "slug already exists" in result.stderr + + +def test_promote_missing_slug_errors(skill_cli_env): + result = _invoke("promote", "missing-skill") + + assert result.exit_code == 1 + assert "no such skill" in result.stderr + + +def test_promote_sets_needs_profile(skill_cli_env): + _seed_patterns(_make_pattern()) + + result = _invoke("promote", "alpha-skill", "--json") + + payload = json.loads(result.output) + assert result.exit_code == 0 + assert payload["needs_profile"] is True + + +def test_promote_already_flagged_exits_0(skill_cli_env): + _seed_patterns(_make_pattern(needs_profile=True)) + + result = _invoke("promote", "alpha-skill") + + assert result.exit_code == 0 + assert "already flagged" in result.stderr + + +def test_promote_already_mature_exits_0(skill_cli_env): + _seed_patterns(_make_pattern(status="mature")) + + result = _invoke("promote", "alpha-skill") + + assert result.exit_code == 0 + assert "already mature" in result.stderr + + +def test_refresh_not_mature_exits_1(skill_cli_env): + _seed_patterns(_make_pattern(status="emerging")) + + result = _invoke("refresh", "alpha-skill") + + assert result.exit_code == 1 + assert "not mature" in result.stderr + + +def test_refresh_sets_needs_refresh_on_mature(skill_cli_env): + _seed_patterns(_make_pattern(status="mature")) + + result = _invoke("refresh", "alpha-skill", "--json") + + payload = json.loads(result.output) + assert result.exit_code == 0 + assert payload["needs_refresh"] is True + + +def test_refresh_already_flagged_exits_0(skill_cli_env): + _seed_patterns(_make_pattern(status="mature", needs_refresh=True)) + + result = _invoke("refresh", "alpha-skill") + + assert result.exit_code == 0 + assert "already flagged" in result.stderr + + +def test_mark_dormant_sets_status(skill_cli_env): + _seed_patterns(_make_pattern()) + + result = _invoke("mark-dormant", "alpha-skill", "--json") + + payload = json.loads(result.output) + assert result.exit_code == 0 + assert payload["status"] == "dormant" + + +def test_mark_dormant_already_flagged_exits_0(skill_cli_env): + _seed_patterns(_make_pattern(status="dormant")) + + result = _invoke("mark-dormant", "alpha-skill") + + assert result.exit_code == 0 + assert "already flagged" in result.stderr + + +def test_retire_sets_status(skill_cli_env): + _seed_patterns(_make_pattern()) + + result = _invoke("retire", "alpha-skill", "--json") + + payload = json.loads(result.output) + assert result.exit_code == 0 + assert payload["status"] == "retired" + + +def test_retire_already_flagged_exits_0(skill_cli_env): + _seed_patterns(_make_pattern(status="retired")) + + result = _invoke("retire", "alpha-skill") + + assert result.exit_code == 0 + assert "already flagged" in result.stderr + + +def test_edit_request_appends_with_unique_id(skill_cli_env): + _seed_patterns(_make_pattern()) + + first = _invoke("edit-request", "alpha-skill", "--instructions", "revise opening") + second = _invoke("edit-request", "alpha-skill", "--instructions", "expand examples") + + assert first.exit_code == 0 + assert second.exit_code == 0 + rows = load_edit_requests() + assert len(rows) == 2 + assert rows[0]["id"] != rows[1]["id"] + + +def test_edit_request_on_retired_skill_allowed(skill_cli_env): + _seed_patterns(_make_pattern(status="retired")) + + result = _invoke( + "edit-request", + "alpha-skill", + "--instructions", + "still worth polishing", + "--json", + ) + + payload = json.loads(result.output) + assert result.exit_code == 0 + assert payload["slug"] == "alpha-skill" + + +def test_edit_request_missing_slug_errors(skill_cli_env): + result = _invoke( + "edit-request", + "missing-skill", + "--instructions", + "revise this", + ) + + assert result.exit_code == 1 + assert "no such skill" in result.stderr + + +def test_rename_moves_profile_and_updates_slug(skill_cli_env): + _seed_patterns(_make_pattern()) + save_profile("alpha-skill", "# Alpha Skill\n") + + result = _invoke("rename", "alpha-skill", "renamed-skill", "--json") + + payload = json.loads(result.output) + assert result.exit_code == 0 + assert payload["slug"] == "renamed-skill" + assert not profile_path("alpha-skill").exists() + assert ( + profile_path("renamed-skill").read_text(encoding="utf-8") == "# Alpha Skill\n" + ) + assert load_patterns()[0]["slug"] == "renamed-skill" + + +def test_rename_target_exists_errors(skill_cli_env): + _seed_patterns(_make_pattern(slug="alpha-skill"), _make_pattern(slug="beta-skill")) + + result = _invoke("rename", "alpha-skill", "beta-skill") + + assert result.exit_code == 1 + assert "new slug already exists" in result.stderr + + +def test_rename_orphan_profile_target_exists_errors(skill_cli_env): + _seed_patterns(_make_pattern(slug="alpha-skill")) + save_profile("orphan-target", "# Orphan Target\n") + + result = _invoke("rename", "alpha-skill", "orphan-target") + + assert result.exit_code == 1 + assert "new slug already exists" in result.stderr + assert ( + profile_path("orphan-target").read_text(encoding="utf-8") == "# Orphan Target\n" + ) + assert load_patterns()[0]["slug"] == "alpha-skill" + + +def test_rename_missing_source_errors(skill_cli_env): + _seed_patterns(_make_pattern(slug="alpha-skill")) + + result = _invoke("rename", "missing-skill", "beta-skill") + + assert result.exit_code == 1 + assert "no such skill" in result.stderr diff --git a/tests/test_think_skills.py b/tests/test_think_skills.py new file mode 100644 index 000000000..2af3e7187 --- /dev/null +++ b/tests/test_think_skills.py @@ -0,0 +1,222 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import logging +import threading +from pathlib import Path + +import pytest + +from think.skills import ( + edit_requests_lock_path, + edit_requests_path, + find_pattern, + load_edit_requests, + load_patterns, + load_profile, + locked_modify_edit_requests, + locked_modify_patterns, + make_request_id, + observation_key, + patterns_lock_path, + patterns_path, + profile_path, + rename_profile, + save_edit_requests, + save_patterns, + save_profile, + skills_dir, + touch_updated, + utc_now_iso, +) + + +@pytest.fixture +def skill_journal(monkeypatch, tmp_path): + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) + return Path(tmp_path) + + +def test_skills_dir_creates_dir(skill_journal): + path = skills_dir() + + assert path == skill_journal / "skills" + assert path.exists() + assert path.is_dir() + + +def test_path_helpers_return_expected_names(skill_journal): + assert patterns_path() == skill_journal / "skills" / "patterns.jsonl" + assert edit_requests_path() == skill_journal / "skills" / "edit_requests.jsonl" + assert profile_path("alpha-skill") == skill_journal / "skills" / "alpha-skill.md" + assert patterns_lock_path() == skill_journal / "skills" / ".patterns.lock" + assert edit_requests_lock_path() == skill_journal / "skills" / ".edit_requests.lock" + + +def test_load_patterns_missing_file_returns_empty(skill_journal): + assert load_patterns() == [] + + +def test_save_and_load_patterns_roundtrip(skill_journal): + rows = [ + {"slug": "alpha-skill", "status": "emerging"}, + {"slug": "beta-skill", "status": "mature"}, + ] + + save_patterns(rows) + + assert load_patterns() == rows + + +def test_save_patterns_empty_list_writes_empty_file(skill_journal): + save_patterns([]) + + assert patterns_path().read_text(encoding="utf-8") == "" + + +def test_load_patterns_skips_malformed_line(skill_journal, caplog): + patterns_path().write_text('{"slug": "alpha-skill"}\nnot-json\n', encoding="utf-8") + + rows = load_patterns() + + assert rows == [{"slug": "alpha-skill"}] + assert "malformed JSONL line 2" in caplog.text + + +def test_load_patterns_warns_on_non_dict_line(skill_journal, caplog): + patterns_path().write_text('{"slug": "alpha-skill"}\n[1, 2]\n', encoding="utf-8") + + with caplog.at_level(logging.WARNING): + rows = load_patterns() + + assert rows == [{"slug": "alpha-skill"}] + assert "non-object JSONL line 2" in caplog.text + assert "list" in caplog.text + + +def test_load_edit_requests_missing_file_returns_empty(skill_journal): + assert load_edit_requests() == [] + + +def test_save_and_load_edit_requests_roundtrip(skill_journal): + rows = [ + {"id": "req_1", "slug": "alpha-skill"}, + {"id": "req_2", "slug": "beta-skill"}, + ] + + save_edit_requests(rows) + + assert load_edit_requests() == rows + + +def test_load_profile_missing_returns_none(skill_journal): + assert load_profile("missing-skill") is None + + +def test_save_profile_writes_and_load_reads_back(skill_journal): + save_profile("alpha-skill", "# Alpha Skill\n") + + assert load_profile("alpha-skill") == "# Alpha Skill\n" + + +def test_rename_profile_renames_and_returns_true(skill_journal): + save_profile("old-skill", "# Old\n") + + renamed = rename_profile("old-skill", "new-skill") + + assert renamed is True + assert not profile_path("old-skill").exists() + assert load_profile("new-skill") == "# Old\n" + + +def test_rename_profile_missing_returns_false(skill_journal): + assert rename_profile("missing-skill", "new-skill") is False + + +def test_rename_profile_target_exists_raises(skill_journal): + save_profile("old-skill", "# Old\n") + save_profile("new-skill", "# New\n") + + with pytest.raises(FileExistsError): + rename_profile("old-skill", "new-skill") + + +def test_find_pattern_returns_row_or_none(skill_journal): + rows = [{"slug": "alpha-skill"}, {"slug": "beta-skill"}] + + assert find_pattern("beta-skill", rows) == {"slug": "beta-skill"} + assert find_pattern("missing-skill", rows) is None + + +def test_observation_key_is_deterministic_and_sort_invariant(skill_journal): + assert observation_key("alpha", "2026-04-19", ["x", "y"]) == observation_key( + "alpha", "2026-04-19", ["y", "x"] + ) + + +def test_make_request_id_unique_across_100_calls(skill_journal): + request_ids = {make_request_id() for _ in range(100)} + + assert len(request_ids) == 100 + + +def test_utc_now_iso_ends_with_z(skill_journal): + assert utc_now_iso().endswith("Z") + + +def test_touch_updated_sets_updated_at(skill_journal): + row = {} + + touch_updated(row) + + assert row["updated_at"].endswith("Z") + + +def test_locked_modify_patterns_applies_fn_and_persists(skill_journal): + def mutate(rows): + return list(rows) + [{"slug": "alpha-skill"}] + + updated = locked_modify_patterns(mutate) + + assert updated == [{"slug": "alpha-skill"}] + assert load_patterns() == [{"slug": "alpha-skill"}] + + +def test_locked_modify_edit_requests_applies_fn_and_persists(skill_journal): + def mutate(rows): + return list(rows) + [{"id": "req_1", "slug": "alpha-skill"}] + + updated = locked_modify_edit_requests(mutate) + + assert updated == [{"id": "req_1", "slug": "alpha-skill"}] + assert load_edit_requests() == [{"id": "req_1", "slug": "alpha-skill"}] + + +def test_locked_modify_patterns_serializes_threads(skill_journal): + barrier = threading.Barrier(4) + exceptions: list[BaseException] = [] + + def worker(i: int) -> None: + try: + barrier.wait() + + def mutate(rows): + next_rows = list(rows) + next_rows.append({"slug": f"s{i}"}) + return next_rows + + locked_modify_patterns(mutate) + except BaseException as exc: # pragma: no cover - assertion surface + exceptions.append(exc) + + threads = [threading.Thread(target=worker, args=(i,)) for i in range(4)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert exceptions == [] + rows = load_patterns() + assert sorted(row["slug"] for row in rows) == ["s0", "s1", "s2", "s3"] diff --git a/think/skills.py b/think/skills.py new file mode 100644 index 000000000..e3a55ae94 --- /dev/null +++ b/think/skills.py @@ -0,0 +1,212 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc +"""Owner-wide skills storage and helpers. + +Sole write-owner of: + journal/skills/patterns.jsonl + journal/skills/edit_requests.jsonl + journal/skills/{slug}.md +""" + +from __future__ import annotations + +import fcntl +import json +import logging +import secrets +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable + +from think.entities.core import atomic_write +from think.utils import get_journal + +logger = logging.getLogger(__name__) + + +def skills_dir() -> Path: + """Return the owner-wide skills directory, creating it if needed.""" + path = Path(get_journal()) / "skills" + path.mkdir(parents=True, exist_ok=True) + return path + + +def patterns_path() -> Path: + """Return the owner-wide skills patterns JSONL path.""" + return skills_dir() / "patterns.jsonl" + + +def edit_requests_path() -> Path: + """Return the owner-wide skill edit requests JSONL path.""" + return skills_dir() / "edit_requests.jsonl" + + +def profile_path(slug: str) -> Path: + """Return the markdown profile path for one skill slug.""" + return skills_dir() / f"{slug}.md" + + +def patterns_lock_path() -> Path: + """Return the sibling lock path for patterns.jsonl.""" + return skills_dir() / ".patterns.lock" + + +def edit_requests_lock_path() -> Path: + """Return the sibling lock path for edit_requests.jsonl.""" + return skills_dir() / ".edit_requests.lock" + + +def _load_jsonl_rows(path: Path) -> list[dict[str, Any]]: + """Load JSONL rows from *path*, skipping blanks and malformed lines.""" + if not path.exists(): + return [] + + rows: list[dict[str, Any]] = [] + with open(path, encoding="utf-8") as handle: + for lineno, line in enumerate(handle, start=1): + raw = line.strip() + if not raw: + continue + try: + data = json.loads(raw) + except json.JSONDecodeError: + logger.warning("skills: malformed JSONL line %s in %s", lineno, path) + continue + if not isinstance(data, dict): + logger.warning( + "skills: non-object JSONL line %s in %s (got %s)", + lineno, + path, + type(data).__name__, + ) + continue + rows.append(data) + return rows + + +def load_patterns() -> list[dict[str, Any]]: + """Load owner-wide skill patterns from JSONL.""" + return _load_jsonl_rows(patterns_path()) + + +def load_edit_requests() -> list[dict[str, Any]]: + """Load owner-wide skill edit requests from JSONL.""" + return _load_jsonl_rows(edit_requests_path()) + + +def load_profile(slug: str) -> str | None: + """Load one markdown skill profile, returning None when absent.""" + path = profile_path(slug) + try: + return path.read_text(encoding="utf-8") + except FileNotFoundError: + return None + + +def find_pattern( + slug: str, patterns: list[dict[str, Any]] | None = None +) -> dict[str, Any] | None: + """Return one pattern by slug, or None when not found.""" + rows = load_patterns() if patterns is None else patterns + for row in rows: + if row.get("slug") == slug: + return row + return None + + +def _save_jsonl_rows(path: Path, rows: list[dict[str, Any]]) -> None: + """Write *rows* to *path* as JSONL using an atomic replace.""" + content = "" + if rows: + content = "\n".join(json.dumps(row, ensure_ascii=False) for row in rows) + "\n" + atomic_write(path, content) + + +def save_patterns(rows: list[dict[str, Any]]) -> None: + """Persist owner-wide skill patterns atomically.""" + _save_jsonl_rows(patterns_path(), rows) + + +def save_edit_requests(rows: list[dict[str, Any]]) -> None: + """Persist owner-wide skill edit requests atomically.""" + _save_jsonl_rows(edit_requests_path(), rows) + + +def save_profile(slug: str, markdown: str) -> None: + """Persist one markdown skill profile atomically.""" + atomic_write(profile_path(slug), markdown) + + +def rename_profile(old_slug: str, new_slug: str) -> bool: + """Rename one skill profile file, returning False when the source is absent.""" + source = profile_path(old_slug) + target = profile_path(new_slug) + if not source.exists(): + return False + if target.exists(): + raise FileExistsError(f"profile already exists for slug {new_slug}") + source.rename(target) + return True + + +def locked_modify_patterns( + fn: Callable[[list[dict[str, Any]]], list[dict[str, Any]]], +) -> list[dict[str, Any]]: + """Apply a locked read-modify-write cycle to patterns.jsonl.""" + skills_dir() + lock_path = patterns_lock_path() + # Lock file contents are irrelevant; opening with "w" matches the existing pattern. + with open(lock_path, "w", encoding="utf-8") as lock_file: + fcntl.flock(lock_file, fcntl.LOCK_EX) + try: + rows = load_patterns() + new_rows = fn(rows) + save_patterns(new_rows) + return new_rows + finally: + fcntl.flock(lock_file, fcntl.LOCK_UN) + + +def locked_modify_edit_requests( + fn: Callable[[list[dict[str, Any]]], list[dict[str, Any]]], +) -> list[dict[str, Any]]: + """Apply a locked read-modify-write cycle to edit_requests.jsonl.""" + skills_dir() + lock_path = edit_requests_lock_path() + # Lock file contents are irrelevant; opening with "w" matches the existing pattern. + with open(lock_path, "w", encoding="utf-8") as lock_file: + fcntl.flock(lock_file, fcntl.LOCK_EX) + try: + rows = load_edit_requests() + new_rows = fn(rows) + save_edit_requests(new_rows) + return new_rows + finally: + fcntl.flock(lock_file, fcntl.LOCK_UN) + + +def observation_key(slug: str, day: str, activity_ids: list[str]) -> str: + """Return the deterministic idempotency key for one observation.""" + return f"{slug}|{day}|{','.join(sorted(activity_ids))}" + + +def _utc_compact() -> str: + """Return a compact UTC timestamp for request ids.""" + return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S") + + +def make_request_id() -> str: + """Return a collision-resistant edit request id.""" + return f"req_{_utc_compact()}_{secrets.token_hex(6)}" + + +def utc_now_iso() -> str: + """Return the current UTC time as an ISO-8601 string ending in Z.""" + return ( + datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") + ) + + +def touch_updated(pattern: dict[str, Any]) -> None: + """Update a pattern row's updated_at timestamp in place.""" + pattern["updated_at"] = utc_now_iso()