update the workspace and skills flow

This commit is contained in:
2026-08-20 11:04:13 +05:30
parent d3f7f439f6
commit 161b237695
8 changed files with 1100 additions and 204 deletions

View File

@@ -1,4 +1,5 @@
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.
@@ -62,13 +63,33 @@ 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: '', source: 'candidates.activity', periods: [],
type: 'flow', placement: '', title: '', source: 'candidates.activity', periods: [],
};
/** The Owliver editor's fields, before a definition is loaded into them. */
/**
* 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: [], source: '', periods: [],
triggers: [], suggestions: [], capabilities: [], responses: {},
};
/**
@@ -94,6 +115,10 @@ export function boardFieldsFromSource(source) {
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 || [],
};
@@ -113,8 +138,61 @@ export function owliverFieldsFromSource(source) {
try {
const skill = readSkill(source);
if (!skill) throw new Error('no frontmatter');
const first = skill.owliver.capabilities[0];
const response = first ? skill.owliver.responses[first] : null;
/**
* 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,
@@ -133,9 +211,8 @@ export function owliverFieldsFromSource(source) {
*/
triggers: skill.declaredTriggers ? skill.triggers : [],
suggestions: skill.owliver.suggestions.map((s) => s.label),
capabilities: skill.owliver.capabilities,
source: response?.source || '',
periods: response?.periods || [],
capabilities,
responses,
};
} catch {
return EMPTY_OWLIVER_FIELDS;
@@ -404,3 +481,115 @@ export function patchFrontmatter(source, patch = {}) {
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));