mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
feat(shared): read a skill invocation out of its expanded scaffolding
The client-side twin of the gateway's projection, shared by the desktop and the TUI so a surface talking to an older gateway still renders the invocation rather than the whole skill body.
This commit is contained in:
parent
dd39ec2694
commit
b700f9f253
4 changed files with 141 additions and 0 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export {
|
|||
JsonRpcGatewayClient,
|
||||
type WebSocketLike
|
||||
} from './json-rpc-gateway'
|
||||
export { dispatchDisplayText, skillInvocationText } from './skill-scaffold'
|
||||
export {
|
||||
type HermesSkin,
|
||||
SKIN_BRANDING_TOKENS,
|
||||
|
|
|
|||
61
apps/shared/src/skill-scaffold.test.ts
Normal file
61
apps/shared/src/skill-scaffold.test.ts
Normal file
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
78
apps/shared/src/skill-scaffold.ts
Normal file
78
apps/shared/src/skill-scaffold.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue