diff --git a/apps/shared/package.json b/apps/shared/package.json index 2a2f46ef73d..e3482d8a466 100644 --- a/apps/shared/package.json +++ b/apps/shared/package.json @@ -8,6 +8,7 @@ "./billing": "./src/billing-types.ts", "./billing-policy": "./src/billing-policy.ts", "./charge-settlement": "./src/charge-settlement.ts", + "./skill-scaffold": "./src/skill-scaffold.ts", "./skin": "./src/skin.ts" }, "types": "./src/index.ts", diff --git a/apps/shared/src/index.ts b/apps/shared/src/index.ts index 21c40a716db..2339c97716f 100644 --- a/apps/shared/src/index.ts +++ b/apps/shared/src/index.ts @@ -44,6 +44,7 @@ export { JsonRpcGatewayClient, type WebSocketLike } from './json-rpc-gateway' +export { dispatchDisplayText, skillInvocationText } from './skill-scaffold' export { type HermesSkin, SKIN_BRANDING_TOKENS, diff --git a/apps/shared/src/skill-scaffold.test.ts b/apps/shared/src/skill-scaffold.test.ts new file mode 100644 index 00000000000..954ff41a6c9 --- /dev/null +++ b/apps/shared/src/skill-scaffold.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' + +import { skillInvocationText } from './skill-scaffold' + +// Byte-identical to what agent/skill_commands.py emits — a desktop/TUI talking +// to an older gateway sees exactly these strings. +const BODY = 'SPIN UP A WORKTREE. Never edit the primary checkout.\n'.repeat(20) + +const singleSkill = (instruction?: string) => + [ + '[IMPORTANT: The user has invoked the "work" skill, indicating they want you to follow its instructions.', + 'The full skill content is loaded below.]', + '', + BODY, + '', + '[Skill directory: /Users/x/skills/work]', + ...(instruction + ? ['', `The user has provided the following instruction alongside the skill invocation: ${instruction}`] + : []) + ].join('\n') + +const bundle = (instruction?: string) => + [ + '[IMPORTANT: The user has invoked the "/clean /work" stacked skill bundle, loading 2 skills together.]', + '', + 'Skills loaded: clean, work', + ...(instruction ? ['', `User instruction: ${instruction}`] : []), + '', + '[Loaded as part of the stacked skill invocation "clean".]', + '', + BODY + ].join('\n') + +describe('skillInvocationText', () => { + it('renders a single-skill turn as the invocation, never the body', () => { + const projected = skillInvocationText(singleSkill('fix the title leak')) + + expect(projected).toBe('/work fix the title leak') + expect(projected).not.toContain('WORKTREE') + }) + + it('renders a bare invocation as just the command', () => { + expect(skillInvocationText(singleSkill())).toBe('/work') + }) + + it('renders a bundle turn as the typed keys plus the instruction', () => { + const projected = skillInvocationText(bundle('ship it')) + + expect(projected).toBe('/clean /work ship it') + expect(projected).not.toContain('WORKTREE') + }) + + it('collapses newlines in a multi-line instruction so the bubble stays one line', () => { + expect(skillInvocationText(singleSkill('fix the leak\n\nthen ship'))).toBe('/work fix the leak then ship') + }) + + it('leaves ordinary user prose alone', () => { + expect(skillInvocationText('just a normal message')).toBeNull() + expect(skillInvocationText('[IMPORTANT: read the docs]')).toBeNull() + }) +}) diff --git a/apps/shared/src/skill-scaffold.ts b/apps/shared/src/skill-scaffold.ts new file mode 100644 index 00000000000..a06ce3d567a --- /dev/null +++ b/apps/shared/src/skill-scaffold.ts @@ -0,0 +1,78 @@ +/** + * A `/skill` invocation expands into a model-facing message that embeds the + * whole skill body. That payload is for the agent — the UI shows the + * invocation the user typed (`/work fix the leak`) and nothing else. + * + * The gateway already projects this (see `_skill_scaffold_projection` in + * tui_gateway/server.py) and ships the result as `display` on a dispatch and + * as the `text` of a `skill_invocation` history row. This module is the + * client-side twin so a desktop/TUI talking to an older gateway — or any + * future path that hands raw scaffolding to a bubble — still renders the + * invocation instead of the body. + * + * The markers below mirror `agent/skill_commands.py` byte for byte. + */ + +const INVOCATION_PREFIX = '[IMPORTANT: The user has invoked the ' +const SINGLE_MARKER = 'The full skill content is loaded below.]' +const SINGLE_INSTRUCTION = 'The user has provided the following instruction alongside the skill invocation: ' +const RUNTIME_NOTE = '\n\n[Runtime note:' +const BUNDLE_MARKER = ' skill bundle,' +const BUNDLE_INSTRUCTION = '\nUser instruction: ' +const BUNDLE_SKILL_BLOCK = '\n\n[Loaded as part of the ' + +// The skill name is the first quoted span of the activation note, for both the +// single-skill (`work`) and the bundle (`/clean /work`) header. +const NAME_RE = new RegExp(`^${INVOCATION_PREFIX.replace(/[[\]]/g, '\\$&')}"([^"]*)"`) + +/** Text between `marker` and `end`, or '' when the marker is absent. */ +function between(text: string, marker: string, end: string, fromEnd = false): string { + const index = fromEnd ? text.lastIndexOf(marker) : text.indexOf(marker) + + if (index < 0) { + return '' + } + + const tail = text.slice(index + marker.length) + const stop = tail.indexOf(end) + + return (stop >= 0 ? tail.slice(0, stop) : tail).trim() +} + +/** + * The invocation a scaffolded turn came from (`/work fix the leak`), or null + * when `text` is ordinary user prose that should render as written. + */ +export function skillInvocationText(text: string): null | string { + if (!text.startsWith(INVOCATION_PREFIX)) { + return null + } + + const name = (NAME_RE.exec(text)?.[1] ?? '').trim() + + if (!name) { + return null + } + + // Bundle headers already carry their typed "/a /b" keys; a single skill is + // a bare name. The single-skill instruction trails the body (which may quote + // the marker), so match it from the end. + const label = name.startsWith('/') ? name : `/${name}` + + const instruction = text.includes(BUNDLE_MARKER) + ? between(text, BUNDLE_INSTRUCTION, BUNDLE_SKILL_BLOCK) + : text.includes(SINGLE_MARKER) + ? between(text, SINGLE_INSTRUCTION, RUNTIME_NOTE, true) + : '' + + return instruction ? `${label} ${instruction.replace(/\s+/g, ' ')}` : label +} + +/** + * What a skill/bundle dispatch should render as. Prefers the gateway's own + * projection; falls back to reading the payload for an older gateway that + * doesn't send one. Undefined for an ordinary send, which renders as written. + */ +export function dispatchDisplayText(display: string | undefined, message: string): string | undefined { + return display?.trim() || skillInvocationText(message) || undefined +}