From 161b2376951ce9497d1544260407c7e2bf61d236 Mon Sep 17 00:00:00 2001 From: Aravind Date: Thu, 20 Aug 2026 11:04:13 +0530 Subject: [PATCH] update the workspace and skills flow --- scripts/skill-check.mjs | 585 ++++++++++++++++++ .../ai-assistant/AssistantMessage.jsx | 1 - src/lib/skills/customSkills.js | 133 ++-- src/lib/skills/skillFields.js | 205 +++++- src/lib/skills/surfaces.js | 62 ++ src/lib/skills/uiConfig.js | 7 +- src/pages/admin/OwliverSkillEditor.jsx | 255 +++++--- src/pages/admin/SkillEditor.jsx | 56 +- 8 files changed, 1100 insertions(+), 204 deletions(-) diff --git a/scripts/skill-check.mjs b/scripts/skill-check.mjs index 3cf20c2..b2be613 100644 --- a/scripts/skill-check.mjs +++ b/scripts/skill-check.mjs @@ -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 ─────────────────────────────────────────────────── */ diff --git a/src/components/ai-assistant/AssistantMessage.jsx b/src/components/ai-assistant/AssistantMessage.jsx index a3119a3..1c9bc70 100644 --- a/src/components/ai-assistant/AssistantMessage.jsx +++ b/src/components/ai-assistant/AssistantMessage.jsx @@ -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'; diff --git a/src/lib/skills/customSkills.js b/src/lib/skills/customSkills.js index 222c614..1cdf542 100644 --- a/src/lib/skills/customSkills.js +++ b/src/lib/skills/customSkills.js @@ -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, + }) + ); }; /** diff --git a/src/lib/skills/skillFields.js b/src/lib/skills/skillFields.js index 79c8351..d23c8da 100644 --- a/src/lib/skills/skillFields.js +++ b/src/lib/skills/skillFields.js @@ -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)); diff --git a/src/lib/skills/surfaces.js b/src/lib/skills/surfaces.js index 1ba8b8e..01eac60 100644 --- a/src/lib/skills/surfaces.js +++ b/src/lib/skills/surfaces.js @@ -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? * diff --git a/src/lib/skills/uiConfig.js b/src/lib/skills/uiConfig.js index ce9c115..330d7a3 100644 --- a/src/lib/skills/uiConfig.js +++ b/src/lib/skills/uiConfig.js @@ -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(', ')}.` ); diff --git a/src/pages/admin/OwliverSkillEditor.jsx b/src/pages/admin/OwliverSkillEditor.jsx index 5a82819..f882268 100644 --- a/src/pages/admin/OwliverSkillEditor.jsx +++ b/src/pages/admin/OwliverSkillEditor.jsx @@ -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() { >