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:
co-authored by
multica-agent
parent
cdd6eba8ba
commit
88660cb3b4
@@ -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.");
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/* ── 中栏 ─────────────────────────────────────────────── */
|
||||
|
||||
Reference in New Issue
Block a user