mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-07 01:21:37 +00:00
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>
162 lines
8.6 KiB
TypeScript
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;
|
|
}
|