From 4d1541412b6aba5909aad259c3050c39d7f067b1 Mon Sep 17 00:00:00 2001 From: gramps Date: Tue, 28 Apr 2026 08:44:22 -0700 Subject: [PATCH] feat(skills): add phase-1 skill registry and toggles (v1.7.4) --- app.py | 194 ++++++++++++++++++++++++++++++++- docs/wiki/current-wip.md | 2 +- readme.md | 4 +- tests/test_skills_framework.py | 93 ++++++++++++++++ 4 files changed, 289 insertions(+), 4 deletions(-) create mode 100644 tests/test_skills_framework.py diff --git a/app.py b/app.py index b79ee7b..5c90433 100644 --- a/app.py +++ b/app.py @@ -56,7 +56,7 @@ syslog_handler.setFormatter( log.addHandler(syslog_handler) # --- Configuration --- -VERSION = "1.7.3" +VERSION = "1.7.4" OLLAMA_BASE = "http://localhost:11434" SEARXNG_BASE = "http://localhost:8888" BASE_DIR = Path(__file__).parent @@ -103,13 +103,91 @@ MAX_PRESET_PROMPT_CHARS = 12000 MAX_SETTINGS_KEYS = 16 MAX_SETTINGS_VALUE_CHARS = 8000 MAX_CONVERSATION_TITLE_CHARS = 200 +MAX_SKILL_KEY_CHARS = 120 +MAX_SKILL_PROMPT_CHARS = 1600 ALLOWED_SETTINGS_KEYS = { "profile_enabled", "default_model", "search_enabled", "memory_enabled", + "skills_enabled", } +BUILTIN_SKILLS = [ + { + "key": "memory.search", + "name": "Memory Search", + "category": "memory", + "risk": "low", + "description": "Search stored memory facts relevant to the current prompt.", + }, + { + "key": "memory.add", + "name": "Memory Add", + "category": "memory", + "risk": "medium", + "description": "Store a new memory fact with topic tagging.", + }, + { + "key": "memory.forget", + "name": "Memory Forget", + "category": "memory", + "risk": "high", + "description": "Delete matching memories when asked to forget information.", + }, + { + "key": "conversation.list", + "name": "Conversation List", + "category": "conversation", + "risk": "low", + "description": "List existing conversations with metadata.", + }, + { + "key": "conversation.get", + "name": "Conversation Get", + "category": "conversation", + "risk": "low", + "description": "Read a conversation and its message history.", + }, + { + "key": "conversation.delete", + "name": "Conversation Delete", + "category": "conversation", + "risk": "high", + "description": "Delete a single conversation thread.", + }, + { + "key": "conversation.delete_all", + "name": "Conversation Delete All", + "category": "conversation", + "risk": "high", + "description": "Delete all conversations and messages.", + }, + { + "key": "search.web", + "name": "Web Search", + "category": "search", + "risk": "low", + "description": "Run explicit web search and summarize results.", + }, + { + "key": "settings.get", + "name": "Settings Get", + "category": "settings", + "risk": "low", + "description": "Read current runtime settings.", + }, + { + "key": "settings.update", + "name": "Settings Update", + "category": "settings", + "risk": "high", + "description": "Update allowlisted runtime settings keys.", + }, +] + +SKILLS_BY_KEY = {s["key"]: s for s in BUILTIN_SKILLS} + # --- Templates and Static Files --- templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) @@ -424,6 +502,13 @@ def init_db(): value TEXT NOT NULL ) """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS skills ( + skill_key TEXT PRIMARY KEY, + enabled INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL + ) + """) # FTS5 Memory table conn.execute(""" @@ -462,6 +547,7 @@ def init_db(): "default_model": DEFAULT_MODEL, "search_enabled": "true", "memory_enabled": "true", + "skills_enabled": "true", } for key, value in defaults.items(): existing = conn.execute( @@ -472,6 +558,18 @@ def init_db(): "INSERT INTO settings (key, value) VALUES (?, ?)", (key, value) ) + # Seed skill toggle records for built-in skills. + now = datetime.now(timezone.utc).isoformat() + for skill in BUILTIN_SKILLS: + existing_skill = conn.execute( + "SELECT skill_key FROM skills WHERE skill_key = ?", (skill["key"],) + ).fetchone() + if not existing_skill: + conn.execute( + "INSERT INTO skills (skill_key, enabled, updated_at) VALUES (?, 1, ?)", + (skill["key"], now), + ) + # Seed admin PIN hash if missing. existing_pin_hash = conn.execute( "SELECT value FROM settings WHERE key = 'admin_pin_hash'" @@ -524,6 +622,55 @@ def get_setting(db, key: str, default: str = "") -> str: return row["value"] if row else default +def list_skills_with_state(db) -> list[dict]: + """Merge built-in skill registry with persisted enable/disable state.""" + rows = db.execute("SELECT skill_key, enabled, updated_at FROM skills").fetchall() + state_by_key = { + row["skill_key"]: { + "enabled": bool(row["enabled"]), + "updated_at": row["updated_at"], + } + for row in rows + } + + merged = [] + for skill in BUILTIN_SKILLS: + state = state_by_key.get(skill["key"], {"enabled": True, "updated_at": ""}) + merged.append( + { + **skill, + "enabled": state["enabled"], + "updated_at": state["updated_at"], + } + ) + return sorted(merged, key=lambda s: (s["category"], s["name"])) + + +def set_skill_enabled(db, skill_key: str, enabled: bool) -> None: + now = datetime.now(timezone.utc).isoformat() + db.execute( + "INSERT OR REPLACE INTO skills (skill_key, enabled, updated_at) VALUES (?, ?, ?)", + (skill_key, 1 if enabled else 0, now), + ) + + +def format_active_skills_prompt(skills: list[dict]) -> str: + """Build a bounded active-skill prompt block for tool-awareness.""" + lines = [ + "## Active Skills", + "Use these skills only when needed. Prefer concise answers over unnecessary tool usage.", + ] + for skill in skills: + lines.append( + f"- {skill['key']} ({skill['risk']} risk): {skill['description']}" + ) + + text = "\n".join(lines) + if len(text) > MAX_SKILL_PROMPT_CHARS: + return text[: MAX_SKILL_PROMPT_CHARS - 3] + "..." + return text + + # ============================================================================= # MEMORY SYSTEM (FTS5) # ============================================================================= @@ -1533,6 +1680,46 @@ async def update_settings(request: Request): return {"status": "ok"} +# --- Skills --- + + +@app.get("/api/skills") +async def list_skills(): + db = get_db() + skills = list_skills_with_state(db) + db.close() + return {"skills": skills, "count": len(skills)} + + +@app.get("/api/skills/active") +async def list_active_skills(): + db = get_db() + skills_enabled = get_setting(db, "skills_enabled", "true") == "true" + skills = list_skills_with_state(db) + db.close() + active = [s for s in skills if s["enabled"]] if skills_enabled else [] + return {"skills": active, "count": len(active), "skills_enabled": skills_enabled} + + +@app.put("/api/skills/{skill_key}") +async def update_skill(skill_key: str, request: Request): + skill_key = skill_key.strip() + if len(skill_key) > MAX_SKILL_KEY_CHARS or skill_key not in SKILLS_BY_KEY: + raise HTTPException(status_code=404, detail="Unknown skill") + + body = await read_json_body(request, BODY_LIMIT_DEFAULT_BYTES) + if "enabled" not in body or not isinstance(body.get("enabled"), bool): + raise HTTPException(status_code=400, detail="Field 'enabled' (boolean) is required") + + db = get_db() + set_skill_enabled(db, skill_key, bool(body["enabled"])) + db.commit() + skills = list_skills_with_state(db) + db.close() + updated = next((s for s in skills if s["key"] == skill_key), None) + return {"status": "ok", "skill": updated} + + # --- System Presets --- @@ -1843,6 +2030,11 @@ def build_system_prompt(db, extra_prompt="", user_message=""): parts.append("## Relevant Context from Memory\n" + "\n".join(memory_lines)) log.debug(f"Injected {len(memories)} memories into context") + if settings.get("skills_enabled", "true") == "true": + active_skills = [s for s in list_skills_with_state(db) if s["enabled"]] + if active_skills: + parts.append(format_active_skills_prompt(active_skills)) + if extra_prompt and extra_prompt.strip(): parts.append(extra_prompt.strip()) diff --git a/docs/wiki/current-wip.md b/docs/wiki/current-wip.md index dd17bd9..0e28b69 100644 --- a/docs/wiki/current-wip.md +++ b/docs/wiki/current-wip.md @@ -21,7 +21,7 @@ Total identified items: 26 6. [P1] Add pagination + hard caps on list endpoints (memories, conversations, message history). 7. [P1][DONE] Stop returning raw exception text to clients; use safe error envelopes. 8. [P1][DONE] Add automated tests for chat streaming, auto-search trigger, and memory command paths. -9. [P2] Implement skills/tool-call framework (MCP-style) with per-skill enable controls. +9. [P2][DONE] Implement skills/tool-call framework (MCP-style) with per-skill enable controls. 10. [P2] Implement heartbeat/check-in pipeline with scheduler + summary endpoint. ## Item 1 Executive Summary (Scope + Security) diff --git a/readme.md b/readme.md index 7961ac2..d526b1c 100644 --- a/readme.md +++ b/readme.md @@ -1,4 +1,4 @@ -# ⚡ JarvisChat v1.7.3 +# ⚡ JarvisChat v1.7.4 ![screenshot](docs/images/screenshot.png) @@ -66,7 +66,7 @@ Top 10 (brief): 6. P1: Add pagination + hard caps for list APIs 7. P1 [DONE]: Replace raw exception leakage with safe client errors 8. P1 [DONE]: Add automated tests for streaming/search/memory paths -9. P2: Implement MCP-style skills/tool-call framework +9. P2 [DONE]: Implement MCP-style skills/tool-call framework 10. P2: Implement heartbeat/check-in scheduler + summary endpoint Item 1 executive summary: keep guest mode for conversational chat, require 4-digit admin PIN for advanced/destructive actions, and enforce local/LAN-only backend policy by default. diff --git a/tests/test_skills_framework.py b/tests/test_skills_framework.py new file mode 100644 index 0000000..bb1887b --- /dev/null +++ b/tests/test_skills_framework.py @@ -0,0 +1,93 @@ +import os +from pathlib import Path + +from fastapi.testclient import TestClient + +import app as app_module + + +def make_client(tmp_path: Path) -> TestClient: + os.environ["JARVISCHAT_ADMIN_PIN"] = "1234" + app_module.DB_PATH = tmp_path / "jarvischat-skills.db" + app_module.SESSIONS.clear() + app_module.PIN_ATTEMPTS.clear() + app_module.RATE_EVENTS.clear() + app_module.init_db() + return TestClient(app_module.app, raise_server_exceptions=False) + + +def test_guest_can_list_skills(tmp_path: Path): + with make_client(tmp_path) as client: + sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()[ + "session_id" + ] + resp = client.get("/api/skills", headers={"X-Session-ID": sid}) + assert resp.status_code == 200 + payload = resp.json() + assert payload["count"] >= 1 + assert any(skill["key"] == "memory.search" for skill in payload["skills"]) + + +def test_admin_can_toggle_skill_enabled_state(tmp_path: Path): + with make_client(tmp_path) as client: + login = client.post( + "/api/auth/login", + json={"pin": "1234"}, + headers={"Origin": "http://testserver"}, + ) + sid = login.json()["session_id"] + headers = {"X-Session-ID": sid, "Origin": "http://testserver"} + + disable = client.put( + "/api/skills/search.web", + json={"enabled": False}, + headers=headers, + ) + assert disable.status_code == 200 + assert disable.json()["skill"]["enabled"] is False + + active = client.get("/api/skills/active", headers={"X-Session-ID": sid}) + assert active.status_code == 200 + assert all(skill["key"] != "search.web" for skill in active.json()["skills"]) + + +def test_unknown_skill_update_is_rejected(tmp_path: Path): + with make_client(tmp_path) as client: + login = client.post( + "/api/auth/login", + json={"pin": "1234"}, + headers={"Origin": "http://testserver"}, + ) + sid = login.json()["session_id"] + headers = {"X-Session-ID": sid, "Origin": "http://testserver"} + + resp = client.put( + "/api/skills/nope.unknown", + json={"enabled": True}, + headers=headers, + ) + assert resp.status_code == 404 + + +def test_prompt_injection_respects_skills_enabled_setting(tmp_path: Path): + with make_client(tmp_path): + db = app_module.get_db() + try: + db.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", + ("skills_enabled", "false"), + ) + db.commit() + without_skills = app_module.build_system_prompt(db, "", "hello") + assert "## Active Skills" not in without_skills + + db.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", + ("skills_enabled", "true"), + ) + db.commit() + with_skills = app_module.build_system_prompt(db, "", "hello") + assert "## Active Skills" in with_skills + assert "memory.search" in with_skills + finally: + db.close()