Skip to content

Commit a61837a

Browse files
AVADSA25Mikarina13claude
authored
feat(chat): conversational create_skill — brief + confirm + explain (beat 21) (#257)
"create a skill for X" used to fire create_skill silently and stage the result with no explanation. Now the chat surface briefs the user first and confirms before building, mirroring the auto-escalation "Start as Project?" offer: - "create a skill that tells me the moon phase" → CODEC replies "I'll build a skill for: <desc>. I'll stage it in your Skills tab for review — nothing runs until you approve. Build it?" + a [Build it]/[Cancel] chip. - Vague ("make a skill") → asks what it should do. - [Build it] → POST /api/chat/build_skill → runs the real review-gate flow → conversational outcome ("Done — generated **name**, staged in Skills, approve there to activate"). Chat-surface only (routes/chat.py intercept before the pre-LLM hijack + a new endpoint; chip in codec_chat.html mirroring renderEscalateChip). MCP/voice create_skill paths untouched. No skills/*.py change → no manifest regen. 119 chat/skill tests pass, ruff clean. Co-authored-by: Mickael Farina <farina.mickael@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8cc2f47 commit a61837a

2 files changed

Lines changed: 125 additions & 1 deletion

File tree

codec_chat.html

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,34 @@ <h1><a href="/" style="color:inherit;text-decoration:none">CODEC</a></h1>
595595
body:JSON.stringify({session_id:(typeof sessionId!=='undefined'&&sessionId)||''})})}catch(e){}
596596
var card=btn.closest('.msg');if(card)card.remove();
597597
}
598+
// ── Conversational create_skill (beat 21) ──
599+
// Backend emits {skill_confirm:{description}} after briefing the user. "Build it"
600+
// runs the real create_skill review-gate flow via /api/chat/build_skill; the
601+
// outcome is dropped back into the chat. Nothing is built until the user clicks.
602+
var _lastSkillDesc='';
603+
function renderSkillConfirmChip(info,userText){
604+
_lastSkillDesc=(info&&info.description)||'';
605+
var div=document.createElement('div');div.className='msg assistant';
606+
div.innerHTML='<div class="msg-bubble" style="border:1px dashed var(--accent,#a78bfa);background:rgba(167,139,250,0.06)">'+
607+
'<div style="font-weight:600;margin-bottom:6px">Build this skill?</div>'+
608+
'<div style="font-size:12px;color:var(--text-dim);margin-bottom:8px">'+escHtml(_lastSkillDesc)+' — I\'ll generate it and stage it in your Skills tab for review. Nothing runs until you approve it there.</div>'+
609+
'<div style="display:flex;gap:8px">'+
610+
'<button onclick="skillConfirmBuild(this)" style="padding:6px 14px;background:var(--accent,#a78bfa);color:#000;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600">Build it</button>'+
611+
'<button onclick="skillConfirmCancel(this)" style="padding:6px 14px;background:transparent;color:var(--text);border:1px solid var(--border,#2a2a30);border-radius:6px;cursor:pointer;font-size:12px">Cancel</button>'+
612+
'</div></div>';
613+
document.getElementById('messages').appendChild(div);scrollBottom();
614+
}
615+
function skillConfirmBuild(btn){
616+
var desc=_lastSkillDesc;var card=btn.closest('.msg');
617+
if(card){var b=card.querySelector('.msg-bubble');if(b)b.innerHTML='<span style="color:var(--text-dim)">Building the skill…</span>';}
618+
fetch('/api/chat/build_skill',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({description:desc})})
619+
.then(function(r){return r.json()})
620+
.then(function(d){if(card)card.remove();var div=document.createElement('div');div.className='msg assistant';
621+
div.innerHTML='<div class="msg-bubble">'+formatMsg(d.response||'Done.')+'</div>';
622+
document.getElementById('messages').appendChild(div);scrollBottom();})
623+
.catch(function(e){if(card)card.remove();showToast('Build failed: '+e,true);});
624+
}
625+
function skillConfirmCancel(btn){var card=btn.closest('.msg');if(card)card.remove();}
598626
function copyCodeBlock(btn){
599627
// Per-code-block copy (2026-07): grabs the rendered code text (already
600628
// HTML-unescaped by innerText) and reuses copyMsgText's clipboard path.
@@ -1463,7 +1491,7 @@ <h1><a href="/" style="color:inherit;text-decoration:none">CODEC</a></h1>
14631491
if(!line.startsWith('data: '))continue;
14641492
var payload=line.substring(6);
14651493
if(payload==='[DONE]')break;
1466-
try{var j=JSON.parse(payload);if(j.token){raw+=j.token;var ps=parseScaffold(raw);if(thinkingEnabled&&ps.think){var tp2=makeToT(div,'Reveal train of thought',true);totSet(tp2,ps.think)}if(ps.answer){if(!answerStarted){bubble.innerHTML='';answerStarted=true}bubble.innerHTML=formatMsg(ps.answer)}scrollBottom()}if(j.escalate_project){renderEscalateChip(j.escalate_project,text)}if(j.error){raw+='\n\nError: '+j.error}}catch(pe){}
1494+
try{var j=JSON.parse(payload);if(j.token){raw+=j.token;var ps=parseScaffold(raw);if(thinkingEnabled&&ps.think){var tp2=makeToT(div,'Reveal train of thought',true);totSet(tp2,ps.think)}if(ps.answer){if(!answerStarted){bubble.innerHTML='';answerStarted=true}bubble.innerHTML=formatMsg(ps.answer)}scrollBottom()}if(j.escalate_project){renderEscalateChip(j.escalate_project,text)}if(j.skill_confirm){renderSkillConfirmChip(j.skill_confirm,text)}if(j.error){raw+='\n\nError: '+j.error}}catch(pe){}
14671495
}
14681496
}
14691497
var pf2=parseScaffold(raw);

routes/chat.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,51 @@ def _enrich_messages(messages: list, config: dict, force_search: bool = False) -
440440
}
441441

442442

443+
# ── Conversational create_skill (beat 21) ────────────────────────────────────
444+
# "create a skill for X" used to fire create_skill silently and stage the result
445+
# with no explanation. Mickael's ask: brief the user + confirm BEFORE building,
446+
# then explain the outcome — mirroring the auto-escalation "Start as Project?"
447+
# offer. _detect_create_skill_request intercepts the chat surface so the handler
448+
# can send a confirm chip instead of building; the actual build runs on confirm
449+
# via POST /api/chat/build_skill.
450+
_CREATE_SKILL_TRIGGERS = (
451+
"create a skill", "make a skill", "new skill", "build a skill",
452+
"create skill", "write a skill", "add a skill",
453+
)
454+
_CREATE_SKILL_STRIP = _CREATE_SKILL_TRIGGERS + ("that", "to", "for", "please", "can you", "which")
455+
456+
457+
def _detect_create_skill_request(user_text: str):
458+
"""If the chat message asks to create a skill, return ("build", <description>)
459+
when there's a usable description, or ("ask", "") when it's too vague to build
460+
yet. Returns None when it isn't a create-skill request at all."""
461+
low = (user_text or "").lower()
462+
if not any(re.search(r"\b" + re.escape(t) + r"\b", low) for t in _CREATE_SKILL_TRIGGERS):
463+
return None
464+
desc = low
465+
for token in _CREATE_SKILL_STRIP:
466+
desc = desc.replace(token, " ")
467+
desc = re.sub(r"\s+", " ", desc).strip(" .,:;!?")
468+
if len(desc) < 5:
469+
return ("ask", "")
470+
return ("build", desc)
471+
472+
473+
def _skill_outcome_message(result: str) -> str:
474+
"""Rephrase create_skill's terse output into a conversational, explanatory
475+
line that tells the user what happened and where to approve it."""
476+
r = result or ""
477+
m = re.search(r"Skill ['\"]?([\w]+)['\"]? generated and staged", r)
478+
if m:
479+
name = m.group(1)
480+
return (
481+
f"Done — I generated **{name}** and staged it in your **Skills** tab. "
482+
f"Open Skills to see exactly what it does, then click Approve to make it "
483+
f"live. Nothing runs until you approve it."
484+
)
485+
return r # error / vague / dashboard-unreachable — pass through as-is
486+
487+
443488
def _try_skill(user_text: str):
444489
"""Check if user_text matches a skill. Returns (skill_name, result) or (None, None).
445490
@@ -766,6 +811,30 @@ async def escalate_silence(request: Request):
766811
return {"ok": True, "silenced": bool(sid)}
767812

768813

814+
@router.post("/api/chat/build_skill")
815+
async def build_skill(request: Request):
816+
"""Confirmed create_skill build (beat 21). The chat handler offers a
817+
"Build it?" chip for "create a skill …"; the chip's Build button POSTs here.
818+
Runs the real create_skill review-gate flow, then returns a conversational
819+
outcome the UI drops into the chat where it happened."""
820+
try:
821+
body = await request.json()
822+
except Exception:
823+
body = {}
824+
desc = str(body.get("description") or "").strip()
825+
if not desc:
826+
return {"response": "No skill description was provided — tell me what the skill should do."}
827+
try:
828+
from codec_dispatch import registry, load_skills
829+
load_skills()
830+
result = await asyncio.to_thread(registry.run, "create_skill",
831+
f"create a skill that {desc}", "CODEC Chat")
832+
return {"response": _skill_outcome_message(result or "")}
833+
except Exception as e:
834+
log.warning(f"[Chat] build_skill failed: {e}")
835+
return {"response": f"Couldn't build the skill: {e}"}
836+
837+
769838
@router.post("/api/pick-folder")
770839
async def pick_folder():
771840
"""Open the native macOS folder chooser and return the selected POSIX path.
@@ -876,6 +945,33 @@ async def _slash_stream():
876945
or "[END DOCUMENT]" in last_user_text
877946
)
878947
if last_user_text and not has_attachment:
948+
# Conversational create_skill (beat 21): brief + confirm BEFORE
949+
# building, instead of firing create_skill silently. The build runs
950+
# on confirm via POST /api/chat/build_skill.
951+
_cs = _detect_create_skill_request(last_user_text)
952+
if _cs:
953+
_cs_kind, _cs_desc = _cs
954+
_budget.consume("skill_hijack")
955+
if _cs_kind == "ask":
956+
_cs_brief = ('What should the skill do? For example: '
957+
'"create a skill that checks the bitcoin price".')
958+
else:
959+
_cs_brief = (f"I'll build a skill for: **{_cs_desc}**. I'll generate it, "
960+
f"then stage it in your **Skills** tab for review — nothing "
961+
f"runs until you approve it there. Build it?")
962+
if body.get("stream", False):
963+
from starlette.responses import StreamingResponse as _CsSR
964+
async def _cs_stream(_brief=_cs_brief, _kind=_cs_kind, _desc=_cs_desc):
965+
yield f"data: {json.dumps({'skill': 'create_skill'})}\n\n"
966+
yield f"data: {json.dumps({'token': _brief})}\n\n"
967+
if _kind == "build":
968+
yield f"data: {json.dumps({'skill_confirm': {'description': _desc}})}\n\n"
969+
yield "data: [DONE]\n\n"
970+
return _CsSR(_cs_stream(), media_type="text/event-stream")
971+
out = {"response": _cs_brief}
972+
if _cs_kind == "build":
973+
out["skill_confirm"] = {"description": _cs_desc}
974+
return out
879975
skill_name, skill_result = await asyncio.to_thread(_try_skill, last_user_text)
880976
if skill_result:
881977
_budget.consume("skill_hijack") # pre-LLM hijack consumes 1

0 commit comments

Comments
 (0)