From d3f7f439f694feaf8e68e39d01ab998980c9265d Mon Sep 17 00:00:00 2001 From: Aravind Date: Wed, 19 Aug 2026 17:36:27 +0530 Subject: [PATCH] update Markdown skills --- package.json | 1 + public/owliver-avatar.png | Bin 0 -> 7789 bytes scripts/skill-check.mjs | 425 +++++++++++++++++- skill-examples/board-invalid-context.md | 26 ++ skill-examples/board-page-level.md | 31 ++ skill-examples/board-position-card.md | 31 ++ skill-examples/board-ui-test.md | 40 ++ skill-examples/owliver-conversation-test.md | 63 +++ src/api/base44Client.js | 50 ++- .../ai-assistant/AssistantMessage.jsx | 9 +- .../ai-assistant/AssistantPanel.jsx | 5 +- src/components/ai-assistant/KrowAssistant.jsx | 5 +- src/components/ai-assistant/routing.js | 21 +- src/components/ai-assistant/useAssistant.js | 31 +- src/components/krow/OwliverAvatar.jsx | 9 +- src/components/krow/OwliverChatBubble.jsx | 2 +- src/components/skills/AddSkillDialog.jsx | 103 ++++- src/lib/krowHooks.js | 14 +- src/lib/skills/customSkills.js | 11 +- src/lib/skills/registry.js | 152 ++++++- src/lib/skills/saveFeedback.js | 32 ++ src/lib/skills/skillFields.js | 406 +++++++++++++++++ src/lib/skills/surfaces.js | 144 +++++- src/lib/skills/uiConfig.js | 123 ++++- src/lib/skills/workforceFlow.js | 165 ++++++- src/pages/CreatePosition.jsx | 5 +- src/pages/admin/OwliverSkillEditor.jsx | 233 +++++++--- src/pages/admin/SkillEditor.jsx | 276 +++++++++--- src/pages/admin/Workspace.jsx | 3 +- src/pages/admin/WorkspaceSkills.jsx | 146 +++++- 30 files changed, 2341 insertions(+), 221 deletions(-) create mode 100644 public/owliver-avatar.png create mode 100644 skill-examples/board-invalid-context.md create mode 100644 skill-examples/board-page-level.md create mode 100644 skill-examples/board-position-card.md create mode 100644 skill-examples/board-ui-test.md create mode 100644 skill-examples/owliver-conversation-test.md create mode 100644 src/lib/skills/saveFeedback.js create mode 100644 src/lib/skills/skillFields.js diff --git a/package.json b/package.json index 8d7bf15..cdff3be 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "vite build", "lint": "eslint . --quiet", "lint:fix": "eslint . --fix", + "test": "node scripts/skill-check.mjs", "typecheck": "tsc -p ./jsconfig.json", "preview": "vite preview" }, diff --git a/public/owliver-avatar.png b/public/owliver-avatar.png new file mode 100644 index 0000000000000000000000000000000000000000..5cc2a3aac8ebe8e15e49627dd53569fbef2aafcc GIT binary patch literal 7789 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 literal 0 HcmV?d00001 diff --git a/scripts/skill-check.mjs b/scripts/skill-check.mjs index 29f71cf..3cf20c2 100644 --- a/scripts/skill-check.mjs +++ b/scripts/skill-check.mjs @@ -375,9 +375,432 @@ for (const n of [1, 4, 9]) { ); } +/* ── 6. Authoring: what the editors save, and what they refuse ────────────── */ +console.log('\n── Authoring ──'); + +const fields = await server.ssrLoadModule('/src/lib/skills/skillFields.js'); +const templates = await server.ssrLoadModule('/src/lib/skills/customSkills.js'); +const surfaces = await server.ssrLoadModule('/src/lib/skills/surfaces.js'); + +/* A template must produce something that works before it is edited. The Board + template used to default to a source its own default placement cannot read. */ +record( + 'the Board template validates as written', + reg.validateSkillSource(templates.uiSkillTemplate({ + id: 'template-check', name: 'Template Check', pages: ['positions'], + })) === null, + reg.validateSkillSource(templates.uiSkillTemplate({ + id: 'template-check', name: 'Template Check', pages: ['positions'], + })) || 'valid' +); + +/* An Owliver block that can answer nothing is refused rather than saved and + then reported as a skill that does not work. */ +record( + 'an Owliver skill with no capabilities is refused', + Boolean(reg.validateSkillSource(templates.owliverSkillTemplate({ + id: 'empty-owliver', name: 'Empty Owliver', pages: ['positions'], + }))), + reg.validateSkillSource(templates.owliverSkillTemplate({ + id: 'empty-owliver', name: 'Empty Owliver', pages: ['positions'], + })) || 'ACCEPTED — should have been refused' +); + +/* The section-can-never-resolve check, in both directions. */ +const boardOn = (page, placement) => `--- +id: context-check +name: Context Check +description: Reads position activity. +pages: + - ${page} +status: active +ui: + type: flow + placement: ${placement} + source: position.activity +--- + +# Context Check +`; + +record( + 'a position source on a page with no position is refused', + Boolean(reg.validateSkillSource(boardOn('analytics', 'after-header'))), + reg.validateSkillSource(boardOn('analytics', 'after-header')) || 'ACCEPTED — should have been refused' +); +record( + 'the same source is refused above the Positions grid', + Boolean(reg.validateSkillSource(boardOn('positions', 'after-position-list'))), + reg.validateSkillSource(boardOn('positions', 'after-position-list')) || 'ACCEPTED — should have been refused' +); +record( + 'and accepted inside a position card', + reg.validateSkillSource(boardOn('positions', 'after-position-card')) === null, + reg.validateSkillSource(boardOn('positions', 'after-position-card')) || 'accepted' +); + +/* Owliver responses are deliberately NOT subject to that rule: an unmet need is + a question back, not a dead card. `hiring-activity-assistant` depends on it. */ +record( + 'a shipped Owliver skill reading one position still validates', + reg.validateSkillSource( + reg.SKILLS.find((s) => s.id === 'hiring-activity-assistant').markdown + ) === null +); + +record( + 'every shipped definition still validates', + reg.SKILLS.every((s) => reg.validateSkillSource(s.markdown) === null), + reg.SKILLS.filter((s) => reg.validateSkillSource(s.markdown)).map((s) => s.id).join(', ') || 'all valid' +); + +/* Reading a definition into the editor fields — the upload path. */ +for (const skill of reg.SKILLS.filter((s) => s.kind === 'assistant')) { + const read = fields.owliverFieldsFromSource(skill.markdown); + record( + `\`${skill.id}\` reads back into the Owliver fields`, + read.id === skill.id && read.name === skill.name + && read.pages.join(',') === skill.pages.join(','), + `${read.name} / ${read.pages.join(', ')}` + ); +} + +record( + 'an unparseable file leaves the fields empty rather than throwing', + fields.owliverFieldsFromSource('not a definition').id === '' +); + +/* Writing a field back — the half that used to do nothing. */ +const original = reg.SKILLS.find((s) => s.id === 'hiring-activity-assistant').markdown; +const renamed = fields.patchFrontmatter(original, { name: 'Renamed Assistant' }); + +record( + 'patching a field changes what the registry reads', + reg.parseSkill(renamed, { custom: true }).name === 'Renamed Assistant', + reg.parseSkill(renamed, { custom: true }).name +); +record( + 'patching a field leaves the body untouched', + reg.parseSkill(renamed, { custom: true }).body === reg.parseSkill(original, { custom: true }).body +); +record( + 'patching a field preserves frontmatter comments', + renamed.includes('# One suggestion per capability.') +); +record( + 'patching a field changes nothing else', + original.split('\n').filter((l) => !l.startsWith('name:')).join('\n') + === renamed.split('\n').filter((l) => !l.startsWith('name:')).join('\n') +); +record( + 'a patched definition still validates', + reg.validateSkillSource(renamed) === null, + reg.validateSkillSource(renamed) || 'valid' +); + +const repaged = fields.patchFrontmatter(original, { pages: ['analytics', 'activity'] }); +record( + 'patching a list replaces the whole block', + reg.parseSkill(repaged, { custom: true }).pages.join(',') === 'analytics,activity', + reg.parseSkill(repaged, { custom: true }).pages.join(',') +); + +const restatused = fields.patchFrontmatter(original, { status: 'inactive' }); +record( + 'patching a key the file never declared adds it', + reg.parseSkill(restatused, { custom: true }).status === 'inactive' +); + +/* Both keys, because that is what the editor's own field handler writes: the + registry reads capabilities as the union of the declared list and the keys of + `responses:`, so patching one without the other changes nothing. */ +const recapped = fields.patchFrontmatter(original, { + 'owliver.capabilities': ['summary'], + 'owliver.responses': { summary: { source: 'position.activity', periods: ['today'] } }, +}); +record( + 'patching a nested block rewrites only that block', + reg.parseSkill(recapped, { custom: true }).owliver.capabilities.join(',') === 'summary', + reg.parseSkill(recapped, { custom: true }).owliver.capabilities.join(',') +); +/* Asserted on the text, not on the parse: with `flow` no longer offered, the + registry correctly drops the suggestion that names it — which is the rule + working, not the patch reaching a sibling key it should not have. */ +record( + 'and leaves its siblings in the same block alone', + recapped.includes('Summarize hiring activity for this position') + && recapped.includes('Show hiring activity as a flow') + && recapped.includes(' enabled: true') +); + +/* The two `ui:` shapes, told apart — the per-page form must not be overwritten + from four single-valued fields. */ +record( + 'the shorthand `ui:` form is recognised', + fields.uiShape(boardOn('positions', 'after-position-card')) === 'shorthand' +); +record( + 'a definition with no `ui:` block reports none', + fields.uiShape(original) === 'none' +); + +/* The picker and the validator read the same table. */ +record( + 'a position card supplies a position', + surfaces.contextSuppliedBy(['positions'], 'after-position-card').includes('positionId') +); +record( + 'the Positions list supplies nothing', + surfaces.contextSuppliedBy(['positions'], 'after-position-list').length === 0 +); +record( + 'Analytics supplies nothing', + surfaces.contextSuppliedBy(['analytics'], 'after-header').length === 0 +); +record( + 'every surface declares what its placements provide', + surfaces.SKILL_SURFACES.every((s) => s.provides && typeof s.provides === 'object'), + surfaces.SKILL_SURFACES.filter((s) => !s.provides).map((s) => s.id).join(', ') || 'all declared' +); + +/* ── 7. Upload hydration: a file, into the fields ─────────────────────────── */ +console.log('\n── Upload hydration ──'); + +/** + * The shapes people actually upload. + * + * Every case here is a definition that arrived from outside the editors, which + * is the only way most definitions arrive. What is being asserted is not that + * the parser is lenient — it is that the three identity fields, the pages and + * the section a reader can see in the file are the ones the form shows. + */ +const COMPLETE = `--- +id: hiring-activity-assistant +name: Hiring Activity Assistant +description: Answer questions about recent hiring activity on a position. +type: board +ui: + - page: Positions + placement: grid-card + source: position.activity +--- + +# Hiring Activity Assistant +`; + +const NO_ID = `--- +name: Hiring Activity Assistant +description: Answer questions about recent hiring activity. +pages: + - positions +--- + +# Hiring Activity Assistant +`; + +const MULTI_PAGE = `--- +id: hiring-activity +name: Hiring Activity +description: Hiring activity across the workspace. +ui: + - page: Positions + placement: grid-card + source: candidates.activity + - page: Analytics + placement: panel + source: hires.performance +--- + +# Hiring Activity +`; + +/* Case 1 — a complete definition hydrates every field it declares. */ +{ + const f = fields.boardFieldsFromSource(COMPLETE); + record('upload: name hydrates', f.name === 'Hiring Activity Assistant', f.name || 'EMPTY'); + record('upload: id hydrates', f.id === 'hiring-activity-assistant', f.id || 'EMPTY'); + record( + 'upload: description hydrates', + f.description === 'Answer questions about recent hiring activity on a position.', + f.description || 'EMPTY' + ); + record('upload: pages come from the `ui:` entries', f.pages.join(',') === 'positions', f.pages.join(',') || 'EMPTY'); + record('upload: `grid-card` resolves to a real placement', f.placement === 'after-position-card', f.placement || 'EMPTY'); + record('upload: source hydrates', f.source === 'position.activity', f.source || 'EMPTY'); + record('upload: an undeclared type is inferred from the source', f.type === 'flow', f.type || 'EMPTY'); + record('upload: the definition validates as written', reg.validateSkillSource(COMPLETE) === null, + reg.validateSkillSource(COMPLETE) || 'valid'); + record('upload: it is classified as a Board skill', + fields.facetsFromSource(COMPLETE).join(',') === 'ui', fields.facetsFromSource(COMPLETE).join(',')); + + /* The same file read by the other editor's reader — one pipeline, two views. */ + const o = fields.owliverFieldsFromSource(COMPLETE); + record('upload: the Owliver reader hydrates the same identity', + o.id === f.id && o.name === f.name && o.description === f.description); +} + +/* Case 2 — no `id:`, so it is slugged from the name and never invented. */ +{ + const f = fields.boardFieldsFromSource(NO_ID); + record('upload: a missing id slugs the name', f.id === 'hiring-activity-assistant', f.id || 'EMPTY'); + record('upload: a missing id does not become the placeholder path', f.id !== 'custom'); + record('upload: an explicit id is never replaced by a generated one', + fields.boardFieldsFromSource(COMPLETE).id === 'hiring-activity-assistant'); + record('upload: a file with no frontmatter hydrates nothing', + fields.boardFieldsFromSource('# Just a heading\n').id === ''); +} + +/* Case 3 — several pages, and every entry survives a field edit. */ +{ + const f = fields.boardFieldsFromSource(MULTI_PAGE); + record('upload: every page in the list is reported', f.pages.join(',') === 'positions,analytics', f.pages.join(',')); + + const parsed = reg.parseSkill(MULTI_PAGE, { custom: true }); + record('upload: every entry becomes a section', + parsed.ui.positions.sections.length === 1 && parsed.ui.analytics.sections.length === 1); + record('upload: each entry keeps its own source', + parsed.ui.positions.sections[0].source === 'candidates.activity' + && parsed.ui.analytics.sections[0].source === 'hires.performance'); + record('upload: a multi-page definition validates', reg.validateSkillSource(MULTI_PAGE) === null, + reg.validateSkillSource(MULTI_PAGE) || 'valid'); + + /* The fields must not be able to flatten it. */ + record('upload: the section fields are read-only against a list', !fields.uiIsEditableFromFields(MULTI_PAGE)); + record('upload: a single-section definition stays field-editable', + fields.uiIsEditableFromFields(templates.uiSkillTemplate({ id: 'x', name: 'X', pages: ['positions'] }))); + + /* Renaming is identity, not structure: it must still work, and must not + touch either entry. */ + const renamedMulti = fields.patchFrontmatter(MULTI_PAGE, { name: 'Renamed Multi' }); + const after = reg.parseSkill(renamedMulti, { custom: true }); + record('upload: renaming a multi-page definition keeps both entries', + after.ui.positions?.sections.length === 1 && after.ui.analytics?.sections.length === 1, + Object.keys(after.ui).join(',')); + record('upload: ...and actually renames it', after.name === 'Renamed Multi', after.name); + record('upload: ...and leaves the `ui:` text byte-identical', + renamedMulti.slice(renamedMulti.indexOf('ui:')) === MULTI_PAGE.slice(MULTI_PAGE.indexOf('ui:'))); +} + +/* Files as they actually arrive: from Windows, from a download, from paste. */ +{ + const dirty = { + 'a byte-order mark': `${COMPLETE}`, + 'CRLF line endings': COMPLETE.replace(/\n/g, '\r\n'), + 'a BOM and CRLF': `${COMPLETE.replace(/\n/g, '\r\n')}`, + 'a blank line above the fence': `\n\n${COMPLETE}`, + 'trailing spaces on the fence': COMPLETE.replace(/^---$/gm, '--- '), + }; + for (const [what, md] of Object.entries(dirty)) { + const f = fields.boardFieldsFromSource(md); + record( + `upload: a file with ${what} still hydrates`, + f.name === 'Hiring Activity Assistant' && f.id === 'hiring-activity-assistant' + && f.pages.join(',') === 'positions', + `${f.name || 'EMPTY'} / ${f.id || 'EMPTY'} / ${f.pages.join(',') || 'EMPTY'}` + ); + record( + `upload: ...and the registry reads it the same way`, + reg.parseSkill(md, { custom: true }).name === 'Hiring Activity Assistant', + reg.parseSkill(md, { custom: true }).name + ); + record(`upload: ...and it validates`, reg.validateSkillSource(md) === null, + reg.validateSkillSource(md) || 'valid'); + } + + /* Normalising on the way in is what keeps `patchFrontmatter` safe: an + unrecognised fence would have it write a second one above the first. */ + const patchedDirty = fields.patchFrontmatter(fields.normalizeUpload(`${COMPLETE}`), { name: 'Clean' }); + record('upload: patching a normalised file writes one frontmatter block', + (patchedDirty.match(/^---$/gm) || []).length === 2, + `${(patchedDirty.match(/^---$/gm) || []).length} fences`); + record('upload: ...and it still parses', reg.parseSkill(patchedDirty, { custom: true }).name === 'Clean'); + + /* A file with nothing to read must be refused, not read as a blank skill. */ + record('upload: a file with no fence is not readable', !fields.isReadableDefinition('# Just prose\n')); + record('upload: a real definition is readable', fields.isReadableDefinition(COMPLETE)); +} + +/* Page names and placements as they are written in the product, not as the + vocabulary spells them internally. */ +record('upload: `Positions` resolves to the positions surface', surfaces.canonicalPage('Positions') === 'positions'); +record('upload: `Talent Pool` resolves to the talent-pool surface', surfaces.canonicalPage('Talent Pool') === 'talent-pool'); +record('upload: `panel` on Analytics resolves to a real placement', + surfaces.placementFor('analytics', 'panel') === 'after-header'); +record('upload: an alias never resolves onto a surface that lacks it', + surfaces.placementFor('analytics', 'grid-card') === null); +record('upload: a canonical placement still resolves to itself', + surfaces.placementFor('positions', 'after-position-card') === 'after-position-card'); + +/* Case 4 — an existing definition reopened for editing. */ +for (const skill of reg.SKILLS) { + const f = skill.facets?.includes('ui') + ? fields.boardFieldsFromSource(skill.markdown) + : fields.owliverFieldsFromSource(skill.markdown); + record( + `reopening \`${skill.id}\` loads its identity unchanged`, + f.id === skill.id && f.name === skill.name && f.description === skill.description + && f.pages.join(',') === skill.pages.join(','), + `${f.id} / ${f.name} / ${f.pages.join(', ')}` + ); +} + +/* Case 5 — a manual edit is not reverted by later synchronisation. */ +{ + /* The editor's own handler, in miniature: hydrate from the file, edit one + field, then edit an unrelated one. The first edit must survive the second. */ + let draft = fields.owliverFieldsFromSource(COMPLETE); + let src = COMPLETE; + const edit = (patch) => { + draft = { ...draft, ...patch }; + src = fields.patchFrontmatter(src, { + id: draft.id || undefined, + name: draft.name || undefined, + description: draft.description || undefined, + pages: draft.pages?.length && !fields.pagesAreDerived(src) ? draft.pages : undefined, + }); + }; + + edit({ name: 'My Own Name' }); + record('edit: a manual name reaches the artefact', + reg.parseSkill(src, { custom: true }).name === 'My Own Name'); + + edit({ description: 'My own description.' }); + record('edit: a later edit does not revert the earlier one', + reg.parseSkill(src, { custom: true }).name === 'My Own Name', + reg.parseSkill(src, { custom: true }).name); + record('edit: ...and applies itself', + reg.parseSkill(src, { custom: true }).description === 'My own description.'); + record('edit: ...and the `ui:` block is untouched throughout', + src.slice(src.indexOf('ui:')) === COMPLETE.slice(COMPLETE.indexOf('ui:'))); + + /* A second upload replaces the draft outright — it is a new source. */ + const rehydrated = fields.owliverFieldsFromSource(MULTI_PAGE); + record('edit: a derived `pages:` is never written back', + !src.includes('\npages:'), src.includes('\npages:') ? 'pages: was inserted' : 'not written'); + + record('edit: a declared `pages:` still patches normally', + reg.parseSkill( + fields.patchFrontmatter(NO_ID, { pages: ['analytics'] }), { custom: true } + ).pages.join(',') === 'analytics'); + + record('edit: an inherited trigger is not written into the file', + !src.includes('triggers:'), src.includes('triggers:') ? 'triggers: was materialised' : 'not written'); + record('edit: a declared trigger is still read into the fields', + fields.owliverFieldsFromSource( + reg.SKILLS.find((x) => x.id === 'hiring-activity-assistant').markdown + ).triggers.includes('hiring activity')); + record('edit: a definition with no triggers reads none', + fields.owliverFieldsFromSource(COMPLETE).triggers.length === 0, + JSON.stringify(fields.owliverFieldsFromSource(COMPLETE).triggers)); + + record('edit: a second upload hydrates from the new file', + rehydrated.id === 'hiring-activity' && rehydrated.name === 'Hiring Activity', + `${rehydrated.id} / ${rehydrated.name}`); +} + await server.close(); -/* ── 6. Production bundle ─────────────────────────────────────────────────── */ +/* ── 8. Production bundle ─────────────────────────────────────────────────── */ if (process.argv.includes('--dist')) { console.log('\n── Production bundle ──'); const dir = join(ROOT, 'dist/assets'); diff --git a/skill-examples/board-invalid-context.md b/skill-examples/board-invalid-context.md new file mode 100644 index 0000000..3475ff8 --- /dev/null +++ b/skill-examples/board-invalid-context.md @@ -0,0 +1,26 @@ +--- +id: board-invalid-context +name: Board (invalid context) +description: Negative test — a position-scoped source on a placement that supplies no position. +pages: + - positions +status: active +ui: + type: card + placement: after-position-list-summary + title: Board + source: position.activity + periods: + - today + - last-7-days + - previous-month +--- + +# Board (invalid context) + +## Purpose + +Deliberately wrong, and kept so the refusal stays tested. `position.activity` +needs one position to read; `after-position-list-summary` renders once above the +grid with no position in context. Saving this must be refused with a message that +names both halves of the mismatch. diff --git a/skill-examples/board-page-level.md b/skill-examples/board-page-level.md new file mode 100644 index 0000000..70ee7c1 --- /dev/null +++ b/skill-examples/board-page-level.md @@ -0,0 +1,31 @@ +--- +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 + +## Purpose + +Helps Owliver understand the current task board by summarizing relevant work activity, identifying items that need attention, and explaining the current status of tasks shown on the board. + +## Capabilities + +- Summarize relevant activity from the current board. +- Identify tasks or work items that need attention. +- Explain the current status of tasks and work items. +- Highlight recent activity and meaningful changes. +- Help Owliver analyze board activity using the available board data. diff --git a/skill-examples/board-position-card.md b/skill-examples/board-position-card.md new file mode 100644 index 0000000..749942e --- /dev/null +++ b/skill-examples/board-position-card.md @@ -0,0 +1,31 @@ +--- +id: board-position-card +name: Board (per position) +description: Control test — the same Board card, rendered inside every position card. +pages: + - positions +status: active +ui: + type: card + placement: grid-card + title: Board + source: position.activity + periods: + - today + - last-7-days + - previous-month +--- + +# Board (per position) + +## Purpose + +The position-aware control for the page-level Board card. `grid-card` resolves to +`after-position-card`, which renders inside each position card and supplies that +position as context — so `position.activity` reads and the card is drawn once per +role rather than once for the page. + +## Capabilities + +- Summarize activity for the position the card belongs to. +- Show that activity across today, the last 7 days and the previous month. diff --git a/skill-examples/board-ui-test.md b/skill-examples/board-ui-test.md new file mode 100644 index 0000000..150e53e --- /dev/null +++ b/skill-examples/board-ui-test.md @@ -0,0 +1,40 @@ +--- +id: board-ui-test +name: Board +description: Renders the Board card once above the position list on the Positions page. +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 + +## Purpose + +The page half of the Board, and only that half. One card, above the position +grid, reading activity across the workspace. + +`after-position-list-summary` renders once for the page and supplies no +position — `surfaces.js` records that placement as providing nothing — so the +card reads `candidates.activity`, which declares `context: null` and counts +applications across the workspace over the periods named here. +`position.activity` is refused at this placement by `unresolvableSections`, and +correctly: it needs one position, and this placement has none to give. + +This definition declares no `owliver:` block, so it contributes no capability, +no suggestion chip and no conversational answer. It is a page extension and +nothing else. + +## Capabilities + +- Show workspace application activity above the position list. +- Compare today, the last 7 days and the previous month in one card. diff --git a/skill-examples/owliver-conversation-test.md b/skill-examples/owliver-conversation-test.md new file mode 100644 index 0000000..77053a6 --- /dev/null +++ b/skill-examples/owliver-conversation-test.md @@ -0,0 +1,63 @@ +--- +id: owliver-conversation-test +name: Workspace Activity Assistant +description: Answer questions about applications across the workspace and the roles still open. +pages: + - positions +status: active +triggers: + - workspace applications + - applications across the workspace + - roles still open + - roles needing attention +owliver: + enabled: true + # Every suggestion names the capability it asks for, so a chip and an answer + # are one-to-one rather than both falling through to the first declared + # capability. + suggestions: + - label: Summarize workspace applications + capability: summary + - label: List the roles still open + capability: list + capabilities: + - summary + - list + responses: + summary: + title: Workspace Applications + source: candidates.activity + periods: + - today + - last-7-days + - previous-month + list: + title: Roles Still Open + source: positions.demand + limit: 5 +--- + +# Workspace Activity Assistant + +## Purpose + +The panel half, and only that half. No `ui:` block, so nothing is drawn on the +Positions page and no card appears above or inside the grid. + +Each response names its own `source:` directly, which is what a definition +without a `ui:` section must do — `normalizeSkillOwliver` inherits a source from +the first `ui:` section only when one exists, and reports +`owliver.responses.: a response needs a source, or a ui: section to +read from` when neither is present. + +Both sources declare `context: null`, so neither answer asks which position is +meant: `resolveEntity` returns immediately and no +"This section needs a position to read" is possible. The shapes match what each +source offers — `summary` is prose and is exempt from the shape check, and +`list` is a shape `positions.demand` declares. + +## Capabilities + +- Summarize applications across the workspace over today, the last 7 days and + the previous month. +- List the open positions and how many applicants each has. diff --git a/src/api/base44Client.js b/src/api/base44Client.js index 89ec7b1..6d38611 100644 --- a/src/api/base44Client.js +++ b/src/api/base44Client.js @@ -43,6 +43,37 @@ function loadUser() { let currentUser = loadUser(); +/** + * Writes the session user, and says whether it actually landed. + * + * The old version was `try { setItem } catch {}` — a swallowed + * `QuotaExceededError` or a private-browsing refusal, and the caller was handed + * a user object indistinguishable from a successful write. For preferences that + * is invisible; for `customSkills`, which is where every account-authored skill + * definition lives, it is the whole "I saved it and it was gone" report: the + * toast said added, the list showed it, the reload did not. + * + * The read-back matters as much as the catch. A write can be accepted and then + * evicted, and a serialisation can land truncated; comparing what came back + * with what went in is the only way to know the record is really there. + */ +function persistUser() { + const payload = JSON.stringify(currentUser); + try { + localStorage.setItem(SESSION_KEY, payload); + } catch (error) { + return { persisted: false, error }; + } + try { + if (localStorage.getItem(SESSION_KEY) !== payload) { + return { persisted: false, error: new Error('The session record did not survive the write.') }; + } + } catch (error) { + return { persisted: false, error }; + } + return { persisted: true, error: null }; +} + const auth = { /** The demo is always signed in as the seeded employer/admin user. */ async me() { @@ -51,11 +82,7 @@ const auth = { async updateMe(patch) { currentUser = { ...currentUser, ...patch }; - try { - localStorage.setItem(SESSION_KEY, JSON.stringify(currentUser)); - } catch { - // Non-persistent session is fine. - } + persistUser(); return { ...currentUser }; }, @@ -72,9 +99,18 @@ const auth = { return { ...DEMO_USER.preferences, ...(currentUser.preferences || {}) }; }, - /** Merges into the stored preferences and persists with the rest of the user. */ + /** + * Merges into the stored preferences and persists with the rest of the user. + * + * Returns the write's outcome alongside the record, rather than the record + * alone. Preferences are where account-authored skills live, so "did this + * survive the reload" is a question the caller has to be able to answer — + * see `persistUser`. + */ async updatePreferences(patch) { - return auth.updateMe({ preferences: { ...auth.preferences(), ...patch } }); + currentUser = { ...currentUser, preferences: { ...auth.preferences(), ...patch } }; + const write = persistUser(); + return { user: { ...currentUser }, ...write }; }, isAuthenticated() { diff --git a/src/components/ai-assistant/AssistantMessage.jsx b/src/components/ai-assistant/AssistantMessage.jsx index b85a549..a3119a3 100644 --- a/src/components/ai-assistant/AssistantMessage.jsx +++ b/src/components/ai-assistant/AssistantMessage.jsx @@ -2,6 +2,7 @@ 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'; /** * The thinking state, shown between sending and the first block arriving. @@ -12,9 +13,7 @@ import { ResponseDocument } from './ResponseBlocks'; export function ThinkingIndicator() { return (
- - + {[0, 1, 2].map((i) => (
- - + Owliver
diff --git a/src/components/ai-assistant/AssistantPanel.jsx b/src/components/ai-assistant/AssistantPanel.jsx index defece7..a146393 100644 --- a/src/components/ai-assistant/AssistantPanel.jsx +++ b/src/components/ai-assistant/AssistantPanel.jsx @@ -4,6 +4,7 @@ import { cn } from '@/lib/utils'; import KrowAssistant from './KrowAssistant'; import { useAssistantPanel } from './AssistantPanelContext'; import { ResizeDivider } from './ResizeDivider'; +import OwliverAvatar from '@/components/krow/OwliverAvatar'; const EXPANDED_WIDTH = 620; /** @@ -68,9 +69,7 @@ function CollapsedTrigger({ page, onRestore }) { hover:border-krow-blue/40 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50" > - - KROW Logo - + Owliver