Files
agentic-mobile-control/storage/local_skills.py
T
q792602257andClaude Opus 4.6 f8054cb58c
Tests / Test passed: 855
chore(skills): docs + ruff format for skill-management-console
Documents Skill Management in CLOUD_DEPLOYMENT.md (cloud-skill store,
per-host entitlement, incremental sync, local authoring/override,
inventory report, skills:admin scope) and applies ruff check/format to
all touched modules. All tasks complete; full non-integration suite
green (593 passed) and openspec validate --strict passes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-15 08:10:20 +08:00

431 lines
16 KiB
Python

"""Persistent local store for agent-managed skills and cloud-skill overrides.
Storage layer per CONSTITUTION.md: zero HTTP/MCP/LLM dependencies. This store
holds the agent's own authored skills and its local overrides of cloud skills,
in a SQLite file (``tasks/local_skills.sqlite3``) physically separate from the
synced catalog store (``tasks/skills.sqlite3``) and from task metadata. The
synced store's read-only-except-sync contract is preserved: this module never
writes to ``tasks/skills.sqlite3`` and :mod:`storage.skill_catalog` never
writes here.
"""
from __future__ import annotations
import json
import sqlite3
from pathlib import Path
from typing import Any
from uuid import uuid4
from core.models import utc_now
from skills_learning.models import (
FlowTemplateSkill,
KnowledgeSkill,
Skill,
SkillMetadata,
)
# Origin tag persisted on local rows (the read-merge layer derives the
# ``origin`` field shown to the LLM from which store a skill came from, not
# from this value, but we keep it explicit for traceability).
LOCAL_SOURCE = "local"
class LocalSkillStore:
"""SQLite-backed store of the agent's local skills and cloud-skill overrides.
Two tables:
* ``local_skills`` — authored local skills (id, content, ...).
* ``overrides`` — local overrides keyed by the cloud skill id they
shadow; the row carries the overriding content.
"""
def __init__(self, db_path: str | Path = "tasks/local_skills.sqlite3") -> None:
self.db_path = Path(db_path)
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._ensure_schema()
# ------------------------------------------------------------------
# Local skills — read
# ------------------------------------------------------------------
def list_local(self) -> list[SkillMetadata]:
with self._connect() as conn:
rows = conn.execute(
"""
SELECT id, name, description, kind, tags_json, version,
source, created_at, updated_at
FROM local_skills
"""
).fetchall()
metas = [_row_to_metadata(row) for row in rows]
metas.sort(key=lambda m: m.name)
return metas
def search_local(self, query: str) -> list[SkillMetadata]:
like = f"%{query}%"
with self._connect() as conn:
rows = conn.execute(
"""
SELECT id, name, description, kind, tags_json, version,
source, created_at, updated_at
FROM local_skills
WHERE name LIKE ? OR description LIKE ? OR tags_json LIKE ?
""",
(like, like, like),
).fetchall()
return [_row_to_metadata(row) for row in rows]
def get_local(self, skill_id: str) -> KnowledgeSkill | FlowTemplateSkill | None:
with self._connect() as conn:
row = conn.execute(
"SELECT * FROM local_skills WHERE id = ?",
(skill_id,),
).fetchone()
return _row_to_skill(row) if row is not None else None
# ------------------------------------------------------------------
# Local skills — write
# ------------------------------------------------------------------
def create_local(self, skill: Skill) -> KnowledgeSkill | FlowTemplateSkill:
"""Persist ``skill`` as a new local skill with a fresh local id."""
stored = _with_metadata(
skill, id=uuid4().hex, source=LOCAL_SOURCE, reassign_timestamps=True
)
with self._connect() as conn:
self._upsert_skill_row(conn, "local_skills", stored)
return stored
def update_local(self, skill: Skill) -> KnowledgeSkill | FlowTemplateSkill:
existing = self.get_local(skill.id)
if existing is None:
raise KeyError(f"unknown local skill {skill.id}")
stored = _with_metadata(skill, source=LOCAL_SOURCE, updated_at=utc_now())
with self._connect() as conn:
self._upsert_skill_row(conn, "local_skills", stored)
return stored
def delete_local(self, skill_id: str) -> bool:
with self._connect() as conn:
cur = conn.execute("DELETE FROM local_skills WHERE id = ?", (skill_id,))
return cur.rowcount > 0
# ------------------------------------------------------------------
# Overrides — keyed by cloud skill id
# ------------------------------------------------------------------
def has_override(self, cloud_skill_id: str) -> bool:
with self._connect() as conn:
row = conn.execute(
"SELECT 1 FROM overrides WHERE cloud_skill_id = ?",
(cloud_skill_id,),
).fetchone()
return row is not None
def get_override(
self, cloud_skill_id: str
) -> KnowledgeSkill | FlowTemplateSkill | None:
with self._connect() as conn:
row = conn.execute(
"SELECT * FROM overrides WHERE cloud_skill_id = ?",
(cloud_skill_id,),
).fetchone()
return _override_row_to_skill(row) if row is not None else None
def list_override_cloud_ids(self) -> set[str]:
with self._connect() as conn:
rows = conn.execute("SELECT cloud_skill_id FROM overrides").fetchall()
return {row["cloud_skill_id"] for row in rows}
def list_overrides(self) -> list[KnowledgeSkill | FlowTemplateSkill]:
with self._connect() as conn:
rows = conn.execute("SELECT * FROM overrides").fetchall()
return [_override_row_to_skill(row) for row in rows]
def upsert_override(
self, cloud_skill_id: str, skill: Skill
) -> KnowledgeSkill | FlowTemplateSkill:
"""Create or replace the local override shadowing ``cloud_skill_id``."""
stored = _with_metadata(
skill, id=cloud_skill_id, source=LOCAL_SOURCE, reassign_timestamps=True
)
with self._connect() as conn:
self._upsert_override_row(conn, cloud_skill_id, stored)
return stored
def remove_override(self, cloud_skill_id: str) -> bool:
with self._connect() as conn:
cur = conn.execute(
"DELETE FROM overrides WHERE cloud_skill_id = ?",
(cloud_skill_id,),
)
return cur.rowcount > 0
def fork_override_to_local(self, cloud_skill_id: str) -> str | None:
"""Promote an override to a standalone local skill (design D9).
Returns the new local skill id, or ``None`` if no override existed.
The override row is deleted; subsequent reads see a local skill.
"""
with self._connect() as conn:
row = conn.execute(
"SELECT * FROM overrides WHERE cloud_skill_id = ?",
(cloud_skill_id,),
).fetchone()
if row is None:
return None
skill = _override_row_to_skill(row)
local_id = uuid4().hex
stored = _with_metadata(
skill,
id=local_id,
source=LOCAL_SOURCE,
reassign_timestamps=True,
)
self._upsert_skill_row(conn, "local_skills", stored)
conn.execute(
"DELETE FROM overrides WHERE cloud_skill_id = ?",
(cloud_skill_id,),
)
return local_id
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
def _upsert_skill_row(
self, conn: sqlite3.Connection, table: str, skill: Skill
) -> None:
meta = skill.metadata
content, steps_payload, parameters_payload = _skill_payload(skill)
conn.execute(
f"""
INSERT INTO {table} (
id, name, description, kind, tags_json, version, source,
created_at, updated_at, content, steps_json, parameters_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
description = excluded.description,
kind = excluded.kind,
tags_json = excluded.tags_json,
version = excluded.version,
source = excluded.source,
created_at = excluded.created_at,
updated_at = excluded.updated_at,
content = excluded.content,
steps_json = excluded.steps_json,
parameters_json = excluded.parameters_json
""",
(
meta.id,
meta.name,
meta.description,
meta.kind,
json.dumps(list(meta.tags)),
meta.version,
meta.source,
meta.created_at.isoformat(),
meta.updated_at.isoformat(),
content,
json.dumps(steps_payload),
json.dumps(parameters_payload),
),
)
def _upsert_override_row(
self,
conn: sqlite3.Connection,
cloud_skill_id: str,
skill: Skill,
) -> None:
meta = skill.metadata
content, steps_payload, parameters_payload = _skill_payload(skill)
conn.execute(
"""
INSERT INTO overrides (
cloud_skill_id, name, description, kind, tags_json, version,
source, created_at, updated_at, content, steps_json,
parameters_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(cloud_skill_id) DO UPDATE SET
name = excluded.name,
description = excluded.description,
kind = excluded.kind,
tags_json = excluded.tags_json,
version = excluded.version,
source = excluded.source,
updated_at = excluded.updated_at,
content = excluded.content,
steps_json = excluded.steps_json,
parameters_json = excluded.parameters_json
""",
(
cloud_skill_id,
meta.name,
meta.description,
meta.kind,
json.dumps(list(meta.tags)),
meta.version,
meta.source,
meta.created_at.isoformat(),
meta.updated_at.isoformat(),
content,
json.dumps(steps_payload),
json.dumps(parameters_payload),
),
)
def _ensure_schema(self) -> None:
with self._connect() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS local_skills (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL,
tags_json TEXT NOT NULL DEFAULT '[]',
version INTEGER NOT NULL DEFAULT 1,
source TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
steps_json TEXT NOT NULL DEFAULT '[]',
parameters_json TEXT NOT NULL DEFAULT '{}'
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS overrides (
cloud_skill_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL,
tags_json TEXT NOT NULL DEFAULT '[]',
version INTEGER NOT NULL DEFAULT 1,
source TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
steps_json TEXT NOT NULL DEFAULT '[]',
parameters_json TEXT NOT NULL DEFAULT '{}'
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_local_skills_name ON local_skills(name)"
)
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.db_path)
connection.row_factory = sqlite3.Row
return connection
# ----------------------------------------------------------------------
# Helpers
# ----------------------------------------------------------------------
def _skill_payload(
skill: Skill,
) -> tuple[str, list[dict[str, Any]], dict[str, dict[str, Any]]]:
if isinstance(skill, KnowledgeSkill):
return skill.content, [], {}
if isinstance(skill, FlowTemplateSkill):
return (
"",
[step.to_dict() for step in skill.steps],
{name: dict(schema) for name, schema in skill.parameters.items()},
)
return "", [], {}
def _with_metadata(
skill: Skill,
*,
id: str | None = None,
source: str | None = None,
updated_at: Any = None,
reassign_timestamps: bool = False,
) -> KnowledgeSkill | FlowTemplateSkill:
changes: dict[str, Any] = {}
if id is not None:
changes["id"] = id
if source is not None:
changes["source"] = source
now = utc_now()
if reassign_timestamps:
changes["created_at"] = now
changes["updated_at"] = now
elif updated_at is not None:
changes["updated_at"] = updated_at
meta = skill.metadata
new_meta = meta
for key, value in changes.items():
new_meta = _replace_meta(new_meta, key, value)
if isinstance(skill, KnowledgeSkill):
return KnowledgeSkill(metadata=new_meta, content=skill.content)
return FlowTemplateSkill(
metadata=new_meta,
steps=list(skill.steps),
parameters={name: dict(schema) for name, schema in skill.parameters.items()},
)
def _replace_meta(meta: SkillMetadata, key: str, value: Any) -> SkillMetadata:
from dataclasses import replace
return replace(meta, **{key: value})
def _row_to_metadata(row: sqlite3.Row) -> SkillMetadata:
return SkillMetadata.from_dict(
{
"id": row["id"],
"name": row["name"],
"description": row["description"],
"kind": row["kind"],
"tags": json.loads(row["tags_json"] or "[]"),
"version": row["version"],
"source": row["source"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
)
def _row_to_skill(row: sqlite3.Row) -> KnowledgeSkill | FlowTemplateSkill:
return _skill_from_row(row, id_key="id")
def _override_row_to_skill(
row: sqlite3.Row,
) -> KnowledgeSkill | FlowTemplateSkill:
return _skill_from_row(row, id_key="cloud_skill_id")
def _skill_from_row(
row: sqlite3.Row, *, id_key: str
) -> KnowledgeSkill | FlowTemplateSkill:
data = {
"id": row[id_key],
"name": row["name"],
"description": row["description"],
"kind": row["kind"],
"tags": json.loads(row["tags_json"] or "[]"),
"version": row["version"],
"source": row["source"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
"content": row["content"],
"steps": json.loads(row["steps_json"] or "[]"),
"parameters": json.loads(row["parameters_json"] or "{}"),
}
if row["kind"] == "knowledge":
return KnowledgeSkill.from_dict(data)
return FlowTemplateSkill.from_dict(data)