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

@@ -798,6 +798,591 @@ for (const skill of reg.SKILLS) {
`${rehydrated.id} / ${rehydrated.name}`);
}
/* ── 8. Markdown ↔ manual equivalence ─────────────────────────────────────
The two authoring paths, asserted to produce one artefact.
A form that cannot express what the format can is not a shortcut to it: the
manual Owliver editor carried a single `source` shared by every selected
capability, so a capability chosen before a source was picked composed no
`responses:` block at all — and a definition whose capabilities all drop
registers, shows as active on its page, and contributes no chip. These check
that both doors compose the same definition, and that the one that cannot be
configured is refused rather than saved empty. */
console.log('\n── Markdown ↔ manual equivalence ──');
const dataResolver = await server.ssrLoadModule('/src/lib/skills/dataResolver.js');
const positionsContext = Object.entries(placement.PLACEMENT_ROUTES)
.find(([route]) => reg.pageKeyForRoute(route) === 'positions')?.[1];
/* The canonical model is what `parseSkill` makes of a definition. Comparing the
whole record would compare its Markdown too, which is the one thing the two
paths are allowed to differ on — so the runtime configuration is compared,
which is exactly what every consumer reads. */
const canonical = (skill) => JSON.parse(JSON.stringify({
id: skill.id,
name: skill.name,
description: skill.description,
status: skill.status,
pages: skill.pages,
kind: skill.kind,
triggers: skill.triggers,
ui: skill.ui,
owliver: skill.owliver,
}));
/* TEST A / B — the Board card, written both ways. */
const BOARD_MD = `---
id: board
name: Board
description: Helps Owliver understand, analyze, and act on the current task board.
pages:
- positions
status: active
ui:
type: card
placement: after-position-list-summary
title: Board
source: candidates.activity
periods:
- today
- last-7-days
- previous-month
---
# Board
`;
const boardManual = templates.uiSkillTemplate({
id: 'board',
name: 'Board',
description: 'Helps Owliver understand, analyze, and act on the current task board.',
pages: ['positions'],
type: 'card',
placement: 'after-position-list-summary',
title: 'Board',
source: 'candidates.activity',
periods: ['today', 'last-7-days', 'previous-month'],
});
record('TEST A: Markdown Board saves', reg.validateSkillSource(BOARD_MD) === null,
reg.validateSkillSource(BOARD_MD) || 'accepted');
record('TEST B: manual Board saves', reg.validateSkillSource(boardManual) === null,
reg.validateSkillSource(boardManual) || 'accepted');
const boardFromMd = reg.parseSkill(BOARD_MD, { custom: true });
const boardFromForm = reg.parseSkill(boardManual, { custom: true });
const boardSection = (skill) => Object.values(skill.ui || {}).flatMap((pg) => pg.sections || [])[0];
record('TEST A: one section, above the position list',
Object.values(boardFromMd.ui).flatMap((pg) => pg.sections).length === 1
&& boardSection(boardFromMd).placement === 'after-position-list-summary',
`${boardSection(boardFromMd).type}@${boardSection(boardFromMd).placement}`);
record('TEST A: the Board card contributes no Owliver capability',
boardFromMd.owliver.enabled === false && boardFromMd.owliver.capabilities.length === 0);
record('TEST A: no unresolvable section', reg.unresolvableSections(boardFromMd).length === 0,
reg.unresolvableSections(boardFromMd)[0]?.message || 'none');
record('TEST B: manual Board renders the identical section',
JSON.stringify(boardSection(boardFromForm)) === JSON.stringify(boardSection(boardFromMd)),
JSON.stringify(boardSection(boardFromForm)));
record('TEST B: manual Board keeps its title', boardSection(boardFromForm).title === 'Board',
String(boardSection(boardFromForm).title));
/* TEST C / D — the conversational skill, written both ways. */
const OWLIVER_MD = `---
id: owliver-conversation-test
name: Hiring Activity Test
description: Shows hiring activity from the Positions page.
pages:
- positions
status: active
triggers:
- hiring activity test
owliver:
enabled: true
suggestions:
- Show hiring activity
- Summarize hiring activity
capabilities:
- summary
responses:
summary:
source: candidates.activity
periods:
- today
- last-7-days
- previous-month
---
# Hiring Activity Test
`;
const owliverManual = templates.owliverSkillTemplate({
id: 'owliver-conversation-test',
name: 'Hiring Activity Test',
description: 'Shows hiring activity from the Positions page.',
pages: ['positions'],
suggestions: ['Show hiring activity', 'Summarize hiring activity'],
capabilities: ['summary'],
responses: {
summary: { source: 'candidates.activity', periods: ['today', 'last-7-days', 'previous-month'] },
},
});
record('TEST C: Markdown Owliver skill saves', reg.validateSkillSource(OWLIVER_MD) === null,
reg.validateSkillSource(OWLIVER_MD) || 'accepted');
record('TEST D: manual Owliver skill saves', reg.validateSkillSource(owliverManual) === null,
reg.validateSkillSource(owliverManual) || 'accepted');
for (const [label, source] of [['TEST C', OWLIVER_MD], ['TEST D', owliverManual]]) {
const custom = [{ path: 'custom/owliver-conversation-test.md', raw: source }];
const skill = reg.parseSkill(source, { custom: true });
record(`${label}: the capability keeps its source`,
skill.owliver.responses.summary?.source === 'candidates.activity',
skill.owliver.responses.summary?.source || 'dropped');
record(`${label}: the capability keeps its periods`,
JSON.stringify(skill.owliver.responses.summary?.periods)
=== JSON.stringify(['today', 'last-7-days', 'previous-month']),
JSON.stringify(skill.owliver.responses.summary?.periods));
record(`${label}: it draws no page card`,
Object.keys(skill.ui).length === 0, `${Object.keys(skill.ui).length} pages`);
const chips = resolver.owliverSuggestions(positionsContext, [], custom, {});
const mine = chips.filter((c) => c.skillId === 'owliver-conversation-test');
record(`${label}: its suggestion reaches the panel`,
mine.some((c) => c.label === 'Show hiring activity'),
JSON.stringify(chips.map((c) => c.label)));
record(`${label}: the suggestion answers without asking for a record`,
mine.every((c) => !c.deferred));
const owlSkills = resolver.owliverSkillsForContext(positionsContext, [], custom);
const matched = resolver.matchOwliverSkill('Show hiring activity', owlSkills);
record(`${label}: clicking it routes to this skill's summary`,
matched?.skill.id === 'owliver-conversation-test' && matched?.capability === 'summary' && matched?.exact,
`${matched?.skill.id}:${matched?.capability} exact=${matched?.exact}`);
const answered = resolver.resolveOwliverResponse({
skill: matched.skill,
capability: matched.capability,
question: 'Show hiring activity',
context: {
positions: [{ id: 'p1', title: 'Line Cook', status: 'active' }],
applications: [{ id: 'a1', job_posting_id: 'p1', created_date: new Date().toISOString() }],
},
now: new Date(),
});
record(`${label}: it answers with the configured reading`,
answered?.missing === null && resolver.summaryLines(answered.data).length === 3,
JSON.stringify(resolver.summaryLines(answered?.data || {})));
}
/* TEST H — the same skill through both doors, normalized, compared. */
record('TEST H: Markdown and manual normalize identically (Board)',
JSON.stringify(canonical(boardFromMd)) === JSON.stringify(canonical(boardFromForm)),
JSON.stringify(canonical(boardFromForm)).slice(0, 120));
record('TEST H: Markdown and manual normalize identically (Owliver)',
JSON.stringify(canonical(reg.parseSkill(OWLIVER_MD, { custom: true })))
=== JSON.stringify(canonical(reg.parseSkill(owliverManual, { custom: true }))),
JSON.stringify(canonical(reg.parseSkill(owliverManual, { custom: true }))).slice(0, 120));
/* TEST E — a capability with no source is refused, never saved empty. */
const noSourceFields = {
id: 'no-source-test',
name: 'No Source Test',
description: 'A capability with nothing to read.',
pages: ['positions'],
suggestions: ['Show hiring activity'],
capabilities: ['summary'],
responses: {},
};
const noSource = templates.owliverSkillTemplate(noSourceFields);
record('TEST E: an unconfigured capability is named by the form',
JSON.stringify(fields.unconfiguredCapabilities(noSourceFields)) === '["summary"]',
JSON.stringify(fields.unconfiguredCapabilities(noSourceFields)));
record('TEST E: and the save is refused',
/owliver\.responses\.summary/.test(reg.validateSkillSource(noSource) || ''),
reg.validateSkillSource(noSource) || 'ACCEPTED — a skill with no capability would register');
record('TEST E: the refused definition would have had zero capabilities',
reg.parseSkill(noSource, { custom: true }).owliver.capabilities.length === 0,
'which is why it is refused rather than stored');
/* TEST F — two capabilities, two different sources, neither inheriting. */
const twoSources = templates.owliverSkillTemplate({
id: 'two-source-test',
name: 'Two Source Test',
description: 'Two capabilities reading two sources.',
pages: ['positions'],
suggestions: ['Summarize workspace applications', 'List the open roles'],
capabilities: ['summary', 'list'],
responses: {
summary: { source: 'candidates.activity', periods: ['today'] },
list: { source: 'positions.demand', limit: 5 },
},
});
const twoSkill = reg.parseSkill(twoSources, { custom: true });
record('TEST F: manual save accepted', reg.validateSkillSource(twoSources) === null,
reg.validateSkillSource(twoSources) || 'accepted');
record('TEST F: each capability keeps its own source',
twoSkill.owliver.responses.summary?.source === 'candidates.activity'
&& twoSkill.owliver.responses.list?.source === 'positions.demand',
`summary→${twoSkill.owliver.responses.summary?.source}, list→${twoSkill.owliver.responses.list?.source}`);
record('TEST F: neither inherits the other\'s options',
JSON.stringify(twoSkill.owliver.responses.summary.periods) === '["today"]'
&& twoSkill.owliver.responses.list.limit === 5
&& twoSkill.owliver.responses.list.periods.length === 0,
`summary periods=${JSON.stringify(twoSkill.owliver.responses.summary.periods)}, list limit=${twoSkill.owliver.responses.list.limit}`);
record('TEST F: both are offered as suggestions',
resolver.owliverSuggestions(positionsContext, [], [{ path: 'custom/two.md', raw: twoSources }], {})
.filter((c) => c.skillId === 'two-source-test').length === 2);
/* TEST G — editing one field leaves every other configuration alone. */
const edited = fields.patchFrontmatter(
twoSources,
fields.owliverPatch(
{ ...fields.owliverFieldsFromSource(twoSources), description: 'A new description.' },
{ existing: twoSources }
)
);
const editedSkill = reg.parseSkill(edited, { custom: true });
record('TEST G: the edit lands', editedSkill.description === 'A new description.', editedSkill.description);
record('TEST G: every capability source survives the edit',
JSON.stringify(canonical(editedSkill).owliver.responses)
=== JSON.stringify(canonical(twoSkill).owliver.responses),
`summary→${editedSkill.owliver.responses.summary?.source}, list→${editedSkill.owliver.responses.list?.source}`);
record('TEST G: suggestions survive the edit',
JSON.stringify(editedSkill.owliver.suggestions) === JSON.stringify(twoSkill.owliver.suggestions));
const boardEdited = fields.patchFrontmatter(
boardManual,
fields.boardPatch(
{ ...fields.boardFieldsFromSource(boardManual), description: 'A new description.' },
{ existing: boardManual }
)
);
record('TEST G: Board UI configuration survives the edit',
JSON.stringify(boardSection(reg.parseSkill(boardEdited, { custom: true })))
=== JSON.stringify(boardSection(boardFromForm)),
JSON.stringify(boardSection(reg.parseSkill(boardEdited, { custom: true }))));
/* TEST 6 — one source/shape resolver, shared by the pickers and the validator. */
record('shape compatibility: the resolver refuses what the normalizer refuses',
surfaces.sourcesForShape('list').every((src) => surfaces.sourceSupportsShape(src.id, 'list'))
&& !surfaces.sourcesForShape('list').some((src) => src.id === 'candidates.activity'),
`list sources: ${surfaces.sourcesForShape('list').map((s) => s.id).join(', ')}`);
record('shape compatibility: a list against candidates.activity is refused on save',
/cannot be shown as/.test(reg.validateSkillSource(templates.owliverSkillTemplate({
id: 'bad-shape-test',
name: 'Bad Shape Test',
description: 'A list of something with no list in it.',
pages: ['positions'],
capabilities: ['list'],
responses: { list: { source: 'candidates.activity' } },
})) || ''),
'the picker cannot offer it, and the save refuses it');
record('summary is prose, so every source suits it',
surfaces.shapeForCapability('summary') === null
&& surfaces.sourcesForShape(null).length === surfaces.DATA_SOURCES.length);
/* ── 9. The reported failure, reproduced end to end ───────────────────────── */
console.log('\n── Regression: manual Owliver skill on Positions ──');
/* Exactly the configuration from the report: Pages = Positions, one suggestion,
Summary, `candidates.activity`, three periods — composed the way the form
composes it, then read the way the panel reads it. */
const REGRESSION = templates.owliverSkillTemplate({
id: 'owliver-conversation-test',
name: 'Hiring Activity Test',
description: 'Shows hiring activity from the Positions page.',
pages: ['positions'],
suggestions: ['Show hiring activity'],
capabilities: ['summary'],
responses: {
summary: { source: 'candidates.activity', periods: ['today', 'last-7-days', 'previous-month'] },
},
});
const stored = [{ path: 'custom/owliver-conversation-test.md', raw: REGRESSION }];
record('the manual definition saves', reg.validateSkillSource(REGRESSION) === null,
reg.validateSkillSource(REGRESSION) || 'accepted');
record('it registers', reg.allSkills(stored).some((s) => s.id === 'owliver-conversation-test'));
record('it is attached to Positions',
reg.skillsForContext(positionsContext, [], stored).some((s) => s.id === 'owliver-conversation-test'));
record('it keeps a capability — the step that used to empty',
reg.parseSkill(REGRESSION, { custom: true }).owliver.capabilities.length === 1,
JSON.stringify(reg.parseSkill(REGRESSION, { custom: true }).owliver.capabilities));
record('it reaches owliverSkillsForContext',
resolver.owliverSkillsForContext(positionsContext, [], stored)
.some((s) => s.id === 'owliver-conversation-test'));
const regressionChips = resolver.owliverSuggestions(positionsContext, [], stored, {});
record('"Show hiring activity" is offered as a chip',
regressionChips.some((c) => c.label === 'Show hiring activity'),
JSON.stringify(regressionChips.map((c) => c.label)));
const clicked = resolver.matchOwliverSkill(
'Show hiring activity',
resolver.owliverSkillsForContext(positionsContext, [], stored)
);
record('clicking it invokes this skill\'s summary',
clicked?.skill.id === 'owliver-conversation-test' && clicked?.capability === 'summary',
`${clicked?.skill.id}:${clicked?.capability}`);
const now = new Date();
const answer = resolver.resolveOwliverResponse({
skill: clicked.skill,
capability: clicked.capability,
question: 'Show hiring activity',
context: {
positions: [{ id: 'p1', title: 'Line Cook', status: 'active' }],
applications: [
{ id: 'a1', job_posting_id: 'p1', created_date: now.toISOString() },
{ id: 'a2', job_posting_id: 'p1', created_date: new Date(now - 3 * 86400000).toISOString() },
],
},
now,
});
record('it answers with the configured source and periods',
answer?.missing === null && answer.section.source === 'candidates.activity'
&& answer.section.periods.length === 3,
`${answer?.section.source} over ${JSON.stringify(answer?.section.periods)}`);
record('the answer carries real figures',
resolver.summaryLines(answer.data).length === 3,
JSON.stringify(resolver.summaryLines(answer.data)));
record('and no `position.activity needs a position` is possible',
dataResolver.resolveSkillData(answer.section, { applications: [], positions: [] }, now).unavailable !== true,
'the source declares no context requirement');
/* Reload: what is stored is the Markdown, so the round trip through storage is
the round trip through the parser. */
const reloaded = reg.parseSkill(
fields.patchFrontmatter(REGRESSION, {}),
{ custom: true }
);
record('the source survives a reload',
reloaded.owliver.responses.summary?.source === 'candidates.activity',
reloaded.owliver.responses.summary?.source || 'lost');
record('the form reads it back unchanged',
JSON.stringify(fields.owliverFieldsFromSource(REGRESSION).responses)
=== JSON.stringify({ summary: { source: 'candidates.activity', periods: ['today', 'last-7-days', 'previous-month'], limit: null } }),
JSON.stringify(fields.owliverFieldsFromSource(REGRESSION).responses));
/* ── 10. Storage round trip ───────────────────────────────────────────────
The editors do not hand the registry a record; they write Markdown into
`preferences.customSkills`, which `base44Client` persists to localStorage and
reads back on the next load. "The source disappeared after reload" is a claim
about *that* path, so it is exercised here rather than assumed: the same
module the app runs, against a localStorage the same shape the browser's is. */
console.log('\n── Storage round trip ──');
const store = new Map();
globalThis.localStorage = {
getItem: (k) => (store.has(k) ? store.get(k) : null),
setItem: (k, v) => store.set(k, String(v)),
removeItem: (k) => store.delete(k),
};
const { base44 } = await server.ssrLoadModule('/src/api/base44Client.js');
const custom = await server.ssrLoadModule('/src/lib/skills/customSkills.js');
/* Saving is what the editor's Save button does: compose, validate, upsert,
write the preference. */
const saved = custom.upsertCustomSkill(base44.auth.preferences().customSkills || [], REGRESSION);
const written = await base44.auth.updatePreferences({ customSkills: saved.next });
record('the definition is persisted', written.persisted, written.error?.message || 'written to storage');
/* Reload: a fresh read of the same key, exactly as a page load does. */
const rehydratedSkills = base44.auth.preferences().customSkills || [];
record('it survives the reload', rehydratedSkills.length === 1
&& String(rehydratedSkills[0].raw).includes('candidates.activity'),
`${rehydratedSkills.length} stored`);
const afterReload = reg.parseSkill(rehydratedSkills[0].raw, { custom: true });
record('its capability survives the reload',
afterReload.owliver.responses.summary?.source === 'candidates.activity',
afterReload.owliver.responses.summary?.source || 'lost');
record('and it still offers its chip after the reload',
resolver.owliverSuggestions(positionsContext, [], rehydratedSkills, {})
.some((c) => c.label === 'Show hiring activity'),
JSON.stringify(resolver.owliverSuggestions(positionsContext, [], rehydratedSkills, {}).map((c) => c.label)));
record('the editor reopens it with its source intact',
fields.owliverFieldsFromSource(rehydratedSkills[0].raw).responses.summary?.source === 'candidates.activity',
JSON.stringify(fields.owliverFieldsFromSource(rehydratedSkills[0].raw).responses));
/* The Board skill through the same path, stored beside it — the two subsystems
are independent, so both must survive one write. */
const bothStored = custom.upsertCustomSkill(rehydratedSkills, boardManual).next;
await base44.auth.updatePreferences({ customSkills: bothStored });
const finalStored = base44.auth.preferences().customSkills || [];
const finalRegistry = reg.readSkillRegistry(finalStored);
record('Board and Owliver skills coexist in storage', finalStored.length === 2,
`${finalStored.length} stored`);
record('neither produces a registry diagnostic',
!finalRegistry.diagnostics.some((d) => d.level === 'error'),
finalRegistry.diagnostics.filter((d) => d.level === 'error').map((d) => d.message).join(' | ') || 'clean');
record('the Board card draws once on Positions, and offers no chip',
reg.getSkillsForPage('positions', { customSources: finalStored })
.filter((sk) => sk.id === 'board')
.every((sk) => Object.values(sk.ui).flatMap((pg) => pg.sections).length === 1
&& sk.owliver.capabilities.length === 0));
record('the Owliver skill offers its chip, and draws no card',
resolver.owliverSuggestions(positionsContext, [], finalStored, {})
.some((c) => c.skillId === 'owliver-conversation-test')
&& Object.keys(reg.parseSkill(REGRESSION, { custom: true }).ui).length === 0);
/* ── 11. A fresh Add Owliver Skill screen ─────────────────────────────────
What the form starts as, asserted at the state that drives the checkbox
rather than at the checkbox. `checked` is
`draft.capabilities.includes(capability.id)` and the per-capability
configuration is rendered under `{checked && …}`, so the array below is the
whole of "what is ticked" and "what is shown". */
console.log('\n── Fresh Add Owliver Skill ──');
const CAPS = surfaces.OWLIVER_CAPABILITIES.map((c) => c.id);
/* 1. NEW skill. `emptyDraft` is `EMPTY_OWLIVER_FIELDS`, which is what the
editor initializes to when there is no `:id` and nothing handed over. */
record('new skill: initial capabilities are []',
Array.isArray(fields.EMPTY_OWLIVER_FIELDS.capabilities)
&& fields.EMPTY_OWLIVER_FIELDS.capabilities.length === 0,
JSON.stringify(fields.EMPTY_OWLIVER_FIELDS.capabilities));
record('new skill: no response is pre-configured either',
Object.keys(fields.EMPTY_OWLIVER_FIELDS.responses).length === 0,
JSON.stringify(fields.EMPTY_OWLIVER_FIELDS.responses));
record(`new skill: all ${CAPS.length} capability checkboxes start unchecked`,
CAPS.every((id) => !fields.EMPTY_OWLIVER_FIELDS.capabilities.includes(id)),
CAPS.filter((id) => fields.EMPTY_OWLIVER_FIELDS.capabilities.includes(id)).join(', ') || 'none ticked');
/* The template the fresh screen composes must not declare capabilities either:
an author who types a name before ticking anything patches *that* file, and a
`capabilities:` key in it would tick boxes nobody chose. */
const freshTemplate = templates.owliverSkillTemplate({});
record('new skill: the composed template declares no capabilities',
!/^\s*capabilities:/m.test(freshTemplate),
freshTemplate.split('\n').filter((l) => /capabilit/i.test(l)).join(' / ') || 'no capabilities key');
record('new skill: reading that template back still yields []',
fields.owliverFieldsFromSource(freshTemplate).capabilities.length === 0,
JSON.stringify(fields.owliverFieldsFromSource(freshTemplate).capabilities));
/* 2. One capability, chosen. The editor's `update({ capabilities })` is a plain
state merge, so the assertion is on what that array then means. */
const pickedSummary = { ...fields.EMPTY_OWLIVER_FIELDS, capabilities: ['summary'] };
record('selecting Summary ticks exactly Summary',
JSON.stringify(pickedSummary.capabilities) === '["summary"]');
record('and only Summary renders a configuration block',
CAPS.filter((id) => pickedSummary.capabilities.includes(id)).length === 1,
CAPS.filter((id) => pickedSummary.capabilities.includes(id)).join(', '));
record('an unsourced Summary is invalid, and only Summary is reported',
JSON.stringify(fields.unconfiguredCapabilities(pickedSummary)) === '["summary"]',
JSON.stringify(fields.unconfiguredCapabilities(pickedSummary)));
/* 3. Choosing its source clears the error, and the definition saves. */
const sourcedSummary = {
...pickedSummary,
id: 'fresh-summary-test',
name: 'Fresh Summary Test',
description: 'One capability, configured.',
pages: ['positions'],
suggestions: ['Show hiring activity'],
responses: { summary: { source: 'candidates.activity', periods: ['today'] } },
};
record('choosing a source clears the validation error',
fields.unconfiguredCapabilities(sourcedSummary).length === 0,
JSON.stringify(fields.unconfiguredCapabilities(sourcedSummary)));
const freshSaved = templates.owliverSkillTemplate(sourcedSummary);
record('and the save succeeds', reg.validateSkillSource(freshSaved) === null,
reg.validateSkillSource(freshSaved) || 'accepted');
record('the saved definition declares only the chosen capability',
JSON.stringify(reg.parseSkill(freshSaved, { custom: true }).owliver.capabilities) === '["summary"]',
JSON.stringify(reg.parseSkill(freshSaved, { custom: true }).owliver.capabilities));
record('and its suggestion reaches the Positions panel',
resolver.owliverSuggestions(positionsContext, [], [{ path: 'custom/fresh.md', raw: freshSaved }], {})
.some((c) => c.skillId === 'fresh-summary-test'));
/* 4. Several, chosen by hand — only those, each validated on its own. */
const pickedTwo = {
...fields.EMPTY_OWLIVER_FIELDS,
capabilities: ['summary', 'list'],
responses: { summary: { source: 'candidates.activity' } },
};
record('selecting two capabilities renders exactly those two',
CAPS.filter((id) => pickedTwo.capabilities.includes(id)).join(',') === 'summary,list');
record('and only the unconfigured one is reported',
JSON.stringify(fields.unconfiguredCapabilities(pickedTwo)) === '["list"]',
JSON.stringify(fields.unconfiguredCapabilities(pickedTwo)));
/* 5. An EXISTING skill hydrates from its own definition, and from nothing else.
Including the capability that did *not* resolve: it is the one the author
opened the editor to fix, and dropping it from the form would delete it
from the file on the next save. */
const DECLARED_ONE = `---
id: declared-one
name: Declared One
description: Declares a single capability.
pages:
- positions
status: active
owliver:
enabled: true
suggestions:
- Show hiring activity
capabilities:
- summary
responses:
summary:
source: candidates.activity
---
# Declared One
`;
record('existing skill: hydrates exactly its declared capability',
JSON.stringify(fields.owliverFieldsFromSource(DECLARED_ONE).capabilities) === '["summary"]',
JSON.stringify(fields.owliverFieldsFromSource(DECLARED_ONE).capabilities));
record('existing skill: no new-skill default is applied on top',
CAPS.filter((id) => fields.owliverFieldsFromSource(DECLARED_ONE).capabilities.includes(id)).length === 1);
const HALF_BROKEN = DECLARED_ONE
.replace(' - summary\n', ' - summary\n - list\n')
.replace('id: declared-one', 'id: half-broken');
const halfFields = fields.owliverFieldsFromSource(HALF_BROKEN);
record('existing skill: a declared capability that lost its source still shows',
JSON.stringify(halfFields.capabilities) === '["summary","list"]',
JSON.stringify(halfFields.capabilities));
record('...unsourced, so it stays invalid rather than silently dropped',
JSON.stringify(fields.unconfiguredCapabilities(halfFields)) === '["list"]',
JSON.stringify(fields.unconfiguredCapabilities(halfFields)));
record('...and the configured one keeps its source through the round trip',
halfFields.responses.summary.source === 'candidates.activity',
halfFields.responses.summary.source);
record('...while the runtime still registers only what can answer',
JSON.stringify(reg.parseSkill(HALF_BROKEN, { custom: true }).owliver.capabilities) === '["summary"]',
JSON.stringify(reg.parseSkill(HALF_BROKEN, { custom: true }).owliver.capabilities));
/* A definition inheriting its reading from a `ui:` section still hydrates the
capability it actually offers — the union is declaration-first, not
declaration-only. */
const INHERITED = `---
id: inherited-cap
name: Inherited Cap
description: A capability reading the page section.
pages:
- positions
status: active
ui:
type: card
placement: after-position-list-summary
source: candidates.activity
owliver:
enabled: true
capabilities:
- card
---
# Inherited Cap
`;
record('a capability inheriting from `ui:` still hydrates with its source',
fields.owliverFieldsFromSource(INHERITED).responses.card?.source === 'candidates.activity',
fields.owliverFieldsFromSource(INHERITED).responses.card?.source || 'lost');
await server.close();
/* ── 8. Production bundle ─────────────────────────────────────────────────── */

View File

@@ -1,5 +1,4 @@
import * as React from 'react';
import { Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import { ResponseDocument } from './ResponseBlocks';
import OwliverAvatar from '@/components/krow/OwliverAvatar';

View File

@@ -1,4 +1,5 @@
import { parseSkill } from './registry';
import { boardPatch, owliverPatch, patchFrontmatter } from './skillFields';
/**
* Account-authored skills, as stored.
@@ -28,6 +29,22 @@ const frontMatter = ({ id, name, description, pages, fallback }) => [
'status: active',
].join('\n');
/**
* The identity block every definition opens with, and nothing else.
*
* The configuration that follows it — the `ui:` section, the `owliver:` block —
* is written by `boardPatch` and `owliverPatch`, which is the same writer the
* editors use on an existing file. A template that composed its own YAML was a
* second writer with a second field set, and the fields one knew about were not
* the fields the other did.
*/
const skeleton = (fields, fallback, body) => `---
${frontMatter({ ...fields, fallback })}
---
${body}
`;
/** A definition that draws a section on the pages it names. */
export const uiSkillTemplate = ({
id = '', name = '', description = '', pages = [],
@@ -40,26 +57,18 @@ export const uiSkillTemplate = ({
* record the page never had. A template must produce something that works
* before it is edited, so the default reads a source that needs nothing.
*/
type = 'flow', placement = '', source = 'candidates.activity', periods = [],
} = {}) => `---
${frontMatter({
id,
name,
description,
pages,
fallback: {
id: 'my-ui-skill',
name: 'My UI Skill',
description: 'What this skill adds to the page.',
},
})}
ui:
type: ${type}
${placement ? ` placement: ${placement}\n` : ''} title: ${name || 'My UI Skill'}
source: ${source}
${periods.length ? ` periods:\n${periods.map((p) => ` - ${p}`).join('\n')}\n` : ''}---
type = 'flow', placement = '', title = '', source = 'candidates.activity', periods = [],
} = {}) => {
const identity = { id, name, description, pages };
const fallback = {
id: 'my-ui-skill',
name: 'My UI Skill',
description: 'What this skill adds to the page.',
};
const heading = name || fallback.name;
# ${name || 'My UI Skill'}
return patchFrontmatter(
skeleton(identity, fallback, `# ${heading}
## Purpose
@@ -68,59 +77,36 @@ Describe what this section shows, and why it belongs on these pages.
## Capabilities
- Describe one thing the section reports.
- Add more as needed.
`;
- Add more as needed.`),
boardPatch({
...identity,
type,
placement,
/* A card with no title of its own is headed by the skill's name, which is
what the previous template wrote out. Kept, so a fresh draft reads the
same as it always did. */
title: title || heading,
source,
periods,
})
);
};
/** A definition that teaches Owliver what it can be asked for. */
export const owliverSkillTemplate = ({
id = '', name = '', description = '', pages = [],
triggers = [], suggestions = [], capabilities = [], source = '', periods = [],
triggers = [], suggestions = [], capabilities = [], responses = {},
} = {}) => {
const label = name || 'My Owliver Skill';
const lines = [`---
${frontMatter({
id,
name,
description,
pages,
fallback: {
id: 'my-owliver-skill',
name: label,
description: 'What this skill helps Owliver answer.',
},
})}`];
const identity = { id, name, description, pages };
const fallback = {
id: 'my-owliver-skill',
name: 'My Owliver Skill',
description: 'What this skill helps Owliver answer.',
};
const label = name || fallback.name;
lines.push('triggers:');
lines.push((triggers.length ? triggers : [label.toLowerCase()]).map((t) => ` - ${t}`).join('\n'));
lines.push('owliver:');
lines.push(' enabled: true');
if (suggestions.length) {
lines.push(' suggestions:');
lines.push(suggestions.map((s) => ` - ${s}`).join('\n'));
}
if (capabilities.length) {
lines.push(' capabilities:');
lines.push(capabilities.map((c) => ` - ${c}`).join('\n'));
if (source) {
lines.push(' responses:');
for (const capability of capabilities) {
lines.push(` ${capability}:`);
lines.push(` source: ${source}`);
if (periods.length) {
lines.push(' periods:');
lines.push(periods.map((p) => ` - ${p}`).join('\n'));
}
}
}
}
lines.push(`---
# ${label}
return patchFrontmatter(
skeleton(identity, fallback, `# ${label}
## Purpose
@@ -129,10 +115,17 @@ Describe what Owliver should be able to answer on these pages.
## Capabilities
- Describe one thing Owliver can be asked for.
- Add more as needed.
`);
return lines.join('\n');
- Add more as needed.`),
owliverPatch({
...identity,
/* A definition that claims no phrase of its own still answers to its
name — written out so the author can see what it will match on. */
triggers: triggers.length ? triggers : [label.toLowerCase()],
suggestions,
capabilities,
responses,
})
);
};
/**

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

View File

@@ -438,6 +438,7 @@ export const DATA_SOURCES = [
context: 'positionId',
summary: 'Applications to this position, counted over time.',
shapes: ['flow', 'stats', 'timeline', 'table', 'insight', 'card'],
options: ['periods'],
},
{
id: 'position.pipeline',
@@ -452,6 +453,7 @@ export const DATA_SOURCES = [
context: 'positionId',
summary: 'Candidates matched to this position, best first.',
shapes: ['list', 'table', 'stats', 'card'],
options: ['limit'],
},
{
/**
@@ -469,6 +471,7 @@ export const DATA_SOURCES = [
context: 'positionId',
summary: 'The candidate pool scored against this position, best first.',
shapes: ['list', 'table', 'stats', 'card', 'insight'],
options: ['limit'],
},
{
id: 'position.requirements',
@@ -504,6 +507,7 @@ export const DATA_SOURCES = [
context: null,
summary: 'Applications across the workspace, counted over time.',
shapes: ['flow', 'stats', 'timeline', 'table', 'card'],
options: ['periods'],
},
{
id: 'positions.demand',
@@ -511,6 +515,7 @@ export const DATA_SOURCES = [
context: null,
summary: 'Open positions and what they still need.',
shapes: ['list', 'table', 'stats', 'card'],
options: ['limit'],
},
{
id: 'workforce.training',
@@ -518,6 +523,7 @@ export const DATA_SOURCES = [
context: null,
summary: 'Training paths and progress against them.',
shapes: ['progress', 'list', 'stats', 'table', 'card'],
options: ['limit'],
},
{
/* The vetting weights a position is being specified with. `positionId`
@@ -548,6 +554,7 @@ export const DATA_SOURCES = [
context: null,
summary: 'Who was hired, for which role, and when.',
shapes: ['list', 'table', 'timeline', 'stats', 'card'],
options: ['limit'],
},
{
id: 'hires.performance',
@@ -562,6 +569,7 @@ export const DATA_SOURCES = [
context: null,
summary: 'What has happened across the workspace, most recent first.',
shapes: ['timeline', 'list', 'table', 'stats', 'card'],
options: ['limit'],
},
];
@@ -569,6 +577,60 @@ export const SUPPORTED_DATA_SOURCES = DATA_SOURCES.map((s) => s.id);
export const dataSourceFor = (id) => DATA_SOURCES.find((s) => s.id === id) || null;
/* ── Source / shape compatibility ───────────────────────────────────────────
One resolver, three consumers: `normalizeSection` refuses on it, the Board
editor's source picker filters on it, and the Owliver editor's per-capability
picker filters on it. They used to be three readings of `source.shapes` —
inline in the normalizer, absent from one editor and approximated in the
other — which is how a form could compose `list` against a source that has no
list in it and only find out at save. */
/**
* The section type a capability is drawn with, or null when it is prose.
*
* `summary` is the one capability with no component, so it is compatible with
* every source: the figures are read back as sentences rather than drawn.
*/
export const shapeForCapability = (capability) => owliverCapabilityFor(capability)?.shape || null;
/** Can this source be drawn as this shape? A null shape is prose, and always can. */
export function sourceSupportsShape(sourceId, shape) {
const source = dataSourceFor(sourceId);
if (!source) return false;
if (!shape) return true;
return Boolean(source.shapes?.includes(shape));
}
/**
* The sources that can fill this shape, optionally narrowed to what a placement
* can supply context for.
*
* `context` is the list a placement provides — `contextSuppliedBy(pages,
* placement)`. Passing it is how the Board editor offers only sources that can
* actually resolve where the section is mounted; the Owliver editor passes
* nothing, because a response with an unmet need asks which record is meant
* rather than rendering dead.
*/
export function sourcesForShape(shape, { context = null } = {}) {
return DATA_SOURCES.filter((source) => {
if (!sourceSupportsShape(source.id, shape)) return false;
if (!context) return true;
return !source.context || context.includes(source.context);
});
}
/**
* Whether a source reads an option a form can offer — `periods` or `limit`.
*
* Declared per source rather than inferred from its shapes, because the two do
* not line up: `positions.demand` is a list that honours `limit` and has no
* periods at all, while `candidates.activity` is the reverse. The editors used
* to guess from the shape list and offered period checkboxes that changed
* nothing.
*/
export const sourceSupportsOption = (sourceId, option) =>
Boolean(dataSourceFor(sourceId)?.options?.includes(option));
/**
* Can this reading be written back?
*

View File

@@ -1,7 +1,7 @@
import {
SUPPORTED_DATA_SOURCES, SUPPORTED_PERIODS, SUPPORTED_SECTION_TYPES,
SUPPORTED_SKILL_PAGES, canonicalPage, dataSourceFor, isSourceWritable, placementFor,
surfaceFor,
sourceSupportsShape, surfaceFor,
} from './surfaces';
/**
@@ -151,7 +151,10 @@ export function normalizeSection(raw, {
summary is prose, so there is no shape for a source to be incompatible
with. Every drawn type maps to itself. */
const shape = shapeFor(type);
if (shape && definition.shapes && !definition.shapes.includes(shape)) {
/* `sourceSupportsShape` rather than a reading of `definition.shapes` here:
the editors' source pickers ask the same question, and the answer has to
come from one place or a form can compose what the normalizer refuses. */
if (!sourceSupportsShape(source, shape)) {
errors.push(
`${at || `${where}.${id}`}: \`${source}\` cannot be shown as \`${type}\`. It supports: ${definition.shapes.join(', ')}.`
);

View File

@@ -12,11 +12,12 @@ import { customSkillSource, owliverSkillTemplate, upsertCustomSkill } from '@/li
import { reportSave } from '@/lib/skills/saveFeedback';
import {
EMPTY_OWLIVER_FIELDS, facetsFromSource, isReadableDefinition, normalizeUpload,
owliverFieldsFromSource, pagesAreDerived, patchFrontmatter,
owliverFieldsFromSource, owliverPatch, patchFrontmatter, unconfiguredCapabilities,
} from '@/lib/skills/skillFields';
import {
DATA_SOURCES, OWLIVER_CAPABILITIES, PERIODS, dataSourceFor, dataSourceLabel,
owliverCapabilityLabel, periodLabel, sectionTypeLabel, surfaceFor,
OWLIVER_CAPABILITIES, PERIODS, contextLabel, dataSourceLabel, owliverCapabilityLabel,
periodLabel, sectionTypeLabel, shapeForCapability, sourceSupportsOption, sourcesForShape,
surfaceFor,
} from '@/lib/skills/surfaces';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
@@ -186,32 +187,12 @@ export default function OwliverSkillEditor() {
return;
}
/* Responses are only rewritten when this form has enough to say what they
are. A definition whose capabilities read from an inherited `ui:` section
declares no `responses:` block, and inventing one here would pin a
reading the author deliberately left to be inherited. */
const responses = next.capabilities.length && next.source
? Object.fromEntries(next.capabilities.map((capability) => [
capability,
next.periods.length
? { source: next.source, periods: next.periods }
: { source: next.source },
]))
: undefined;
setSource(patchFrontmatter(source, {
id: next.id || undefined,
name: next.name || undefined,
description: next.description || undefined,
/* A definition whose reach comes from its `ui:` entries shows those pages
as a read-out; writing them back would pin a list that then governs the
block. Same rule as the Board editor, for the same reason. */
pages: next.pages?.length && !pagesAreDerived(source) ? next.pages : undefined,
triggers: next.triggers?.length ? next.triggers : undefined,
'owliver.suggestions': next.suggestions?.length ? next.suggestions : undefined,
'owliver.capabilities': next.capabilities?.length ? next.capabilities : undefined,
'owliver.responses': responses,
}));
/* `owliverPatch` is the same writer the template composes a fresh draft
with, so a definition typed into this form and one pasted as Markdown are
the same artefact rather than two dialects of it. Each selected capability
carries its own source, periods and limit — nothing is inherited from a
`ui:` block, which this editor's definitions frequently do not have. */
setSource(patchFrontmatter(source, owliverPatch(next, { existing: source })));
};
const setStatus = (nextActive) => {
@@ -288,11 +269,23 @@ export default function OwliverSkillEditor() {
navigate('/admin/workspace/skills?tab=owliver');
};
/* Only the sources the declared pages can actually supply context for are
worth offering — a source needing a candidate is not answerable from the
Analytics panel, and offering it would validate and then never resolve. */
const source_ = draft.source ? dataSourceFor(draft.source) : null;
const timeBased = Boolean(source_?.shapes?.includes('flow') || source_?.shapes?.includes('timeline'));
/**
* One capability's response, edited.
*
* Keyed by capability because the format is: `owliver.responses.summary` and
* `owliver.responses.list` are two readings, and a form with one source field
* could only ever write one of them.
*/
const updateResponse = (capability, patch) => update({
responses: {
...draft.responses,
[capability]: { ...(draft.responses[capability] || {}), ...patch },
},
});
/* Capabilities selected but not yet configured. Shown against the capability
itself; `validateSkillSource` is still what refuses the save. */
const unconfigured = unconfiguredCapabilities(draft);
/* The other half of the same definition, when it has one. Read-only: this
editor does not own it, but hiding it would misrepresent the skill. */
@@ -312,7 +305,16 @@ export default function OwliverSkillEditor() {
>
<ChevronLeft aria-hidden="true" /> Skills
</Button>
<Button size="sm" shape="rounded" onClick={save} loading={updatePreferences.isPending}>
{/* A capability with no source cannot be saved: `validateSkillSource`
refuses it, and refusing at the button says so before the author
has left the form. */}
<Button
size="sm"
shape="rounded"
onClick={save}
disabled={unconfigured.length > 0}
loading={updatePreferences.isPending}
>
{editingId ? 'Save skill' : 'Add skill'}
</Button>
</>
@@ -441,21 +443,31 @@ export default function OwliverSkillEditor() {
</Surface>
</section>
{/* ── What an answer looks like ──────────────────────────────── */}
{/* ── What an answer looks like, and what each one reads ────── */}
<section className="space-y-3">
<SectionTitle title="Capabilities" meta="The shapes an answer can take" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
<div className="grid gap-2.5 sm:grid-cols-2">
{OWLIVER_CAPABILITIES.map((capability) => {
const checked = draft.capabilities.includes(capability.id);
return (
<label
key={capability.id}
className={cn(
'flex cursor-pointer items-start gap-2.5 rounded-lg border px-3 py-2.5 transition-colors',
checked ? 'border-krow-blue/30 bg-krow-blue-tint/50' : 'border-border bg-surface hover:bg-surface-subtle'
)}
>
<SectionTitle title="Capabilities" meta="Each one reads its own source" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-2.5">
{OWLIVER_CAPABILITIES.map((capability) => {
const checked = draft.capabilities.includes(capability.id);
const response = draft.responses[capability.id] || {};
/* The shapes a source must be able to fill for this capability.
`summary` is prose and has none, so every source suits it. */
const shape = shapeForCapability(capability.id);
/* One compatibility resolver, the same one `normalizeSection`
refuses on — so this list cannot offer what the save will
reject. */
const options = sourcesForShape(shape);
const missing = checked && !response.source;
return (
<div
key={capability.id}
className={cn(
'rounded-lg border transition-colors',
checked ? 'border-krow-blue/30 bg-krow-blue-tint/40' : 'border-border bg-surface'
)}
>
<label className="flex cursor-pointer items-start gap-2.5 px-3 py-2.5">
<Checkbox
checked={checked}
onCheckedChange={() => update({
@@ -471,58 +483,105 @@ export default function OwliverSkillEditor() {
<span className="block text-caption leading-relaxed text-ink-3">{capability.summary}</span>
</span>
</label>
);
})}
</div>
</Surface>
</section>
{/* ── What it reads ──────────────────────────────────────────── */}
<section className="space-y-3">
<SectionTitle title="Data" meta="The same resolver the pages use" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-4">
<Field
label="Source"
hint="A named reading of data KROW already holds. A UI skill naming the same source reads exactly the same records."
>
<Select value={draft.source || undefined} onValueChange={(value) => update({ source: value })}>
<SelectTrigger><SelectValue placeholder="Select a data source" /></SelectTrigger>
<SelectContent>
{DATA_SOURCES.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.label} — {s.summary}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
{timeBased && (
<Field label="Periods" hint="Computed against the current date at read time, never stored.">
<div className="flex flex-wrap gap-1.5">
{PERIODS.map((period) => {
const selected = draft.periods.includes(period.id);
return (
<button
key={period.id}
type="button"
onClick={() => update({
periods: selected
? draft.periods.filter((p) => p !== period.id)
: [...draft.periods, period.id],
})}
className={cn(
'inline-flex cursor-pointer items-center gap-1 rounded-full border px-2.5 py-0.5 text-caption transition-all',
selected
? 'border-krow-blue/30 bg-krow-blue-tint font-medium text-krow-blue shadow-2xs'
: 'border-border/60 bg-surface-sunken/60 text-ink-3 hover:bg-surface-sunken hover:text-ink-1'
)}
{/* The response this capability answers with. Shown only
when it is selected, because an unselected capability has
no response to configure — and every selected one must
have a source of its own before the definition will
save. */}
{checked && (
<div className="space-y-3 border-t border-border/60 px-3 py-3">
<Field
label="Source"
hint={`A named reading of data KROW already holds. Only sources that can be answered as ${capability.label.toLowerCase()} are offered.`}
error={missing ? `${capability.label} needs a source before this skill can be saved.` : undefined}
>
<span>{period.label}</span>
<span className="text-[10px] font-bold opacity-75">{selected ? '✓' : '+'}</span>
</button>
);
})}
<Select
value={response.source || undefined}
onValueChange={(value) => updateResponse(capability.id, { source: value })}
>
<SelectTrigger aria-label={`${capability.label} source`}>
<SelectValue placeholder="Select a data source" />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.label} — {option.summary}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
{/* A reading that needs a record says so. Not a refusal:
an Owliver response with an unmet need asks which
position is meant and answers once told — which is
exactly what a page card cannot do. */}
{response.source && (
<p className="text-caption text-ink-3">
{sourcesForShape(shape).find((o) => o.id === response.source)?.context
? `Reads ${contextLabel(sourcesForShape(shape).find((o) => o.id === response.source).context)}. Owliver asks which one when the page has none open.`
: 'Reads across the workspace, so it answers with nothing selected.'}
</p>
)}
{sourceSupportsOption(response.source, 'periods') && (
<Field label="Periods" hint="Computed against the current date at read time, never stored.">
<div className="flex flex-wrap gap-1.5">
{PERIODS.map((period) => {
const selected = (response.periods || []).includes(period.id);
return (
<button
key={period.id}
type="button"
onClick={() => updateResponse(capability.id, {
periods: selected
? (response.periods || []).filter((p) => p !== period.id)
: [...(response.periods || []), period.id],
})}
className={cn(
'inline-flex cursor-pointer items-center gap-1 rounded-full border px-2.5 py-0.5 text-caption transition-all',
selected
? 'border-krow-blue/30 bg-krow-blue-tint font-medium text-krow-blue shadow-2xs'
: 'border-border/60 bg-surface-sunken/60 text-ink-3 hover:bg-surface-sunken hover:text-ink-1'
)}
>
<span>{period.label}</span>
<span className="text-[10px] font-bold opacity-75">{selected ? '✓' : '+'}</span>
</button>
);
})}
</div>
</Field>
)}
{sourceSupportsOption(response.source, 'limit') && (
<Field label="Limit" hint="How many records the answer lists. Left empty, the reading uses its own default.">
<Input
type="number"
min="1"
max="50"
value={response.limit ?? ''}
aria-label={`${capability.label} limit`}
onChange={(e) => updateResponse(capability.id, {
limit: e.target.value ? Number(e.target.value) : null,
})}
/>
</Field>
)}
</div>
)}
</div>
</Field>
);
})}
{unconfigured.length > 0 && (
<p className="text-caption text-destructive">
{unconfigured.map(owliverCapabilityLabel).join(', ')}
{unconfigured.length === 1 ? ' has' : ' have'} no source yet.
A capability with nothing to read is dropped when the definition is registered,
so the skill would save and then offer no suggestion at all.
</p>
)}
</Surface>
</section>

View File

@@ -11,12 +11,13 @@ import { PAGE_KEYS, allSkills, parseSkill, validateSkillSource } from '@/lib/ski
import { customSkillSource, uiSkillTemplate, upsertCustomSkill } from '@/lib/skills/customSkills';
import { reportSave } from '@/lib/skills/saveFeedback';
import {
EMPTY_BOARD_FIELDS, boardFieldsFromSource, facetsFromSource, isReadableDefinition,
EMPTY_BOARD_FIELDS, boardFieldsFromSource, boardPatch, facetsFromSource, isReadableDefinition,
normalizeUpload, pagesAreDerived, patchFrontmatter, uiIsEditableFromFields,
} from '@/lib/skills/skillFields';
import {
DATA_SOURCES, PERIODS, SECTION_TYPES, contextLabel, contextSuppliedBy, dataSourceLabel,
owliverCapabilityLabel, periodLabel, placementLabel, sectionTypeLabel, surfaceFor,
PERIODS, SECTION_TYPES, contextLabel, contextSuppliedBy, dataSourceLabel,
owliverCapabilityLabel, periodLabel, placementLabel, sectionTypeLabel, sourceSupportsOption,
sourcesForShape, surfaceFor,
} from '@/lib/skills/surfaces';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
@@ -188,29 +189,13 @@ export default function SkillEditor() {
return;
}
/* Only the section keys this form can faithfully represent. A definition
/* `boardPatch` is the same writer the template composes a fresh draft
with, and it is what decides which keys this form may own: a definition
written as a list, or addressing pages by name, may hold several sections
across several pages; overwriting that from four single-valued fields
would throw away everything but the first, so those fields are read-only
against it. Identity and reach still patch normally — a multi-page
across several pages, and single-valued fields would throw away all but
the first. Identity and reach still patch normally — a multi-page
definition must still be renameable. */
const section = uiIsEditableFromFields(source) ? {
'ui.type': next.type || undefined,
'ui.placement': next.placement || undefined,
'ui.source': next.source || undefined,
'ui.periods': next.periods?.length ? next.periods : undefined,
} : {};
setSource(patchFrontmatter(source, {
id: next.id || undefined,
name: next.name || undefined,
description: next.description || undefined,
/* Only when the definition owns a `pages:` key. A definition whose reach
comes from its `ui:` entries is showing a read-out here, and writing it
back would pin a list that governs the block from then on. */
pages: pages.length && !pagesAreDerived(source) ? pages : undefined,
...section,
}));
setSource(patchFrontmatter(source, boardPatch({ ...next, pages }, { existing: source })));
};
const setStatus = (nextActive) => {
@@ -450,6 +435,17 @@ export default function SkillEditor() {
</Field>
</div>
<Field
label="Card title"
hint="The heading the section draws. Left empty, it is headed by the skill's name."
>
<Input
value={meta.title}
placeholder={meta.name || 'Board'}
onChange={(e) => syncFromMeta({ ...meta, title: e.target.value })}
/>
</Field>
<Field
label="Data source"
hint="A named reading of data KROW already holds. An Owliver skill naming the same source reads exactly the same records."
@@ -457,7 +453,15 @@ export default function SkillEditor() {
<Select value={meta.source} onValueChange={(source) => syncFromMeta({ ...meta, source })}>
<SelectTrigger><SelectValue placeholder="Select a data source" /></SelectTrigger>
<SelectContent>
{DATA_SOURCES.map((src) => {
{/* Only the sources that can be drawn as the chosen
component, from the one resolver `normalizeSection`
refuses on — a picker that offers a shape the source
cannot fill is a form composing what the save rejects.
The ones that *could* be drawn but have no record here
stay visible and disabled, with the reason: an author who
cannot find a source they know exists assumes it was
removed. */}
{sourcesForShape(meta.type).map((src) => {
const why = sourceUnavailable(src);
return (
<SelectItem key={src.id} value={src.id} disabled={Boolean(why)}>
@@ -469,6 +473,7 @@ export default function SkillEditor() {
</Select>
</Field>
{sourceSupportsOption(meta.source, 'periods') && (
<Field label="Periods" hint="Computed against the current date at read time, never stored.">
<div className="flex flex-wrap gap-1.5">
{PERIODS.map((period) => {
@@ -497,6 +502,7 @@ export default function SkillEditor() {
})}
</div>
</Field>
)}
{!uiIsEditableFromFields(source) && (
<p className="border-t border-border/50 pt-3 text-caption leading-relaxed text-ink-4">