mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-11 16:36:32 +02:00
* refactor(skills): make the brief's skill list a names-only index (MUL-5529)
Step 3 of MUL-5529. Every runtime CLI discovers the SKILL.md files the daemon
writes and builds its own listing from their frontmatter — verified against 11
locally installed CLIs plus official docs for 5 more. The brief's copy of those
descriptions was therefore the same routing signal paid for twice: measured on
a real task, `## Skills` was 13,295 chars, 40% of the entire brief, against a
16,304-char CLI listing of the same 28 skills.
Now 850 chars for that same set — roughly 3,100 tokens back per brief.
The index itself stays. It is the one skill listing Multica controls; each
CLI's own listing is theirs, and its format — or its existence — can change
with any release.
Three changes:
- Descriptions dropped from the `## Skills` entries.
- The per-provider branch is gone. Its fallback told providers outside a
hardcoded list to read `.agent_context/skills/`, but the only providers
that ever reached it were grok and traecli, whose files are written to
`.grok/skills` and `.traecli/skills` and which discover natively. The
pointer was wrong for everyone it addressed, so removing the branch
deletes the bug rather than relocating it. This closes MUL-5537.
- issue_context.md and its quick-create / autopilot variants no longer render
`## Agent Skills`. That copy duplicated the brief once both were
names-only, and nothing ever read it: no prompt references the path, and
grepping the server finds only the writer. `.agent_context/skills/` had the
same fate for hermes (issue #5242). Quick-create, previously skipped in the
brief and served only by that unread copy, now gets the brief section like
every other kind — one index, one place.
Not included: skills carrying `disable-model-invocation` are still written to
disk for every provider. The plan assumed that key needed provider-specific
handling for everything except claude; probing the installed CLIs shows 9 of 11
honor it, and only opencode and hermes do not. The remaining question is
narrow and a genuine product tradeoff — withholding the file honors the
author's intent but also removes explicit invocation — so it is left to a
separate decision rather than folded in here.
Co-authored-by: multica-agent <github@multica.ai>
* docs(skills): align stale comments with the names-only brief contract (MUL-5529)
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Steve Jobs (Multica Agent) <agent-steve-jobs@multica.ai>
100 lines
3.4 KiB
Go
100 lines
3.4 KiB
Go
package execenv
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// modelVisibleSkills returns the skills a model may invoke, with Name
|
|
// normalized to the on-disk slug.
|
|
//
|
|
// The normalization is not cosmetic. The runtime brief renders these entries
|
|
// into the listing the model reads to pick a skill, and the only name it can
|
|
// actually invoke is the directory slug writeSkillFiles lays down —
|
|
// sanitizeSkillName of the same field. A workspace skill's Name is its human
|
|
// display name ("PR review"), so listing it verbatim hands the model an
|
|
// identifier that does not resolve (MUL-5529).
|
|
//
|
|
// Slugs come from resolveSkillSlugs over the *unfiltered* batch, because
|
|
// writeSkillFiles lays down every skill — including the ones hidden here — and
|
|
// each one consumes a slug. Filtering first would shift the suffixes and make
|
|
// the listing disagree with the directories.
|
|
//
|
|
// Known gap: resolveSkillSlugs cannot see the filesystem, so a skill that
|
|
// collides with a *user-installed* directory is still written to
|
|
// `<slug>-multica` while the listing shows the bare slug. Closing that needs
|
|
// the allocated slug threaded back from Prepare, which these renderers
|
|
// deliberately cannot reach — they are pure so the brief stays byte-identical
|
|
// across runs. Tracked in MUL-5550.
|
|
func modelVisibleSkills(skills []SkillContextForEnv) []SkillContextForEnv {
|
|
if len(skills) == 0 {
|
|
return nil
|
|
}
|
|
slugs := resolveSkillSlugs(skills)
|
|
visible := make([]SkillContextForEnv, 0, len(skills))
|
|
for i, skill := range skills {
|
|
if skillModelInvocationVisible(skill) {
|
|
skill.Name = slugs[i]
|
|
visible = append(visible, skill)
|
|
}
|
|
}
|
|
return visible
|
|
}
|
|
|
|
// resolveSkillSlugs assigns each skill in a batch its on-disk directory slug,
|
|
// deduplicating within the batch so two skills never claim the same directory.
|
|
//
|
|
// sanitizeSkillName alone is not injective: "A B" and "A-B" both reduce to
|
|
// "a-b". writeSkillFiles resolves that at write time via
|
|
// allocateCollisionFreeSkillDir, so the second skill lands in `a-b-multica` —
|
|
// but a listing built from sanitizeSkillName alone would name both `a-b`,
|
|
// leaving the second skill with no invocable name and silently pointing the
|
|
// model at the first. Deriving both sides from this function keeps them in
|
|
// step. It is deterministic in the batch (index order only), so the brief stays
|
|
// byte-identical across runs for identical input.
|
|
func resolveSkillSlugs(skills []SkillContextForEnv) []string {
|
|
slugs := make([]string, len(skills))
|
|
taken := make(map[string]struct{}, len(skills))
|
|
for i, skill := range skills {
|
|
base := sanitizeSkillName(skill.Name)
|
|
slug := base
|
|
for attempt := 1; ; attempt++ {
|
|
if _, clash := taken[slug]; !clash {
|
|
break
|
|
}
|
|
slug = skillSlugCandidate(base, attempt)
|
|
}
|
|
taken[slug] = struct{}{}
|
|
slugs[i] = slug
|
|
}
|
|
return slugs
|
|
}
|
|
|
|
func skillModelInvocationVisible(skill SkillContextForEnv) bool {
|
|
return !skillDisablesModelInvocation(skill.Content)
|
|
}
|
|
|
|
func skillDisablesModelInvocation(content string) bool {
|
|
fmBody, _, ok := frontmatterParts(content)
|
|
if !ok || strings.TrimSpace(fmBody) == "" {
|
|
return false
|
|
}
|
|
var data map[string]any
|
|
if err := yaml.Unmarshal([]byte(fmBody), &data); err != nil {
|
|
return false
|
|
}
|
|
value, ok := data["disable-model-invocation"]
|
|
if !ok {
|
|
return false
|
|
}
|
|
switch v := value.(type) {
|
|
case bool:
|
|
return v
|
|
case string:
|
|
return strings.EqualFold(strings.TrimSpace(v), "true")
|
|
default:
|
|
return false
|
|
}
|
|
}
|