import json from pathlib import Path import aiosqlite from pydantic import BaseModel from duck_core.tasks.store import utc_now class ExperienceRecord(BaseModel): id: int | None = None task_id: str skill_id: str | None = None summary: str result: str what_worked: list[str] = [] what_failed: list[str] = [] reusable_lesson: str | None = None suggested_skill_patch: str | None = None confidence: float | None = None created_at: str class ExperienceRecorder: def __init__(self, db_path: str): self.db_path = Path(db_path) async def init(self) -> None: self.db_path.parent.mkdir(parents=True, exist_ok=True) async with aiosqlite.connect(self.db_path) as db: await db.execute( """ create table if not exists experience_records ( id integer primary key autoincrement, task_id text not null, skill_id text, summary text not null, result text not null, what_worked_json text, what_failed_json text, reusable_lesson text, suggested_skill_patch text, confidence real, created_at text not null ) """ ) await db.commit() async def record( self, task_id: str, summary: str, result: str, skill_id: str | None = None, what_worked: list[str] | None = None, what_failed: list[str] | None = None, reusable_lesson: str | None = None, suggested_skill_patch: str | None = None, confidence: float | None = None, ) -> ExperienceRecord: await self.init() now = utc_now() async with aiosqlite.connect(self.db_path) as db: cursor = await db.execute( """ insert into experience_records( task_id, skill_id, summary, result, what_worked_json, what_failed_json, reusable_lesson, suggested_skill_patch, confidence, created_at ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( task_id, skill_id, summary, result, json.dumps(what_worked or []), json.dumps(what_failed or []), reusable_lesson, suggested_skill_patch, confidence, now, ), ) await db.commit() row_id = cursor.lastrowid if suggested_skill_patch and skill_id: self.write_skill_update_proposal(task_id, skill_id, suggested_skill_patch) return ExperienceRecord( id=row_id, task_id=task_id, skill_id=skill_id, summary=summary, result=result, what_worked=what_worked or [], what_failed=what_failed or [], reusable_lesson=reusable_lesson, suggested_skill_patch=suggested_skill_patch, confidence=confidence, created_at=now, ) async def list_records(self) -> list[ExperienceRecord]: await self.init() async with aiosqlite.connect(self.db_path) as db: db.row_factory = aiosqlite.Row cursor = await db.execute( "select * from experience_records order by created_at desc" ) rows = await cursor.fetchall() return [self._row_to_record(row) for row in rows] async def get_record(self, record_id: int) -> ExperienceRecord | None: await self.init() async with aiosqlite.connect(self.db_path) as db: db.row_factory = aiosqlite.Row cursor = await db.execute( "select * from experience_records where id = ?", (record_id,) ) row = await cursor.fetchone() return self._row_to_record(row) if row else None def write_skill_update_proposal(self, task_id: str, skill_id: str, patch: str) -> Path: directory = Path("skills/_proposals") directory.mkdir(parents=True, exist_ok=True) path = directory / f"{utc_now().replace(':', '').replace('+', '_')}_{skill_id}.patch.md" path.write_text( "\n".join( [ "# Skill update proposal", "", f"Skill: {skill_id}", "", "## Reason", "", "Reflection suggested a reusable skill improvement.", "", "## Proposed changes", "", patch, "", "## Evidence", "", f"Task id: {task_id}", "", "## Risk", "", "Low.", "", "## Requires human approval", "", "Yes.", ] ) ) return path def _row_to_record(self, row: aiosqlite.Row) -> ExperienceRecord: return ExperienceRecord( id=row["id"], task_id=row["task_id"], skill_id=row["skill_id"], summary=row["summary"], result=row["result"], what_worked=json.loads(row["what_worked_json"] or "[]"), what_failed=json.loads(row["what_failed_json"] or "[]"), reusable_lesson=row["reusable_lesson"], suggested_skill_patch=row["suggested_skill_patch"], confidence=row["confidence"], created_at=row["created_at"], )