feat(skills): open skill-management-console change + local skill store
Opens the skill-management-console openspec change (cloud/local skill split with local override) with proposal, design (D1-D11), four delta specs, and tasks. Implements the agent-side persistent local skill store (storage/local_skills.py): authored local skills + cloud-skill overrides in a physically separate SQLite file, with fork-on-revocation. 10 tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,438 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user