fix error line

This commit is contained in:
2026-08-17 17:33:56 +05:30
parent 69b38abced
commit 7fa21a4513
105 changed files with 1296 additions and 323 deletions

View File

@@ -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');