fix error line
This commit is contained in:
@@ -41,14 +41,68 @@ function parseFrontmatter(raw) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Bullets under a `## Heading`, for the capability list shown in Settings. */
|
||||
/**
|
||||
* The text under a `## Heading`, up to the next one.
|
||||
*
|
||||
* The heading is escaped before it becomes a pattern. It used to be
|
||||
* interpolated raw, so a heading containing regular-expression punctuation —
|
||||
* `## Capabilities (v2)` is the obvious one — compiled to a pattern that could
|
||||
* not match the words it was built from, and the whole section silently read as
|
||||
* absent. A section that is present and unreadable is the failure this parser
|
||||
* must never have: it looks exactly like a section the author did not write.
|
||||
*/
|
||||
function sectionSource(body, heading) {
|
||||
const escaped = String(heading).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const section = new RegExp(`##\\s+${escaped}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i').exec(body);
|
||||
return section ? section[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List items under a `## Heading`, for the capability list shown in Settings.
|
||||
*
|
||||
* Two kinds of content were being dropped without a word, and both are the same
|
||||
* mistake — treating "a line I do not recognise" as "a line that is not there":
|
||||
*
|
||||
* - **Wrapped items.** A bullet long enough to run onto a second line kept
|
||||
* only its first line. `create-position.md` documented reading a request
|
||||
* "out of a single sentence" and the registry held the sentence without its
|
||||
* last three words. An item is now everything up to the next item or the
|
||||
* blank line that ends the list.
|
||||
* - **Numbered items.** Only `-` counted, so a `1.` list — the natural way to
|
||||
* write ordered instructions — parsed as an empty section. Both markers are
|
||||
* list items in Markdown and both are read as one here.
|
||||
*
|
||||
* Neither change alters any definition currently on disk: every one of them
|
||||
* uses single-line `-` bullets, so this widens what can be written without
|
||||
* moving what already was.
|
||||
*/
|
||||
const LIST_ITEM = /^\s*(?:-|\*|\d+[.)])\s+(.*)$/;
|
||||
|
||||
function sectionBullets(body, heading) {
|
||||
const section = new RegExp(`##\\s+${heading}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i').exec(body);
|
||||
if (!section) return [];
|
||||
return section[1]
|
||||
.split(/\r?\n/)
|
||||
.map((l) => /^\s*-\s+(.*)$/.exec(l)?.[1]?.trim())
|
||||
.filter(Boolean);
|
||||
const source = sectionSource(body, heading);
|
||||
if (source == null) return [];
|
||||
|
||||
const items = [];
|
||||
for (const line of source.split(/\r?\n/)) {
|
||||
const item = LIST_ITEM.exec(line);
|
||||
if (item) {
|
||||
items.push(item[1].trim());
|
||||
continue;
|
||||
}
|
||||
/* A blank line closes the current item; anything else indented under one is
|
||||
its continuation and belongs to it. Prose before the first item — the
|
||||
explanatory paragraph `## Conversation` opens with — matches neither and
|
||||
is ignored, exactly as before. */
|
||||
if (!line.trim()) {
|
||||
if (items.length) items.push('');
|
||||
continue;
|
||||
}
|
||||
if (items.length && items[items.length - 1] !== '' && /^\s+/.test(line)) {
|
||||
items[items.length - 1] += ` ${line.trim()}`;
|
||||
}
|
||||
}
|
||||
|
||||
return items.map((i) => i.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,11 +137,11 @@ function sectionSteps(body, heading) {
|
||||
* rather than a list.
|
||||
*/
|
||||
function sectionText(body, heading) {
|
||||
const section = new RegExp(`##\\s+${heading}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i').exec(body);
|
||||
if (!section) return '';
|
||||
return section[1]
|
||||
const source = sectionSource(body, heading);
|
||||
if (source == null) return '';
|
||||
return source
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.replace(/^\s*[-*]\s+/, '').trim())
|
||||
.map((l) => l.replace(/^\s*(?:[-*]|\d+[.)])\s+/, '').trim())
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.trim();
|
||||
@@ -323,6 +377,139 @@ export const SKILLS = Object.entries(FILES)
|
||||
.map(([path, raw]) => parseSkill(raw, { path }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
/**
|
||||
* The registry as actually assembled, with everything that went wrong assembling
|
||||
* it.
|
||||
*
|
||||
* This function exists because the three ways a definition could fail to arrive
|
||||
* intact were all *silent*, and between them they are the whole of the
|
||||
* "sometimes a skill only half works" report:
|
||||
*
|
||||
* 1. **A stored definition that will not parse was dropped.** The `catch`
|
||||
* here returned `null` and the entry vanished. Nothing anywhere said a
|
||||
* skill had been discarded, so the skill simply stopped answering and the
|
||||
* registry looked healthy.
|
||||
* 2. **A definition that parses can still register incomplete.**
|
||||
* `normalizeSkillOwliver` keeps the capabilities that resolve and reports
|
||||
* the rest as errors — correct, and the reason a single bad response does
|
||||
* not cost an author their whole file. But only `validateSkillSource`
|
||||
* reads those errors, and that runs in the authoring dialog. A definition
|
||||
* arriving from storage was registered with its broken capability quietly
|
||||
* missing, which is exactly "some checks work and others do not".
|
||||
* 3. **A custom definition silently replaces a built-in of the same id.**
|
||||
* Intended — it is how a shipped skill is overridden — but indistinguishable
|
||||
* from the built-in having broken, because nothing reported the override.
|
||||
*
|
||||
* Why deployment exposed all three: custom definitions live in per-account
|
||||
* `preferences`, not in the repository. The bundled `.md` files are identical
|
||||
* everywhere — verified — so a definition that behaves differently in a
|
||||
* deployed workspace differs because of what that *account* has stored, and
|
||||
* because stored Markdown is validated when it is written and never again.
|
||||
* Ship a change to the supported vocabulary and yesterday's valid definition
|
||||
* silently loses a capability on next load.
|
||||
*
|
||||
* Nothing is dropped that used to register, and nothing new registers. The only
|
||||
* change is that the failures now have somewhere to be read from.
|
||||
*/
|
||||
export function readSkillRegistry(customSources = []) {
|
||||
const diagnostics = [];
|
||||
const custom = [];
|
||||
|
||||
customSources.forEach((entry, i) => {
|
||||
const path = entry?.path || `custom/${i}.md`;
|
||||
let skill = null;
|
||||
try {
|
||||
skill = parseSkill(entry?.raw ?? entry, { path, custom: true });
|
||||
} catch (error) {
|
||||
diagnostics.push({
|
||||
level: 'error',
|
||||
kind: 'unreadable',
|
||||
path,
|
||||
skillId: null,
|
||||
message: `A stored skill could not be read and is not registered. ${error?.message || ''}`.trim(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!skill?.id) {
|
||||
diagnostics.push({
|
||||
level: 'error',
|
||||
kind: 'unreadable',
|
||||
path,
|
||||
skillId: null,
|
||||
message: 'A stored skill has no `id` and is not registered.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
custom.push(skill);
|
||||
});
|
||||
|
||||
const byId = new Map(SKILLS.map((s) => [s.id, s]));
|
||||
for (const skill of custom) {
|
||||
if (byId.has(skill.id) && !byId.get(skill.id).custom) {
|
||||
diagnostics.push({
|
||||
level: 'warning',
|
||||
kind: 'shadowed',
|
||||
path: skill.path,
|
||||
skillId: skill.id,
|
||||
message: `\`${skill.id}\` replaces the built-in skill of the same id. The built-in definition is not registered.`,
|
||||
});
|
||||
}
|
||||
byId.set(skill.id, skill);
|
||||
}
|
||||
|
||||
const skills = [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
/* A registered definition that lost part of itself on the way in. Reported
|
||||
for every skill, not only custom ones, so a file on disk that stops
|
||||
resolving after a vocabulary change is just as visible. */
|
||||
for (const skill of skills) {
|
||||
for (const message of [...(skill.owliverErrors || []), ...(skill.uiErrors || [])]) {
|
||||
diagnostics.push({
|
||||
level: 'error',
|
||||
kind: 'incomplete',
|
||||
path: skill.path,
|
||||
skillId: skill.id,
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Two definitions claiming one phrase on one page.
|
||||
*
|
||||
* Only the first is ever consulted, and "first" means alphabetically by name —
|
||||
* so the losing definition answers nothing, with no way to tell that from it
|
||||
* being switched off. Reported per page, because a shared trigger on two
|
||||
* different pages is not a contest.
|
||||
*/
|
||||
const claims = new Map();
|
||||
for (const skill of skills) {
|
||||
if (skill.status !== 'active' || !skill.declaredTriggers) continue;
|
||||
for (const page of skill.pages) {
|
||||
const key = canonicalPage(page) || page;
|
||||
for (const trigger of new Set(skill.triggers)) {
|
||||
const at = `${key}::${trigger}`;
|
||||
if (!claims.has(at)) claims.set(at, []);
|
||||
claims.get(at).push(skill);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [at, claimants] of claims) {
|
||||
if (claimants.length < 2) continue;
|
||||
const [page, trigger] = at.split('::');
|
||||
diagnostics.push({
|
||||
level: 'warning',
|
||||
kind: 'trigger-collision',
|
||||
path: claimants[0].path,
|
||||
skillId: claimants[0].id,
|
||||
message: `On ${page}, "${trigger}" is claimed by ${claimants.map((s) => s.id).join(' and ')}. `
|
||||
+ `Only ${claimants[0].id} is consulted.`,
|
||||
});
|
||||
}
|
||||
|
||||
return { skills, diagnostics };
|
||||
}
|
||||
|
||||
/**
|
||||
* The built-in skills plus any the account has added.
|
||||
*
|
||||
@@ -330,21 +517,17 @@ export const SKILLS = Object.entries(FILES)
|
||||
* is the same artefact a file on disk would be — nothing is half-parsed into a
|
||||
* bespoke record. A custom skill sharing an id with a built-in replaces it,
|
||||
* which is how one would be overridden without editing the repository.
|
||||
*
|
||||
* Unchanged in what it returns; `readSkillRegistry` is where the same work is
|
||||
* done with its failures kept.
|
||||
*/
|
||||
export function allSkills(customSources = []) {
|
||||
const custom = customSources
|
||||
.map((entry, i) => {
|
||||
try {
|
||||
return parseSkill(entry.raw ?? entry, { path: entry.path || `custom/${i}.md`, custom: true });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((s) => s && s.id);
|
||||
return readSkillRegistry(customSources).skills;
|
||||
}
|
||||
|
||||
const byId = new Map(SKILLS.map((s) => [s.id, s]));
|
||||
custom.forEach((s) => byId.set(s.id, s));
|
||||
return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
/** Everything that went wrong assembling the registry, for the Skills page. */
|
||||
export function skillDiagnostics(customSources = []) {
|
||||
return readSkillRegistry(customSources).diagnostics;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -472,11 +655,27 @@ function triggerMatches(trigger, question) {
|
||||
*/
|
||||
const addressable = (skill) => skill.declaredTriggers || skill.facets?.includes('owliver');
|
||||
|
||||
export function matchSkill(question, contextId, disabled = [], customSources = []) {
|
||||
/**
|
||||
* Every skill on this page whose triggers match — in registry order.
|
||||
*
|
||||
* `matchSkill` answers with the first and is what routing uses, because one
|
||||
* question gets one answer. The full list is what makes the choice *auditable*:
|
||||
* when two definitions claim the same phrase, the loser was previously
|
||||
* invisible, and the winner was decided by `allSkills`'s alphabetical sort —
|
||||
* a definition renamed from "Hiring…" to "Activity…" could take over a phrase
|
||||
* without either file's triggers changing. Nothing here changes which skill
|
||||
* answers; it makes the fact that there was a contest something a caller can
|
||||
* see and report.
|
||||
*/
|
||||
export function matchSkills(question, contextId, disabled = [], customSources = []) {
|
||||
const q = String(question).toLowerCase();
|
||||
return skillsForContext(contextId, disabled, customSources).find(
|
||||
return skillsForContext(contextId, disabled, customSources).filter(
|
||||
(s) => addressable(s) && s.triggers.length > 0 && s.triggers.some((t) => triggerMatches(t, q))
|
||||
) ?? null;
|
||||
);
|
||||
}
|
||||
|
||||
export function matchSkill(question, contextId, disabled = [], customSources = []) {
|
||||
return matchSkills(question, contextId, disabled, customSources)[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -489,3 +688,28 @@ export function matchSkill(question, contextId, disabled = [], customSources = [
|
||||
*/
|
||||
export const skillsWithFacet = (skills = [], facet) =>
|
||||
skills.filter((s) => s.facets?.includes(facet));
|
||||
|
||||
/**
|
||||
* AI agent skills — the ones Workspace & Skills governs.
|
||||
*
|
||||
* Two different things live in this registry, and they belong to two different
|
||||
* products:
|
||||
*
|
||||
* - **AI agent skills** (`kind: 'assistant'`) are capabilities Owliver gains.
|
||||
* An Owliver skill teaches it what it can be asked; a Board skill draws a
|
||||
* section on a page. Both are governed in Workspace & Skills.
|
||||
* - **Workforce training** (`kind: 'workforce'`) is what a *person* learns and
|
||||
* proves — Bartending, Food Safety, Customer Service. That belongs to KROW
|
||||
* Forge, and is read through `workforceSkillStates`.
|
||||
*
|
||||
* This exists so no surface has to reach for `allSkills()` and hope. A page that
|
||||
* calls `allSkills()` gets both kinds, which is how five training paths ended up
|
||||
* counted as Owliver capabilities on the Workspace landing page — the registry
|
||||
* was right and the reading was not. Asking for agent skills by name means a
|
||||
* training path added tomorrow cannot appear there by default.
|
||||
*/
|
||||
export const aiAgentSkills = (skills = []) => skills.filter((s) => s.kind === 'assistant');
|
||||
|
||||
/** Workforce training definitions — KROW Forge's half of the same registry. */
|
||||
export const workforceTrainingSkills = (skills = []) =>
|
||||
skills.filter((s) => s.kind === 'workforce');
|
||||
|
||||
Reference in New Issue
Block a user