Initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
"""YAML-backed allowlist for persisted tool and command approvals."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from fnmatch import fnmatchcase
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def _empty_scope() -> dict[str, dict[str, list[str]]]:
|
||||
return {
|
||||
"tool": {},
|
||||
"external_agent": {},
|
||||
"work_item_projection_title": {},
|
||||
}
|
||||
|
||||
|
||||
def _empty_payload() -> dict[str, Any]:
|
||||
return {
|
||||
"version": 1,
|
||||
"global": _empty_scope(),
|
||||
"projects": {},
|
||||
}
|
||||
|
||||
|
||||
class ApprovalAllowlistManager:
|
||||
"""Persists reusable approval rules in a user-editable YAML file."""
|
||||
|
||||
def __init__(self, opc_home: str | Path) -> None:
|
||||
self.opc_home = Path(opc_home)
|
||||
self.path = self.opc_home / "config" / "approval_allowlist.yaml"
|
||||
|
||||
def ensure_file(self) -> None:
|
||||
if not self.path.exists():
|
||||
self.save(_empty_payload())
|
||||
|
||||
def load(self) -> dict[str, Any]:
|
||||
if not self.path.exists():
|
||||
return _empty_payload()
|
||||
try:
|
||||
raw = yaml.safe_load(self.path.read_text(encoding="utf-8")) or {}
|
||||
except Exception:
|
||||
return _empty_payload()
|
||||
return self._normalize_payload(raw)
|
||||
|
||||
def save(self, payload: dict[str, Any]) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
normalized = self._normalize_payload(payload)
|
||||
self.path.write_text(
|
||||
yaml.safe_dump(
|
||||
normalized,
|
||||
allow_unicode=True,
|
||||
sort_keys=False,
|
||||
default_flow_style=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def list_patterns(
|
||||
self,
|
||||
action_kind: str,
|
||||
action_name: str,
|
||||
project_id: str | None = None,
|
||||
) -> list[str]:
|
||||
payload = self.load()
|
||||
patterns: list[str] = []
|
||||
if project_id:
|
||||
patterns.extend(self._scope_patterns(payload["projects"].get(project_id, {}), action_kind, action_name))
|
||||
patterns.extend(self._scope_patterns(payload["global"], action_kind, action_name))
|
||||
return list(dict.fromkeys(patterns))
|
||||
|
||||
def add_patterns(
|
||||
self,
|
||||
action_kind: str,
|
||||
action_name: str,
|
||||
patterns: list[str],
|
||||
project_id: str | None = None,
|
||||
) -> list[str]:
|
||||
normalized_patterns = [
|
||||
self._normalize_pattern(pattern)
|
||||
for pattern in patterns
|
||||
if self._normalize_pattern(pattern)
|
||||
]
|
||||
if not normalized_patterns:
|
||||
return []
|
||||
|
||||
payload = self.load()
|
||||
scope = payload["global"]
|
||||
if project_id:
|
||||
scope = payload["projects"].setdefault(project_id, _empty_scope())
|
||||
scope = self._normalize_scope(scope)
|
||||
payload["projects"][project_id] = scope
|
||||
|
||||
action_bucket = scope.setdefault(action_kind, {})
|
||||
existing = [
|
||||
self._normalize_pattern(pattern)
|
||||
for pattern in action_bucket.get(action_name, [])
|
||||
if self._normalize_pattern(pattern)
|
||||
]
|
||||
added: list[str] = []
|
||||
for pattern in normalized_patterns:
|
||||
if pattern in existing:
|
||||
continue
|
||||
existing.append(pattern)
|
||||
added.append(pattern)
|
||||
action_bucket[action_name] = existing
|
||||
if added:
|
||||
self.save(payload)
|
||||
return added
|
||||
|
||||
def reset(self, project_id: str | None = None) -> None:
|
||||
payload = self.load()
|
||||
if project_id:
|
||||
payload["projects"].pop(project_id, None)
|
||||
else:
|
||||
payload["global"] = _empty_scope()
|
||||
self.save(payload)
|
||||
|
||||
def is_allowed(
|
||||
self,
|
||||
action_kind: str,
|
||||
action_name: str,
|
||||
candidates: list[str],
|
||||
project_id: str | None = None,
|
||||
) -> tuple[bool, list[str], str | None]:
|
||||
normalized_candidates = [
|
||||
self._normalize_candidate(candidate)
|
||||
for candidate in candidates
|
||||
if self._normalize_candidate(candidate)
|
||||
]
|
||||
if not normalized_candidates:
|
||||
return False, [], None
|
||||
|
||||
payload = self.load()
|
||||
scopes: list[tuple[str | None, dict[str, Any]]] = []
|
||||
if project_id:
|
||||
scopes.append((project_id, payload["projects"].get(project_id, {})))
|
||||
scopes.append((None, payload["global"]))
|
||||
|
||||
for scope_id, scope in scopes:
|
||||
patterns = self._scope_patterns(scope, action_kind, action_name)
|
||||
matched: list[str] = []
|
||||
all_matched = True
|
||||
for candidate in normalized_candidates:
|
||||
candidate_patterns = [
|
||||
pattern
|
||||
for pattern in patterns
|
||||
if self._matches(pattern, candidate)
|
||||
]
|
||||
if not candidate_patterns:
|
||||
all_matched = False
|
||||
break
|
||||
matched.extend(candidate_patterns)
|
||||
if all_matched:
|
||||
return True, list(dict.fromkeys(matched)), scope_id
|
||||
return False, [], None
|
||||
|
||||
def summarize(self, project_id: str | None = None, limit: int = 20) -> list[str]:
|
||||
payload = self.load()
|
||||
lines: list[str] = []
|
||||
if project_id:
|
||||
lines.extend(self._summarize_scope(payload["projects"].get(project_id, {}), scope_label=f"project:{project_id}"))
|
||||
lines.extend(self._summarize_scope(payload["global"], scope_label="global"))
|
||||
return lines[:limit]
|
||||
|
||||
@staticmethod
|
||||
def _normalize_payload(payload: Any) -> dict[str, Any]:
|
||||
data = deepcopy(payload) if isinstance(payload, dict) else {}
|
||||
normalized = _empty_payload()
|
||||
normalized["version"] = int(data.get("version", 1) or 1)
|
||||
normalized["global"] = ApprovalAllowlistManager._normalize_scope(data.get("global", {}))
|
||||
|
||||
projects = data.get("projects", {})
|
||||
if isinstance(projects, dict):
|
||||
for project_id, scope in projects.items():
|
||||
key = str(project_id).strip()
|
||||
if not key:
|
||||
continue
|
||||
normalized["projects"][key] = ApprovalAllowlistManager._normalize_scope(scope)
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _normalize_scope(scope: Any) -> dict[str, dict[str, list[str]]]:
|
||||
normalized = _empty_scope()
|
||||
if not isinstance(scope, dict):
|
||||
return normalized
|
||||
for action_kind, entries in scope.items():
|
||||
kind = str(action_kind).strip()
|
||||
if not kind:
|
||||
continue
|
||||
bucket: dict[str, list[str]] = {}
|
||||
if isinstance(entries, dict):
|
||||
for action_name, patterns in entries.items():
|
||||
name = str(action_name).strip()
|
||||
if not name:
|
||||
continue
|
||||
bucket[name] = ApprovalAllowlistManager._normalize_pattern_list(patterns)
|
||||
normalized[kind] = bucket
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _normalize_pattern_list(patterns: Any) -> list[str]:
|
||||
if isinstance(patterns, str):
|
||||
pattern_list = [patterns]
|
||||
elif isinstance(patterns, list):
|
||||
pattern_list = patterns
|
||||
else:
|
||||
pattern_list = []
|
||||
result: list[str] = []
|
||||
for pattern in pattern_list:
|
||||
normalized = ApprovalAllowlistManager._normalize_pattern(pattern)
|
||||
if normalized:
|
||||
result.append(normalized)
|
||||
return list(dict.fromkeys(result))
|
||||
|
||||
@staticmethod
|
||||
def _normalize_pattern(pattern: Any) -> str:
|
||||
return " ".join(str(pattern).strip().split())
|
||||
|
||||
@staticmethod
|
||||
def _normalize_candidate(candidate: Any) -> str:
|
||||
return " ".join(str(candidate).strip().split()).casefold()
|
||||
|
||||
@staticmethod
|
||||
def _scope_patterns(scope: Any, action_kind: str, action_name: str) -> list[str]:
|
||||
if not isinstance(scope, dict):
|
||||
return []
|
||||
entries = scope.get(action_kind, {})
|
||||
if not isinstance(entries, dict):
|
||||
return []
|
||||
return ApprovalAllowlistManager._normalize_pattern_list(entries.get(action_name, []))
|
||||
|
||||
@staticmethod
|
||||
def _matches(pattern: str, candidate: str) -> bool:
|
||||
normalized_pattern = ApprovalAllowlistManager._normalize_candidate(pattern)
|
||||
if not normalized_pattern or normalized_pattern == "*":
|
||||
return True
|
||||
if any(token in normalized_pattern for token in "*?[]"):
|
||||
return fnmatchcase(candidate, normalized_pattern)
|
||||
return candidate == normalized_pattern or candidate.startswith(normalized_pattern + " ")
|
||||
|
||||
@staticmethod
|
||||
def _summarize_scope(scope: Any, *, scope_label: str) -> list[str]:
|
||||
if not isinstance(scope, dict):
|
||||
return []
|
||||
lines: list[str] = []
|
||||
for action_kind in sorted(scope.keys()):
|
||||
entries = scope.get(action_kind, {})
|
||||
if not isinstance(entries, dict):
|
||||
continue
|
||||
for action_name in sorted(entries.keys()):
|
||||
patterns = ApprovalAllowlistManager._normalize_pattern_list(entries.get(action_name, []))
|
||||
if not patterns:
|
||||
continue
|
||||
joined = ", ".join(patterns[:4])
|
||||
if len(patterns) > 4:
|
||||
joined += ", ..."
|
||||
lines.append(f"- [{scope_label}] {action_kind}:{action_name} -> {joined}")
|
||||
return lines
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Capability manager for local skill discovery (ClawHub replaces remote search)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from opc.core.config import CapabilityConfig, RoleConfig
|
||||
from opc.layer5_memory.skill_library import SkillLibrary, Skill
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillCandidate:
|
||||
name: str
|
||||
description: str = ""
|
||||
source: str = "local"
|
||||
content: str = ""
|
||||
score: float = 0.0
|
||||
domains: list[str] = field(default_factory=list)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class LocalSkillProvider:
|
||||
"""Searches the existing local skill library by keyword matching."""
|
||||
|
||||
def __init__(self, skill_library: SkillLibrary) -> None:
|
||||
self.skill_library = skill_library
|
||||
|
||||
def search(self, query: str, limit: int = 5) -> list[SkillCandidate]:
|
||||
query_terms = [term for term in re.split(r"\W+", query.lower()) if term]
|
||||
candidates: list[tuple[float, Skill]] = []
|
||||
for skill in self.skill_library.list_skills():
|
||||
haystack = " ".join(
|
||||
[skill.name.lower(), skill.description.lower(), skill.content[:1200].lower()]
|
||||
)
|
||||
score = 0.0
|
||||
for term in query_terms:
|
||||
if term in haystack:
|
||||
score += 1.0
|
||||
if skill.always:
|
||||
score += 0.25
|
||||
if score > 0:
|
||||
candidates.append((score, skill))
|
||||
candidates.sort(key=lambda item: item[0], reverse=True)
|
||||
return [
|
||||
SkillCandidate(
|
||||
name=skill.name,
|
||||
description=skill.description,
|
||||
source="local",
|
||||
content=skill.content,
|
||||
score=score,
|
||||
metadata={"source_path": skill.source_path, "level": skill.level},
|
||||
)
|
||||
for score, skill in candidates[:limit]
|
||||
]
|
||||
|
||||
|
||||
class CapabilityManager:
|
||||
"""Unified capability discovery across local skills and tools.
|
||||
|
||||
Remote skill search is handled by the ClawHub skill (always loaded) which
|
||||
instructs the agent to use ``npx clawhub`` via ``shell_exec``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: CapabilityConfig,
|
||||
skill_library: SkillLibrary,
|
||||
tool_registry: Any | None = None,
|
||||
adapter_registry: Any | None = None,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.skill_library = skill_library
|
||||
self.tool_registry = tool_registry
|
||||
self.adapter_registry = adapter_registry
|
||||
self.local_skills = LocalSkillProvider(skill_library)
|
||||
async def search_skills(self, query: str, domains: list[str] | None = None, limit: int = 5) -> list[SkillCandidate]:
|
||||
return self.local_skills.search(query, limit=limit)
|
||||
|
||||
def list_attachable_tools(self) -> list[dict[str, Any]]:
|
||||
if not self.tool_registry:
|
||||
return []
|
||||
return [
|
||||
{"name": tool.name, "description": tool.description, "category": tool.category}
|
||||
for tool in self.tool_registry.list_tools()
|
||||
]
|
||||
|
||||
def list_external_agents(self) -> list[dict[str, Any]]:
|
||||
if not self.adapter_registry:
|
||||
return []
|
||||
return self.adapter_registry.describe_available()
|
||||
|
||||
def build_catalog_summary(self) -> str:
|
||||
parts: list[str] = []
|
||||
|
||||
local_skills = self.skill_library.list_skills()
|
||||
if local_skills:
|
||||
skill_lines = [f"- {skill.name}: {skill.description or 'No description'}" for skill in local_skills[:10]]
|
||||
parts.append("## Local Skills\n" + "\n".join(skill_lines))
|
||||
|
||||
tools = self.list_attachable_tools()
|
||||
if tools:
|
||||
tool_lines = [f"- {tool['name']} ({tool['category']}): {tool['description']}" for tool in tools[:12]]
|
||||
parts.append("## Available Tools\n" + "\n".join(tool_lines))
|
||||
|
||||
agents = self.list_external_agents()
|
||||
if agents:
|
||||
agent_lines = []
|
||||
for agent in agents[:8]:
|
||||
agent_lines.append(
|
||||
f"- {agent.get('agent')}: model={agent.get('model')} run_mode={agent.get('run_mode')} session_mode={agent.get('session_mode')}"
|
||||
)
|
||||
parts.append("## External Agents\n" + "\n".join(agent_lines))
|
||||
|
||||
return "\n\n".join(parts)
|
||||
|
||||
async def build_recovery_context(self, query: str, domains: list[str] | None = None) -> tuple[str, list[SkillCandidate]]:
|
||||
candidates = await self.search_skills(query, domains=domains, limit=self.config.max_remote_skill_results)
|
||||
if not candidates:
|
||||
return "", []
|
||||
lines: list[str] = []
|
||||
for candidate in candidates:
|
||||
body = candidate.content.strip()
|
||||
if not body:
|
||||
body = candidate.description
|
||||
lines.append(
|
||||
f"### Skill Candidate: {candidate.name} [{candidate.source}]\n"
|
||||
f"{body.strip()}"
|
||||
)
|
||||
return "## Capability Recovery\n" + "\n\n".join(lines), candidates
|
||||
@@ -0,0 +1,998 @@
|
||||
"""Employee experience tracking, project reflections, and learned-skill distillation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import json
|
||||
import hashlib
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from opc.core.config import validate_organization_id
|
||||
from opc.core.models import TaskStatus
|
||||
from opc.layer2_organization.work_item_identity import projection_id_for_task
|
||||
from opc.layer5_memory.preference import PreferenceManager
|
||||
from opc.layer5_memory.skill_library import Skill, SkillLibrary
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
||||
|
||||
|
||||
class EmployeeEvolutionManager:
|
||||
"""Tracks employee outcomes, project reflections, and learned skills."""
|
||||
|
||||
LEARNED_SKILL_THRESHOLD = 2
|
||||
EMPLOYEE_EXPERIENCE_SCHEMA_VERSION = 1
|
||||
|
||||
def __init__(self, opc_home) -> None:
|
||||
self.opc_home = Path(opc_home)
|
||||
self.preferences = PreferenceManager(self.opc_home)
|
||||
self.skills = SkillLibrary(self.opc_home)
|
||||
self.skills.load_all()
|
||||
|
||||
def evolution_profile_path(self, project_id: str | None = None) -> Path:
|
||||
"""Return the structured company-evolution state path.
|
||||
|
||||
Employee evolution is runtime/company state, not user/project durable
|
||||
memory. Keeping it outside ``memory/*.md`` lets company mode retain
|
||||
experience scoring and learned playbooks without reintroducing hidden
|
||||
user preference writes.
|
||||
"""
|
||||
project = str(project_id or "").strip()
|
||||
if project:
|
||||
return self.opc_home / "projects" / project / "employee_evolution.json"
|
||||
return self.opc_home / "evolution" / "employees.json"
|
||||
|
||||
def load_evolution_profile(self, project_id: str | None = None) -> dict[str, Any]:
|
||||
path = self.evolution_profile_path(project_id)
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def save_evolution_profile(self, profile: dict[str, Any], project_id: str | None = None) -> None:
|
||||
path = self.evolution_profile_path(project_id)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(dict(profile or {}), ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def employee_experience_dir(self, organization_id: str) -> Path:
|
||||
org_id = validate_organization_id(organization_id)
|
||||
return self.opc_home / "company_state" / org_id / "employee_experience"
|
||||
|
||||
def employee_experience_path(self, organization_id: str, employee_id: str) -> Path:
|
||||
safe_id = self._safe_employee_filename(employee_id)
|
||||
return self.employee_experience_dir(organization_id) / f"{safe_id}.json"
|
||||
|
||||
def load_employee_experience(self, organization_id: str, employee_id: str) -> dict[str, Any]:
|
||||
path = self.employee_experience_path(organization_id, employee_id)
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def save_employee_experience(self, organization_id: str, employee_id: str, profile: dict[str, Any]) -> None:
|
||||
path = self.employee_experience_path(organization_id, employee_id)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(dict(profile or {}), ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def get_employee_profile(self, employee_id: str, project_id: str | None = None) -> dict[str, Any]:
|
||||
global_profile = self.load_evolution_profile()
|
||||
project_profile = self.load_evolution_profile(project_id) if project_id else {}
|
||||
global_data = dict(global_profile.get("employees", {}).get(employee_id, {}))
|
||||
project_data = dict(project_profile.get("employees", {}).get(employee_id, {}))
|
||||
return self._deep_merge(global_data, project_data)
|
||||
|
||||
def get_learned_skill_refs(self, employee_id: str, project_id: str | None = None) -> list[str]:
|
||||
profile = self.get_employee_profile(employee_id, project_id)
|
||||
refs = list(profile.get("learned_skill_refs", []))
|
||||
unique: list[str] = []
|
||||
for ref in refs:
|
||||
if ref and ref not in unique:
|
||||
unique.append(ref)
|
||||
return unique
|
||||
|
||||
def build_employee_delta_context(
|
||||
self,
|
||||
employee_id: str,
|
||||
project_id: str | None = None,
|
||||
organization_id: str | None = None,
|
||||
) -> str:
|
||||
profile = self.get_employee_profile(employee_id, project_id)
|
||||
delta = dict(profile.get("delta_profile", {}))
|
||||
experience_profile: dict[str, Any] = {}
|
||||
experience_delta: dict[str, Any] = {}
|
||||
if organization_id:
|
||||
experience_profile = self.load_employee_experience(organization_id, employee_id)
|
||||
experience_delta = dict(experience_profile.get("delta_profile", {}) or {})
|
||||
if not delta and not experience_delta:
|
||||
return ""
|
||||
|
||||
parts: list[str] = []
|
||||
evolution_count = int(experience_profile.get("evolution_count", 0) or len(experience_profile.get("events", []) or []))
|
||||
if evolution_count:
|
||||
parts.append(f"Self-evolution reviews: {evolution_count}")
|
||||
projects_reflected = int(profile.get("projects_reflected", 0))
|
||||
if projects_reflected:
|
||||
parts.append(f"Reflected projects: {projects_reflected}")
|
||||
|
||||
for title, key in (
|
||||
("Self-Evolved Strengths", "strengths"),
|
||||
("Self-Evolved Adjustments", "adjustments"),
|
||||
("Self-Evolved Watchouts", "avoid_next_time"),
|
||||
("Self-Evolved Routing Notes", "routing_notes"),
|
||||
):
|
||||
values = [str(item).strip() for item in list(experience_delta.get(key, []) or []) if str(item).strip()]
|
||||
if values:
|
||||
parts.append(f"## {title}\n" + "\n".join(f"- {item}" for item in values[:6]))
|
||||
|
||||
for title, key in (
|
||||
("Working Patterns", "working_patterns"),
|
||||
("Default Checklists", "default_checklists"),
|
||||
("Reviewer Preferences", "reviewer_preferences"),
|
||||
("Risk Watchouts", "risk_watchouts"),
|
||||
("Tool Preferences", "tool_preferences"),
|
||||
("Fit Domains", "fit_domains"),
|
||||
("Avoid Domains", "avoid_domains"),
|
||||
):
|
||||
values = [str(item).strip() for item in delta.get(key, []) if str(item).strip()]
|
||||
if values:
|
||||
parts.append(f"## {title}\n" + "\n".join(f"- {item}" for item in values[:6]))
|
||||
|
||||
return "\n\n".join(parts)
|
||||
|
||||
def get_experience_score(
|
||||
self,
|
||||
employee_id: str,
|
||||
role_id: str,
|
||||
domains: list[str] | None = None,
|
||||
project_id: str | None = None,
|
||||
organization_id: str | None = None,
|
||||
) -> float:
|
||||
profile = self.get_employee_profile(employee_id, project_id)
|
||||
total_successes = int(profile.get("successes", 0))
|
||||
total_partials = int(profile.get("partial_successes", 0))
|
||||
total_failures = int(profile.get("failures", 0))
|
||||
role_successes = int(profile.get("roles", {}).get(role_id, {}).get("successes", 0))
|
||||
role_partials = int(profile.get("roles", {}).get(role_id, {}).get("partial_successes", 0))
|
||||
role_failures = int(profile.get("roles", {}).get(role_id, {}).get("failures", 0))
|
||||
learned_bonus = 2 * len(profile.get("learned_skill_refs", []))
|
||||
reflection_bonus = min(4, int(profile.get("projects_reflected", 0)))
|
||||
domain_bonus = 0
|
||||
for domain in domains or []:
|
||||
domain_record = profile.get("domains", {}).get(domain, {})
|
||||
domain_bonus += int(domain_record.get("successes", 0))
|
||||
domain_bonus += 0.5 * int(domain_record.get("partial_successes", 0))
|
||||
domain_bonus -= 0.25 * int(domain_record.get("failures", 0))
|
||||
score = (
|
||||
total_successes
|
||||
+ (0.5 * total_partials)
|
||||
- (0.25 * total_failures)
|
||||
+ (2 * role_successes)
|
||||
+ role_partials
|
||||
- (0.5 * role_failures)
|
||||
+ domain_bonus
|
||||
+ learned_bonus
|
||||
+ reflection_bonus
|
||||
)
|
||||
if organization_id:
|
||||
experience_profile = self.load_employee_experience(organization_id, employee_id)
|
||||
events = [item for item in list(experience_profile.get("events", []) or []) if isinstance(item, dict)]
|
||||
score += min(8.0, float(len(events)))
|
||||
for event in events[-8:]:
|
||||
if str(event.get("role_id", "") or "").strip() == role_id:
|
||||
score += 0.5
|
||||
return float(max(0.0, score))
|
||||
|
||||
def apply_employee_evolution_patch(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
patch: dict[str, Any],
|
||||
source: dict[str, Any] | None = None,
|
||||
allowed_employee_ids: set[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
org_id = validate_organization_id(organization_id)
|
||||
source_payload = dict(source or {})
|
||||
raw_events = list(patch.get("patches", []) or [])
|
||||
allowed = {str(item).strip() for item in (allowed_employee_ids or set()) if str(item).strip()}
|
||||
recorded: list[dict[str, Any]] = []
|
||||
|
||||
for raw_event in raw_events:
|
||||
if not isinstance(raw_event, dict):
|
||||
continue
|
||||
employee_id = str(raw_event.get("employee_id", "") or "").strip()
|
||||
if not employee_id or (allowed and employee_id not in allowed):
|
||||
continue
|
||||
event = self._normalize_self_evolution_event(raw_event, source_payload)
|
||||
if not event:
|
||||
continue
|
||||
|
||||
profile = self.load_employee_experience(org_id, employee_id)
|
||||
if not profile:
|
||||
profile = {
|
||||
"schema_version": self.EMPLOYEE_EXPERIENCE_SCHEMA_VERSION,
|
||||
"kind": "company_employee_experience",
|
||||
"organization_id": org_id,
|
||||
"employee_id": employee_id,
|
||||
"events": [],
|
||||
"delta_profile": {},
|
||||
"evolution_count": 0,
|
||||
}
|
||||
profile["schema_version"] = self.EMPLOYEE_EXPERIENCE_SCHEMA_VERSION
|
||||
profile["kind"] = "company_employee_experience"
|
||||
profile["organization_id"] = org_id
|
||||
profile["employee_id"] = employee_id
|
||||
events = [item for item in list(profile.get("events", []) or []) if isinstance(item, dict)]
|
||||
event_id = str(event.get("event_id", "") or "").strip()
|
||||
if event_id and any(str(item.get("event_id", "") or "").strip() == event_id for item in events):
|
||||
continue
|
||||
events.append(event)
|
||||
profile["events"] = events[-100:]
|
||||
profile["evolution_count"] = int(profile.get("evolution_count", 0) or 0) + 1
|
||||
profile["updated_at"] = _utc_now()
|
||||
profile["delta_profile"] = self._build_self_evolution_delta(profile["events"])
|
||||
self.save_employee_experience(org_id, employee_id, profile)
|
||||
recorded.append({
|
||||
"employee_id": employee_id,
|
||||
"event_id": event_id,
|
||||
"status": "recorded",
|
||||
"path": str(self.employee_experience_path(org_id, employee_id)),
|
||||
})
|
||||
|
||||
return recorded
|
||||
|
||||
def record_work_item_completion(
|
||||
self,
|
||||
task: Any,
|
||||
result_content: str,
|
||||
*,
|
||||
outcome: str = "success",
|
||||
feedback_summary: str = "",
|
||||
strengths: list[str] | None = None,
|
||||
weaknesses: list[str] | None = None,
|
||||
rationale: str = "",
|
||||
) -> dict[str, Any]:
|
||||
assignment = dict(getattr(task, "metadata", {}).get("employee_assignment", {}) or {})
|
||||
employee_id = str(assignment.get("employee_id", "")).strip()
|
||||
if not employee_id:
|
||||
return {}
|
||||
|
||||
role_id = str(
|
||||
assignment.get("role_id")
|
||||
or getattr(task, "assigned_to", "")
|
||||
or getattr(task, "metadata", {}).get("work_item_role_id", "")
|
||||
).strip()
|
||||
domains = list(assignment.get("domains") or getattr(task, "tags", []) or [])
|
||||
project_id = getattr(task, "project_id", None) or None
|
||||
summary = str(getattr(task, "metadata", {}).get("work_item_summary_for_downstream", "") or result_content).strip()
|
||||
pattern_key = self._pattern_key(role_id, domains)
|
||||
normalized_outcome = self._normalize_outcome(outcome)
|
||||
base_payload = {
|
||||
"employee_id": employee_id,
|
||||
"employee_name": assignment.get("name", ""),
|
||||
"role_id": role_id,
|
||||
"template_id": assignment.get("template_id", ""),
|
||||
"category": assignment.get("category", ""),
|
||||
}
|
||||
|
||||
global_profile = self.load_evolution_profile()
|
||||
self._record_in_profile(
|
||||
global_profile,
|
||||
base_payload=base_payload,
|
||||
role_id=role_id,
|
||||
domains=domains,
|
||||
pattern_key=pattern_key,
|
||||
summary=summary,
|
||||
outcome=normalized_outcome,
|
||||
feedback_summary=feedback_summary,
|
||||
strengths=list(strengths or []),
|
||||
weaknesses=list(weaknesses or []),
|
||||
rationale=rationale,
|
||||
)
|
||||
self.save_evolution_profile(global_profile)
|
||||
|
||||
if project_id:
|
||||
project_profile = self.load_evolution_profile(project_id)
|
||||
self._record_in_profile(
|
||||
project_profile,
|
||||
base_payload=base_payload,
|
||||
role_id=role_id,
|
||||
domains=domains,
|
||||
pattern_key=pattern_key,
|
||||
summary=summary,
|
||||
outcome=normalized_outcome,
|
||||
feedback_summary=feedback_summary,
|
||||
strengths=list(strengths or []),
|
||||
weaknesses=list(weaknesses or []),
|
||||
rationale=rationale,
|
||||
)
|
||||
self.save_evolution_profile(project_profile, project_id)
|
||||
|
||||
return {
|
||||
"employee_id": employee_id,
|
||||
"project_id": project_id or "",
|
||||
"pattern_key": pattern_key,
|
||||
"outcome": normalized_outcome,
|
||||
}
|
||||
|
||||
def record_project_reflections(
|
||||
self,
|
||||
delivery_task: Any,
|
||||
work_item_tasks: list[Any],
|
||||
partial: bool = False,
|
||||
*,
|
||||
feedback: dict[str, Any] | None = None,
|
||||
evaluation: dict[str, Any] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
project_id = str(getattr(delivery_task, "project_id", "") or "").strip()
|
||||
delivery_task_id = str(getattr(delivery_task, "id", "") or "").strip()
|
||||
if not project_id or not delivery_task_id:
|
||||
return []
|
||||
|
||||
tasks = self._normalize_work_item_tasks(work_item_tasks, delivery_task)
|
||||
terminal = {TaskStatus.DONE, TaskStatus.FAILED, TaskStatus.CANCELLED}
|
||||
employee_groups: dict[str, list[Any]] = {}
|
||||
for task in tasks:
|
||||
if getattr(task, "status", None) not in terminal:
|
||||
continue
|
||||
assignment = dict(getattr(task, "metadata", {}).get("employee_assignment", {}) or {})
|
||||
employee_id = str(assignment.get("employee_id", "")).strip()
|
||||
if not employee_id:
|
||||
continue
|
||||
employee_groups.setdefault(employee_id, []).append(task)
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
for employee_id, employee_tasks in employee_groups.items():
|
||||
reflection = self._build_project_reflection(
|
||||
delivery_task=delivery_task,
|
||||
employee_tasks=employee_tasks,
|
||||
project_id=project_id,
|
||||
partial=partial,
|
||||
feedback=feedback,
|
||||
evaluation=evaluation,
|
||||
)
|
||||
reflection_path = self.evolution_profile_path(project_id)
|
||||
|
||||
global_profile = self.load_evolution_profile()
|
||||
self._record_project_reflection_in_profile(
|
||||
global_profile,
|
||||
reflection=reflection,
|
||||
reflection_path=reflection_path,
|
||||
)
|
||||
learned_skill_ref = self._maybe_promote_reflection_skill(global_profile, reflection, project_id=project_id)
|
||||
self.save_evolution_profile(global_profile)
|
||||
|
||||
project_profile = self.load_evolution_profile(project_id)
|
||||
self._record_project_reflection_in_profile(
|
||||
project_profile,
|
||||
reflection=reflection,
|
||||
reflection_path=reflection_path,
|
||||
)
|
||||
if learned_skill_ref:
|
||||
self._attach_learned_skill(
|
||||
project_profile,
|
||||
reflection["employee_id"],
|
||||
reflection["pattern_key"],
|
||||
learned_skill_ref,
|
||||
)
|
||||
else:
|
||||
learned_skill_ref = self._maybe_promote_reflection_skill(project_profile, reflection, project_id=project_id)
|
||||
self.save_evolution_profile(project_profile, project_id)
|
||||
|
||||
results.append({
|
||||
"employee_id": employee_id,
|
||||
"reflection_path": str(reflection_path),
|
||||
"learned_skill_ref": learned_skill_ref,
|
||||
"status": "recorded",
|
||||
"reflection": reflection,
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
def _record_in_profile(
|
||||
self,
|
||||
profile: dict[str, Any],
|
||||
*,
|
||||
base_payload: dict[str, Any],
|
||||
role_id: str,
|
||||
domains: list[str],
|
||||
pattern_key: str,
|
||||
summary: str,
|
||||
outcome: str,
|
||||
feedback_summary: str,
|
||||
strengths: list[str],
|
||||
weaknesses: list[str],
|
||||
rationale: str,
|
||||
) -> None:
|
||||
employees = profile.setdefault("employees", {})
|
||||
record = employees.setdefault(base_payload["employee_id"], {})
|
||||
record.update({
|
||||
"employee_name": base_payload.get("employee_name", ""),
|
||||
"role_id": role_id,
|
||||
"template_id": base_payload.get("template_id", ""),
|
||||
"category": base_payload.get("category", ""),
|
||||
"updated_at": _utc_now(),
|
||||
})
|
||||
self._increment_outcome_counts(record, outcome)
|
||||
record["last_outcome"] = outcome
|
||||
if feedback_summary:
|
||||
record["last_feedback_summary"] = feedback_summary
|
||||
if rationale:
|
||||
record["last_feedback_rationale"] = rationale
|
||||
roles = record.setdefault("roles", {})
|
||||
role_record = roles.setdefault(role_id, {"successes": 0, "partial_successes": 0, "failures": 0})
|
||||
self._increment_outcome_counts(role_record, outcome)
|
||||
role_record["last_outcome"] = outcome
|
||||
domain_records = record.setdefault("domains", {})
|
||||
for domain in domains:
|
||||
domain_record = domain_records.setdefault(domain, {"successes": 0, "partial_successes": 0, "failures": 0})
|
||||
self._increment_outcome_counts(domain_record, outcome)
|
||||
domain_record["last_outcome"] = outcome
|
||||
patterns = record.setdefault("patterns", {})
|
||||
pattern = patterns.setdefault(pattern_key, self._base_pattern_record(role_id, domains))
|
||||
self._increment_outcome_counts(pattern, outcome)
|
||||
pattern["last_summary"] = summary
|
||||
pattern["last_outcome"] = outcome
|
||||
pattern["last_feedback_summary"] = feedback_summary
|
||||
pattern["last_feedback_rationale"] = rationale
|
||||
if outcome == "success":
|
||||
pattern["last_success_at"] = _utc_now()
|
||||
elif outcome == "partial_success":
|
||||
pattern["last_partial_success_at"] = _utc_now()
|
||||
else:
|
||||
pattern["last_failure_at"] = _utc_now()
|
||||
self._increment_counts(record, "working_pattern_counts", strengths)
|
||||
self._increment_counts(record, "risk_watchout_counts", weaknesses)
|
||||
self._increment_counts(pattern, "working_pattern_counts", strengths)
|
||||
self._increment_counts(pattern, "risk_watchout_counts", weaknesses)
|
||||
record["delta_profile"] = self._build_delta_profile(record)
|
||||
record.setdefault("learned_skill_refs", [])
|
||||
|
||||
def _record_project_reflection_in_profile(
|
||||
self,
|
||||
profile: dict[str, Any],
|
||||
*,
|
||||
reflection: dict[str, Any],
|
||||
reflection_path: Path,
|
||||
) -> None:
|
||||
employees = profile.setdefault("employees", {})
|
||||
employee_id = str(reflection.get("employee_id", "")).strip()
|
||||
if not employee_id:
|
||||
return
|
||||
record = employees.setdefault(employee_id, {})
|
||||
record.update({
|
||||
"employee_name": reflection.get("employee_name", ""),
|
||||
"role_id": reflection.get("role_id", ""),
|
||||
"template_id": reflection.get("template_id", ""),
|
||||
"category": reflection.get("category", ""),
|
||||
"updated_at": _utc_now(),
|
||||
"last_reflection_at": _utc_now(),
|
||||
})
|
||||
reflection_ids = record.setdefault("project_reflection_ids", [])
|
||||
if reflection["delivery_task_id"] in reflection_ids:
|
||||
return
|
||||
reflection_ids.append(reflection["delivery_task_id"])
|
||||
reflection_paths = record.setdefault("reflection_paths", [])
|
||||
reflection_paths.append(str(reflection_path))
|
||||
project_ids = record.setdefault("project_ids", [])
|
||||
if reflection["project_id"] not in project_ids:
|
||||
project_ids.append(reflection["project_id"])
|
||||
record["projects_reflected"] = len(project_ids)
|
||||
|
||||
patterns = record.setdefault("patterns", {})
|
||||
pattern_key = str(reflection.get("pattern_key", ""))
|
||||
pattern = patterns.setdefault(
|
||||
pattern_key,
|
||||
self._base_pattern_record(
|
||||
str(reflection.get("role_id", "")).strip(),
|
||||
list(reflection.get("domains", [])),
|
||||
),
|
||||
)
|
||||
pattern["role_id"] = reflection.get("role_id", "")
|
||||
pattern["domains"] = list(reflection.get("domains", []))
|
||||
pattern["reflection_count"] = int(pattern.get("reflection_count", 0)) + 1
|
||||
pattern["latest_project_summary"] = str(reflection.get("project_summary", ""))
|
||||
pattern["last_reflection_at"] = _utc_now()
|
||||
project_reflection_paths = pattern.setdefault("reflection_paths", [])
|
||||
project_reflection_paths.append(str(reflection_path))
|
||||
|
||||
self._increment_counts(pattern, "working_pattern_counts", reflection.get("what_worked", []))
|
||||
self._increment_counts(pattern, "checklist_counts", reflection.get("reusable_checklist", []))
|
||||
self._increment_counts(pattern, "reviewer_preference_counts", reflection.get("reviewer_preferences", []))
|
||||
self._increment_counts(pattern, "tool_preference_counts", reflection.get("tool_preferences", []))
|
||||
self._increment_counts(pattern, "risk_watchout_counts", reflection.get("mistakes_to_avoid", []))
|
||||
self._increment_counts(pattern, "fit_domain_counts", reflection.get("suitable_for", []))
|
||||
self._increment_counts(pattern, "avoid_domain_counts", reflection.get("avoid_for", []))
|
||||
|
||||
self._increment_counts(record, "working_pattern_counts", reflection.get("what_worked", []))
|
||||
self._increment_counts(record, "checklist_counts", reflection.get("reusable_checklist", []))
|
||||
self._increment_counts(record, "reviewer_preference_counts", reflection.get("reviewer_preferences", []))
|
||||
self._increment_counts(record, "tool_preference_counts", reflection.get("tool_preferences", []))
|
||||
self._increment_counts(record, "risk_watchout_counts", reflection.get("mistakes_to_avoid", []))
|
||||
self._increment_counts(record, "fit_domain_counts", reflection.get("suitable_for", []))
|
||||
self._increment_counts(record, "avoid_domain_counts", reflection.get("avoid_for", []))
|
||||
record["delta_profile"] = self._build_delta_profile(record)
|
||||
record.setdefault("learned_skill_refs", [])
|
||||
|
||||
def _build_project_reflection(
|
||||
self,
|
||||
*,
|
||||
delivery_task: Any,
|
||||
employee_tasks: list[Any],
|
||||
project_id: str,
|
||||
partial: bool = False,
|
||||
feedback: dict[str, Any] | None = None,
|
||||
evaluation: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
first_assignment = dict(employee_tasks[0].metadata.get("employee_assignment", {}) or {})
|
||||
employee_id = str(first_assignment.get("employee_id", "")).strip()
|
||||
role_id = str(first_assignment.get("role_id") or getattr(employee_tasks[0], "assigned_to", "") or "").strip()
|
||||
domains = self._collect_domains(employee_tasks, fallback=list(first_assignment.get("domains", [])))
|
||||
pattern_key = self._pattern_key(role_id, domains)
|
||||
task_summaries = self._collect_task_summaries(employee_tasks)
|
||||
artifacts = self._collect_items(employee_tasks, "artifacts")
|
||||
decisions = self._collect_items(employee_tasks, "decisions")
|
||||
risks = self._collect_items(employee_tasks, "risks")
|
||||
open_questions = self._collect_items(employee_tasks, "open_questions")
|
||||
preferred_agents = self._collect_preferred_agents(employee_tasks, first_assignment)
|
||||
employee_feedback = self._find_employee_feedback(employee_id, evaluation)
|
||||
historical_context = self.build_employee_delta_context(employee_id, project_id=project_id)
|
||||
feedback_summary = str((evaluation or {}).get("summary", "") or (feedback or {}).get("raw_feedback", "")).strip()
|
||||
|
||||
what_worked = [
|
||||
"Break work into explicit deliverables and leave concise handoff summaries.",
|
||||
"Preserve reviewer-friendly artifacts so downstream validation is faster.",
|
||||
]
|
||||
if decisions:
|
||||
what_worked.append("Capture explicit implementation decisions that downstream reviewers can verify.")
|
||||
if artifacts:
|
||||
what_worked.append("Reference exact artifact paths or outputs in every handoff.")
|
||||
|
||||
reusable_checklist = [
|
||||
"State the objective and completion summary explicitly in the handoff.",
|
||||
"Leave reviewer-friendly artifacts for the next work item.",
|
||||
]
|
||||
if decisions:
|
||||
reusable_checklist.append("Record key decisions that affect downstream execution.")
|
||||
if artifacts:
|
||||
reusable_checklist.append("List concrete artifact references for changed outputs.")
|
||||
if any(domain in {"coding", "api", "backend", "frontend", "devops"} for domain in domains):
|
||||
reusable_checklist.append("Include validation or test evidence before requesting review.")
|
||||
|
||||
reviewer_preferences = [
|
||||
"Make decisions, risks, and artifact references explicit for review.",
|
||||
]
|
||||
if risks or open_questions:
|
||||
reviewer_preferences.append("Flag unresolved risks and open questions before requesting approval.")
|
||||
if artifacts:
|
||||
reviewer_preferences.append("Point reviewers to exact changed files or deliverables.")
|
||||
|
||||
tool_preferences = [
|
||||
f"Prefer external agent `{agent}` for similar `{role_id}` work."
|
||||
for agent in preferred_agents
|
||||
]
|
||||
|
||||
mistakes_to_avoid = list(risks[:4])
|
||||
if open_questions:
|
||||
mistakes_to_avoid.extend(item for item in open_questions[:2] if item not in mistakes_to_avoid)
|
||||
if not mistakes_to_avoid:
|
||||
mistakes_to_avoid.append("Avoid handoffs that omit concrete artifacts, risks, or validation notes.")
|
||||
what_worked.extend(str(item).strip() for item in list(employee_feedback.get("strengths", [])) if str(item).strip())
|
||||
mistakes_to_avoid.extend(str(item).strip() for item in list(employee_feedback.get("weaknesses", [])) if str(item).strip())
|
||||
if feedback_summary:
|
||||
reviewer_preferences.append(f"Carry forward user feedback themes: {feedback_summary}")
|
||||
|
||||
suitable_for = list(domains or [role_id])
|
||||
avoid_for = self._infer_avoid_domains(mistakes_to_avoid)
|
||||
confidence = round(min(0.95, 0.55 + (0.08 * len(employee_tasks))), 2)
|
||||
|
||||
failed_tasks = [
|
||||
t for t in employee_tasks
|
||||
if getattr(t, "status", None) != TaskStatus.DONE
|
||||
]
|
||||
failures: list[dict[str, str]] = []
|
||||
if failed_tasks:
|
||||
for t in failed_tasks:
|
||||
reason = str(getattr(t, "metadata", {}).get("failure_reason", ""))
|
||||
failures.append({
|
||||
"task": getattr(t, "title", ""),
|
||||
"status": str(getattr(t, "status", "")),
|
||||
"reason": reason,
|
||||
})
|
||||
if reason:
|
||||
mistakes_to_avoid.append(reason)
|
||||
|
||||
if partial:
|
||||
confidence = round(min(confidence, 0.4), 2)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"employee_id": employee_id,
|
||||
"employee_name": first_assignment.get("name", ""),
|
||||
"template_id": first_assignment.get("template_id", ""),
|
||||
"category": first_assignment.get("category", ""),
|
||||
"project_id": project_id,
|
||||
"delivery_task_id": str(getattr(delivery_task, "id", "") or ""),
|
||||
"delivery_projection_id": projection_id_for_task(delivery_task),
|
||||
"role_id": role_id,
|
||||
"domains": domains,
|
||||
"pattern_key": pattern_key,
|
||||
"project_summary": " ".join(task_summaries[:3]),
|
||||
"what_worked": self._dedupe_preserve_order(what_worked),
|
||||
"mistakes_to_avoid": self._dedupe_preserve_order(mistakes_to_avoid),
|
||||
"reusable_checklist": self._dedupe_preserve_order(reusable_checklist),
|
||||
"reviewer_preferences": self._dedupe_preserve_order(reviewer_preferences),
|
||||
"tool_preferences": self._dedupe_preserve_order(tool_preferences),
|
||||
"suitable_for": self._dedupe_preserve_order(suitable_for),
|
||||
"avoid_for": self._dedupe_preserve_order(avoid_for),
|
||||
"confidence": confidence,
|
||||
"source_task_ids": [str(getattr(task, "id", "")) for task in employee_tasks],
|
||||
"created_at": _utc_now(),
|
||||
"employee_outcome": self._normalize_outcome(str(employee_feedback.get("outcome", "partial_success") or "partial_success")),
|
||||
"feedback_summary": feedback_summary,
|
||||
"feedback_rationale": str(employee_feedback.get("reason", "")).strip(),
|
||||
}
|
||||
if historical_context:
|
||||
result["historical_context"] = historical_context
|
||||
if feedback:
|
||||
result["user_feedback"] = {
|
||||
"label": str(feedback.get("label", "")).strip(),
|
||||
"raw_feedback": str(feedback.get("raw_feedback", "")).strip(),
|
||||
"scope": str(feedback.get("scope", "")).strip(),
|
||||
}
|
||||
if evaluation:
|
||||
result["runtime_feedback_evaluation"] = {
|
||||
"overall_outcome": str(evaluation.get("overall_outcome", "")).strip(),
|
||||
"summary": str(evaluation.get("summary", "")).strip(),
|
||||
"strengths": [str(item).strip() for item in list(evaluation.get("strengths", [])) if str(item).strip()][:6],
|
||||
"weaknesses": [str(item).strip() for item in list(evaluation.get("weaknesses", [])) if str(item).strip()][:6],
|
||||
}
|
||||
if failures:
|
||||
result["failures"] = failures
|
||||
result["partial"] = True
|
||||
return result
|
||||
|
||||
def _maybe_promote_reflection_skill(self, profile: dict[str, Any], reflection: dict[str, Any], project_id: str | None = None) -> str:
|
||||
employees = profile.setdefault("employees", {})
|
||||
employee_id = str(reflection.get("employee_id", "")).strip()
|
||||
record = employees.get(employee_id)
|
||||
if not record:
|
||||
return ""
|
||||
pattern_key = str(reflection.get("pattern_key", "")).strip()
|
||||
pattern = dict(record.get("patterns", {}).get(pattern_key, {}))
|
||||
if not pattern or pattern.get("learned_skill_ref"):
|
||||
return str(pattern.get("learned_skill_ref", ""))
|
||||
if int(pattern.get("reflection_count", 0)) < self.LEARNED_SKILL_THRESHOLD:
|
||||
return ""
|
||||
|
||||
repeated_working = self._repeated_items(pattern.get("working_pattern_counts", {}))
|
||||
repeated_checklists = self._repeated_items(pattern.get("checklist_counts", {}))
|
||||
repeated_reviewer = self._repeated_items(pattern.get("reviewer_preference_counts", {}))
|
||||
repeated_tools = self._repeated_items(pattern.get("tool_preference_counts", {}))
|
||||
repeated_risks = self._repeated_items(pattern.get("risk_watchout_counts", {}))
|
||||
if not any((repeated_working, repeated_checklists, repeated_reviewer, repeated_tools, repeated_risks)):
|
||||
return ""
|
||||
|
||||
employee_name = str(reflection.get("employee_name", "Employee")).strip() or "Employee"
|
||||
role_id = str(reflection.get("role_id", "")).strip() or record.get("role_id", "general")
|
||||
domains = list(reflection.get("domains", [])) or list(pattern.get("domains", [])) or [role_id]
|
||||
primary_domain = domains[0] if domains else role_id
|
||||
display_name = f"{employee_name} {role_id} {primary_domain} playbook"
|
||||
skill_name = self._normalize_skill_name(display_name)
|
||||
reflection_count = int(pattern.get("reflection_count", 0))
|
||||
sections = [
|
||||
f"# {display_name}",
|
||||
"",
|
||||
f"Learned playbook for **{employee_name}** in role `{role_id}` across {', '.join(domains)}.",
|
||||
f"Distilled from {reflection_count} project reflections.",
|
||||
]
|
||||
if repeated_working:
|
||||
sections.extend(["", "## Successful Behaviors", *[f"- {item}" for item in repeated_working]])
|
||||
if repeated_checklists:
|
||||
sections.extend(["", "## Default Checklist", *[f"- {item}" for item in repeated_checklists]])
|
||||
if repeated_reviewer:
|
||||
sections.extend(["", "## Reviewer Preferences", *[f"- {item}" for item in repeated_reviewer]])
|
||||
if repeated_tools:
|
||||
sections.extend(["", "## Tool Preferences", *[f"- {item}" for item in repeated_tools]])
|
||||
if repeated_risks:
|
||||
sections.extend(["", "## Risk Watchouts", *[f"- {item}" for item in repeated_risks]])
|
||||
avoid_for = self._rank_items(record.get("avoid_domain_counts", {}), limit=4)
|
||||
if avoid_for:
|
||||
sections.extend(["", "## Avoid For", *[f"- {item}" for item in avoid_for]])
|
||||
|
||||
skill = Skill(
|
||||
name=skill_name,
|
||||
description=(
|
||||
f"Learned playbook for `{role_id}` work ({', '.join(domains)}) by {employee_name}. "
|
||||
f"Use when assigning similar {role_id} tasks in these domains."
|
||||
),
|
||||
metadata={
|
||||
"employee_id": employee_id,
|
||||
"employee_name": employee_name,
|
||||
"role_id": role_id,
|
||||
"template_id": reflection.get("template_id", ""),
|
||||
"pattern_key": pattern_key,
|
||||
"built_from": "project_reflections",
|
||||
},
|
||||
content="\n".join(sections).strip() + "\n",
|
||||
)
|
||||
self.skills.save_skill(skill, project_id=project_id)
|
||||
self._attach_learned_skill(profile, employee_id, pattern_key, skill.name)
|
||||
return skill.name
|
||||
|
||||
@staticmethod
|
||||
def _normalize_skill_name(raw: str) -> str:
|
||||
"""Normalize to lowercase hyphen-case, matching skill-creator conventions."""
|
||||
normalized = raw.strip().lower()
|
||||
normalized = re.sub(r"[^a-z0-9]+", "-", normalized)
|
||||
normalized = normalized.strip("-")
|
||||
normalized = re.sub(r"-{2,}", "-", normalized)
|
||||
return normalized[:64]
|
||||
|
||||
def _attach_learned_skill(
|
||||
self,
|
||||
profile: dict[str, Any],
|
||||
employee_id: str,
|
||||
pattern_key: str,
|
||||
skill_name: str,
|
||||
) -> None:
|
||||
employees = profile.setdefault("employees", {})
|
||||
record = employees.setdefault(employee_id, {})
|
||||
refs = record.setdefault("learned_skill_refs", [])
|
||||
if skill_name not in refs:
|
||||
refs.append(skill_name)
|
||||
patterns = record.setdefault("patterns", {})
|
||||
pattern = patterns.setdefault(pattern_key, {})
|
||||
pattern["learned_skill_ref"] = skill_name
|
||||
record["updated_at"] = _utc_now()
|
||||
record.setdefault("delta_profile", {})
|
||||
|
||||
def _reflection_path(self, project_id: str, employee_id: str, delivery_task_id: str) -> Path:
|
||||
return self.opc_home / "projects" / project_id / "employees" / employee_id / "reflections" / f"{delivery_task_id}.yaml"
|
||||
|
||||
def _base_pattern_record(self, role_id: str, domains: list[str]) -> dict[str, Any]:
|
||||
return {
|
||||
"role_id": role_id,
|
||||
"domains": list(domains),
|
||||
"successes": 0,
|
||||
"partial_successes": 0,
|
||||
"failures": 0,
|
||||
"learned_skill_ref": "",
|
||||
"reflection_count": 0,
|
||||
}
|
||||
|
||||
def _normalize_work_item_tasks(self, work_item_tasks: list[Any], delivery_task: Any) -> list[Any]:
|
||||
tasks_by_id: dict[str, Any] = {}
|
||||
for task in work_item_tasks:
|
||||
task_id = str(getattr(task, "id", "") or "").strip()
|
||||
if task_id:
|
||||
tasks_by_id[task_id] = task
|
||||
delivery_task_id = str(getattr(delivery_task, "id", "") or "").strip()
|
||||
if delivery_task_id:
|
||||
tasks_by_id[delivery_task_id] = delivery_task
|
||||
return list(tasks_by_id.values())
|
||||
|
||||
def _collect_domains(self, tasks: list[Any], fallback: list[str]) -> list[str]:
|
||||
collected: list[str] = []
|
||||
for task in tasks:
|
||||
assignment = dict(getattr(task, "metadata", {}).get("employee_assignment", {}) or {})
|
||||
for value in list(assignment.get("domains", [])) + list(getattr(task, "tags", []) or []):
|
||||
item = str(value).strip()
|
||||
if item and item not in collected:
|
||||
collected.append(item)
|
||||
for value in fallback:
|
||||
item = str(value).strip()
|
||||
if item and item not in collected:
|
||||
collected.append(item)
|
||||
return collected
|
||||
|
||||
def _collect_task_summaries(self, tasks: list[Any]) -> list[str]:
|
||||
summaries: list[str] = []
|
||||
for task in tasks:
|
||||
for candidate in (
|
||||
str(getattr(task, "metadata", {}).get("work_item_summary_for_downstream", "") or "").strip(),
|
||||
str(getattr(task, "result", {}).get("content", "") or "").strip(),
|
||||
str(getattr(task, "title", "") or "").strip(),
|
||||
):
|
||||
if candidate and candidate not in summaries:
|
||||
summaries.append(candidate)
|
||||
break
|
||||
return summaries
|
||||
|
||||
def _collect_items(self, tasks: list[Any], key: str) -> list[str]:
|
||||
items: list[str] = []
|
||||
for task in tasks:
|
||||
for value in list(getattr(task, "metadata", {}).get(key, []) or []):
|
||||
text = str(value).strip()
|
||||
if text and text not in items:
|
||||
items.append(text)
|
||||
return items
|
||||
|
||||
def _collect_preferred_agents(self, tasks: list[Any], assignment: dict[str, Any]) -> list[str]:
|
||||
agents: list[str] = []
|
||||
assignment_agent = str(assignment.get("preferred_external_agent", "") or "").strip()
|
||||
if assignment_agent:
|
||||
agents.append(assignment_agent)
|
||||
for task in tasks:
|
||||
value = str(getattr(task, "assigned_external_agent", "") or "").strip()
|
||||
if value and value not in agents:
|
||||
agents.append(value)
|
||||
return agents
|
||||
|
||||
def _infer_avoid_domains(self, risks: list[str]) -> list[str]:
|
||||
avoid: list[str] = []
|
||||
joined = " ".join(risks).lower()
|
||||
for token in ("security", "compliance", "billing", "finance", "production", "deployment"):
|
||||
if token in joined and token not in avoid:
|
||||
avoid.append(token)
|
||||
return avoid
|
||||
|
||||
def _find_employee_feedback(self, employee_id: str, evaluation: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not evaluation:
|
||||
return {}
|
||||
for item in list(evaluation.get("employees", [])):
|
||||
if str(item.get("employee_id", "")).strip() == employee_id:
|
||||
return dict(item)
|
||||
return {}
|
||||
|
||||
def _increment_outcome_counts(self, container: dict[str, Any], outcome: str) -> None:
|
||||
normalized = self._normalize_outcome(outcome)
|
||||
if normalized == "success":
|
||||
container["successes"] = int(container.get("successes", 0)) + 1
|
||||
elif normalized == "failure":
|
||||
container["failures"] = int(container.get("failures", 0)) + 1
|
||||
else:
|
||||
container["partial_successes"] = int(container.get("partial_successes", 0)) + 1
|
||||
|
||||
def _normalize_outcome(self, outcome: str) -> str:
|
||||
normalized = str(outcome or "").strip().lower()
|
||||
if normalized in {"success", "approved", "fully_approved", "complete_success"}:
|
||||
return "success"
|
||||
if normalized in {"failure", "failed", "rejected", "fully_rejected"}:
|
||||
return "failure"
|
||||
return "partial_success"
|
||||
|
||||
def _increment_counts(self, container: dict[str, Any], key: str, items: list[str]) -> None:
|
||||
counts = container.setdefault(key, {})
|
||||
for item in self._dedupe_preserve_order(items):
|
||||
counts[item] = int(counts.get(item, 0)) + 1
|
||||
|
||||
def _build_delta_profile(self, record: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"working_patterns": self._rank_items(record.get("working_pattern_counts", {}), limit=5),
|
||||
"default_checklists": self._rank_items(record.get("checklist_counts", {}), limit=6),
|
||||
"reviewer_preferences": self._rank_items(record.get("reviewer_preference_counts", {}), limit=5),
|
||||
"risk_watchouts": self._rank_items(record.get("risk_watchout_counts", {}), limit=5),
|
||||
"tool_preferences": self._rank_items(record.get("tool_preference_counts", {}), limit=4),
|
||||
"fit_domains": self._rank_items(record.get("fit_domain_counts", {}), limit=4),
|
||||
"avoid_domains": self._rank_items(record.get("avoid_domain_counts", {}), limit=4),
|
||||
}
|
||||
|
||||
def _normalize_self_evolution_event(
|
||||
self,
|
||||
event: dict[str, Any],
|
||||
source: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
employee_id = str(event.get("employee_id", "") or "").strip()
|
||||
if not employee_id:
|
||||
return {}
|
||||
event_id = str(event.get("event_id", "") or "").strip()
|
||||
if not event_id:
|
||||
checkpoint_id = str(source.get("checkpoint_id", "") or "").strip()
|
||||
role_id = str(event.get("role_id", "") or "").strip()
|
||||
nonce = json.dumps(event, ensure_ascii=False, sort_keys=True)
|
||||
digest = hashlib.sha256(nonce.encode("utf-8")).hexdigest()[:12]
|
||||
event_id = self._safe_employee_filename(f"{checkpoint_id or _utc_now()}-{employee_id}-{role_id}-{digest}")
|
||||
return {
|
||||
"event_id": event_id,
|
||||
"employee_id": employee_id,
|
||||
"role_id": str(event.get("role_id", "") or "").strip(),
|
||||
"summary": str(event.get("summary", "") or "").strip(),
|
||||
"strengths": self._string_list(event.get("strengths", []), limit=8),
|
||||
"adjustments": self._string_list(event.get("adjustments", event.get("adjust", [])), limit=8),
|
||||
"avoid_next_time": self._string_list(event.get("avoid_next_time", event.get("avoid", [])), limit=8),
|
||||
"routing_notes": self._string_list(event.get("routing_notes", []), limit=8),
|
||||
"evidence_task_ids": self._string_list(event.get("evidence_task_ids", []), limit=12),
|
||||
"confidence": self._bounded_float(event.get("confidence", 0.7), default=0.7),
|
||||
"source": dict(source or {}),
|
||||
"created_at": _utc_now(),
|
||||
}
|
||||
|
||||
def _build_self_evolution_delta(self, events: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
counts: dict[str, dict[str, int]] = {
|
||||
"strengths": {},
|
||||
"adjustments": {},
|
||||
"avoid_next_time": {},
|
||||
"routing_notes": {},
|
||||
}
|
||||
for event in events:
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
for key in counts:
|
||||
for item in self._string_list(event.get(key, []), limit=20):
|
||||
counts[key][item] = int(counts[key].get(item, 0)) + 1
|
||||
return {
|
||||
"strengths": self._rank_items(counts["strengths"], limit=8),
|
||||
"adjustments": self._rank_items(counts["adjustments"], limit=8),
|
||||
"avoid_next_time": self._rank_items(counts["avoid_next_time"], limit=8),
|
||||
"routing_notes": self._rank_items(counts["routing_notes"], limit=8),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _safe_employee_filename(value: str) -> str:
|
||||
safe = re.sub(r"[^A-Za-z0-9._-]+", "-", str(value or "").strip()).strip("-")
|
||||
return safe or "employee"
|
||||
|
||||
@staticmethod
|
||||
def _bounded_float(value: Any, *, default: float = 0.0) -> float:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
number = default
|
||||
return max(0.0, min(1.0, number))
|
||||
|
||||
@staticmethod
|
||||
def _string_list(value: Any, *, limit: int = 8) -> list[str]:
|
||||
items = value if isinstance(value, list) else [value]
|
||||
result: list[str] = []
|
||||
for item in items:
|
||||
text = str(item or "").strip()
|
||||
if text and text not in result:
|
||||
result.append(text)
|
||||
if len(result) >= limit:
|
||||
break
|
||||
return result
|
||||
|
||||
def _rank_items(self, counts: dict[str, Any], *, limit: int = 6) -> list[str]:
|
||||
ranked = sorted(
|
||||
((str(item).strip(), int(count)) for item, count in dict(counts).items() if str(item).strip()),
|
||||
key=lambda pair: (-pair[1], pair[0].lower()),
|
||||
)
|
||||
return [item for item, _count in ranked[:limit]]
|
||||
|
||||
def _repeated_items(self, counts: dict[str, Any], *, minimum: int = 2) -> list[str]:
|
||||
ranked = sorted(
|
||||
((str(item).strip(), int(count)) for item, count in dict(counts).items() if int(count) >= minimum and str(item).strip()),
|
||||
key=lambda pair: (-pair[1], pair[0].lower()),
|
||||
)
|
||||
return [item for item, _count in ranked[:6]]
|
||||
|
||||
def _pattern_key(self, role_id: str, domains: list[str]) -> str:
|
||||
domain_key = ",".join(sorted({domain.strip().lower() for domain in domains if domain.strip()}))
|
||||
return f"{role_id}|{domain_key or 'general'}"
|
||||
|
||||
def _dedupe_preserve_order(self, items: list[str]) -> list[str]:
|
||||
unique: list[str] = []
|
||||
for item in items:
|
||||
text = str(item).strip()
|
||||
if text and text not in unique:
|
||||
unique.append(text)
|
||||
return unique
|
||||
|
||||
def _deep_merge(self, base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
||||
merged = dict(base)
|
||||
for key, value in override.items():
|
||||
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
||||
merged[key] = self._deep_merge(merged[key], value)
|
||||
elif isinstance(value, list) and isinstance(merged.get(key), list):
|
||||
existing = list(merged[key])
|
||||
for item in value:
|
||||
if item not in existing:
|
||||
existing.append(item)
|
||||
merged[key] = existing
|
||||
else:
|
||||
merged[key] = value
|
||||
return merged
|
||||
@@ -0,0 +1,607 @@
|
||||
"""Persistent history compaction for session and employee memory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from opc.core.models import (
|
||||
AgentCompactionRecord,
|
||||
AgentMemorySnapshotRecord,
|
||||
SessionCompactionRecord,
|
||||
SessionMemorySnapshotRecord,
|
||||
SessionMessageRecord,
|
||||
)
|
||||
|
||||
|
||||
class HistoryCompactor:
|
||||
"""Compacts persisted history into summary + memory snapshots."""
|
||||
|
||||
_COMPACTION_MESSAGE_CHAR_BUDGET = 4_000
|
||||
_COMPACTION_TRUNCATION_MARKER = "[history compaction input truncated]"
|
||||
_RETRY_TRUNCATION_MARKER = "[history compaction retry truncated]"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
llm: Any | None,
|
||||
store: Any | None,
|
||||
memory_manager: Any,
|
||||
task_type: str = "quick_tasks",
|
||||
compression_threshold: float = 0.85,
|
||||
) -> None:
|
||||
self.llm = llm
|
||||
self.store = store
|
||||
self.memory_manager = memory_manager
|
||||
self.task_type = task_type
|
||||
self.compression_threshold = compression_threshold
|
||||
|
||||
async def maybe_compact_after_message(self, message: SessionMessageRecord) -> None:
|
||||
_ = message
|
||||
return
|
||||
|
||||
async def maybe_compact_session(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
session_id: str,
|
||||
force: bool = False,
|
||||
) -> bool:
|
||||
if not self.llm or not self.store:
|
||||
return False
|
||||
visible_items = await self.memory_manager._get_visible_session_transcript(session_id)
|
||||
if not visible_items:
|
||||
return False
|
||||
raw_items = [item for item in visible_items if not getattr(item["message"], "summary_flag", False)]
|
||||
if not raw_items:
|
||||
return False
|
||||
visible_messages = self._items_to_messages(visible_items)
|
||||
if not self._should_compact(visible_messages, force=force):
|
||||
return False
|
||||
compact_items, boundary_message_id = self._select_compaction_items(raw_items, force=force)
|
||||
if not compact_items or not boundary_message_id:
|
||||
return False
|
||||
messages = self._items_to_messages(
|
||||
compact_items,
|
||||
per_message_budget=self._COMPACTION_MESSAGE_CHAR_BUDGET,
|
||||
)
|
||||
|
||||
existing = await self.store.get_latest_session_memory_snapshot(session_id)
|
||||
result = await self._summarize_session(
|
||||
project_id=project_id,
|
||||
session_id=session_id,
|
||||
messages=messages,
|
||||
existing_memory=(existing.memory_text if existing else ""),
|
||||
existing_summary=(existing.summary_text if existing else ""),
|
||||
)
|
||||
summary_message = await self.memory_manager.append_session_message(
|
||||
session_id=session_id,
|
||||
role="assistant",
|
||||
text=result["history_summary"],
|
||||
project_id=project_id,
|
||||
summary_flag=True,
|
||||
parent_message_id=boundary_message_id,
|
||||
metadata={
|
||||
"kind": "session_history_summary",
|
||||
"summary_scope": "session",
|
||||
"skip_compaction": True,
|
||||
},
|
||||
)
|
||||
if not summary_message:
|
||||
return False
|
||||
|
||||
await self.store.save_session_compaction(
|
||||
SessionCompactionRecord(
|
||||
session_id=session_id,
|
||||
compaction_message_id=summary_message.message_id,
|
||||
source_boundary_message_id=boundary_message_id,
|
||||
metadata={
|
||||
"project_id": project_id,
|
||||
"raw_message_count": len(compact_items),
|
||||
"summary_scope": "session",
|
||||
},
|
||||
)
|
||||
)
|
||||
await self.store.save_session_memory_snapshot(
|
||||
SessionMemorySnapshotRecord(
|
||||
project_id=project_id,
|
||||
session_id=session_id,
|
||||
summary_message_id=summary_message.message_id,
|
||||
source_boundary_message_id=boundary_message_id,
|
||||
summary_text=result["history_summary"],
|
||||
memory_text=result["memory_summary"],
|
||||
metadata={
|
||||
"summary_scope": "session",
|
||||
"raw_message_count": len(compact_items),
|
||||
},
|
||||
)
|
||||
)
|
||||
await self.memory_manager.update_session_summary(session_id, result["history_summary"])
|
||||
return True
|
||||
|
||||
async def maybe_compact_agent(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
session_id: str,
|
||||
employee_id: str,
|
||||
role_id: str = "",
|
||||
force: bool = False,
|
||||
) -> bool:
|
||||
if not self.llm or not self.store or not employee_id:
|
||||
return False
|
||||
visible_items = await self.memory_manager._get_visible_agent_transcript(
|
||||
project_id=project_id,
|
||||
session_id=session_id,
|
||||
employee_id=employee_id,
|
||||
)
|
||||
if not visible_items:
|
||||
return False
|
||||
raw_items = [item for item in visible_items if not getattr(item["message"], "summary_flag", False)]
|
||||
if not raw_items:
|
||||
return False
|
||||
visible_messages = self._items_to_messages(visible_items)
|
||||
if not self._should_compact(visible_messages, force=force):
|
||||
return False
|
||||
compact_items, boundary_message_id = self._select_compaction_items(raw_items, force=force)
|
||||
if not compact_items or not boundary_message_id:
|
||||
return False
|
||||
messages = self._items_to_messages(
|
||||
compact_items,
|
||||
per_message_budget=self._COMPACTION_MESSAGE_CHAR_BUDGET,
|
||||
)
|
||||
|
||||
existing = await self.store.get_agent_memory_snapshot(
|
||||
project_id=project_id,
|
||||
session_id=session_id,
|
||||
employee_id=employee_id,
|
||||
memory_kind="process",
|
||||
memory_scope="session",
|
||||
)
|
||||
result = await self._summarize_agent_process(
|
||||
project_id=project_id,
|
||||
session_id=session_id,
|
||||
employee_id=employee_id,
|
||||
role_id=role_id,
|
||||
messages=messages,
|
||||
existing_memory=(existing.memory_text if existing else ""),
|
||||
existing_summary=(existing.summary_text if existing else ""),
|
||||
)
|
||||
summary_message = await self.memory_manager.append_session_message(
|
||||
session_id=session_id,
|
||||
role="assistant",
|
||||
text=result["history_summary"],
|
||||
project_id=project_id,
|
||||
summary_flag=True,
|
||||
parent_message_id=boundary_message_id,
|
||||
metadata={
|
||||
"kind": "agent_history_summary",
|
||||
"summary_scope": "agent",
|
||||
"employee_id": employee_id,
|
||||
"role_id": role_id,
|
||||
"skip_compaction": True,
|
||||
},
|
||||
)
|
||||
if not summary_message:
|
||||
return False
|
||||
|
||||
await self.store.save_agent_compaction(
|
||||
AgentCompactionRecord(
|
||||
project_id=project_id,
|
||||
session_id=session_id,
|
||||
employee_id=employee_id,
|
||||
role_id=role_id,
|
||||
compaction_message_id=summary_message.message_id,
|
||||
source_boundary_message_id=boundary_message_id,
|
||||
metadata={
|
||||
"summary_scope": "agent",
|
||||
"raw_message_count": len(compact_items),
|
||||
},
|
||||
)
|
||||
)
|
||||
await self.store.save_agent_memory_snapshot(
|
||||
AgentMemorySnapshotRecord(
|
||||
project_id=project_id,
|
||||
session_id=session_id,
|
||||
employee_id=employee_id,
|
||||
role_id=role_id,
|
||||
memory_scope="session",
|
||||
memory_kind="process",
|
||||
summary_message_id=summary_message.message_id,
|
||||
source_boundary_message_id=boundary_message_id,
|
||||
summary_text=result["history_summary"],
|
||||
memory_text=result["memory_summary"],
|
||||
metadata={
|
||||
"summary_scope": "agent",
|
||||
"raw_message_count": len(compact_items),
|
||||
},
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
async def finalize_agent_memory(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
session_id: str,
|
||||
employee_id: str,
|
||||
role_id: str,
|
||||
process_memory: str,
|
||||
reflection_payload: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
if not self.llm:
|
||||
return self._fallback_final_agent_memory(process_memory, reflection_payload)
|
||||
prompt_payload = {
|
||||
"project_id": project_id,
|
||||
"session_id": session_id,
|
||||
"employee_id": employee_id,
|
||||
"role_id": role_id,
|
||||
"process_memory": process_memory,
|
||||
"reflection": reflection_payload,
|
||||
}
|
||||
raw = await self.llm.simple_chat(
|
||||
prompt=json.dumps(prompt_payload, ensure_ascii=False),
|
||||
system=(
|
||||
"You are finalizing employee memory for a multi-agent runtime.\n"
|
||||
"Return strict JSON with keys `summary_text`, `memory_text`, and `metadata`.\n"
|
||||
"`memory_text` must be concise markdown with sections:\n"
|
||||
"## Effective Patterns\n## Watchouts\n## Preferred Tools\n## Reviewer Preferences\n## Reusable Checklist\n"
|
||||
"`metadata` must contain arrays with keys `effective_patterns`, `watchouts`, "
|
||||
"`preferred_tools`, `reviewer_preferences`, `reusable_checklist`.\n"
|
||||
"Merge the process memory with the reflection, remove duplication, and keep only durable guidance."
|
||||
),
|
||||
task_type=self.task_type,
|
||||
)
|
||||
parsed = self._parse_json_response(raw)
|
||||
if not parsed:
|
||||
return self._fallback_final_agent_memory(process_memory, reflection_payload)
|
||||
metadata = parsed.get("metadata", {})
|
||||
return {
|
||||
"summary_text": str(parsed.get("summary_text", "")).strip() or str(parsed.get("memory_text", "")).strip(),
|
||||
"memory_text": str(parsed.get("memory_text", "")).strip() or process_memory.strip(),
|
||||
"metadata": metadata if isinstance(metadata, dict) else {},
|
||||
}
|
||||
|
||||
def _get_token_threshold(self, *, reserve_tokens: int = 0) -> int | None:
|
||||
if not self.llm:
|
||||
return None
|
||||
context_limit = self.llm.get_context_window(task_type=self.task_type)
|
||||
if context_limit is None:
|
||||
return None
|
||||
threshold = int(context_limit * self.compression_threshold)
|
||||
if reserve_tokens:
|
||||
threshold = max(0, threshold - reserve_tokens)
|
||||
return threshold
|
||||
|
||||
def should_compact_prompt(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
force: bool = False,
|
||||
reserve_tokens: int = 0,
|
||||
) -> bool:
|
||||
_ = messages
|
||||
_ = tools
|
||||
_ = force
|
||||
_ = reserve_tokens
|
||||
return False
|
||||
|
||||
def _is_context_overflow_error(self, error: Exception) -> bool:
|
||||
detector = getattr(self.llm, "is_context_overflow_error", None)
|
||||
if callable(detector):
|
||||
try:
|
||||
return bool(detector(error))
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _truncate_messages_for_retry(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
if len(messages) <= 1:
|
||||
return messages
|
||||
drop_count = max(1, len(messages) // 5)
|
||||
if drop_count >= len(messages):
|
||||
drop_count = len(messages) - 1
|
||||
return messages[drop_count:]
|
||||
|
||||
@classmethod
|
||||
def _truncate_message_content(cls, content: str, *, budget: int, marker: str) -> str:
|
||||
if len(content) <= budget:
|
||||
return content
|
||||
clipped = max(120, budget - len(marker) - 1)
|
||||
return content[:clipped].rstrip() + "\n" + marker
|
||||
|
||||
@classmethod
|
||||
def _compact_messages_for_retry(
|
||||
cls,
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
budget: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
compacted: list[dict[str, Any]] = []
|
||||
for message in messages:
|
||||
compacted.append({
|
||||
**message,
|
||||
"content": cls._truncate_message_content(
|
||||
str(message.get("content", "") or ""),
|
||||
budget=budget,
|
||||
marker=cls._RETRY_TRUNCATION_MARKER,
|
||||
),
|
||||
})
|
||||
return compacted
|
||||
|
||||
async def _simple_chat_with_retry(
|
||||
self,
|
||||
*,
|
||||
payload: dict[str, Any],
|
||||
system: str,
|
||||
) -> str:
|
||||
retries = 0
|
||||
working_payload = dict(payload)
|
||||
while True:
|
||||
try:
|
||||
return await self.llm.simple_chat(
|
||||
prompt=json.dumps(working_payload, ensure_ascii=False),
|
||||
system=system,
|
||||
task_type=self.task_type,
|
||||
)
|
||||
except Exception as exc:
|
||||
messages = list(working_payload.get("messages", []) or [])
|
||||
if retries >= 3 or not self._is_context_overflow_error(exc) or not messages:
|
||||
raise
|
||||
retries += 1
|
||||
retry_budget = max(1_200, 4_000 // (2 ** (retries - 1)))
|
||||
retry_messages = self._compact_messages_for_retry(messages, budget=retry_budget)
|
||||
if len(retry_messages) > 1:
|
||||
retry_messages = self._truncate_messages_for_retry(retry_messages)
|
||||
working_payload["messages"] = retry_messages
|
||||
logger.debug(
|
||||
"History compactor retrying after context overflow with "
|
||||
f"{len(working_payload['messages'])} messages preserved and budget={retry_budget} chars."
|
||||
)
|
||||
|
||||
def _should_compact(self, messages: list[dict[str, Any]], *, force: bool = False) -> bool:
|
||||
if force:
|
||||
return True
|
||||
if not messages or not self.llm:
|
||||
return False
|
||||
counted_tokens = self.llm.count_input_tokens(messages, task_type=self.task_type)
|
||||
threshold_tokens = self._get_token_threshold()
|
||||
if counted_tokens is None or threshold_tokens is None:
|
||||
return False
|
||||
return counted_tokens >= threshold_tokens
|
||||
|
||||
def _select_compaction_items(
|
||||
self,
|
||||
raw_items: list[dict[str, Any]],
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> tuple[list[dict[str, Any]], str]:
|
||||
if not raw_items:
|
||||
return [], ""
|
||||
if force or not self.llm:
|
||||
return raw_items, raw_items[-1]["message"].message_id
|
||||
|
||||
threshold_tokens = self._get_token_threshold()
|
||||
if threshold_tokens is None:
|
||||
return [], ""
|
||||
|
||||
for keep_start in range(len(raw_items)):
|
||||
tail_messages = self._items_to_messages(raw_items[keep_start:])
|
||||
tail_tokens = self.llm.count_input_tokens(tail_messages, task_type=self.task_type)
|
||||
if tail_tokens is None:
|
||||
continue
|
||||
if tail_tokens < threshold_tokens:
|
||||
compact_items = raw_items if keep_start == 0 else raw_items[:keep_start]
|
||||
return compact_items, compact_items[-1]["message"].message_id
|
||||
|
||||
return raw_items, raw_items[-1]["message"].message_id
|
||||
|
||||
def _items_to_messages(
|
||||
self,
|
||||
items: list[dict[str, Any]],
|
||||
*,
|
||||
per_message_budget: int | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
messages: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
message = item["message"]
|
||||
content = self.memory_manager._render_session_parts(item["parts"]).strip()
|
||||
if not content:
|
||||
continue
|
||||
if per_message_budget:
|
||||
content = self._truncate_message_content(
|
||||
content,
|
||||
budget=per_message_budget,
|
||||
marker=self._COMPACTION_TRUNCATION_MARKER,
|
||||
)
|
||||
role = "user" if message.role == "user" else "assistant"
|
||||
messages.append({"role": role, "content": content})
|
||||
return messages
|
||||
|
||||
async def _summarize_session(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
session_id: str,
|
||||
messages: list[dict[str, Any]],
|
||||
existing_memory: str,
|
||||
existing_summary: str,
|
||||
) -> dict[str, str]:
|
||||
if not self.llm:
|
||||
return self._fallback_session_summary(messages, existing_memory)
|
||||
payload = {
|
||||
"project_id": project_id,
|
||||
"session_id": session_id,
|
||||
"existing_memory": existing_memory,
|
||||
"existing_summary": existing_summary,
|
||||
"messages": messages,
|
||||
}
|
||||
raw = await self._simple_chat_with_retry(
|
||||
payload=payload,
|
||||
system=(
|
||||
"You are compacting persisted session history.\n"
|
||||
"Return strict JSON with keys `history_summary` and `memory_summary`.\n"
|
||||
"`history_summary` should help another agent continue the session after restart.\n"
|
||||
"`memory_summary` should be concise markdown with sections:\n"
|
||||
"## Primary Goal\n## Active Rules\n## Key Progress\n## Current State\n## Open Risks\n"
|
||||
"Merge with existing memory and remove duplication."
|
||||
),
|
||||
)
|
||||
parsed = self._parse_json_response(raw)
|
||||
if not parsed:
|
||||
return self._fallback_session_summary(messages, existing_memory)
|
||||
return {
|
||||
"history_summary": str(parsed.get("history_summary", "")).strip() or self._fallback_session_summary(messages, existing_memory)["history_summary"],
|
||||
"memory_summary": str(parsed.get("memory_summary", "")).strip() or self._fallback_session_summary(messages, existing_memory)["memory_summary"],
|
||||
}
|
||||
|
||||
async def _summarize_agent_process(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
session_id: str,
|
||||
employee_id: str,
|
||||
role_id: str,
|
||||
messages: list[dict[str, Any]],
|
||||
existing_memory: str,
|
||||
existing_summary: str,
|
||||
) -> dict[str, str]:
|
||||
if not self.llm:
|
||||
return self._fallback_agent_summary(messages, existing_memory)
|
||||
payload = {
|
||||
"project_id": project_id,
|
||||
"session_id": session_id,
|
||||
"employee_id": employee_id,
|
||||
"role_id": role_id,
|
||||
"existing_memory": existing_memory,
|
||||
"existing_summary": existing_summary,
|
||||
"messages": messages,
|
||||
}
|
||||
raw = await self._simple_chat_with_retry(
|
||||
payload=payload,
|
||||
system=(
|
||||
"You are compacting employee-level process history.\n"
|
||||
"Return strict JSON with keys `history_summary` and `memory_summary`.\n"
|
||||
"`history_summary` should summarize what this employee already did in this session.\n"
|
||||
"`memory_summary` should be concise markdown with sections:\n"
|
||||
"## Effective Patterns\n## Watchouts\n## Current Progress\n## Current State\n"
|
||||
"Keep it durable, specific, and deduplicated."
|
||||
),
|
||||
)
|
||||
parsed = self._parse_json_response(raw)
|
||||
if not parsed:
|
||||
return self._fallback_agent_summary(messages, existing_memory)
|
||||
return {
|
||||
"history_summary": str(parsed.get("history_summary", "")).strip() or self._fallback_agent_summary(messages, existing_memory)["history_summary"],
|
||||
"memory_summary": str(parsed.get("memory_summary", "")).strip() or self._fallback_agent_summary(messages, existing_memory)["memory_summary"],
|
||||
}
|
||||
|
||||
def _fallback_session_summary(self, messages: list[dict[str, Any]], existing_memory: str) -> dict[str, str]:
|
||||
snippets = [str(item.get("content", "")).strip() for item in messages if str(item.get("content", "")).strip()]
|
||||
summary = "\n".join(f"- {snippet}" for snippet in snippets[-6:]) or "- No prior details captured."
|
||||
memory_parts = [
|
||||
"## Primary Goal",
|
||||
f"- {snippets[0]}" if snippets else "- (unknown)",
|
||||
"",
|
||||
"## Active Rules",
|
||||
"- Reuse durable constraints from earlier turns.",
|
||||
"",
|
||||
"## Key Progress",
|
||||
*([f"- {snippet}" for snippet in snippets[-4:]] or ["- (none)"]),
|
||||
"",
|
||||
"## Current State",
|
||||
f"- Existing memory length: {len(existing_memory.strip())} characters",
|
||||
"",
|
||||
"## Open Risks",
|
||||
"- Re-check older transcript if the summary omits key details.",
|
||||
]
|
||||
return {
|
||||
"history_summary": summary.strip(),
|
||||
"memory_summary": "\n".join(memory_parts).strip(),
|
||||
}
|
||||
|
||||
def _fallback_agent_summary(self, messages: list[dict[str, Any]], existing_memory: str) -> dict[str, str]:
|
||||
snippets = [str(item.get("content", "")).strip() for item in messages if str(item.get("content", "")).strip()]
|
||||
memory_parts = [
|
||||
"## Effective Patterns",
|
||||
*([f"- {snippet}" for snippet in snippets[-3:]] or ["- (none yet)"]),
|
||||
"",
|
||||
"## Watchouts",
|
||||
"- Avoid repeating failed or already-compacted paths without new evidence.",
|
||||
"",
|
||||
"## Current Progress",
|
||||
*([f"- {snippet}" for snippet in snippets[-2:]] or ["- (none)"]),
|
||||
"",
|
||||
"## Current State",
|
||||
f"- Existing process memory length: {len(existing_memory.strip())} characters",
|
||||
]
|
||||
return {
|
||||
"history_summary": "\n".join(f"- {snippet}" for snippet in snippets[-5:]) or "- No agent history captured.",
|
||||
"memory_summary": "\n".join(memory_parts).strip(),
|
||||
}
|
||||
|
||||
def _fallback_final_agent_memory(
|
||||
self,
|
||||
process_memory: str,
|
||||
reflection_payload: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
what_worked = [str(item).strip() for item in reflection_payload.get("what_worked", []) if str(item).strip()]
|
||||
watchouts = [str(item).strip() for item in reflection_payload.get("mistakes_to_avoid", []) if str(item).strip()]
|
||||
preferred_tools = [str(item).strip() for item in reflection_payload.get("tool_preferences", []) if str(item).strip()]
|
||||
reviewer_preferences = [str(item).strip() for item in reflection_payload.get("reviewer_preferences", []) if str(item).strip()]
|
||||
checklist = [str(item).strip() for item in reflection_payload.get("reusable_checklist", []) if str(item).strip()]
|
||||
parts = [
|
||||
"## Effective Patterns",
|
||||
*([f"- {item}" for item in what_worked[:6]] or ["- (none)"]),
|
||||
"",
|
||||
"## Watchouts",
|
||||
*([f"- {item}" for item in watchouts[:6]] or ["- (none)"]),
|
||||
"",
|
||||
"## Preferred Tools",
|
||||
*([f"- {item}" for item in preferred_tools[:6]] or ["- (none)"]),
|
||||
"",
|
||||
"## Reviewer Preferences",
|
||||
*([f"- {item}" for item in reviewer_preferences[:6]] or ["- (none)"]),
|
||||
"",
|
||||
"## Reusable Checklist",
|
||||
*([f"- {item}" for item in checklist[:6]] or ["- (none)"]),
|
||||
]
|
||||
summary_text = str(reflection_payload.get("project_summary", "")).strip() or process_memory.strip()
|
||||
return {
|
||||
"summary_text": summary_text,
|
||||
"memory_text": "\n".join(parts).strip(),
|
||||
"metadata": {
|
||||
"effective_patterns": what_worked[:6],
|
||||
"watchouts": watchouts[:6],
|
||||
"preferred_tools": preferred_tools[:6],
|
||||
"reviewer_preferences": reviewer_preferences[:6],
|
||||
"reusable_checklist": checklist[:6],
|
||||
},
|
||||
}
|
||||
|
||||
def _parse_json_response(self, raw: str) -> dict[str, Any]:
|
||||
text = raw.strip()
|
||||
if text.startswith("```"):
|
||||
parts = text.split("\n", 1)
|
||||
text = parts[1] if len(parts) == 2 else text[3:]
|
||||
if text.endswith("```"):
|
||||
text = text[:-3]
|
||||
text = text.strip()
|
||||
try:
|
||||
data = json.loads(text)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
try:
|
||||
data = json.loads(text[start : end + 1])
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse compaction JSON: {e}")
|
||||
return {}
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Markdown-backed storage for durable global/project memory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
_STRUCTURED_BEGIN = "<!-- OPC_STRUCTURED_MEMORY:BEGIN -->"
|
||||
_STRUCTURED_END = "<!-- OPC_STRUCTURED_MEMORY:END -->"
|
||||
|
||||
_EMPTY_SECRETARY = {
|
||||
"memory_notes": [],
|
||||
"authorization_rules": [],
|
||||
"workspace_guardrails": [],
|
||||
"skill_injection_rules": [],
|
||||
}
|
||||
|
||||
|
||||
class MarkdownMemoryStore:
|
||||
"""Persists pure Markdown memory at global and project scopes."""
|
||||
|
||||
def __init__(self, opc_home: Path) -> None:
|
||||
self.opc_home = Path(opc_home)
|
||||
self.global_memory_dir = self.opc_home / "memory"
|
||||
self.project_memory_dir = self.global_memory_dir / "projects"
|
||||
self.global_memory_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.project_memory_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.migrate_legacy_memory()
|
||||
|
||||
def memory_path(self, project_id: str | None = None) -> Path:
|
||||
project = str(project_id or "").strip()
|
||||
if project:
|
||||
return self.project_memory_dir / f"{project}.md"
|
||||
return self.global_memory_dir / "global.md"
|
||||
|
||||
def legacy_memory_path(self, project_id: str | None = None) -> Path:
|
||||
project = str(project_id or "").strip()
|
||||
if project:
|
||||
return self.opc_home / "projects" / project / "MEMORY.md"
|
||||
return self.global_memory_dir / "MEMORY.md"
|
||||
|
||||
def history_path(self, project_id: str | None = None) -> Path:
|
||||
project = str(project_id or "").strip()
|
||||
if project:
|
||||
return self.opc_home / "projects" / project / "HISTORY.md"
|
||||
return self.global_memory_dir / "HISTORY.md"
|
||||
|
||||
def load_raw_text(self, project_id: str | None = None) -> str:
|
||||
path = self.memory_path(project_id)
|
||||
if path.exists():
|
||||
return path.read_text(encoding="utf-8")
|
||||
return ""
|
||||
|
||||
def load_visible_text(self, project_id: str | None = None) -> str:
|
||||
return self._strip_structured_block(self.load_raw_text(project_id)).strip()
|
||||
|
||||
def save_visible_text(self, content: str, project_id: str | None = None) -> None:
|
||||
self._write_file(project_id, str(content).strip())
|
||||
|
||||
def append_visible_entry(self, entry: str, project_id: str | None = None) -> None:
|
||||
existing = self.load_visible_text(project_id)
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
updated = f"{existing}\n\n## [{timestamp}]\n{entry}".strip()
|
||||
self.save_visible_text(updated, project_id)
|
||||
|
||||
def ensure_memory_file(self, project_id: str | None = None, heading: str | None = None) -> Path:
|
||||
path = self.memory_path(project_id)
|
||||
if not path.exists():
|
||||
title = heading or (f"# Project Memory ({project_id})" if project_id else "# Global Memory")
|
||||
self._write_file(project_id, title)
|
||||
return path
|
||||
|
||||
def delete_project(self, project_id: str) -> None:
|
||||
path = self.memory_path(project_id)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
for legacy in (
|
||||
self.legacy_memory_path(project_id),
|
||||
self.history_path(project_id),
|
||||
self.opc_home / "projects" / project_id / "project_profile.yaml",
|
||||
self.opc_home / "projects" / project_id / "secretary_profile.yaml",
|
||||
):
|
||||
self._safe_unlink(legacy)
|
||||
|
||||
def migrate_legacy_memory(self) -> None:
|
||||
self._migrate_one(None)
|
||||
projects_dir = self.opc_home / "projects"
|
||||
if projects_dir.is_dir():
|
||||
for entry in sorted(projects_dir.iterdir()):
|
||||
if entry.is_dir():
|
||||
self._migrate_one(entry.name)
|
||||
self._safe_unlink(self.history_path(None))
|
||||
profiles_dir = self.opc_home / "profiles"
|
||||
if profiles_dir.exists():
|
||||
shutil.rmtree(profiles_dir, ignore_errors=True)
|
||||
|
||||
# Compatibility surface for old preference/secretary managers. These
|
||||
# managers are now disabled by default; returning empty structures keeps
|
||||
# existing callers harmless without reintroducing hidden YAML memory.
|
||||
def load_scope_data(self, project_id: str | None = None) -> dict[str, Any]:
|
||||
_ = project_id
|
||||
return {}
|
||||
|
||||
def save_scope_data(self, data: dict[str, Any], project_id: str | None = None) -> None:
|
||||
_ = (data, project_id)
|
||||
|
||||
def load_profile(self, project_id: str | None = None) -> dict[str, Any]:
|
||||
_ = project_id
|
||||
return {}
|
||||
|
||||
def save_profile(self, profile: dict[str, Any], project_id: str | None = None) -> None:
|
||||
_ = (profile, project_id)
|
||||
|
||||
def load_secretary(self, project_id: str | None = None) -> dict[str, Any]:
|
||||
_ = project_id
|
||||
return {key: list(value) for key, value in _EMPTY_SECRETARY.items()}
|
||||
|
||||
def save_secretary(self, secretary: dict[str, Any], project_id: str | None = None) -> None:
|
||||
_ = (secretary, project_id)
|
||||
|
||||
def _migrate_one(self, project_id: str | None) -> None:
|
||||
canonical = self.memory_path(project_id)
|
||||
legacy = self.legacy_memory_path(project_id)
|
||||
legacy_visible = self._read_visible_file(legacy)
|
||||
if legacy_visible:
|
||||
existing = self._strip_structured_block(
|
||||
canonical.read_text(encoding="utf-8") if canonical.exists() else ""
|
||||
)
|
||||
merged = self._merge_markdown(existing, legacy_visible)
|
||||
if merged != existing.strip():
|
||||
self._write_file(project_id, merged)
|
||||
self._safe_unlink(legacy)
|
||||
self._safe_unlink(self.history_path(project_id))
|
||||
if project_id:
|
||||
self._safe_unlink(self.opc_home / "projects" / project_id / "project_profile.yaml")
|
||||
self._safe_unlink(self.opc_home / "projects" / project_id / "secretary_profile.yaml")
|
||||
if canonical.exists():
|
||||
visible = self._strip_structured_block(canonical.read_text(encoding="utf-8")).strip()
|
||||
if visible != canonical.read_text(encoding="utf-8").strip():
|
||||
self._write_file(project_id, visible)
|
||||
|
||||
def _write_file(self, project_id: str | None, visible: str) -> None:
|
||||
path = self.memory_path(project_id)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cleaned = self._strip_structured_block(str(visible or "")).strip()
|
||||
path.write_text((cleaned + "\n") if cleaned else "", encoding="utf-8")
|
||||
|
||||
@staticmethod
|
||||
def _read_visible_file(path: Path) -> str:
|
||||
if not path.exists():
|
||||
return ""
|
||||
try:
|
||||
return MarkdownMemoryStore._strip_structured_block(path.read_text(encoding="utf-8")).strip()
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to read legacy memory file {path}: {exc}")
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _strip_structured_block(raw: str) -> str:
|
||||
if not str(raw or "").strip():
|
||||
return ""
|
||||
pattern = re.compile(
|
||||
rf"{re.escape(_STRUCTURED_BEGIN)}\s*```yaml\s*.*?\s*```\s*{re.escape(_STRUCTURED_END)}",
|
||||
re.DOTALL,
|
||||
)
|
||||
return pattern.sub("", raw).strip()
|
||||
|
||||
@staticmethod
|
||||
def _merge_markdown(existing: str, incoming: str) -> str:
|
||||
current = str(existing or "").strip()
|
||||
addition = str(incoming or "").strip()
|
||||
if not addition:
|
||||
return current
|
||||
if not current:
|
||||
return addition
|
||||
if addition in current:
|
||||
return current
|
||||
return f"{current}\n\n{addition}".strip()
|
||||
|
||||
@staticmethod
|
||||
def _safe_unlink(path: Path) -> None:
|
||||
try:
|
||||
if path.exists() and path.is_file():
|
||||
path.unlink()
|
||||
except Exception as exc:
|
||||
logger.debug(f"Failed to delete legacy memory file {path}: {exc}")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,116 @@
|
||||
"""Compatibility preference manager.
|
||||
|
||||
Durable user/project preferences now live in the canonical Markdown memory
|
||||
files and are edited by agents through the memory skill. This manager remains
|
||||
only for old call sites that still expect a preference object.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from opc.layer5_memory.markdown_memory import MarkdownMemoryStore
|
||||
|
||||
|
||||
class PreferenceManager:
|
||||
"""No-op compatibility facade for legacy structured preference storage."""
|
||||
|
||||
def __init__(self, opc_home) -> None:
|
||||
self.opc_home = opc_home
|
||||
self.memory_store = MarkdownMemoryStore(opc_home)
|
||||
|
||||
# --- Load ---
|
||||
|
||||
def load_global(self) -> dict[str, Any]:
|
||||
return self.memory_store.load_profile()
|
||||
|
||||
def load_project(self, project_id: str) -> dict[str, Any]:
|
||||
return self.memory_store.load_profile(project_id)
|
||||
|
||||
def load_project_knowledge(self, project_id: str) -> dict[str, Any]:
|
||||
profile = self.load_project(project_id)
|
||||
knowledge = profile.get("project_knowledge", {})
|
||||
return knowledge if isinstance(knowledge, dict) else {}
|
||||
|
||||
def load_merged(self, project_id: str | None = None) -> dict[str, Any]:
|
||||
"""Load and merge preferences with project overrides on top of global."""
|
||||
merged = self.load_global()
|
||||
if project_id:
|
||||
merged = self._deep_merge(merged, self.load_project(project_id))
|
||||
return merged
|
||||
|
||||
# --- Save ---
|
||||
|
||||
def save_global(self, prefs: dict[str, Any]) -> None:
|
||||
self.memory_store.save_profile(prefs)
|
||||
|
||||
def save_project(self, project_id: str, prefs: dict[str, Any]) -> None:
|
||||
self.memory_store.save_profile(prefs, project_id)
|
||||
|
||||
# --- Update (partial merge) ---
|
||||
|
||||
def update_global(self, updates: dict[str, Any]) -> None:
|
||||
_ = updates
|
||||
|
||||
def update_project(self, project_id: str, updates: dict[str, Any]) -> None:
|
||||
_ = (project_id, updates)
|
||||
|
||||
def update_project_knowledge(self, project_id: str, updates: dict[str, Any]) -> None:
|
||||
_ = (project_id, updates)
|
||||
|
||||
def record_autonomy_feedback(
|
||||
self,
|
||||
action_name: str,
|
||||
approved: bool,
|
||||
project_id: str | None = None,
|
||||
explicit: bool = False,
|
||||
notes: str = "",
|
||||
) -> None:
|
||||
_ = (action_name, approved, project_id, explicit, notes)
|
||||
|
||||
def get_autonomy_preferences(self, project_id: str | None = None) -> dict[str, Any]:
|
||||
_ = project_id
|
||||
return {}
|
||||
|
||||
def reset_autonomy_preferences(self, project_id: str | None = None) -> None:
|
||||
_ = project_id
|
||||
|
||||
# --- Preference context for agent prompts ---
|
||||
|
||||
def build_preference_context(self, project_id: str | None = None) -> str:
|
||||
_ = project_id
|
||||
return ""
|
||||
|
||||
def summarize_autonomy_preferences(self, project_id: str | None = None) -> str:
|
||||
_ = project_id
|
||||
return ""
|
||||
|
||||
def render_project_knowledge_context(self, project_id: str | None) -> str:
|
||||
_ = project_id
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _render_project_knowledge_lines(value: Any) -> list[str]:
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
return [f"- {text}"] if text else []
|
||||
if isinstance(value, list):
|
||||
return [f"- {str(item).strip()}" for item in value if str(item).strip()]
|
||||
if isinstance(value, dict):
|
||||
lines: list[str] = []
|
||||
for key, nested in value.items():
|
||||
nested_text = str(nested).strip()
|
||||
if nested_text:
|
||||
lines.append(f"- {key}: {nested_text}")
|
||||
return lines
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _deep_merge(base: dict, override: dict) -> dict:
|
||||
result = base.copy()
|
||||
for key, value in override.items():
|
||||
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
||||
result[key] = PreferenceManager._deep_merge(result[key], value)
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
@@ -0,0 +1,344 @@
|
||||
"""Compatibility secretary policy manager.
|
||||
|
||||
Secretary-authored durable rules are disabled. The class remains for older
|
||||
call sites, skill import plumbing, and tests that construct it directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from opc.layer5_memory.markdown_memory import MarkdownMemoryStore
|
||||
|
||||
|
||||
_DEFAULT_POLICY = {
|
||||
"version": 1,
|
||||
"memory_notes": [],
|
||||
"authorization_rules": [],
|
||||
"workspace_guardrails": [],
|
||||
"skill_injection_rules": [],
|
||||
}
|
||||
|
||||
_DEFAULT_RISKY_TOOLS = ["file_write", "file_edit", "shell_exec", "git_commit"]
|
||||
|
||||
|
||||
class SecretaryPolicyManager:
|
||||
"""No-op compatibility facade for legacy secretary policy storage."""
|
||||
|
||||
def __init__(self, opc_home: Path) -> None:
|
||||
self.opc_home = opc_home
|
||||
self.memory_store = MarkdownMemoryStore(opc_home)
|
||||
|
||||
def load_global(self) -> dict[str, Any]:
|
||||
return self._empty_policy()
|
||||
|
||||
def load_project(self, project_id: str) -> dict[str, Any]:
|
||||
_ = project_id
|
||||
return self._empty_policy()
|
||||
|
||||
def load_merged(self, project_id: str | None = None) -> dict[str, Any]:
|
||||
merged = self._empty_policy()
|
||||
global_policy = self.load_global()
|
||||
merged["memory_notes"].extend(global_policy["memory_notes"])
|
||||
merged["authorization_rules"].extend(global_policy["authorization_rules"])
|
||||
merged["workspace_guardrails"].extend(global_policy["workspace_guardrails"])
|
||||
merged["skill_injection_rules"].extend(global_policy["skill_injection_rules"])
|
||||
if project_id:
|
||||
project_policy = self.load_project(project_id)
|
||||
merged["memory_notes"].extend(project_policy["memory_notes"])
|
||||
merged["authorization_rules"].extend(project_policy["authorization_rules"])
|
||||
merged["workspace_guardrails"].extend(project_policy["workspace_guardrails"])
|
||||
merged["skill_injection_rules"].extend(project_policy["skill_injection_rules"])
|
||||
return merged
|
||||
|
||||
def add_rule(self, kind: str, rule: dict[str, Any], project_id: str | None = None) -> dict[str, Any]:
|
||||
_ = (kind, rule, project_id)
|
||||
return {}
|
||||
|
||||
def add_memory_note(self, note: str, project_id: str | None = None, source: str = "user") -> dict[str, Any]:
|
||||
_ = (note, project_id, source)
|
||||
return {}
|
||||
|
||||
def get_injected_skills(self, project_id: str | None, domains: list[str] | None = None) -> list[str]:
|
||||
_ = (project_id, domains)
|
||||
return []
|
||||
|
||||
def render_context(self, project_id: str | None = None, domains: list[str] | None = None) -> str:
|
||||
_ = (project_id, domains)
|
||||
return ""
|
||||
|
||||
def render_cross_project_skills(self, current_project_id: str | None = None) -> str:
|
||||
"""Build a summary of skills available across all projects for cross-project recommendations."""
|
||||
projects_dir = self.opc_home / "projects"
|
||||
if not projects_dir.exists():
|
||||
return ""
|
||||
lines: list[str] = []
|
||||
for child in sorted(projects_dir.iterdir()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
pid = child.name
|
||||
if pid == current_project_id:
|
||||
continue
|
||||
skills_dir = child / "skills"
|
||||
if not skills_dir.exists():
|
||||
continue
|
||||
skill_items: list[str] = []
|
||||
for skill_dir in sorted(skills_dir.iterdir()):
|
||||
if not skill_dir.is_dir():
|
||||
continue
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
if not skill_md.exists():
|
||||
continue
|
||||
try:
|
||||
import re
|
||||
text = skill_md.read_text(encoding="utf-8")
|
||||
fm_match = re.match(r"^---\s*\n(.*?)\n---\s*\n", text, re.DOTALL)
|
||||
if fm_match:
|
||||
fm = yaml.safe_load(fm_match.group(1)) or {}
|
||||
name = fm.get("name", skill_dir.name)
|
||||
desc = fm.get("description", "")
|
||||
skill_items.append(f" - **{name}**: {desc} [{skill_md}]")
|
||||
except Exception:
|
||||
continue
|
||||
if skill_items:
|
||||
lines.append(f"### Project: {pid}")
|
||||
lines.extend(skill_items)
|
||||
if not lines:
|
||||
return ""
|
||||
return "## Skills from Other Projects\nThese skills can be referenced via `file_read`.\n\n" + "\n".join(lines)
|
||||
|
||||
def summarize_policies(self, project_id: str | None = None) -> str:
|
||||
_ = project_id
|
||||
return "Secretary durable policies are disabled."
|
||||
|
||||
def evaluate_tool_policy(
|
||||
self,
|
||||
project_id: str | None,
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any],
|
||||
safe_command_prefixes: list[str] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
_ = (project_id, tool_name, arguments, safe_command_prefixes)
|
||||
return None
|
||||
|
||||
def _evaluate_workspace_guardrails(
|
||||
self,
|
||||
rules: list[dict[str, Any]],
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any],
|
||||
safe_command_prefixes: list[str],
|
||||
) -> dict[str, Any] | None:
|
||||
for rule in rules:
|
||||
if not rule.get("enabled", True):
|
||||
continue
|
||||
risky_tool_names = [str(item).strip() for item in rule.get("risky_tool_names", []) if str(item).strip()]
|
||||
if risky_tool_names and tool_name not in risky_tool_names:
|
||||
continue
|
||||
if tool_name == "shell_exec" and not self._command_is_risky(str(arguments.get("command", "")), safe_command_prefixes):
|
||||
continue
|
||||
allowed_roots = [self._normalize_path(value) for value in rule.get("allowed_roots", []) if str(value).strip()]
|
||||
if not allowed_roots:
|
||||
continue
|
||||
target_paths = self._extract_target_paths(tool_name, arguments)
|
||||
if not target_paths and tool_name == "shell_exec" and rule.get("require_working_directory_for_risky_shell", True):
|
||||
return {
|
||||
"effect": rule.get("outside_allowed_action", "escalate"),
|
||||
"reason": "Secretary guardrail requires an explicit working directory for risky shell commands.",
|
||||
"rule_id": rule.get("id", ""),
|
||||
"policy_type": "workspace_guardrail",
|
||||
}
|
||||
if target_paths and all(not self._path_within(path, allowed_roots) for path in target_paths):
|
||||
return {
|
||||
"effect": rule.get("outside_allowed_action", "escalate"),
|
||||
"reason": (
|
||||
"Secretary guardrail blocks risky actions outside approved roots: "
|
||||
+ ", ".join(allowed_roots[:3])
|
||||
),
|
||||
"rule_id": rule.get("id", ""),
|
||||
"policy_type": "workspace_guardrail",
|
||||
}
|
||||
return None
|
||||
|
||||
def _evaluate_authorization_rules(
|
||||
self,
|
||||
rules: list[dict[str, Any]],
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
for rule in rules:
|
||||
if not rule.get("enabled", True):
|
||||
continue
|
||||
if str(rule.get("tool_name", "")).strip() != tool_name:
|
||||
continue
|
||||
path_prefixes = [self._normalize_path(value) for value in rule.get("path_prefixes", []) if str(value).strip()]
|
||||
target_paths = self._extract_target_paths(tool_name, arguments)
|
||||
if path_prefixes and target_paths:
|
||||
if not all(self._path_within(path, path_prefixes) for path in target_paths):
|
||||
continue
|
||||
return {
|
||||
"effect": rule.get("action", "auto_allow"),
|
||||
"reason": str(rule.get("rationale", "")).strip() or "Matched a secretary authorization rule.",
|
||||
"rule_id": rule.get("id", ""),
|
||||
"policy_type": "authorization_rule",
|
||||
}
|
||||
return None
|
||||
|
||||
def _extract_target_paths(self, tool_name: str, arguments: dict[str, Any]) -> list[str]:
|
||||
if tool_name in {"file_read", "file_write", "file_edit"}:
|
||||
path = str(arguments.get("path", "")).strip()
|
||||
return [self._normalize_path(path)] if path else []
|
||||
if tool_name == "shell_exec":
|
||||
cwd = str(arguments.get("working_directory", "")).strip()
|
||||
return [self._normalize_path(cwd)] if cwd else []
|
||||
return []
|
||||
|
||||
def _command_is_risky(self, command: str, safe_command_prefixes: list[str]) -> bool:
|
||||
cleaned = command.strip()
|
||||
if not cleaned:
|
||||
return False
|
||||
if self._command_has_redirection(cleaned):
|
||||
return True
|
||||
segments = self._split_shell_command_segments(cleaned)
|
||||
if len(segments) != 1:
|
||||
return True
|
||||
segment = " ".join(segments[0]).strip().casefold()
|
||||
for prefix in safe_command_prefixes:
|
||||
candidate = str(prefix or "").strip().casefold()
|
||||
if not candidate:
|
||||
continue
|
||||
if segment == candidate or segment.startswith(f"{candidate} "):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _split_shell_command_segments(self, command: str) -> list[list[str]]:
|
||||
text = str(command or "").replace("\r\n", "\n").replace("\n", " ; ").strip()
|
||||
if not text:
|
||||
return []
|
||||
try:
|
||||
lexer = shlex.shlex(text, posix=True, punctuation_chars=";&|")
|
||||
lexer.whitespace_split = True
|
||||
lexer.commenters = ""
|
||||
tokens = list(lexer)
|
||||
except ValueError:
|
||||
try:
|
||||
tokens = shlex.split(text)
|
||||
except ValueError:
|
||||
tokens = text.split()
|
||||
|
||||
segments: list[list[str]] = []
|
||||
current: list[str] = []
|
||||
for token in tokens:
|
||||
if token in {"&&", "||", ";", "|", "&"}:
|
||||
if current:
|
||||
segments.append(current)
|
||||
current = []
|
||||
continue
|
||||
current.append(token)
|
||||
if current:
|
||||
segments.append(current)
|
||||
return segments
|
||||
|
||||
def _command_has_redirection(self, command: str) -> bool:
|
||||
text = str(command or "").replace("\r\n", "\n").replace("\n", " ; ").strip()
|
||||
if not text:
|
||||
return False
|
||||
try:
|
||||
lexer = shlex.shlex(text, posix=True, punctuation_chars=";&|<>")
|
||||
lexer.whitespace_split = True
|
||||
lexer.commenters = ""
|
||||
tokens = list(lexer)
|
||||
except ValueError:
|
||||
return any(marker in text for marker in (">", "<"))
|
||||
return any(token in {">", ">>", "<", "<<"} for token in tokens)
|
||||
|
||||
def _normalize_policy(self, payload: dict[str, Any], scope: str) -> dict[str, Any]:
|
||||
data = self._empty_policy()
|
||||
data.update(payload or {})
|
||||
data["memory_notes"] = [
|
||||
self._normalize_memory_note(item, scope=scope)
|
||||
for item in list(data.get("memory_notes", []))
|
||||
if isinstance(item, dict) or str(item).strip()
|
||||
]
|
||||
data["authorization_rules"] = [
|
||||
self._normalize_rule("authorization_rules", item, scope=scope)
|
||||
for item in list(data.get("authorization_rules", []))
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
data["workspace_guardrails"] = [
|
||||
self._normalize_rule("workspace_guardrails", item, scope=scope)
|
||||
for item in list(data.get("workspace_guardrails", []))
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
data["skill_injection_rules"] = [
|
||||
self._normalize_rule("skill_injection_rules", item, scope=scope)
|
||||
for item in list(data.get("skill_injection_rules", []))
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
return data
|
||||
|
||||
def _normalize_memory_note(self, item: dict[str, Any] | str, scope: str) -> dict[str, Any]:
|
||||
if isinstance(item, str):
|
||||
return {
|
||||
"id": f"note-{datetime.now().strftime('%Y%m%d%H%M%S%f')}",
|
||||
"text": item.strip(),
|
||||
"scope": scope,
|
||||
"source": "imported",
|
||||
"created_at": datetime.now().isoformat(),
|
||||
}
|
||||
note = dict(item)
|
||||
note.setdefault("id", f"note-{datetime.now().strftime('%Y%m%d%H%M%S%f')}")
|
||||
note["text"] = str(note.get("text", "")).strip()
|
||||
note.setdefault("scope", scope)
|
||||
note.setdefault("source", "user")
|
||||
note.setdefault("created_at", datetime.now().isoformat())
|
||||
return note
|
||||
|
||||
def _normalize_rule(self, kind: str, item: dict[str, Any], scope: str) -> dict[str, Any]:
|
||||
rule = deepcopy(item)
|
||||
rule.setdefault("id", f"{kind[:-1]}-{datetime.now().strftime('%Y%m%d%H%M%S%f')}")
|
||||
rule.setdefault("scope", scope)
|
||||
rule.setdefault("enabled", True)
|
||||
rule.setdefault("created_at", datetime.now().isoformat())
|
||||
if kind == "authorization_rules":
|
||||
rule["tool_name"] = str(rule.get("tool_name", "")).strip()
|
||||
rule["action"] = str(rule.get("action", "auto_allow")).strip() or "auto_allow"
|
||||
rule["path_prefixes"] = [self._normalize_path(value) for value in rule.get("path_prefixes", []) if str(value).strip()]
|
||||
rule["rationale"] = str(rule.get("rationale", "")).strip()
|
||||
elif kind == "workspace_guardrails":
|
||||
rule["allowed_roots"] = [self._normalize_path(value) for value in rule.get("allowed_roots", []) if str(value).strip()]
|
||||
tool_names = [str(item).strip() for item in rule.get("risky_tool_names", []) if str(item).strip()]
|
||||
rule["risky_tool_names"] = tool_names or list(_DEFAULT_RISKY_TOOLS)
|
||||
rule["outside_allowed_action"] = str(rule.get("outside_allowed_action", "escalate")).strip() or "escalate"
|
||||
rule["require_working_directory_for_risky_shell"] = bool(
|
||||
rule.get("require_working_directory_for_risky_shell", True)
|
||||
)
|
||||
rule["rationale"] = str(rule.get("rationale", "")).strip()
|
||||
elif kind == "skill_injection_rules":
|
||||
rule["domains"] = [str(value).strip() for value in rule.get("domains", []) if str(value).strip()]
|
||||
rule["skill_names"] = [str(value).strip() for value in rule.get("skill_names", []) if str(value).strip()]
|
||||
rule["rationale"] = str(rule.get("rationale", "")).strip()
|
||||
return rule
|
||||
|
||||
def _normalize_path(self, value: str) -> str:
|
||||
try:
|
||||
return str(Path(str(value).strip()).expanduser().resolve(strict=False))
|
||||
except Exception:
|
||||
return str(value).strip()
|
||||
|
||||
def _path_within(self, candidate: str, allowed_roots: list[str]) -> bool:
|
||||
normalized = self._normalize_path(candidate)
|
||||
for root in allowed_roots:
|
||||
if normalized == root:
|
||||
return True
|
||||
if normalized.startswith(root.rstrip("/") + "/"):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _empty_policy(self) -> dict[str, Any]:
|
||||
return deepcopy(_DEFAULT_POLICY)
|
||||
@@ -0,0 +1,572 @@
|
||||
"""Import and normalize skills from multiple sources for immediate project use."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import shutil
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
import yaml
|
||||
from loguru import logger
|
||||
|
||||
from opc.layer5_memory.secretary_policy import SecretaryPolicyManager
|
||||
from opc.layer5_memory.skill_library import SkillLibrary
|
||||
|
||||
ALLOWED_FRONTMATTER_KEYS = {
|
||||
"name",
|
||||
"description",
|
||||
"metadata",
|
||||
"always",
|
||||
"license",
|
||||
"allowed-tools",
|
||||
"homepage",
|
||||
}
|
||||
ALLOWED_RESOURCE_DIRS = {"scripts", "references", "assets"}
|
||||
MAX_SKILL_NAME_LENGTH = 64
|
||||
_SLUG_RE = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")
|
||||
_PLACEHOLDER_MARKERS = ("[todo", "todo:")
|
||||
_STOPWORDS = {
|
||||
"clawhub",
|
||||
"skill",
|
||||
"skills",
|
||||
"search",
|
||||
"install",
|
||||
"update",
|
||||
"results",
|
||||
"latest",
|
||||
}
|
||||
|
||||
CommandRunner = Callable[[list[str], Path], Awaitable[tuple[int, str, str]]]
|
||||
|
||||
|
||||
class SkillImportError(RuntimeError):
|
||||
"""Raised when importing or normalizing a skill fails."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillImportResult:
|
||||
skill_name: str
|
||||
skill_path: str
|
||||
source_slug: str
|
||||
validation_message: str
|
||||
available: bool = True
|
||||
enabled_domains: list[str] = field(default_factory=list)
|
||||
search_output: str = ""
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class ExternalSkillImporter:
|
||||
"""Imports skills from external or local sources and normalizes them."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
skill_library: SkillLibrary,
|
||||
policies: SecretaryPolicyManager | None = None,
|
||||
command_runner: CommandRunner | None = None,
|
||||
) -> None:
|
||||
self.skill_library = skill_library
|
||||
self.policies = policies
|
||||
self.opc_home = skill_library.projects_dir.parent
|
||||
self.command_runner = command_runner or self._run_command
|
||||
|
||||
async def import_skill(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
source: str = "clawhub",
|
||||
query: str = "",
|
||||
slug: str = "",
|
||||
path: str = "",
|
||||
domains: list[str] | None = None,
|
||||
enable: bool = True,
|
||||
) -> SkillImportResult:
|
||||
if not project_id:
|
||||
raise SkillImportError("Skill import requires a project context.")
|
||||
|
||||
cleaned_query = str(query).strip()
|
||||
cleaned_slug = self.normalize_skill_name(slug)
|
||||
cleaned_source = str(source or "clawhub").strip().lower()
|
||||
source_path = str(path).strip()
|
||||
search_output = ""
|
||||
project_root = self.opc_home / "projects" / project_id
|
||||
skills_root = project_root / "skills"
|
||||
project_root.mkdir(parents=True, exist_ok=True)
|
||||
skills_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
imported_dir: Path
|
||||
if cleaned_source == "clawhub":
|
||||
if not cleaned_slug:
|
||||
cleaned_slug, search_output = await self._resolve_slug(cleaned_query)
|
||||
imported_dir = await self._install_from_clawhub(
|
||||
project_root=project_root,
|
||||
skills_root=skills_root,
|
||||
slug=cleaned_slug,
|
||||
)
|
||||
elif cleaned_source in {"path", "directory", "local"}:
|
||||
imported_dir = self._prepare_local_source(
|
||||
skills_root=skills_root,
|
||||
source_path=source_path,
|
||||
suggested_name=cleaned_slug or Path(source_path or "imported-skill").name,
|
||||
)
|
||||
cleaned_slug = cleaned_slug or self.normalize_skill_name(Path(source_path).name)
|
||||
else:
|
||||
raise SkillImportError(
|
||||
f"Unsupported skill source '{cleaned_source}'. Supported sources: clawhub, path."
|
||||
)
|
||||
|
||||
final_dir, final_name, warnings = self._normalize_imported_dir(
|
||||
imported_dir,
|
||||
source=cleaned_source,
|
||||
source_slug=cleaned_slug,
|
||||
query=cleaned_query,
|
||||
source_path=source_path,
|
||||
)
|
||||
valid, validation_message = validate_skill_directory(final_dir)
|
||||
if not valid:
|
||||
raise SkillImportError(validation_message)
|
||||
|
||||
self.skill_library.load_all(project_id)
|
||||
available = self.skill_library.get(final_name) is not None
|
||||
if not available:
|
||||
raise SkillImportError(f"Imported skill '{final_name}' was normalized but did not load into the skill library.")
|
||||
|
||||
enabled_domains = self._enable_domains(final_name, project_id, domains or [], enable=enable)
|
||||
return SkillImportResult(
|
||||
skill_name=final_name,
|
||||
skill_path=str(final_dir),
|
||||
source_slug=cleaned_slug,
|
||||
validation_message=validation_message,
|
||||
available=True,
|
||||
enabled_domains=enabled_domains,
|
||||
search_output=search_output,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
async def _install_from_clawhub(self, *, project_root: Path, skills_root: Path, slug: str) -> Path:
|
||||
before = {child.name for child in skills_root.iterdir() if child.is_dir()}
|
||||
exit_code, stdout, stderr = await self.command_runner(
|
||||
["npx", "--yes", "clawhub@latest", "install", slug, "--workdir", str(project_root)],
|
||||
self.opc_home,
|
||||
)
|
||||
if exit_code != 0:
|
||||
message = (stderr or stdout).strip()
|
||||
if "Unsupported engine" in message or "Node.js v" in message:
|
||||
raise SkillImportError(
|
||||
"ClawHub install failed. This environment needs Node.js >= 20 to run "
|
||||
"`npx clawhub@latest`."
|
||||
)
|
||||
raise SkillImportError(message or f"ClawHub install failed for '{slug}'.")
|
||||
return self._locate_imported_dir(skills_root, before, slug)
|
||||
|
||||
def _prepare_local_source(self, *, skills_root: Path, source_path: str, suggested_name: str) -> Path:
|
||||
if not source_path:
|
||||
raise SkillImportError("Path-based skill import requires a `path`.")
|
||||
resolved = Path(source_path).expanduser().resolve(strict=False)
|
||||
if not resolved.exists() or not resolved.is_dir():
|
||||
raise SkillImportError(f"Local skill source does not exist or is not a directory: {resolved}")
|
||||
|
||||
prepared_name = self.normalize_skill_name(suggested_name) or "imported-skill"
|
||||
prepared_dir = self._build_prepare_dir(skills_root, prepared_name)
|
||||
shutil.copytree(resolved, prepared_dir, dirs_exist_ok=False)
|
||||
return prepared_dir
|
||||
|
||||
async def _resolve_slug(self, query: str) -> tuple[str, str]:
|
||||
cleaned = str(query).strip().strip("`\"'")
|
||||
if not cleaned:
|
||||
raise SkillImportError("Need a skill query or exact slug to import.")
|
||||
if self._looks_like_slug(cleaned):
|
||||
return self.normalize_skill_name(cleaned), ""
|
||||
|
||||
exit_code, stdout, stderr = await self.command_runner(
|
||||
["npx", "--yes", "clawhub@latest", "search", cleaned, "--limit", "5"],
|
||||
self.opc_home,
|
||||
)
|
||||
if exit_code != 0:
|
||||
message = (stderr or stdout).strip()
|
||||
if "Unsupported engine" in message or "Node.js v" in message:
|
||||
raise SkillImportError(
|
||||
"ClawHub search failed. This environment needs Node.js >= 20 to run "
|
||||
"`npx clawhub@latest`."
|
||||
)
|
||||
raise SkillImportError(message or f"ClawHub search failed for '{cleaned}'.")
|
||||
|
||||
candidates = self._extract_slug_candidates(stdout)
|
||||
if not candidates:
|
||||
raise SkillImportError(
|
||||
"Could not determine a ClawHub slug from search results. Please specify the exact skill slug."
|
||||
)
|
||||
return candidates[0], stdout.strip()
|
||||
|
||||
def _locate_imported_dir(self, skills_root: Path, before: set[str], slug: str) -> Path:
|
||||
preferred = skills_root / self.normalize_skill_name(slug)
|
||||
if preferred.exists():
|
||||
return preferred
|
||||
|
||||
after_dirs = [child for child in skills_root.iterdir() if child.is_dir()]
|
||||
new_dirs = [child for child in after_dirs if child.name not in before]
|
||||
if len(new_dirs) == 1:
|
||||
return new_dirs[0]
|
||||
|
||||
matches = [child for child in after_dirs if self.normalize_skill_name(slug) in child.name]
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
|
||||
if after_dirs:
|
||||
after_dirs.sort(key=lambda item: item.stat().st_mtime, reverse=True)
|
||||
return after_dirs[0]
|
||||
raise SkillImportError(f"Could not locate the installed skill directory for '{slug}'.")
|
||||
|
||||
def _normalize_imported_dir(
|
||||
self,
|
||||
skill_dir: Path,
|
||||
*,
|
||||
source: str,
|
||||
source_slug: str,
|
||||
query: str,
|
||||
source_path: str,
|
||||
) -> tuple[Path, str, list[str]]:
|
||||
source_skill_md = skill_dir / "SKILL.md"
|
||||
warnings: list[str] = []
|
||||
if not source_skill_md.exists():
|
||||
matches = sorted(skill_dir.rglob("SKILL.md"))
|
||||
if not matches:
|
||||
raise SkillImportError(f"Installed skill at {skill_dir} does not contain a SKILL.md file.")
|
||||
source_skill_md = matches[0]
|
||||
if source_skill_md.parent != skill_dir:
|
||||
warnings.append("Imported skill had nested content; normalized from the nested SKILL.md root.")
|
||||
|
||||
frontmatter, body = _load_skill_document(source_skill_md.read_text(encoding="utf-8"))
|
||||
proposed_name = (
|
||||
str(frontmatter.get("name", "")).strip()
|
||||
or source_slug
|
||||
or skill_dir.name
|
||||
or "imported-skill"
|
||||
)
|
||||
desired_name = self.normalize_skill_name(proposed_name) or self.normalize_skill_name(source_slug) or "imported-skill"
|
||||
final_name = self._dedupe_skill_name(skill_dir.parent, desired_name, current_dir=skill_dir)
|
||||
|
||||
description = self._normalize_description(frontmatter.get("description"), body, final_name)
|
||||
normalized_frontmatter: dict[str, Any] = {
|
||||
"name": final_name,
|
||||
"description": description,
|
||||
}
|
||||
if isinstance(frontmatter.get("always"), bool):
|
||||
normalized_frontmatter["always"] = frontmatter["always"]
|
||||
|
||||
metadata: dict[str, Any] = {}
|
||||
if isinstance(frontmatter.get("metadata"), dict):
|
||||
metadata.update(frontmatter["metadata"])
|
||||
|
||||
imported_extra_frontmatter: dict[str, Any] = {}
|
||||
for key, value in frontmatter.items():
|
||||
if key in {"name", "description", "always", "metadata"}:
|
||||
continue
|
||||
if key in {"license", "allowed-tools", "homepage"} and value not in (None, ""):
|
||||
normalized_frontmatter[key] = value
|
||||
else:
|
||||
imported_extra_frontmatter[key] = value
|
||||
|
||||
metadata.setdefault("imported_from", {
|
||||
"source": source,
|
||||
"slug": source_slug,
|
||||
"query": query,
|
||||
"path": source_path,
|
||||
"imported_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
if imported_extra_frontmatter:
|
||||
metadata["imported_frontmatter"] = imported_extra_frontmatter
|
||||
if metadata:
|
||||
normalized_frontmatter["metadata"] = metadata
|
||||
|
||||
normalized_body = body.strip()
|
||||
if not normalized_body:
|
||||
normalized_body = (
|
||||
f"# {final_name}\n\n"
|
||||
f"Imported and normalized from {source}. Fill in more guidance if this skill needs project-specific detail.\n"
|
||||
)
|
||||
warnings.append("Imported skill had no body content; inserted a minimal placeholder body.")
|
||||
|
||||
prepare_dir = self._build_prepare_dir(skill_dir.parent, final_name)
|
||||
prepare_dir.mkdir(parents=True, exist_ok=False)
|
||||
try:
|
||||
skill_md = prepare_dir / "SKILL.md"
|
||||
skill_md.write_text(
|
||||
_render_skill_document(normalized_frontmatter, normalized_body),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
source_root = source_skill_md.parent
|
||||
for child in source_root.iterdir():
|
||||
if child.name == "SKILL.md":
|
||||
continue
|
||||
if child.is_dir() and child.name in ALLOWED_RESOURCE_DIRS:
|
||||
shutil.copytree(child, prepare_dir / child.name, dirs_exist_ok=True)
|
||||
continue
|
||||
fallback = prepare_dir / "assets" / "imported-root" / child.name
|
||||
_copy_path(child, fallback)
|
||||
|
||||
final_dir = skill_dir.parent / final_name
|
||||
backup_dir = self._build_backup_dir(skill_dir.parent, skill_dir.name)
|
||||
if backup_dir.exists():
|
||||
shutil.rmtree(backup_dir, ignore_errors=True)
|
||||
if skill_dir.exists():
|
||||
skill_dir.rename(backup_dir)
|
||||
prepare_dir.rename(final_dir)
|
||||
shutil.rmtree(backup_dir, ignore_errors=True)
|
||||
return final_dir, final_name, warnings
|
||||
except Exception:
|
||||
shutil.rmtree(prepare_dir, ignore_errors=True)
|
||||
raise
|
||||
|
||||
def _enable_domains(self, skill_name: str, project_id: str, domains: list[str], *, enable: bool) -> list[str]:
|
||||
if not enable or not self.policies:
|
||||
return []
|
||||
|
||||
normalized_domains: list[str] = []
|
||||
for item in domains:
|
||||
value = str(item).strip()
|
||||
if value and value not in normalized_domains:
|
||||
normalized_domains.append(value)
|
||||
if not normalized_domains:
|
||||
return []
|
||||
|
||||
existing_rules = self.policies.load_project(project_id).get("skill_injection_rules", [])
|
||||
for rule in existing_rules:
|
||||
if not rule.get("enabled", True):
|
||||
continue
|
||||
current_domains = [str(item).strip() for item in rule.get("domains", []) if str(item).strip()]
|
||||
current_skills = [str(item).strip() for item in rule.get("skill_names", []) if str(item).strip()]
|
||||
if current_domains == normalized_domains and skill_name in current_skills:
|
||||
return normalized_domains
|
||||
|
||||
self.policies.add_rule(
|
||||
"skill_injection_rules",
|
||||
{
|
||||
"domains": normalized_domains,
|
||||
"skill_names": [skill_name],
|
||||
"rationale": "Imported by the secretary and enabled for immediate project use.",
|
||||
},
|
||||
project_id=project_id,
|
||||
)
|
||||
return normalized_domains
|
||||
|
||||
def _extract_slug_candidates(self, output: str) -> list[str]:
|
||||
candidates: list[str] = []
|
||||
for raw_line in output.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
matches = [
|
||||
re.search(r"`([a-z0-9]+(?:-[a-z0-9]+)*)`", line),
|
||||
re.search(r"^\d+[\).\s-]+([a-z0-9]+(?:-[a-z0-9]+)*)\b", line),
|
||||
re.search(r"^\|\s*([a-z0-9]+(?:-[a-z0-9]+)*)\s*\|", line),
|
||||
re.search(r"^[-*]\s*([a-z0-9]+(?:-[a-z0-9]+)*)\b", line),
|
||||
]
|
||||
chosen = next((match.group(1) for match in matches if match), "")
|
||||
if not chosen:
|
||||
for token in _SLUG_RE.findall(line):
|
||||
if token in _STOPWORDS:
|
||||
continue
|
||||
if "-" not in token and len(token) < 6:
|
||||
continue
|
||||
chosen = token
|
||||
break
|
||||
if chosen and chosen not in candidates:
|
||||
candidates.append(chosen)
|
||||
return candidates
|
||||
|
||||
@staticmethod
|
||||
def normalize_skill_name(raw: str) -> str:
|
||||
normalized = raw.strip().lower()
|
||||
normalized = re.sub(r"[^a-z0-9]+", "-", normalized)
|
||||
normalized = normalized.strip("-")
|
||||
normalized = re.sub(r"-{2,}", "-", normalized)
|
||||
return normalized[:MAX_SKILL_NAME_LENGTH]
|
||||
|
||||
@staticmethod
|
||||
def _looks_like_slug(value: str) -> bool:
|
||||
return bool(re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", value.strip().lower()))
|
||||
|
||||
def _normalize_description(self, description: Any, body: str, fallback_name: str) -> str:
|
||||
if isinstance(description, str) and _validate_description(description.strip()) is None:
|
||||
return description.strip()
|
||||
|
||||
heading_match = re.search(r"^\s*#\s+(.+?)\s*$", body, re.MULTILINE)
|
||||
first_sentence = ""
|
||||
for line in body.splitlines():
|
||||
stripped = line.strip().strip("#").strip()
|
||||
if not stripped:
|
||||
continue
|
||||
if stripped.lower().startswith("description:"):
|
||||
continue
|
||||
first_sentence = stripped
|
||||
break
|
||||
fallback = heading_match.group(1).strip() if heading_match else first_sentence
|
||||
fallback = fallback.strip()
|
||||
if not fallback:
|
||||
fallback = f"Imported skill `{fallback_name}`."
|
||||
if len(fallback) > 1024:
|
||||
fallback = fallback[:1021].rstrip() + "..."
|
||||
if _validate_description(fallback) is not None:
|
||||
return f"Imported skill `{fallback_name}` for project use."
|
||||
return fallback
|
||||
|
||||
def _dedupe_skill_name(self, parent: Path, desired_name: str, *, current_dir: Path) -> str:
|
||||
candidate = desired_name[:MAX_SKILL_NAME_LENGTH]
|
||||
if not candidate:
|
||||
candidate = "imported-skill"
|
||||
if not (parent / candidate).exists() or current_dir.name == candidate:
|
||||
return candidate
|
||||
for index in range(2, 100):
|
||||
suffix = f"-{index}"
|
||||
trimmed = candidate[: MAX_SKILL_NAME_LENGTH - len(suffix)].rstrip("-")
|
||||
attempt = f"{trimmed}{suffix}"
|
||||
if not (parent / attempt).exists():
|
||||
return attempt
|
||||
raise SkillImportError(f"Could not find an available normalized name for imported skill '{desired_name}'.")
|
||||
|
||||
@staticmethod
|
||||
def _build_prepare_dir(parent: Path, final_name: str) -> Path:
|
||||
for index in range(1, 100):
|
||||
temp_dir = parent / f".{final_name}.normalize-{index}"
|
||||
if not temp_dir.exists():
|
||||
return temp_dir
|
||||
raise SkillImportError(f"Could not allocate a temporary normalization directory for '{final_name}'.")
|
||||
|
||||
@staticmethod
|
||||
def _build_backup_dir(parent: Path, original_name: str) -> Path:
|
||||
for index in range(1, 100):
|
||||
backup = parent / f".{original_name}.backup-{index}"
|
||||
if not backup.exists():
|
||||
return backup
|
||||
raise SkillImportError(f"Could not allocate a backup directory for '{original_name}'.")
|
||||
|
||||
@staticmethod
|
||||
async def _run_command(argv: list[str], cwd: Path) -> tuple[int, str, str]:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*argv,
|
||||
cwd=str(cwd),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout_bytes, stderr_bytes = await proc.communicate()
|
||||
return proc.returncode or 0, stdout_bytes.decode("utf-8", errors="replace"), stderr_bytes.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def validate_skill_directory(skill_path: Path) -> tuple[bool, str]:
|
||||
skill_path = Path(skill_path).resolve()
|
||||
if not skill_path.exists():
|
||||
return False, f"Skill folder not found: {skill_path}"
|
||||
if not skill_path.is_dir():
|
||||
return False, f"Path is not a directory: {skill_path}"
|
||||
|
||||
skill_md = skill_path / "SKILL.md"
|
||||
if not skill_md.exists():
|
||||
return False, "SKILL.md not found"
|
||||
|
||||
frontmatter, _ = _load_skill_document(skill_md.read_text(encoding="utf-8"))
|
||||
unexpected_keys = sorted(set(frontmatter.keys()) - ALLOWED_FRONTMATTER_KEYS)
|
||||
if unexpected_keys:
|
||||
allowed = ", ".join(sorted(ALLOWED_FRONTMATTER_KEYS))
|
||||
return False, (
|
||||
f"Unexpected key(s) in SKILL.md frontmatter: {', '.join(unexpected_keys)}. "
|
||||
f"Allowed properties are: {allowed}"
|
||||
)
|
||||
|
||||
name = frontmatter.get("name")
|
||||
if not isinstance(name, str):
|
||||
return False, "Missing or invalid 'name' in frontmatter"
|
||||
name_error = _validate_skill_name(name.strip(), skill_path.name)
|
||||
if name_error:
|
||||
return False, name_error
|
||||
|
||||
description = frontmatter.get("description")
|
||||
if not isinstance(description, str):
|
||||
return False, "Missing or invalid 'description' in frontmatter"
|
||||
description_error = _validate_description(description.strip())
|
||||
if description_error:
|
||||
return False, description_error
|
||||
|
||||
always = frontmatter.get("always")
|
||||
if always is not None and not isinstance(always, bool):
|
||||
return False, f"'always' must be a boolean, got {type(always).__name__}"
|
||||
|
||||
metadata = frontmatter.get("metadata")
|
||||
if metadata is not None and not isinstance(metadata, dict):
|
||||
return False, f"'metadata' must be a dictionary, got {type(metadata).__name__}"
|
||||
|
||||
for child in skill_path.iterdir():
|
||||
if child.name == "SKILL.md":
|
||||
continue
|
||||
if child.is_dir() and child.name in ALLOWED_RESOURCE_DIRS:
|
||||
continue
|
||||
if child.is_symlink():
|
||||
continue
|
||||
return (
|
||||
False,
|
||||
f"Unexpected file or directory in skill root: {child.name}. "
|
||||
"Only SKILL.md, scripts/, references/, and assets/ are allowed.",
|
||||
)
|
||||
return True, "Skill is valid!"
|
||||
|
||||
|
||||
def _load_skill_document(text: str) -> tuple[dict[str, Any], str]:
|
||||
if text.startswith("---"):
|
||||
parts = text.split("\n")
|
||||
for index in range(1, len(parts)):
|
||||
if parts[index].strip() == "---":
|
||||
frontmatter_text = "\n".join(parts[1:index])
|
||||
body = "\n".join(parts[index + 1 :]).lstrip("\n")
|
||||
try:
|
||||
frontmatter = yaml.safe_load(frontmatter_text) or {}
|
||||
except yaml.YAMLError as exc:
|
||||
logger.warning(f"Failed to parse skill frontmatter: {exc}")
|
||||
frontmatter = {}
|
||||
return frontmatter if isinstance(frontmatter, dict) else {}, body
|
||||
return {}, text
|
||||
|
||||
|
||||
def _render_skill_document(frontmatter: dict[str, Any], body: str) -> str:
|
||||
fm = yaml.dump(frontmatter, default_flow_style=False, allow_unicode=True, sort_keys=False).strip()
|
||||
return f"---\n{fm}\n---\n\n{body.rstrip()}\n"
|
||||
|
||||
|
||||
def _copy_path(source: Path, destination: Path) -> None:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
if source.is_dir():
|
||||
shutil.copytree(source, destination, dirs_exist_ok=True)
|
||||
else:
|
||||
shutil.copy2(source, destination)
|
||||
|
||||
|
||||
def _validate_skill_name(name: str, folder_name: str) -> str | None:
|
||||
if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name):
|
||||
return (
|
||||
f"Name '{name}' should be hyphen-case "
|
||||
"(lowercase letters, digits, and single hyphens only)"
|
||||
)
|
||||
if len(name) > MAX_SKILL_NAME_LENGTH:
|
||||
return (
|
||||
f"Name is too long ({len(name)} characters). Maximum is {MAX_SKILL_NAME_LENGTH} characters."
|
||||
)
|
||||
if name != folder_name:
|
||||
return f"Skill name '{name}' must match directory name '{folder_name}'"
|
||||
return None
|
||||
|
||||
|
||||
def _validate_description(description: str) -> str | None:
|
||||
trimmed = description.strip()
|
||||
if not trimmed:
|
||||
return "Description cannot be empty"
|
||||
lowered = trimmed.lower()
|
||||
if any(marker in lowered for marker in _PLACEHOLDER_MARKERS):
|
||||
return "Description still contains TODO placeholder text"
|
||||
if "<" in trimmed or ">" in trimmed:
|
||||
return "Description cannot contain angle brackets (< or >)"
|
||||
if len(trimmed) > 1024:
|
||||
return f"Description is too long ({len(trimmed)} characters). Maximum is 1024 characters."
|
||||
return None
|
||||
@@ -0,0 +1,295 @@
|
||||
"""Skill library — loads and manages SKILL.md format skills (nanobot-compatible)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from loguru import logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class Skill:
|
||||
name: str
|
||||
description: str = ""
|
||||
always: bool = False
|
||||
content: str = ""
|
||||
source_path: str = ""
|
||||
level: str = "system" # "system" or "project"
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
# Execution modes under which this skill is visible at all. Empty list
|
||||
# means "visible everywhere" (backward compat). Non-empty list means
|
||||
# the skill is filtered out entirely — body *and* description — when
|
||||
# the current execution mode is not in the list. Use this for skills
|
||||
# that are only meaningful under a specific runtime context, e.g. a
|
||||
# collaboration playbook that only applies in company_mode.
|
||||
modes: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class SkillLibrary:
|
||||
"""Manages skills stored as ``<skill-name>/SKILL.md`` directories.
|
||||
|
||||
Two-level loading:
|
||||
1. System skills — ``opc_home/skills/`` (shared across all projects)
|
||||
2. Project skills — ``opc_home/projects/<project_id>/skills/``
|
||||
|
||||
Project skills with the same name override system skills.
|
||||
"""
|
||||
|
||||
def __init__(self, opc_home: Path) -> None:
|
||||
self.opc_home = opc_home
|
||||
self.system_skills_dir = opc_home / "skills"
|
||||
self.projects_dir = opc_home / "projects"
|
||||
self._skills: dict[str, Skill] = {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Loading
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def load_all(self, project_id: str | None = None) -> None:
|
||||
"""Scan system + project skill directories and load metadata."""
|
||||
self._skills.clear()
|
||||
self._scan_dir(self.system_skills_dir, level="system")
|
||||
if project_id:
|
||||
project_skills_dir = self.projects_dir / project_id / "skills"
|
||||
self._scan_dir(project_skills_dir, level="project")
|
||||
logger.info(f"Loaded {len(self._skills)} skills")
|
||||
|
||||
def _scan_dir(self, base: Path, level: str) -> None:
|
||||
if not base.exists():
|
||||
return
|
||||
for child in sorted(base.iterdir()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
skill_md = child / "SKILL.md"
|
||||
if not skill_md.exists():
|
||||
continue
|
||||
skill = self._parse_skill_file(skill_md, level=level)
|
||||
if skill:
|
||||
self._skills[skill.name] = skill
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Accessors
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get(self, name: str) -> Skill | None:
|
||||
return self._skills.get(name)
|
||||
|
||||
def list_skills(self) -> list[Skill]:
|
||||
return list(self._skills.values())
|
||||
|
||||
def get_skill_path(self, name: str) -> str | None:
|
||||
"""Return the SKILL.md path for a given skill name."""
|
||||
skill = self._skills.get(name)
|
||||
return skill.source_path if skill else None
|
||||
|
||||
def list_project_skills(self, project_id: str) -> list[Skill]:
|
||||
"""List skills belonging to a specific project (for cross-project recommendations)."""
|
||||
project_skills_dir = self.projects_dir / project_id / "skills"
|
||||
skills: list[Skill] = []
|
||||
if not project_skills_dir.exists():
|
||||
return skills
|
||||
for child in sorted(project_skills_dir.iterdir()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
skill_md = child / "SKILL.md"
|
||||
if not skill_md.exists():
|
||||
continue
|
||||
skill = self._parse_skill_file(skill_md, level="project")
|
||||
if skill:
|
||||
skills.append(skill)
|
||||
return skills
|
||||
|
||||
def list_all_project_ids_with_skills(self) -> list[str]:
|
||||
"""Return project IDs that have a skills/ directory with at least one skill."""
|
||||
result: list[str] = []
|
||||
if not self.projects_dir.exists():
|
||||
return result
|
||||
for child in sorted(self.projects_dir.iterdir()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
skills_dir = child / "skills"
|
||||
if skills_dir.exists() and any(
|
||||
(d / "SKILL.md").exists() for d in skills_dir.iterdir() if d.is_dir()
|
||||
):
|
||||
result.append(child.name)
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Summary builder (for system prompt injection)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def build_skills_summary(
|
||||
self,
|
||||
project_id: str | None = None,
|
||||
*,
|
||||
execution_mode: str | None = None,
|
||||
role_id: str | None = None,
|
||||
user_facing: bool = False,
|
||||
final_decider_role_id: str | None = None,
|
||||
) -> str:
|
||||
"""Build prompt text: always-on skill bodies + summary list for the rest.
|
||||
|
||||
``execution_mode`` is used to filter out skills whose frontmatter
|
||||
declared a restricted ``modes`` list. Skills with a non-empty
|
||||
``modes`` list are hidden completely (both body and description)
|
||||
when the current ``execution_mode`` is not in that list. Skills
|
||||
with an empty ``modes`` list are always visible.
|
||||
"""
|
||||
if project_id:
|
||||
self.load_all(project_id)
|
||||
elif not self._skills:
|
||||
self.load_all()
|
||||
|
||||
always_parts: list[str] = []
|
||||
summary_lines: list[str] = []
|
||||
|
||||
for skill in self._skills.values():
|
||||
if not self._skill_visible_in_mode(
|
||||
skill,
|
||||
execution_mode,
|
||||
role_id=role_id,
|
||||
user_facing=user_facing,
|
||||
final_decider_role_id=final_decider_role_id,
|
||||
):
|
||||
continue
|
||||
if skill.always:
|
||||
always_parts.append(f"## Skill: {skill.name}\n{skill.content}")
|
||||
else:
|
||||
summary_lines.append(
|
||||
f"- **{skill.name}**: {skill.description} [{skill.source_path}]"
|
||||
)
|
||||
|
||||
parts: list[str] = []
|
||||
if summary_lines or always_parts:
|
||||
header = (
|
||||
"## Available Skills\n"
|
||||
"Below are available skills. To use a skill, read its SKILL.md with `file_read`.\n"
|
||||
)
|
||||
if summary_lines:
|
||||
header += "\n".join(summary_lines)
|
||||
parts.append(header)
|
||||
|
||||
for ap in always_parts:
|
||||
parts.append(ap)
|
||||
|
||||
return "\n\n".join(parts)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Mode filtering
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _skill_visible_in_mode(
|
||||
skill: Skill,
|
||||
execution_mode: str | None,
|
||||
*,
|
||||
role_id: str | None = None,
|
||||
user_facing: bool = False,
|
||||
final_decider_role_id: str | None = None,
|
||||
) -> bool:
|
||||
"""Return True if the skill should be visible under ``execution_mode``.
|
||||
|
||||
- Skills with no ``modes`` constraint are visible everywhere.
|
||||
- Skills with a ``modes`` list are visible only when the current
|
||||
``execution_mode`` (normalized to a non-empty string) is in
|
||||
that list. A ``None`` or empty mode means the caller has not
|
||||
supplied a mode yet (e.g. top-level context loading before
|
||||
routing), and restricted skills are hidden in that case.
|
||||
"""
|
||||
current = str(execution_mode or "").strip()
|
||||
if str(skill.name or "").strip() == "memory":
|
||||
if current == "task_mode":
|
||||
return True
|
||||
if current == "company_mode":
|
||||
current_role = str(role_id or "").strip()
|
||||
final_role = str(final_decider_role_id or "").strip()
|
||||
return bool(user_facing and current_role and final_role and current_role == final_role)
|
||||
return False
|
||||
|
||||
allowed = [str(m).strip() for m in (skill.modes or []) if str(m).strip()]
|
||||
if not allowed:
|
||||
return True
|
||||
return bool(current) and current in allowed
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Parsing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _parse_skill_file(self, path: Path, level: str = "system") -> Skill | None:
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
frontmatter: dict[str, Any] = {}
|
||||
content = text
|
||||
|
||||
fm_match = re.match(r"^---\s*\n(.*?)\n---\s*\n", text, re.DOTALL)
|
||||
if fm_match:
|
||||
frontmatter = yaml.safe_load(fm_match.group(1)) or {}
|
||||
content = text[fm_match.end():]
|
||||
|
||||
name = frontmatter.get("name", path.parent.name)
|
||||
raw_modes = frontmatter.get("modes", [])
|
||||
if isinstance(raw_modes, str):
|
||||
modes_list = [raw_modes.strip()] if raw_modes.strip() else []
|
||||
elif isinstance(raw_modes, list):
|
||||
modes_list = [str(m).strip() for m in raw_modes if str(m).strip()]
|
||||
else:
|
||||
modes_list = []
|
||||
return Skill(
|
||||
name=name,
|
||||
description=frontmatter.get("description", ""),
|
||||
always=frontmatter.get("always", False),
|
||||
content=content.strip(),
|
||||
source_path=str(path),
|
||||
level=level,
|
||||
metadata=frontmatter.get("metadata", {}) or {},
|
||||
modes=modes_list,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse skill {path}: {e}")
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Persistence helpers (for skill evolution / creation)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def save_skill(self, skill: Skill, project_id: str | None = None) -> None:
|
||||
"""Save a skill to disk. Project skills go under projects/<id>/skills/."""
|
||||
if project_id:
|
||||
target_dir = self.projects_dir / project_id / "skills" / skill.name
|
||||
else:
|
||||
target_dir = self.system_skills_dir / skill.name
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = target_dir / "SKILL.md"
|
||||
|
||||
fm: dict[str, Any] = {"name": skill.name, "description": skill.description}
|
||||
if skill.always:
|
||||
fm["always"] = True
|
||||
if skill.modes:
|
||||
fm["modes"] = list(skill.modes)
|
||||
if skill.metadata:
|
||||
fm["metadata"] = skill.metadata
|
||||
|
||||
text = f"---\n{yaml.dump(fm, default_flow_style=False)}---\n\n{skill.content}"
|
||||
path.write_text(text, encoding="utf-8")
|
||||
skill.source_path = str(path)
|
||||
skill.level = "project" if project_id else "system"
|
||||
self._skills[skill.name] = skill
|
||||
logger.info(f"Skill saved: {path}")
|
||||
|
||||
def delete_skill(self, name: str) -> bool:
|
||||
skill = self._skills.get(name)
|
||||
if not skill or not skill.source_path:
|
||||
return False
|
||||
path = Path(skill.source_path)
|
||||
if not path.exists():
|
||||
return False
|
||||
import shutil
|
||||
skill_dir = path.parent
|
||||
shutil.rmtree(skill_dir, ignore_errors=True)
|
||||
self._skills.pop(name, None)
|
||||
logger.info(f"Skill deleted: {skill_dir}")
|
||||
return True
|
||||
Reference in New Issue
Block a user