Initial commit

This commit is contained in:
LZH-YS1998
2026-07-01 17:56:31 +08:00
commit d78931979d
731 changed files with 311088 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
+262
View File
@@ -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
+133
View File
@@ -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
+998
View File
@@ -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
+607
View File
@@ -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 {}
+192
View File
@@ -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
+116
View File
@@ -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
+344
View File
@@ -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)
+572
View File
@@ -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
+295
View File
@@ -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