Files
krow_talent_app/src/lib/skills/skillFields.js
Aravind 6249e00a3a
Some checks failed
CI / check (push) Failing after 4m58s
candidates and board ui agent issue
2026-09-05 10:46:06 +05:30

621 lines
25 KiB
JavaScript

import { hasFrontmatter, normalizeDefinition, parseFrontmatter, parseSkill } from './registry';
import { SUPPORTED_OWLIVER_CAPABILITIES, sourceSupportsOption } from './surfaces';
/**
* A definition, read into editor fields — and edited fields, written back.
*
* Two things used to be missing here, and between them they are the whole of
* "uploading a `.md` does nothing" and "the form fields do not work":
*
* - **Nothing read a file into the fields.** All three upload buttons set the
* Markdown and stopped, so an author who uploaded a complete definition was
* looking at a form full of empty boxes beside it. The two editors each had
* a reader that did exactly this job — `metaFromSource` and
* `draftFromSource` — and neither was reachable from the upload path. They
* live here now, so one file is read the same way wherever it arrives.
* - **Nothing wrote the fields back.** Both editors regenerated the whole
* definition from a template while the Markdown was untouched, and stopped
* the moment it was touched — which is every edit of an existing skill,
* because `touched` starts true there. Typing a new name updated React
* state that nothing saved. `patchFrontmatter` is the missing half: a field
* edits the key it owns, in place, and the body is never rewritten.
*
* Everything here reads the *registered* form of a definition — what
* `parseSkill` made of it — rather than the raw text. The form therefore shows
* what the product will actually do, including the placement an author left out
* and the vocabulary that filled it in.
*/
/* ── Reading a definition into fields ───────────────────────────────────── */
/**
* A definition, or nothing.
*
* `parseSkill` is deliberately forgiving: a file with no frontmatter at all
* still parses, taking its id from the path it was given. That is right for the
* registry, and wrong here — a text file dropped on the upload button would
* otherwise fill the ID field with `custom` and the name with `Untitled skill`,
* which reads as a definition the product understood. A form is filled from a
* definition or it is left alone.
*/
function readSkill(source) {
/* `hasFrontmatter`, not a second regex of this module's own. The two used to
be written out separately and drifted: a file the registry was willing to
read could be one this rejected, and rejecting it here empties a form the
preview beside it has just filled in. One detector, one answer. */
if (!hasFrontmatter(source)) return null;
return parseSkill(source, { custom: true });
}
/**
* A definition, cleaned up on the way in.
*
* Re-exported under the name the editors use, so an uploaded file is stored in
* the form every reader already agrees on rather than carrying a byte-order
* mark into `patchFrontmatter`, which would not recognise the fence and would
* write a second one above it.
*/
export const normalizeUpload = normalizeDefinition;
/** Whether a definition can be read into the fields at all. */
export const isReadableDefinition = (source) => hasFrontmatter(source);
/** The Board (UI) editor's fields, before a definition is loaded into them. */
export const EMPTY_BOARD_FIELDS = {
id: '', name: '', description: '', pages: [],
type: 'flow', placement: '', title: '', source: 'candidates.activity', periods: [],
};
/**
* The Owliver editor's fields, before a definition is loaded into them.
*
* `responses` is keyed by capability, because that is how the format is keyed
* and how the runtime reads it. It used to be one `source` and one `periods`
* shared by every selected capability, and that single field is the whole of
* the bug this shape exists to remove:
*
* - A capability selected before a source was picked composed **no**
* `responses:` block at all. `normalizeSkillOwliver` then dropped every
* capability for want of a source, `owliver.capabilities` normalized to
* `[]`, and `owliverSkillsForContext` excludes a skill with none — so the
* definition registered, showed as active on its page, and contributed no
* suggestion chip.
* - Two capabilities could not read different sources, and one global source
* that suited the first often could not be drawn as the second: `list`
* against `candidates.activity` is refused by `normalizeSection`, so
* picking it silently cost the author that capability.
*
* A form that cannot express what the format can is not a shortcut to it.
*/
export const EMPTY_OWLIVER_FIELDS = {
id: '', name: '', description: '', pages: [],
triggers: [], suggestions: [], capabilities: [], responses: {},
};
/**
* A Board skill, read back into the fields that compose one.
*
* The section comes from the registry's reading rather than from the Markdown
* text, so a definition that omitted `placement:` shows the placement it will
* actually render at.
*/
export function boardFieldsFromSource(source) {
try {
const skill = readSkill(source);
if (!skill) throw new Error('no frontmatter');
/* The first section, in declaration order — what the single-valued fields
can show. `pages` still carries every page the definition reaches, and
`uiIsEditableFromFields` is what stops those fields writing over the
ones this record cannot represent. */
const section = Object.values(skill.ui || {}).flatMap((p) => p.sections || [])[0];
return {
id: skill.id,
name: skill.name,
description: skill.description,
pages: skill.pages,
type: section?.type || EMPTY_BOARD_FIELDS.type,
placement: section?.placement || '',
/* The heading the card draws. Composed from the skill's name when it is
left out, but it is a field of its own in the format and a definition
that set it must survive a round trip through the form. */
title: section?.title || '',
source: section?.source || EMPTY_BOARD_FIELDS.source,
periods: section?.periods || [],
};
} catch {
return EMPTY_BOARD_FIELDS;
}
}
/**
* An Owliver skill, read back into the fields that compose one.
*
* Reads the resolved capabilities, which includes the ones whose source was
* inherited from a `ui:` block rather than written out — so the form shows what
* Owliver can be asked for, not what the file happened to spell.
*/
export function owliverFieldsFromSource(source) {
try {
const skill = readSkill(source);
if (!skill) throw new Error('no frontmatter');
/**
* Every capability the definition *declares*, not only the ones that
* resolved.
*
* `skill.owliver.capabilities` is filtered to those that found a reading,
* which is right for the runtime and wrong for a form: a stored definition
* declaring `summary` and `list` where `list` lost its source would open
* with `list` simply absent, and the next save would delete a capability
* its author never removed. The one that cannot answer is exactly the one
* they opened the editor to fix, so it is shown — checked, with no source,
* and refused by the same validation as before.
*
* Read from the frontmatter the definition actually carries, and narrowed
* to the closed vocabulary, so a typo is still not a capability.
*/
const declared = (() => {
let data;
try {
({ data } = parseFrontmatter(String(source)));
} catch {
return [];
}
const block = data?.owliver;
if (!block || typeof block !== 'object' || Array.isArray(block)) return [];
const listed = Array.isArray(block.capabilities) ? block.capabilities : [];
const keyed = block.responses && typeof block.responses === 'object' && !Array.isArray(block.responses)
? Object.keys(block.responses)
: [];
return [...listed, ...keyed]
.map((capability) => String(capability).trim())
.filter((capability) => SUPPORTED_OWLIVER_CAPABILITIES.includes(capability));
})();
/* Declaration order first, then anything that resolved without being
listed — a definition whose capability came from its `ui:` section. */
const capabilities = [...new Set([...declared, ...skill.owliver.capabilities])];
/**
* Every capability's own reading, not the first one's.
*
* Reading one and showing it against all of them is how a two-capability
* definition lost the second's source the first time any field was edited:
* the form wrote back what it had read, and it had read half the block.
*/
const responses = Object.fromEntries(
capabilities.map((capability) => {
const response = skill.owliver.responses[capability];
return [capability, {
source: response?.source || '',
periods: response?.periods || [],
limit: response?.limit ?? null,
}];
})
);
return {
id: skill.id,
name: skill.name,
description: skill.description,
pages: skill.pages,
/**
* Only the triggers the definition actually claimed.
*
* `skill.triggers` falls back to the skill's own name when none are
* declared, which is right for matching and wrong to put in a form: the
* field would fill with an inherited phrase, and the next edit would
* write it into the file as a declared one. That flips
* `declaredTriggers`, which the matcher weighs differently — and it would
* pin the *old* name's phrase the moment the skill is renamed.
*/
triggers: skill.declaredTriggers ? skill.triggers : [],
suggestions: skill.owliver.suggestions.map((s) => s.label),
capabilities,
responses,
};
} catch {
return EMPTY_OWLIVER_FIELDS;
}
}
/**
* Which management surfaces this definition belongs to, for the upload handoff.
*
* A file dropped into the Board editor that declares only an `owliver:` block
* is being edited in the wrong half of the product. Knowing that lets the
* editor offer the other one rather than showing a form none of whose fields
* apply.
*/
export function facetsFromSource(source) {
try {
return readSkill(source)?.facets || [];
} catch {
return [];
}
}
/**
* Which of the `ui:` shapes a definition uses.
*
* `shorthand` is one section applying to every declared page — the form the
* editors compose, and the only one whose four fields can represent the whole
* block. `list` and `per-page` can each hold several sections across several
* pages, so the fields would have to throw away everything but the first to
* write them back. Telling them apart is what lets the editor show a multi-page
* definition without being able to flatten it.
*/
export function uiShape(source) {
let data;
try {
({ data } = parseFrontmatter(String(source)));
} catch {
return 'none';
}
const ui = data?.ui;
if (!ui || typeof ui !== 'object') return 'none';
if (Array.isArray(ui)) return ui.length ? 'list' : 'none';
const SECTION_KEYS = ['type', 'source', 'data', 'placement', 'position', 'periods', 'title'];
return SECTION_KEYS.some((key) => key in ui) ? 'shorthand' : 'per-page';
}
/** Whether the section fields can write this definition's `ui:` block back. */
export const uiIsEditableFromFields = (source) => uiShape(source) !== 'list'
&& uiShape(source) !== 'per-page';
/**
* Whether this definition's pages come from its `ui:` block rather than a
* `pages:` key of its own.
*
* When they do, the Pages field is a read-out and must not be written back.
* Writing it would add a `pages:` list that agrees with the block today and
* silently governs it tomorrow: `normalizeSkillUi` refuses an entry naming a
* page that `pages:` does not list, so the next entry the author adds to the
* block would be rejected by a key they never wrote. A derived value is shown,
* not owned.
*/
export function pagesAreDerived(source) {
let data;
try {
({ data } = parseFrontmatter(String(source)));
} catch {
return false;
}
if (Array.isArray(data?.pages) && data.pages.length) return false;
return uiShape(source) !== 'none';
}
/* ── Writing fields back into the frontmatter ───────────────────────────── */
/**
* A scalar, written so the parser reads back what was meant.
*
* Quoted whenever the plain form would be read as something else — a value
* containing `:` or `#`, one with edge whitespace, or one that looks like a
* number, a boolean or null but is a string.
*/
function writeScalar(value) {
if (value === null || value === undefined) return 'null';
if (typeof value === 'boolean' || typeof value === 'number') return String(value);
const text = String(value);
const ambiguous = text === ''
|| /[:#]/.test(text)
|| text !== text.trim()
|| /^(true|false|null|~)$/.test(text)
|| /^-?\d+$/.test(text)
|| /^-?\d*\.\d+$/.test(text)
|| /^['"-]/.test(text);
return ambiguous ? `'${text.replace(/'/g, "''")}'` : text;
}
/**
* A value, as the block lines that follow its key.
*
* Returns `null` for a scalar, which is written on the key's own line instead.
* Only the subset in `yaml.js` is emitted — block maps and block sequences,
* nested to any depth — because that is the only subset the parser reads back.
*/
function writeBlock(value, indent) {
const pad = ' '.repeat(indent);
if (Array.isArray(value)) {
return value.map((item) => {
if (item && typeof item === 'object' && !Array.isArray(item)) {
const entries = Object.entries(item).filter(([, v]) => v !== undefined);
if (!entries.length) return `${pad}- {}`;
return entries
.map(([k, v], i) => {
const prefix = i === 0 ? `${pad}- ` : `${pad} `;
const nested = writeBlock(v, indent + 4);
return nested === null ? `${prefix}${k}: ${writeScalar(v)}` : `${prefix}${k}:\n${nested}`;
})
.join('\n');
}
return `${pad}- ${writeScalar(item)}`;
}).join('\n');
}
if (value && typeof value === 'object') {
return Object.entries(value)
.filter(([, v]) => v !== undefined)
.map(([k, v]) => {
const nested = writeBlock(v, indent + 2);
return nested === null ? `${pad}${k}: ${writeScalar(v)}` : `${pad}${k}:\n${nested}`;
})
.join('\n');
}
return null;
}
/** The lines belonging to the key at `start`: everything indented under it. */
function blockEnd(lines, start, indent) {
let end = start + 1;
while (end < lines.length) {
const line = lines[end];
if (line.trim() === '' || /^\s*#/.test(line)) { end += 1; continue; }
if (line.match(/^\s*/)[0].replace(/\t/g, ' ').length <= indent) break;
end += 1;
}
/* Trailing blanks and comments belong to whatever comes next, not to this
key — a comment written above the following key must not be swallowed by
the block above it. */
while (end > start + 1 && lines[end - 1].trim() === '') end -= 1;
return end;
}
/**
* How wide a line's indentation is.
*
* The same measurement the parser makes — `yaml.js` reads a leading run of
* whitespace and counts a tab as two — and it has to be, because a key the
* parser can see and the
* writer cannot is a key the writer will decide is missing and add a second
* copy of.
*
* That is not hypothetical: a definition stored on this account indents with
* U+00A0. JavaScript's `\s` matches it, so the parser read the file correctly
* and every screen showed the right values; the writer compared against literal
* spaces, found no `title:` inside `ui:`, and appended a whole second `ui:`
* block on the first edit. The Go port agrees with the parser here too — see
* `jsIsSpace` in `internal/definition/jsvalue.go`, which lists `0x00A0` — so the
* writer was the only thing in the chain using a narrower idea of a space.
*/
const indentWidth = (line) => (line.match(/^\s*/)?.[0] || '').replace(/\t/g, ' ').length;
/** The index of `key` at `indent` within `[from, to)`, or -1. */
function findKey(lines, key, indent, from, to) {
const pattern = new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:`);
for (let i = from; i < to; i += 1) {
const line = lines[i];
if (line === undefined || indentWidth(line) !== indent) continue;
/* Measured, then matched on what is left — so the comparison is about how
deep the key sits, never about which characters were used to put it
there. Identical for ASCII input, which is every definition this
repository ships. */
if (pattern.test(line.replace(/^\s*/, ''))) return i;
}
return -1;
}
/**
* One dotted path, set in a frontmatter line array.
*
* Scalars replace the value on their own line; anything else replaces the block
* beneath it. A key that is not there is appended to the end of its parent, so
* a definition that never declared `status:` gains one rather than being
* refused. Every line the path does not touch — including comments and key
* order — is left exactly as written, which is why this is a patch and not a
* re-serialisation.
*/
function setPath(lines, path, value) {
const parts = path.split('.');
let from = 0;
let to = lines.length;
let indent = 0;
for (let depth = 0; depth < parts.length - 1; depth += 1) {
const at = findKey(lines, parts[depth], indent, from, to);
/* A parent that does not exist is created empty, then descended into. */
if (at === -1) {
lines.splice(to, 0, `${' '.repeat(indent)}${parts[depth]}:`);
from = to + 1;
to = from;
indent += 2;
continue;
}
const end = blockEnd(lines, at, indent);
from = at + 1;
to = end;
indent += 2;
}
const key = parts[parts.length - 1];
const at = findKey(lines, key, indent, from, to);
const block = writeBlock(value, indent + 2);
const replacement = block === null
? [`${' '.repeat(indent)}${key}: ${writeScalar(value)}`]
: [`${' '.repeat(indent)}${key}:`, ...block.split('\n')];
if (at === -1) {
lines.splice(to, 0, ...replacement);
return replacement.length;
}
const end = blockEnd(lines, at, indent);
lines.splice(at, end - at, ...replacement);
return replacement.length - (end - at);
}
/**
* A definition with some frontmatter keys changed, and nothing else touched.
*
* `patch` is keyed by dotted path — `name`, `pages`, `ui.source`,
* `owliver.capabilities` — and a value of `undefined` leaves that path alone.
* `null` writes `null`; to remove a key, pass `REMOVE`.
*
* The body below the closing `---` is never read and never rewritten. That is
* the property that makes this safe to run on every keystroke: an author's
* prose, their comments and the order they wrote their keys in all survive a
* change to a field they did not write.
*/
export const REMOVE = Symbol('remove');
export function patchFrontmatter(source, patch = {}) {
/* Normalized first, for the same reason the parser is: a fence this does not
recognise is a fence it writes a second copy of. */
const raw = normalizeDefinition(source);
const match = /^---[ \t]*\n([\s\S]*?)\n---[ \t]*(?=\n|$)/.exec(raw);
/* No frontmatter to patch: give the definition one rather than silently
dropping the edit. */
if (!match) {
const lines = [];
for (const [path, value] of Object.entries(patch)) {
if (value === undefined || value === REMOVE) continue;
setPath(lines, path, value);
}
return `---\n${lines.join('\n')}\n---\n\n${raw.trim()}\n`;
}
const lines = match[1].split('\n');
for (const [path, value] of Object.entries(patch)) {
if (value === undefined) continue;
if (value === REMOVE) {
const parts = path.split('.');
const indent = (parts.length - 1) * 2;
/* Only a top-level or one-deep key is removable, which is every key the
editors own. */
let from = 0;
let to = lines.length;
if (parts.length > 1) {
const parent = findKey(lines, parts[0], 0, 0, lines.length);
if (parent === -1) continue;
from = parent + 1;
to = blockEnd(lines, parent, 0);
}
const at = findKey(lines, parts[parts.length - 1], indent, from, to);
if (at !== -1) lines.splice(at, blockEnd(lines, at, indent) - at);
continue;
}
setPath(lines, path, value);
}
return `---\n${lines.join('\n')}\n---${raw.slice(match[0].length)}`;
}
/* ── Writing fields back as one canonical patch ─────────────────────────── */
/**
* The frontmatter a set of Board fields means.
*
* Both composing paths go through this: a fresh draft applies it to an empty
* definition, an existing one applies it to the file already open. That is what
* makes "typed into the form" and "pasted as Markdown" the same artefact — the
* fields have one writer, so there is no second field set that only one path
* knows how to express.
*
* `existing` is the definition being edited, and only two questions are asked of
* it: whether its `pages:` are derived from its `ui:` entries, and whether its
* `ui:` block is one this form can represent at all. Both are refusals to
* overwrite what the fields cannot hold, never a second shape.
*/
export function boardPatch(fields, { existing = '' } = {}) {
const derived = existing ? pagesAreDerived(existing) : false;
const editable = existing ? uiIsEditableFromFields(existing) : true;
const section = editable ? {
'ui.type': fields.type || undefined,
'ui.placement': fields.placement || undefined,
'ui.title': fields.title || undefined,
'ui.source': fields.source || undefined,
/* Periods only where the reading has any. A source that counts nothing over
time keeps no period list, so switching to one cannot leave the previous
source's windows behind as a block that validates and does nothing. */
'ui.periods': fields.periods?.length && sourceSupportsOption(fields.source, 'periods')
? fields.periods
: REMOVE,
} : {};
return {
id: fields.id || undefined,
name: fields.name || undefined,
description: fields.description || undefined,
pages: fields.pages?.length && !derived ? fields.pages : undefined,
...section,
};
}
/**
* One capability's response, as the format writes it.
*
* Returns null when the capability has no source. That is deliberate and is the
* point of the whole change: an unconfigured capability is left out of
* `responses:` so `normalizeSkillOwliver` reports it by name and
* `validateSkillSource` refuses the save. The alternative — inventing a source
* to make the block well-formed — is how a definition gets saved reading data
* its author never chose.
*/
function responseFor(fields, capability) {
const response = fields.responses?.[capability];
const source = response?.source || '';
if (!source) return null;
const entry = { source };
if (response.periods?.length && sourceSupportsOption(source, 'periods')) {
entry.periods = response.periods;
}
if (response.limit && sourceSupportsOption(source, 'limit')) {
entry.limit = Number(response.limit);
}
return entry;
}
/**
* The frontmatter a set of Owliver fields means.
*
* Every selected capability that has a source is written with its *own*
* `source:`, and its own periods or limit where the reading takes them. Nothing
* is inherited from a `ui:` block here: this editor owns definitions that have
* no `ui:` block at all, and a response that relies on inheriting one silently
* loses its reading the day the section is edited.
*/
export function owliverPatch(fields, { existing = '' } = {}) {
const derived = existing ? pagesAreDerived(existing) : false;
const responses = {};
for (const capability of fields.capabilities || []) {
const response = responseFor(fields, capability);
if (response) responses[capability] = response;
}
return {
id: fields.id || undefined,
name: fields.name || undefined,
description: fields.description || undefined,
pages: fields.pages?.length && !derived ? fields.pages : undefined,
triggers: fields.triggers?.length ? fields.triggers : REMOVE,
'owliver.enabled': true,
'owliver.suggestions': fields.suggestions?.length ? fields.suggestions : REMOVE,
'owliver.capabilities': fields.capabilities?.length ? fields.capabilities : REMOVE,
/* An empty mapping is not a mapping the parser will take — `owliver.responses`
must be a mapping of capability names — so nothing configured removes the
key rather than writing a header with nothing under it. */
'owliver.responses': Object.keys(responses).length ? responses : REMOVE,
};
}
/**
* Which selected capabilities are not configured, by name.
*
* The editor shows these against the capability rather than only as a refusal
* on save, so "Summary needs a source" is read where the source is chosen. The
* *refusal* is still `validateSkillSource`'s, on the same definition the
* registry reads — this only says the same thing earlier.
*/
export const unconfiguredCapabilities = (fields) =>
(fields.capabilities || []).filter((capability) => !responseFor(fields, capability));