mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(pi): expose native skills through $ completion (#1286)
This commit is contained in:
@@ -69,7 +69,44 @@ vi.mock('./piTransport', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import { runPi } from './runPi';
|
||||
import { buildPiCommandInventory, formatPiUserMessage, rewritePiSkillPrompt, runPi } from './runPi';
|
||||
|
||||
describe('Pi command namespaces', () => {
|
||||
const commands = [
|
||||
{ name: 'session-name', description: 'Rename session', source: 'extension' as const },
|
||||
{ name: 'fix-tests', description: 'Fix tests', source: 'prompt' as const },
|
||||
{ name: 'skill:brave-search', description: 'Search the web', source: 'skill' as const },
|
||||
];
|
||||
|
||||
it('exposes native skills through $ and keeps them out of slash completion', () => {
|
||||
expect(buildPiCommandInventory(commands)).toEqual({
|
||||
skills: [
|
||||
{ name: 'brave-search', description: 'Search the web' },
|
||||
],
|
||||
slashCommands: [
|
||||
{ name: 'session-name', description: 'Rename session', source: 'plugin' },
|
||||
{ name: 'fix-tests', description: 'Fix tests', source: 'user' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('rewrites HAPI $ skills to Pi native skill commands', () => {
|
||||
expect(rewritePiSkillPrompt('$brave-search latest news', commands))
|
||||
.toBe('/skill:brave-search latest news');
|
||||
expect(rewritePiSkillPrompt('$new-skill now', [])).toBe('/skill:new-skill now');
|
||||
expect(rewritePiSkillPrompt('$PATH', commands)).toBe('$PATH');
|
||||
});
|
||||
|
||||
it('keeps the native skill command first when the message has attachments', () => {
|
||||
expect(formatPiUserMessage('$brave-search', [{
|
||||
id: 'attachment-1',
|
||||
filename: 'query.txt',
|
||||
mimeType: 'text/plain',
|
||||
size: 5,
|
||||
path: '/tmp/query.txt',
|
||||
}], commands)).toBe('/skill:brave-search\n\n@/tmp/query.txt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runPi startup', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
+81
-24
@@ -3,22 +3,72 @@ import { bootstrapExistingSession, bootstrapSession } from '@/agent/sessionFacto
|
||||
import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler';
|
||||
import { registerLocalHandoffHandler } from '@/agent/localHandoff';
|
||||
import { createRunnerLifecycle, createModeChangeHandler, setControlledByUser } from '@/agent/runnerLifecycle';
|
||||
import { formatMessageWithAttachments } from '@/utils/attachmentFormatter';
|
||||
import { formatAttachmentsForClaude, formatMessageWithAttachments } from '@/utils/attachmentFormatter';
|
||||
import { getInvokedCwd } from '@/utils/invokedCwd';
|
||||
import { PiTransport } from './piTransport';
|
||||
import { PiSession } from './session';
|
||||
import { parsePiModels, parsePiCommands, sendPiRpcAndWait, wireTransportEvents } from './loop';
|
||||
import { PiThinkingLevelSchema, SetSessionConfigPayloadSchema } from './schemas';
|
||||
import type { PiThinkingLevel } from './types';
|
||||
import type { SlashCommandsResponse } from '@hapi/protocol/apiTypes';
|
||||
import type { ListPiModelsResponse } from '@hapi/protocol/apiTypes';
|
||||
import type { ListPiModelsResponse, PiCommandSummary, SlashCommand, SlashCommandsResponse } from '@hapi/protocol/apiTypes';
|
||||
import { RPC_METHODS } from '@hapi/protocol/rpcMethods';
|
||||
import type { ListSkillsResponse, SkillSummary } from '@/modules/common/skills';
|
||||
import type { AttachmentMetadata } from '@/api/types';
|
||||
|
||||
// Grace period before force-draining prompts buffered during Pi startup when no
|
||||
// get_state response arrives. Comfortably above the 10s Pi RPC timeout so a slow
|
||||
// but healthy startup still flips ready via get_state first (issue #1143).
|
||||
const PI_READY_FALLBACK_MS = 30_000;
|
||||
|
||||
function getPiSkillName(commandName: string): string {
|
||||
return commandName.startsWith('skill:') ? commandName.slice('skill:'.length) : commandName;
|
||||
}
|
||||
|
||||
export function buildPiCommandInventory(commands: readonly PiCommandSummary[]): {
|
||||
skills: SkillSummary[];
|
||||
slashCommands: SlashCommand[];
|
||||
} {
|
||||
const skills: SkillSummary[] = [];
|
||||
const slashCommands: SlashCommand[] = [];
|
||||
|
||||
for (const command of commands) {
|
||||
if (command.source === 'skill') {
|
||||
const name = getPiSkillName(command.name);
|
||||
if (name) skills.push({ name, description: command.description });
|
||||
continue;
|
||||
}
|
||||
slashCommands.push({
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
source: command.source === 'prompt' ? 'user' : 'plugin',
|
||||
});
|
||||
}
|
||||
|
||||
return { skills, slashCommands };
|
||||
}
|
||||
|
||||
export function rewritePiSkillPrompt(message: string, commands: readonly PiCommandSummary[]): string {
|
||||
const match = /^(\s*)\$([a-z0-9]+(?:-[a-z0-9]+)*)(?=\s|$)/.exec(message);
|
||||
if (!match) return message;
|
||||
|
||||
const command = commands.find(candidate =>
|
||||
candidate.source === 'skill' && getPiSkillName(candidate.name) === match[2]
|
||||
);
|
||||
return `${match[1]}/${command?.name ?? `skill:${match[2]}`}${message.slice(match[0].length)}`;
|
||||
}
|
||||
|
||||
export function formatPiUserMessage(
|
||||
message: string,
|
||||
attachments: AttachmentMetadata[] | undefined,
|
||||
commands: readonly PiCommandSummary[],
|
||||
): string {
|
||||
const skillPrompt = rewritePiSkillPrompt(message, commands);
|
||||
if (skillPrompt === message) return formatMessageWithAttachments(message, attachments);
|
||||
|
||||
const attachmentText = formatAttachmentsForClaude(attachments);
|
||||
return attachmentText ? `${skillPrompt}\n\n${attachmentText}` : skillPrompt;
|
||||
}
|
||||
|
||||
export async function runPi(opts: {
|
||||
startedBy?: 'runner' | 'terminal';
|
||||
startingMode?: 'local' | 'remote';
|
||||
@@ -285,38 +335,45 @@ export async function runPi(opts: {
|
||||
}
|
||||
);
|
||||
|
||||
// --- Slash commands (Pi skills/commands) ---
|
||||
const getPiCommands = async (): Promise<PiCommandSummary[]> => {
|
||||
if (piSession.cachedPiCommands.length > 0) return piSession.cachedPiCommands;
|
||||
try {
|
||||
const data = await sendPiRpcAndWait(piSession, transport, { type: 'get_commands' });
|
||||
const commands = parsePiCommands(data);
|
||||
if (commands.length > 0) piSession.cachedPiCommands = commands;
|
||||
return commands;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// --- Pi commands and skills ---
|
||||
apiSession.rpcHandlerManager.registerHandler<{ agent?: string }, SlashCommandsResponse>(
|
||||
RPC_METHODS.ListSlashCommands,
|
||||
async () => {
|
||||
let commands = piSession.cachedPiCommands;
|
||||
if (commands.length === 0) {
|
||||
try {
|
||||
const data = await sendPiRpcAndWait(piSession, transport, { type: 'get_commands' });
|
||||
commands = parsePiCommands(data);
|
||||
if (commands.length > 0) {
|
||||
piSession.cachedPiCommands = commands;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to return empty
|
||||
}
|
||||
}
|
||||
const { slashCommands } = buildPiCommandInventory(await getPiCommands());
|
||||
return {
|
||||
success: true,
|
||||
commands: commands.map((cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description,
|
||||
source: cmd.source === 'skill' ? 'plugin' as const
|
||||
: cmd.source === 'prompt' ? 'user' as const
|
||||
: 'plugin' as const,
|
||||
})),
|
||||
commands: slashCommands,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
apiSession.rpcHandlerManager.registerHandler<Record<string, never>, ListSkillsResponse>(
|
||||
RPC_METHODS.ListSkills,
|
||||
async () => {
|
||||
const { skills } = buildPiCommandInventory(await getPiCommands());
|
||||
return { success: true, skills };
|
||||
}
|
||||
);
|
||||
|
||||
// --- User message handler ---
|
||||
apiSession.onUserMessage((message, localId) => {
|
||||
const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments);
|
||||
const formattedText = formatPiUserMessage(
|
||||
message.content.text,
|
||||
message.content.attachments,
|
||||
piSession.cachedPiCommands,
|
||||
);
|
||||
// Gate the send behind Pi startup readiness. A prompt POSTed immediately
|
||||
// after spawn (supported handoff pattern — hapi-ping-peer, intake
|
||||
// scripts) would otherwise reach Pi before its initial get_state finishes
|
||||
|
||||
Reference in New Issue
Block a user