Files
hapi/cli/src/pi/titleExtension.ts
T
d3f7c107d5 feat(pi): display inline tool-result images (#2060)
Build on tiann/hapi#1831, retain bounded image reads, and support current
Pi nested and failed tool results through the existing generated-image path.
Keep conversion synchronous to preserve transport ordering without a new
publication queue.

Co-authored-by: Debin <luodeb@outlook.com>
2026-10-26 10:01:55 +08:00

162 lines
8.6 KiB
TypeScript

import { access, mkdir, rename, unlink, writeFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { join } from 'node:path';
import { configuration } from '@/configuration';
import packageJson from '../../package.json';
/**
* Source of the bundled Pi extension that gives Pi sessions the same
* auto-titling capability the Claude/Codex/OpenCode launchers already have:
* a `hapi_change_title` tool callable by the model plus a persistent
* system-prompt instruction. The title is applied via `ctx.ui.setTitle()`,
* which the Pi extension UI bridge (`extensionUiHandler`) renames via
* `metadata.name` (same field as web rename / MCP `change_title`).
*
* The source is kept as a string (plain JS, no template literals) so it can be
* materialized to disk at launch time and passed to `pi --extension`, including
* for Bun-compiled builds where virtual-fs module paths are not readable by the
* child process.
*/
export const PI_TITLE_EXTENSION_SOURCE = `// Generated by HAPI - auto session titling for Pi sessions.
import { Type } from '@sinclair/typebox';
const TOOL_NAME = 'hapi_change_title';
const TITLE_INSTRUCTION = [
'',
'',
'## Session title',
'For a new chat, call the "' + TOOL_NAME + '" tool once after the user\\'s initial request is clear - ideally as your very first action, before starting any work - and set a concise task title.',
'Do not rename the chat for routine progress, substeps, implementation details, or a slightly better wording.',
'Rename only when the user\\'s primary objective changes substantially and the existing title would be misleading.'
].join('\\n');
export default function hapiTitleExtension(pi) {
// The tool name is namespaced ('hapi_change_title', matching the OpenCode
// launcher convention) so user extensions registering their own tools
// cannot collide with it.
pi.on('before_agent_start', function (event) {
return { systemPrompt: event.systemPrompt + TITLE_INSTRUCTION };
});
pi.registerTool({
name: TOOL_NAME,
label: 'Change Chat Title',
description: "Change the title of the current chat session. Call once when the user's primary objective is clear (usually right after the first exchange); use a concise task title. Do not rename for routine progress or substeps.",
parameters: Type.Object({
title: Type.String({ description: 'Short task title, ideally under 20 characters, no quotes or trailing punctuation.' })
}),
// Positional compatibility: current Pi calls
// (toolCallId, params, signal, onUpdate, ctx) while legacy releases
// called (toolCallId, params, onUpdate, ctx, signal). Normalize the
// context by looking for the object that carries the ui bridge.
async execute(_toolCallId, params, _third, fourth, fifth) {
const ctx = fifth && typeof fifth === 'object' && 'ui' in fifth ? fifth : fourth;
const title = String((params && params.title) || '').replace(/\\s+/g, ' ').trim().slice(0, 80);
if (!title) {
return { content: [{ type: 'text', text: 'Error: title must not be empty' }], details: {} };
}
// HAPI only launches this extension in RPC mode; do not gate on
// ctx.hasUI, which older RPC releases report as false even though
// ctx.ui.setTitle() emits the extension UI event.
if (ctx && ctx.ui) ctx.ui.setTitle(title);
return { content: [{ type: 'text', text: 'Session title set: ' + title }], details: {} };
}
});
pi.registerTool({
name: 'hapi_display_image',
label: 'Display Image',
promptSnippet: 'Show a local image to the user in the HAPI chat.',
description: "Display a local image file to the human user inline in the current HAPI chat session. Call it when the user asks to view a local image, screenshot, or diagram. The image is shown in the chat; note it also enters the model context, so prefer reading the file for analysis and this tool for presentation.",
parameters: Type.Object({
path: Type.String({ description: 'Absolute filesystem path of the local image to display (png, jpeg, gif or webp, up to 25 MiB).' })
}),
async execute(_toolCallId, params) {
const filePath = String((params && params.path) || '').trim();
if (!filePath) {
return { content: [{ type: 'text', text: 'Error: path must be non-empty' }], details: {}, isError: true };
}
const { constants, open } = await import('node:fs/promises');
let bytes;
try {
// One descriptor and a fixed buffer keep replacement/growth bounded.
const handle = await open(filePath, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
try {
const meta = await handle.stat();
if (!meta.isFile()) throw new Error('Path is not a regular file');
if (meta.size > 25 * 1024 * 1024) throw new Error('File exceeds the 25 MiB display limit');
bytes = Buffer.alloc(meta.size);
for (let offset = 0; offset < bytes.length;) {
const { bytesRead } = await handle.read(bytes, offset, bytes.length - offset, offset);
if (bytesRead === 0) throw new Error('File changed while reading');
offset += bytesRead;
}
if ((await handle.stat()).size !== meta.size) throw new Error('File changed while reading');
} finally {
await handle.close();
}
} catch (error) {
const detail = error && error.message ? error.message : String(error);
return { content: [{ type: 'text', text: 'Failed to read file: ' + detail }], details: {}, isError: true };
}
const mimeType = detectImageMime(bytes);
if (!mimeType) {
return { content: [{ type: 'text', text: 'Unsupported image content (png, jpeg, gif or webp only)' }], details: {}, isError: true };
}
return {
content: [{ type: 'image', data: bytes.toString('base64'), mimeType: mimeType }],
details: {}
};
}
});
}
function detectImageMime(bytes) {
if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 && bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a) return 'image/png';
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg';
if (bytes.length >= 6 && bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38 && (bytes[4] === 0x37 || bytes[4] === 0x39) && bytes[5] === 0x61) return 'image/gif';
if (bytes.length >= 12 && bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 && bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50) return 'image/webp';
return null;
}
`;
async function fileExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
/**
* Materializes the bundled title extension under the HAPI runtime dir and
* returns the path that should be passed to `pi --extension`.
*
* The file is published atomically (write to a unique temp file, then rename):
* concurrent Pi launches share the same versioned path, a plain write could be
* observed half-written by another process, and Pi treats extension-load
* errors as fatal. If `rename` loses a race against another launcher that
* already published the file, the existing copy is kept.
*
* Layout mirrors `runtimePath()` in compiled builds
* (`<happyHomeDir>/runtime/<version>/pi/`), but is derived from
* `configuration.happyHomeDir` directly so dev runs (where `runtimePath()`
* points into the repo) and test runs stay side-effect free.
*/
export async function materializePiTitleExtension(targetDir?: string): Promise<string> {
const dir = targetDir ?? join(configuration.happyHomeDir, 'runtime', packageJson.version, 'pi');
const file = join(dir, 'hapi-title-extension.ts');
await mkdir(dir, { recursive: true });
const temporaryFile = `${file}.${process.pid}.${randomUUID()}.tmp`;
await writeFile(temporaryFile, PI_TITLE_EXTENSION_SOURCE, 'utf8');
try {
await rename(temporaryFile, file);
} catch (error) {
if (!(await fileExists(file))) throw error;
await unlink(temporaryFile).catch(() => {});
}
return file;
}