From 0454ab2baaa5255fa9ad5afa819d6ee6ae709446 Mon Sep 17 00:00:00 2001 From: Isaac Kargar Date: Sat, 30 May 2026 21:13:47 +0300 Subject: [PATCH 1/3] Enhance context and skills management features by adding role filtering and a new skills page. Updated context page to support role selection and display, integrated role data into the Graph Explorer, and introduced a new SkillsPage component for managing skill targets and proposals. --- dashboard/app/context/page.tsx | 43 +- dashboard/app/skills/page.tsx | 540 +++++++++++++++ dashboard/components/GraphExplorer.tsx | 176 +++-- dashboard/components/RecordEditor.tsx | 38 +- dashboard/components/Sidebar.tsx | 23 +- dashboard/lib/api.ts | 71 +- dashboard/lib/labels.ts | 16 + dashboard/lib/types.ts | 104 +++ src/lerim/agents/context_answerer/pipeline.py | 19 + src/lerim/agents/context_answerer/schemas.py | 10 + .../agents/context_answerer/signatures.py | 4 + src/lerim/agents/context_brief/pipeline.py | 10 + src/lerim/agents/context_brief/schemas.py | 4 + src/lerim/agents/context_brief/signatures.py | 6 +- .../agents/trace_ingestion/persistence.py | 3 + src/lerim/agents/trace_ingestion/pipeline.py | 96 +++ src/lerim/agents/trace_ingestion/schemas.py | 31 +- .../agents/trace_ingestion/signatures.py | 50 ++ src/lerim/agents/working_memory/pipeline.py | 7 +- src/lerim/agents/working_memory/signatures.py | 1 + src/lerim/context/__init__.py | 19 + src/lerim/context/retrieval.py | 12 + src/lerim/context/roles.py | 144 ++++ src/lerim/context/spec.py | 28 + src/lerim/context/store.py | 118 +++- src/lerim/context_brief.py | 17 +- src/lerim/mcp_server.py | 6 + src/lerim/server/api.py | 165 +++++ src/lerim/server/cli.py | 205 +++++- src/lerim/server/httpd.py | 130 ++++ src/lerim/skill_stewardship/__init__.py | 12 + src/lerim/skill_stewardship/artifacts.py | 274 ++++++++ src/lerim/skill_stewardship/patching.py | 69 ++ src/lerim/skill_stewardship/pipeline.py | 302 +++++++++ src/lerim/skill_stewardship/repository.py | 616 ++++++++++++++++++ src/lerim/skill_stewardship/schemas.py | 173 +++++ src/lerim/skill_stewardship/signatures.py | 32 + src/lerim/skill_stewardship/validation.py | 83 +++ .../trace_ingestion/test_persistence.py | 32 + .../agents/trace_ingestion/test_pipeline.py | 40 +- tests/unit/context/test_spec.py | 23 + tests/unit/context/test_store.py | 72 ++ .../unit/skill_stewardship/test_artifacts.py | 40 ++ .../test_repository_and_pipeline.py | 141 ++++ tests/unit/test_context_brief.py | 13 + 45 files changed, 3937 insertions(+), 81 deletions(-) create mode 100644 dashboard/app/skills/page.tsx create mode 100644 src/lerim/context/roles.py create mode 100644 src/lerim/skill_stewardship/__init__.py create mode 100644 src/lerim/skill_stewardship/artifacts.py create mode 100644 src/lerim/skill_stewardship/patching.py create mode 100644 src/lerim/skill_stewardship/pipeline.py create mode 100644 src/lerim/skill_stewardship/repository.py create mode 100644 src/lerim/skill_stewardship/schemas.py create mode 100644 src/lerim/skill_stewardship/signatures.py create mode 100644 src/lerim/skill_stewardship/validation.py create mode 100644 tests/unit/skill_stewardship/test_artifacts.py create mode 100644 tests/unit/skill_stewardship/test_repository_and_pipeline.py diff --git a/dashboard/app/context/page.tsx b/dashboard/app/context/page.tsx index 5e2af5d5..049ed641 100644 --- a/dashboard/app/context/page.tsx +++ b/dashboard/app/context/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, useCallback, useRef } from "react"; import { api } from "@/lib/api"; -import { formatRecordKind, formatScopeLabel } from "@/lib/labels"; +import { formatRecordKind, formatRecordRole, formatScopeLabel } from "@/lib/labels"; import type { ContextRecord, IntelligenceResponse } from "@/lib/types"; import RecordEditor from "@/components/RecordEditor"; @@ -12,10 +12,12 @@ export default function ContextPage() { const [debouncedQuery, setDebouncedQuery] = useState(""); const [project, setProject] = useState(""); const [selectedType, setSelectedType] = useState(""); + const [selectedRole, setSelectedRole] = useState(""); const [statusFilter, setStatusFilter] = useState("active"); /* ---- filter options from server ---- */ const [filterTypes, setFilterTypes] = useState([]); + const [filterRoles, setFilterRoles] = useState([]); const [filterProjects, setFilterProjects] = useState([]); /* ---- data state ---- */ @@ -53,6 +55,7 @@ export default function ContextPage() { .getRecordFilters() .then((f) => { setFilterTypes(f.types); + setFilterRoles(f.roles); setFilterProjects(f.projects); }) .catch(() => { @@ -69,6 +72,7 @@ export default function ContextPage() { if (debouncedQuery) params.q = debouncedQuery; if (project) params.project = project; if (selectedType) params.record_kind = selectedType; + if (selectedRole) params.record_role = selectedRole; if (statusFilter) params.status = statusFilter; const data = await api.getRecords(params); setRecords(data.records); @@ -78,7 +82,7 @@ export default function ContextPage() { } finally { setLoading(false); } - }, [debouncedQuery, project, selectedType, statusFilter]); + }, [debouncedQuery, project, selectedRole, selectedType, statusFilter]); useEffect(() => { load(); @@ -100,6 +104,8 @@ export default function ContextPage() { setSelected(record); }; + const operationalFilterRoles = filterRoles.filter((role) => role !== "general"); + const scrollToIntel = () => { setIntelCollapsed(false); setTimeout(() => { @@ -199,6 +205,34 @@ export default function ContextPage() { ))} + {operationalFilterRoles.length > 0 && ( +
+ + {operationalFilterRoles.map((role) => ( + + ))} +
+ )} + {/* ---- Error ---- */} {error && (
@@ -246,6 +280,11 @@ export default function ContextPage() { {formatRecordKind(record.record_kind)} + {record.record_role && record.record_role !== "general" && ( + + {formatRecordRole(record.record_role)} + + )} {record.project && ( {formatScopeLabel(record.project)} diff --git a/dashboard/app/skills/page.tsx b/dashboard/app/skills/page.tsx new file mode 100644 index 00000000..dd972cbe --- /dev/null +++ b/dashboard/app/skills/page.tsx @@ -0,0 +1,540 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { api } from "@/lib/api"; +import type { SkillProposal, SkillTarget } from "@/lib/types"; +import { useToast } from "@/components/Toast"; + +export default function SkillsPage() { + const { addToast } = useToast(); + const [targets, setTargets] = useState([]); + const [proposals, setProposals] = useState([]); + const [selectedTargetId, setSelectedTargetId] = useState(""); + const [selectedProposalId, setSelectedProposalId] = useState(""); + const [path, setPath] = useState(""); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(null); + const [editText, setEditText] = useState(""); + const [selectedPatchIndex, setSelectedPatchIndex] = useState(0); + + const load = useCallback(async () => { + setLoading(true); + try { + const [targetData, proposalData] = await Promise.all([ + api.getSkillTargets(), + api.getSkillProposals(), + ]); + setTargets(targetData.targets); + setProposals(proposalData.proposals); + if (!selectedTargetId && targetData.targets[0]) setSelectedTargetId(targetData.targets[0].target_id); + if (!selectedProposalId && proposalData.proposals[0]) setSelectedProposalId(proposalData.proposals[0].proposal_id); + } catch (err) { + addToast({ type: "error", message: err instanceof Error ? err.message : "Failed to load skills" }); + } finally { + setLoading(false); + } + }, [addToast, selectedProposalId, selectedTargetId]); + + useEffect(() => { + load(); + }, [load]); + + const selectedTarget = targets.find((target) => target.target_id === selectedTargetId) || targets[0] || null; + const filteredProposals = useMemo( + () => proposals.filter((proposal) => !selectedTarget || proposal.target_id === selectedTarget.target_id), + [proposals, selectedTarget], + ); + const selectedProposal = + filteredProposals.find((proposal) => proposal.proposal_id === selectedProposalId) || + filteredProposals[0] || + null; + const selectedPatch = selectedProposal?.patch_json.patches[selectedPatchIndex] || null; + + useEffect(() => { + setSelectedPatchIndex(0); + }, [selectedProposal?.proposal_id]); + + useEffect(() => { + setEditText(selectedPatch?.after_text || ""); + }, [selectedPatch?.after_text, selectedPatchIndex, selectedProposal?.proposal_id]); + + const register = async () => { + if (!path.trim()) return; + setBusy("register"); + try { + await api.addSkillTarget({ + path: path.trim(), + name: name.trim() || undefined, + description: description.trim() || undefined, + update_mode: "review", + }); + setPath(""); + setName(""); + setDescription(""); + addToast({ type: "success", message: "Skill registered" }); + await load(); + } catch (err) { + addToast({ type: "error", message: err instanceof Error ? err.message : "Could not register skill" }); + } finally { + setBusy(null); + } + }; + + const refreshTarget = async (target: SkillTarget) => { + setBusy(`refresh:${target.target_id}`); + try { + await api.refreshSkillTarget(target.target_id); + addToast({ type: "success", message: "Refresh completed" }); + await load(); + } catch (err) { + addToast({ type: "error", message: err instanceof Error ? err.message : "Refresh failed" }); + } finally { + setBusy(null); + } + }; + + const toggleAutoApply = async (target: SkillTarget) => { + const enabled = target.update_mode !== "auto_apply"; + setBusy(`mode:${target.target_id}`); + try { + await api.updateSkillTargetMode(target.target_id, { + update_mode: enabled ? "auto_apply" : "review", + auto_apply_policy: { ...target.auto_apply_policy, enabled, max_risk: "low" }, + }); + addToast({ type: "success", message: enabled ? "Auto-apply enabled" : "Auto-apply disabled" }); + await load(); + } catch (err) { + addToast({ type: "error", message: err instanceof Error ? err.message : "Mode update failed" }); + } finally { + setBusy(null); + } + }; + + const applyProposal = async (proposal: SkillProposal) => { + setBusy(`apply:${proposal.proposal_id}`); + try { + await api.applySkillProposal(proposal.proposal_id); + addToast({ type: "success", message: "Proposal applied" }); + await load(); + } catch (err) { + addToast({ type: "error", message: err instanceof Error ? err.message : "Apply failed" }); + } finally { + setBusy(null); + } + }; + + const rejectProposal = async (proposal: SkillProposal) => { + setBusy(`reject:${proposal.proposal_id}`); + try { + await api.rejectSkillProposal(proposal.proposal_id); + addToast({ type: "success", message: "Proposal rejected" }); + await load(); + } catch (err) { + addToast({ type: "error", message: err instanceof Error ? err.message : "Reject failed" }); + } finally { + setBusy(null); + } + }; + + const saveEditedProposal = async (proposal: SkillProposal) => { + if (!selectedPatch) return; + setBusy(`edit:${proposal.proposal_id}`); + try { + await api.editSkillProposal(proposal.proposal_id, { + ...proposal.patch_json, + patches: proposal.patch_json.patches.map((patch, index) => + index === selectedPatchIndex ? { ...patch, after_text: editText } : patch, + ), + }); + addToast({ type: "success", message: "Proposal updated" }); + await load(); + } catch (err) { + addToast({ type: "error", message: err instanceof Error ? err.message : "Edit failed" }); + } finally { + setBusy(null); + } + }; + + return ( +
+
+
+

Skills

+

+ Registered instruction artifacts, evidence-backed updates, and review gates. +

+
+ +
+ +
+
+ setPath(event.target.value)} + placeholder="Path to SKILL.md, AGENTS.md, or skill folder" + className="min-h-11 rounded-md border border-[var(--border)] bg-black/20 px-3 text-sm text-[var(--text)] outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-blue)] md:col-span-3" + /> + setName(event.target.value)} + placeholder="Name" + className="min-h-11 rounded-md border border-[var(--border)] bg-black/20 px-3 text-sm text-[var(--text)] outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-blue)]" + /> + setDescription(event.target.value)} + placeholder="Improvement intent" + className="min-h-11 rounded-md border border-[var(--border)] bg-black/20 px-3 text-sm text-[var(--text)] outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-blue)] md:col-span-2" + /> +
+ +
+ +
+
+
+

Registered Skills

+
+
+ {loading &&
Loading…
} + {!loading && targets.length === 0 &&
No skills registered.
} + {targets.map((target) => ( + + ))} +
+
+ +
+ {selectedTarget && ( +
+
+
+

{selectedTarget.name}

+

{selectedTarget.path}

+
+
+ + +
+
+
+ + + proposal.status === "pending_review").length)} /> +
+
+ + +
+
+ )} + +
+ + +
+
+
+
+ ); +} + +function Metric({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function FileList({ title, files }: { title: string; files: NonNullable }) { + return ( +
+

{title}

+
+ {files.map((file) => ( +
+ {file.relative_path} + + {file.file_role} + +
+ ))} +
+
+ ); +} + +function ManifestPanel({ target }: { target: SkillTarget }) { + const manifest = target.manifest; + return ( +
+

Update Surfaces

+
+ {(manifest?.allowed_update_surfaces || []).map((surface) => ( + + {surface} + + ))} + {(manifest?.high_risk_surfaces || []).map((surface) => ( + + {surface} + + ))} +
+
+ ); +} + +function ProposalList({ + proposals, + selectedId, + onSelect, +}: { + proposals: SkillProposal[]; + selectedId: string; + onSelect: (id: string) => void; +}) { + return ( +
+
+

Updates

+
+
+ {proposals.length === 0 &&
No update proposals.
} + {proposals.map((proposal) => ( + + ))} +
+
+ ); +} + +function ProposalReview({ + proposal, + editText, + onEditText, + selectedPatchIndex, + onSelectPatch, + onSave, + onApply, + onReject, + busy, +}: { + proposal: SkillProposal | null; + editText: string; + onEditText: (value: string) => void; + selectedPatchIndex: number; + onSelectPatch: (index: number) => void; + onSave: (proposal: SkillProposal) => void; + onApply: (proposal: SkillProposal) => void; + onReject: (proposal: SkillProposal) => void; + busy: string | null; +}) { + if (!proposal) { + return ( +
+ Select an update proposal to review. +
+ ); + } + const patches = proposal.patch_json.patches; + const patch = patches[selectedPatchIndex] || patches[0]; + return ( +
+
+
+
+

{proposal.title}

+

{proposal.summary}

+
+ {proposal.risk_level} +
+
+ {Array.from(new Set(patches.flatMap((item) => item.evidence_record_ids || []))).map((recordId) => ( + + {recordId} + + ))} +
+ {patches.length > 1 && ( +
+ {patches.map((item, index) => ( + + ))} +
+ )} +
+ {patch ? ( +
+
+
+

Editable Result

+ {patch.relative_path} +
+