ec27228a8e
Em dashes (—) and en dashes (–) had spread across comments, docs, tests, and a few UI strings, reading as AI-generated boilerplate rather than house style. Replaced each with punctuation matching its context: colon for explanatory clauses, comma for asides, plain hyphen for numeric/legal ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for paired-dash asides. messages/en.json and messages/sv.json were fixed by hand together to keep sv/en in sync. Left untouched where the dash is the functional subject rather than decorative punctuation: date-range-parser.ts's separator regex, charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the agent system-prompt files that already instruct against em dashes, and a golden iXBRL test fixture compared byte-for-byte. Also fixes two bugs surfaced along the way: an off-by-one in ApiKeysPanel's scope-label split (a leftover from an earlier partial pass), and a charset-repair test that had lost the literal en-dash it exists to verify. Regenerated the agent atom seed migration (skills:generate) since 27 SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes, with an explicit carve-out for the functional-dash cases above. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
379 lines
13 KiB
TypeScript
379 lines
13 KiB
TypeScript
/**
|
|
* Shared atom discovery + SKILL.md frontmatter parsing.
|
|
*
|
|
* Used by both:
|
|
* - scripts/seed-agent-atom-registry.ts (dev/manual: writes the registry directly)
|
|
* - scripts/generate-skill-bodies.ts (production: emits a seed migration)
|
|
*
|
|
* Keeping discovery in one place means the two paths can never drift on which
|
|
* skills count as atoms, how titles/tokens are derived, or how frontmatter is read.
|
|
*
|
|
* Tiers discovered (the curated set: swarm-* and other Claude-Code-only skills
|
|
* are intentionally NOT matched here, so they never become atoms):
|
|
* horizontal: `.claude/skills/swedish-*\/SKILL.md` (regulatory)
|
|
* vertical : `.claude/skills/industry/<slug>\/SKILL.md` (industry)
|
|
* modifier : `.claude/skills/modifier/<slug>\/SKILL.md` (cross-cutting)
|
|
*/
|
|
|
|
import { readdir, readFile, stat } from 'node:fs/promises'
|
|
import { join, relative, sep } from 'node:path'
|
|
|
|
export type Tier = 'horizontal' | 'vertical' | 'modifier'
|
|
|
|
export interface DiscoveredAtom {
|
|
/** Stable id shaped as "<tier>/<slug>" (e.g. "horizontal/swedish-vat"). */
|
|
id: string
|
|
tier: Tier
|
|
slug: string
|
|
title: string
|
|
description: string
|
|
sni_prefixes: string[]
|
|
trigger_signals: Record<string, unknown>
|
|
/**
|
|
* Token estimate over the SKILL.md content ONLY: the unit actually loaded
|
|
* into the system prompt / returned by gnubok_load_skill. (We deliberately do
|
|
* NOT count references/*.md, which are not read at runtime.)
|
|
*/
|
|
estimated_tokens: number
|
|
/**
|
|
* Repo-relative path to the body source: SKILL.md for top-level skills,
|
|
* the references/*.md file for reference children (provenance + dev-fallback).
|
|
*/
|
|
body_path: string
|
|
/**
|
|
* Body inlined into the DB. For a top-level skill: the raw SKILL.md content
|
|
* (frontmatter included) with a "Loadable references" footer appended when the
|
|
* skill has any. For a reference child: the raw references/*.md content.
|
|
*/
|
|
body: string
|
|
/**
|
|
* NULL for a top-level skill; the parent skill's id for a reference child.
|
|
* Reference rows are hidden from every catalog (the metadata index, the MCP
|
|
* skill list, the composer atom index, the settings panel) by a
|
|
* `parent_atom_id IS NULL` filter: they reach the model only via an explicit
|
|
* gnubok_load_skill(<child id>) call after the parent SKILL.md is loaded.
|
|
*/
|
|
parent_atom_id: string | null
|
|
/** Version declared in frontmatter, or 1. The generator may override this. */
|
|
frontmatter_version: number
|
|
schema_version: number
|
|
}
|
|
|
|
// Normalize CRLF → LF so frontmatter parsing and body inlining are
|
|
// platform-independent (Windows checkouts ship .md files with CRLF unless
|
|
// .gitattributes forces LF, which it doesn't for *.md).
|
|
function normalizeLineEndings(text: string): string {
|
|
return text.replace(/\r\n/g, '\n')
|
|
}
|
|
|
|
// ── Frontmatter parsing ────────────────────────────────────────────────
|
|
// SKILL.md files use YAML frontmatter with `name`, `description`, and optionally
|
|
// `tier`, `sni_prefixes`, `trigger_signals`, `estimated_tokens`, `version`. We
|
|
// parse only the keys we care about: js-yaml is not in deps.
|
|
|
|
interface Frontmatter {
|
|
raw: string
|
|
name?: string
|
|
title?: string
|
|
description?: string
|
|
tier?: Tier
|
|
sniPrefixes?: string[]
|
|
triggerSignals?: Record<string, unknown>
|
|
estimatedTokens?: number
|
|
version?: number
|
|
}
|
|
|
|
function extractFrontmatter(content: string): Frontmatter | null {
|
|
const match = content.match(/^---\n([\s\S]*?)\n---/)
|
|
if (!match) return null
|
|
const raw = match[1]
|
|
return {
|
|
raw,
|
|
name: parseScalar(raw, 'name'),
|
|
title: parseScalar(raw, 'title'),
|
|
description: parseScalar(raw, 'description'),
|
|
tier: parseScalar(raw, 'tier') as Tier | undefined,
|
|
sniPrefixes: parseArray(raw, 'sni_prefixes'),
|
|
triggerSignals: parseInlineObject(raw, 'trigger_signals'),
|
|
estimatedTokens: parseNumber(raw, 'estimated_tokens'),
|
|
version: parseNumber(raw, 'version'),
|
|
}
|
|
}
|
|
|
|
// Handles `key: value`, `key: "quoted"`, `key: >`+folded, `key: |`+literal.
|
|
function parseScalar(yaml: string, key: string): string | undefined {
|
|
const inline = new RegExp(`^${escapeKey(key)}:\\s*(.*)$`, 'm').exec(yaml)
|
|
if (!inline) return undefined
|
|
const head = inline[1].trim()
|
|
|
|
if (head === '>' || head === '|' || head === '>-' || head === '|-') {
|
|
const after = yaml.slice(inline.index + inline[0].length).split('\n')
|
|
const lines: string[] = []
|
|
for (const line of after) {
|
|
if (line.length === 0) continue
|
|
if (/^\s/.test(line)) {
|
|
lines.push(line.trim())
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
return head.startsWith('>') ? lines.join(' ') : lines.join('\n')
|
|
}
|
|
|
|
return unquote(head)
|
|
}
|
|
|
|
function parseNumber(yaml: string, key: string): number | undefined {
|
|
const v = parseScalar(yaml, key)
|
|
if (v == null) return undefined
|
|
const n = Number(v)
|
|
return Number.isFinite(n) ? n : undefined
|
|
}
|
|
|
|
function parseArray(yaml: string, key: string): string[] | undefined {
|
|
const inline = new RegExp(`^${escapeKey(key)}:\\s*\\[(.*)\\]\\s*$`, 'm').exec(yaml)
|
|
if (inline) {
|
|
return inline[1]
|
|
.split(',')
|
|
.map((s) => unquote(s.trim()))
|
|
.filter(Boolean)
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
function parseInlineObject(yaml: string, key: string): Record<string, unknown> | undefined {
|
|
// POC: only recognize `trigger_signals: {}` or absent. Deep parsing is deferred.
|
|
const line = new RegExp(`^${escapeKey(key)}:\\s*\\{\\s*\\}\\s*$`, 'm').exec(yaml)
|
|
if (line) return {}
|
|
return undefined
|
|
}
|
|
|
|
function escapeKey(key: string): string {
|
|
return key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
}
|
|
|
|
function unquote(s: string): string {
|
|
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
|
return s.slice(1, -1)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// ── Token estimation ──────────────────────────────────────────────────
|
|
// Chars/4 baseline (Anthropic guidance for English). Swedish text inflates on
|
|
// Opus 4.7's tokenizer: re-measure post-POC.
|
|
export function estimateTokens(text: string): number {
|
|
return Math.ceil(text.length / 4)
|
|
}
|
|
|
|
// ── Title derivation ──────────────────────────────────────────────────
|
|
export function deriveTitle(slug: string): string {
|
|
// 'swedish-vat' → 'Swedish VAT'; 'swedish-year-end-closing' → 'Swedish Year-End Closing'
|
|
return slug
|
|
.split('-')
|
|
.map((w) => (w === 'vat' || w === 'sru' || w === 'sie' ? w.toUpperCase() : capitalize(w)))
|
|
.join(' ')
|
|
}
|
|
|
|
function capitalize(s: string): string {
|
|
return s.length === 0 ? s : s[0].toUpperCase() + s.slice(1)
|
|
}
|
|
|
|
// ── Discovery ─────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Scan `<rootDir>/.claude/skills/` and return one DiscoveredAtom per skill,
|
|
* sorted by id for deterministic output. Skills without frontmatter or without
|
|
* a description are skipped (with a warning).
|
|
*/
|
|
export async function discoverAtoms(rootDir: string): Promise<DiscoveredAtom[]> {
|
|
const skillsDir = join(rootDir, '.claude', 'skills')
|
|
const rows: DiscoveredAtom[] = []
|
|
const entries = await readdir(skillsDir, { withFileTypes: true })
|
|
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue
|
|
|
|
// Horizontal: top-level swedish-* directory
|
|
if (entry.name.startsWith('swedish-')) {
|
|
rows.push(...(await readAtom(rootDir, 'horizontal', entry.name, join(skillsDir, entry.name))))
|
|
continue
|
|
}
|
|
|
|
// Vertical / modifier: subdirectories under those names
|
|
if (entry.name === 'industry' || entry.name === 'modifier') {
|
|
const tier: Tier = entry.name === 'industry' ? 'vertical' : 'modifier'
|
|
const tierDir = join(skillsDir, entry.name)
|
|
const subs = await readdir(tierDir, { withFileTypes: true })
|
|
for (const sub of subs) {
|
|
if (!sub.isDirectory()) continue
|
|
rows.push(...(await readAtom(rootDir, tier, sub.name, join(tierDir, sub.name))))
|
|
}
|
|
}
|
|
}
|
|
|
|
rows.sort((a, b) => a.id.localeCompare(b.id))
|
|
return rows
|
|
}
|
|
|
|
/**
|
|
* Read one skill directory into a top-level atom plus one child atom per
|
|
* references/*.md file. Returns an empty array if the SKILL.md is missing or
|
|
* lacks the frontmatter we require.
|
|
*/
|
|
async function readAtom(
|
|
rootDir: string,
|
|
tier: Tier,
|
|
slug: string,
|
|
dir: string
|
|
): Promise<DiscoveredAtom[]> {
|
|
const skillPath = join(dir, 'SKILL.md')
|
|
try {
|
|
await stat(skillPath)
|
|
} catch {
|
|
return []
|
|
}
|
|
|
|
const content = normalizeLineEndings(await readFile(skillPath, 'utf8'))
|
|
const fm = extractFrontmatter(content)
|
|
if (!fm) {
|
|
console.warn(` skipped ${relative(rootDir, skillPath)}: no frontmatter`)
|
|
return []
|
|
}
|
|
if (!fm.description) {
|
|
console.warn(` skipped ${relative(rootDir, skillPath)}: missing description`)
|
|
return []
|
|
}
|
|
|
|
const parentId = `${tier}/${slug}`
|
|
const childTier: Tier = fm.tier ?? tier
|
|
const refs = await readReferenceFiles(dir)
|
|
|
|
// Bridge the SKILL.md router (which points at dead `references/*.md` paths at
|
|
// runtime) to the loadable child ids the model can actually call. Appended to
|
|
// the parent body so it ships in the seeded DB body, visible only once the
|
|
// skill itself is loaded.
|
|
const body = refs.length > 0 ? content + buildReferencesFooter(parentId, refs) : content
|
|
|
|
const parent: DiscoveredAtom = {
|
|
id: parentId,
|
|
tier: childTier,
|
|
slug,
|
|
title: fm.title ?? deriveTitle(slug),
|
|
description: fm.description,
|
|
sni_prefixes: fm.sniPrefixes ?? [],
|
|
trigger_signals: fm.triggerSignals ?? {},
|
|
// Estimate over the loaded unit (SKILL.md + footer), not the whole
|
|
// directory: references are loaded separately and budgeted on their own row.
|
|
estimated_tokens: fm.estimatedTokens ?? estimateTokens(body),
|
|
body_path: relative(rootDir, skillPath),
|
|
body,
|
|
parent_atom_id: null,
|
|
frontmatter_version: fm.version ?? 1,
|
|
schema_version: 1,
|
|
}
|
|
|
|
const children: DiscoveredAtom[] = refs.map((r) => ({
|
|
id: `${parentId}/${r.slug}`,
|
|
tier: childTier,
|
|
slug: `${slug}/${r.slug}`,
|
|
title: r.descriptor || deriveTitle(r.slug),
|
|
description: r.descriptor
|
|
? `${r.descriptor}: reference for ${parent.title}`
|
|
: `Reference for ${parent.title}`,
|
|
sni_prefixes: [],
|
|
trigger_signals: {},
|
|
estimated_tokens: estimateTokens(r.body),
|
|
body_path: relative(rootDir, r.absPath),
|
|
body: r.body,
|
|
parent_atom_id: parentId,
|
|
frontmatter_version: 1,
|
|
schema_version: 1,
|
|
}))
|
|
|
|
return [parent, ...children]
|
|
}
|
|
|
|
// ── Reference discovery ───────────────────────────────────────────────
|
|
// A skill's deep material lives in <skillDir>/references/**.md. Each file
|
|
// becomes a hidden child atom loadable by id; the bytes never count toward the
|
|
// parent's token budget and never appear in any catalog listing.
|
|
|
|
interface ReferenceFile {
|
|
/** Absolute path on disk (provenance + dev-fallback anchor). */
|
|
absPath: string
|
|
/** Path as the SKILL.md router writes it, e.g. "references/bfl-bfnar.md". */
|
|
relPath: string
|
|
/** Child-id suffix derived from the path, e.g. "bfl-bfnar". */
|
|
slug: string
|
|
/** Raw file content: the child's DB-inlined body. */
|
|
body: string
|
|
/** First ATX heading (or first line), used as a human-readable label. */
|
|
descriptor: string
|
|
}
|
|
|
|
async function readReferenceFiles(skillDir: string): Promise<ReferenceFile[]> {
|
|
const refsDir = join(skillDir, 'references')
|
|
try {
|
|
await stat(refsDir)
|
|
} catch {
|
|
return []
|
|
}
|
|
|
|
const files = (await walkMarkdown(refsDir)).sort()
|
|
const out: ReferenceFile[] = []
|
|
for (const absPath of files) {
|
|
const body = normalizeLineEndings(await readFile(absPath, 'utf8'))
|
|
const relFromRefs = relative(refsDir, absPath).split(sep).join('/')
|
|
out.push({
|
|
absPath,
|
|
relPath: `references/${relFromRefs}`,
|
|
slug: relFromRefs.replace(/\.md$/i, '').replace(/\//g, '-'),
|
|
body,
|
|
descriptor: firstHeadingOrLine(body),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
async function walkMarkdown(dir: string): Promise<string[]> {
|
|
const out: string[] = []
|
|
const entries = await readdir(dir, { withFileTypes: true })
|
|
for (const e of entries) {
|
|
const p = join(dir, e.name)
|
|
if (e.isDirectory()) out.push(...(await walkMarkdown(p)))
|
|
else if (e.isFile() && e.name.toLowerCase().endsWith('.md')) out.push(p)
|
|
}
|
|
return out
|
|
}
|
|
|
|
function firstHeadingOrLine(md: string): string {
|
|
const lines = md.split('\n')
|
|
for (const line of lines) {
|
|
const h = /^#{1,3}\s+(.+?)\s*#*$/.exec(line.trim())
|
|
if (h) return h[1].trim()
|
|
}
|
|
for (const line of lines) {
|
|
const t = line.trim()
|
|
if (t.length > 0) return t.slice(0, 100)
|
|
}
|
|
return ''
|
|
}
|
|
|
|
function buildReferencesFooter(parentId: string, refs: ReferenceFile[]): string {
|
|
const lines = [
|
|
'',
|
|
'---',
|
|
'',
|
|
'## Loadable references',
|
|
'',
|
|
'The reference files named above are NOT included in this body. When a question genuinely needs that depth, load the specific one on demand with `gnubok_load_skill`, and only then:',
|
|
'',
|
|
]
|
|
for (const r of refs) {
|
|
const label = r.descriptor ? `: ${r.descriptor}` : ''
|
|
lines.push(`- \`${r.relPath}\` → \`gnubok_load_skill("${parentId}/${r.slug}")\`${label}`)
|
|
}
|
|
return '\n' + lines.join('\n') + '\n'
|
|
}
|