fix: strip SKILL.md frontmatter in the skill editor (HEL-613 rework)

The editor's strip regex anchored on \\A — a Python-ism JS treats as a
literal — so it never matched: the editor showed the raw frontmatter and
every save re-wrapped the file in one more frontmatter layer. Extract the
strip into lib/skillFrontmatter.ts with a ^ anchor, import it from the
page, and guard it with scripts/check-skill-frontmatter.mjs (wired into
npm test): strips the render_skill_md block shape, exact split boundary,
idempotent, save-reload round-trip adds no layer, and the inline \\A
pattern is banned statically.

Also per review notes: skill toggles are now the spec-v2 §5 34x19 capsule
(amber track + dark knob when on), and version-history timestamps render
localized instead of the raw SQLite UTC string.

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
shigongyuan-agent
2026-09-28 12:03:20 +08:00
co-authored by multica-agent
parent cdd6eba8ba
commit 88660cb3b4
4 changed files with 165 additions and 15 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "node scripts/check-storage-ssr.mjs && node scripts/check-random-uuid.mjs",
"test": "node scripts/check-storage-ssr.mjs && node scripts/check-random-uuid.mjs && node scripts/check-skill-frontmatter.mjs",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
@@ -0,0 +1,121 @@
#!/usr/bin/env node
/**
* Regression guard for the HEL-613 rework: the skill editor must load the
* SKILL.md BODY only — never the YAML frontmatter — so saving cannot wrap
* the file in a second frontmatter layer (each edit used to nest one more
* `---\nname: …` block into the content: the old strip regex anchored on
* `\A`, a Python-ism JS treats as a literal, so it never matched).
*
* Layers:
* 1. behavioural — stripSkillFrontmatter() must strip the exact block
* shape the backend's render_skill_md writes, split at the right
* boundary, be idempotent (the no-doubling invariant), and pass
* plain bodies through unchanged.
* 2. round-trip — strip → re-render (frontmatter + body) → strip again
* yields the identical body: editing twice adds zero layers.
* 3. static — the editor page must use the shared helper; the inline
* `\A`-anchored regex cannot creep back in.
*
* Run with `npm test` (or `node scripts/check-skill-frontmatter.mjs`).
*/
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..");
let failed = false;
function check(name, cond, detail = "") {
console.log(
`${cond ? "ok " : "FAIL "} ${name}${cond ? "" : ` :: ${detail}`}`,
);
if (!cond) failed = true;
}
// Mirror of the backend's render_skill_md output shape
// (calliope-backend/src/calliope/agent/skills_store.py).
const rendered = [
"---",
"name: cyberpunk-style-kit",
'description: "赛博朋克风格词库"',
"version: 1.0.1",
"metadata:",
" author: user",
" tags: [neon, rain]",
" type: template",
"---",
"",
"# 赛博朋克风格词库",
"neon, rain, hologram",
].join("\n");
const { stripSkillFrontmatter } = await import(
pathToFileURL(join(ROOT, "src/lib/skillFrontmatter.ts"))
);
// 1. Editor load: body only, no frontmatter.
const body = stripSkillFrontmatter(rendered);
check(
"edit load strips the frontmatter block",
!body.includes("name: cyberpunk-style-kit") &&
!body.trimStart().startsWith("---"),
JSON.stringify(body.slice(0, 40)),
);
check(
"edit load keeps the body",
body.includes("# 赛博朋克风格词库") && body.includes("neon, rain, hologram"),
JSON.stringify(body),
);
// 2. Strip splits at the exact boundary: frontmatter + body reassembles
// the file byte-identically (proves no body text was eaten).
const fm = rendered.slice(0, rendered.length - body.length);
check(
"strip boundary is exact",
fm + body === rendered && fm.trimEnd().endsWith("---"),
"frontmatter/body split drifted",
);
// 3. No-doubling invariant: idempotent strip + round-trip stability.
check(
"strip is idempotent",
stripSkillFrontmatter(body) === body,
"second strip changed the body",
);
const savedAgain = fm + body; // render_skill_md re-wraps the same frontmatter
check(
"second edit-save-reload adds no layer",
stripSkillFrontmatter(savedAgain) === body,
"round-trip grew the content",
);
// 4. Body without frontmatter passes through unchanged.
const plain = "# Just a body\nwith text\n";
check(
"plain body unchanged",
stripSkillFrontmatter(plain) === plain,
JSON.stringify(stripSkillFrontmatter(plain)),
);
// 5. Static: the page uses the shared helper; no `\A` regex remains.
const page = readFileSync(
join(ROOT, "src/routes/agents/+page.svelte"),
"utf-8",
);
check(
"page imports the shared strip helper",
page.includes("stripSkillFrontmatter"),
"helper import missing",
);
check(
"no \\A-anchored strip regex left behind",
!/\\A---/.test(page),
"inline \\A regex present",
);
if (failed) {
console.error("skill frontmatter checks FAILED");
process.exit(1);
}
console.log("skill frontmatter checks passed.");
+15
View File
@@ -0,0 +1,15 @@
/**
* Strip a SKILL.md YAML frontmatter block, leaving only the body the
* skill editor edits.
*
* The block is `---` … `---` at the START of the file. The anchor is `^`
* (string start) — `\A` is a Python-ism that JS silently treats as a
* literal, which made the strip a no-op and let every save re-wrap the
* whole file in a new frontmatter layer (HEL-613 rework).
*/
const FRONTMATTER_RE = /^---\s*\n[\s\S]*?\n---\s*\n?/;
export function stripSkillFrontmatter(text: string): string {
const m = text.match(FRONTMATTER_RE);
return m ? text.slice(m[0].length) : text;
}
+28 -14
View File
@@ -22,6 +22,7 @@
} from '$lib/api';
import { AGENT_TASK_PROMPTS, AGENT_TASK_TITLE_KEYS, isAgentTaskKind } from '$lib/agentTasks';
import { connectEvents } from '$lib/events';
import { stripSkillFrontmatter } from '$lib/skillFrontmatter';
import { toStore } from 'svelte/store';
import { t } from '$lib/i18n.svelte';
import { toast } from '$lib/toast';
@@ -442,10 +443,7 @@
openDrawer();
try {
const res = await agentApi.readSkillFile(skill.dir, 'SKILL.md');
// Strip frontmatter for the editor body.
const text = res.content ?? '';
const m = text.match(/\A---\s*\n[\s\S]*?\n---\s*\n?/);
form.content = m ? text.slice(m[0].length) : text;
form.content = stripSkillFrontmatter(res.content ?? '');
} catch {
toast.error(t('agentsPage.skillLoadFailed'));
}
@@ -556,6 +554,22 @@
});
}
/** SQLite CURRENT_TIMESTAMP ships as "YYYY-MM-DD HH:MM:SS" in UTC —
* parse it as such and render in the viewer's local timezone. */
function formatUtcString(raw: string | null | undefined): string {
if (!raw) return '';
const m = raw.match(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})$/);
const date = m ? new Date(`${m[1]}T${m[2]}Z`) : new Date(raw);
if (Number.isNaN(date.getTime())) return raw;
return date.toLocaleString([], {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
const editingVersion = $derived(
editorMode === 'edit' && editingDir
? (skills.find((s) => s.dir === editingDir)?.version ?? '')
@@ -816,7 +830,7 @@
<div class="version-row">
<div class="version-meta">
<span class="mono">#{v.id}</span>
<span class="version-time">{v.created_at}</span>
<span class="version-time">{formatUtcString(v.created_at)}</span>
<span class="version-len mono">{v.length}B</span>
</div>
<button
@@ -1206,10 +1220,10 @@
flex-shrink: 0;
}
/* Toggle switch (spec §6: 启停即开关) */
/* Toggle switch (spec §5: 34×19 capsule; on = amber track, dark knob) */
.switch {
width: 30px;
height: 17px;
width: 34px;
height: 19px;
border-radius: 999px;
border: 1px solid var(--border-strong);
background: var(--surface-3);
@@ -1220,21 +1234,21 @@
}
.switch .knob {
position: absolute;
top: 1.5px;
top: 2px;
left: 2px;
width: 12px;
height: 12px;
width: 15px;
height: 15px;
border-radius: 999px;
background: var(--text-3);
transition: transform 150ms cubic-bezier(0.16, 1, 0.3, 1), background-color 150ms;
}
.switch.on {
background: var(--accent-dim);
background: var(--accent);
border-color: var(--accent);
}
.switch.on .knob {
transform: translateX(13px);
background: var(--accent);
transform: translateX(15px);
background: var(--accent-contrast);
}
/* ── 中栏 ─────────────────────────────────────────────── */