888a3b3307
- Extracted credential storage to shared @oh-my-pi/pi-ai package with AuthCredentialStore and AuthStorage classes. - Consolidated UI formatting logic from ToolUIKit class into standalone utility functions across render-utils and output-meta modules. - Moved utility functions (parseCommandArgs, substituteArgs, expandPath, normalizeUnicode) to dedicated modules for improved code reuse. - Extracted JTD type definitions and type guards to jtd-utils module for shared use across schema conversion tools. - Updated Claude model pricing and added cache read costs in models.json for accurate billing calculations. - Refactored agent-storage to delegate credential management to AuthCredentialStore instead of direct SQLite operations.
487 lines
14 KiB
TypeScript
487 lines
14 KiB
TypeScript
/**
|
|
* Structured metadata for tool outputs.
|
|
*
|
|
* Tools populate details.meta using the fluent OutputMetaBuilder.
|
|
* The tool wrapper automatically formats and appends notices at message boundary.
|
|
*/
|
|
import type {
|
|
AgentTool,
|
|
AgentToolContext,
|
|
AgentToolExecFn,
|
|
AgentToolResult,
|
|
AgentToolUpdateCallback,
|
|
} from "@oh-my-pi/pi-agent-core";
|
|
import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
|
|
import type { Theme } from "../modes/theme/theme";
|
|
import type { OutputSummary, TruncationResult } from "../session/streaming-output";
|
|
import { formatBytes, wrapBrackets } from "./render-utils";
|
|
import { renderError } from "./tool-errors";
|
|
|
|
/**
|
|
* Truncation metadata for the output notice.
|
|
*/
|
|
export interface TruncationMeta {
|
|
direction: "head" | "tail";
|
|
truncatedBy: "lines" | "bytes";
|
|
totalLines: number;
|
|
totalBytes: number;
|
|
outputLines: number;
|
|
outputBytes: number;
|
|
maxBytes?: number;
|
|
/** Line range shown (1-indexed, inclusive) */
|
|
shownRange?: { start: number; end: number };
|
|
/** Artifact ID if full output was saved */
|
|
artifactId?: string;
|
|
/** Next offset for pagination (head truncation only) */
|
|
nextOffset?: number;
|
|
}
|
|
|
|
/**
|
|
* Source resolution info for the output.
|
|
*/
|
|
export type SourceMeta =
|
|
| { type: "path"; value: string }
|
|
| { type: "url"; value: string }
|
|
| { type: "internal"; value: string };
|
|
|
|
/**
|
|
* LSP diagnostic info (for edit/write tools).
|
|
*/
|
|
export interface DiagnosticMeta {
|
|
summary: string;
|
|
messages: string[];
|
|
}
|
|
|
|
/**
|
|
* Limit-specific notices.
|
|
*/
|
|
export interface LimitsMeta {
|
|
matchLimit?: { reached: number; suggestion: number };
|
|
resultLimit?: { reached: number; suggestion: number };
|
|
headLimit?: { reached: number; suggestion: number };
|
|
columnTruncated?: { maxColumn: number };
|
|
}
|
|
|
|
/**
|
|
* Structured metadata for tool outputs.
|
|
*/
|
|
export interface OutputMeta {
|
|
truncation?: TruncationMeta;
|
|
source?: SourceMeta;
|
|
diagnostics?: DiagnosticMeta;
|
|
limits?: LimitsMeta;
|
|
}
|
|
|
|
// =============================================================================
|
|
// OutputMetaBuilder - Fluent API for building OutputMeta
|
|
// =============================================================================
|
|
|
|
export interface TruncationOptions {
|
|
direction: "head" | "tail";
|
|
startLine?: number;
|
|
totalFileLines?: number;
|
|
artifactId?: string;
|
|
}
|
|
|
|
export interface TruncationSummaryOptions {
|
|
direction: "head" | "tail";
|
|
startLine?: number;
|
|
totalFileLines?: number;
|
|
}
|
|
|
|
export interface TruncationTextOptions {
|
|
direction: "head" | "tail";
|
|
totalLines?: number;
|
|
totalBytes?: number;
|
|
maxBytes?: number;
|
|
}
|
|
|
|
/**
|
|
* Fluent builder for OutputMeta.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* details.meta = outputMeta()
|
|
* .truncation(truncation, { direction: "head" })
|
|
* .matchLimit(limitReached ? effectiveLimit : 0)
|
|
* .columnTruncated(linesTruncated ? DEFAULT_MAX_COLUMN : 0)
|
|
* .get();
|
|
* ```
|
|
*/
|
|
export class OutputMetaBuilder {
|
|
#meta: OutputMeta = {};
|
|
|
|
/** Add truncation info from TruncationResult. No-op if not truncated. */
|
|
truncation(result: TruncationResult, options: TruncationOptions): this {
|
|
if (!result.truncated) return this;
|
|
|
|
const { direction, startLine = 1, totalFileLines, artifactId } = options;
|
|
|
|
let shownStart: number;
|
|
let shownEnd: number;
|
|
|
|
if (direction === "tail") {
|
|
shownStart = result.totalLines - result.outputLines + 1;
|
|
shownEnd = result.totalLines;
|
|
} else {
|
|
shownStart = startLine;
|
|
shownEnd = startLine + result.outputLines - 1;
|
|
}
|
|
|
|
this.#meta.truncation = {
|
|
direction,
|
|
truncatedBy: result.truncatedBy!,
|
|
totalLines: totalFileLines ?? result.totalLines,
|
|
totalBytes: result.totalBytes,
|
|
outputLines: result.outputLines,
|
|
outputBytes: result.outputBytes,
|
|
maxBytes: result.maxBytes,
|
|
shownRange: { start: shownStart, end: shownEnd },
|
|
artifactId,
|
|
nextOffset: direction === "head" ? shownEnd + 1 : undefined,
|
|
};
|
|
|
|
return this;
|
|
}
|
|
|
|
/** Add truncation info from OutputSummary. No-op if not truncated. */
|
|
truncationFromSummary(summary: OutputSummary, options: TruncationSummaryOptions): this {
|
|
if (!summary.truncated) return this;
|
|
|
|
const { direction, startLine = 1, totalFileLines } = options;
|
|
const totalLines = totalFileLines ?? summary.totalLines;
|
|
const truncatedBy: "lines" | "bytes" =
|
|
summary.outputBytes < summary.totalBytes
|
|
? "bytes"
|
|
: summary.outputLines < summary.totalLines
|
|
? "lines"
|
|
: "bytes";
|
|
|
|
let shownStart: number;
|
|
let shownEnd: number;
|
|
|
|
if (direction === "tail") {
|
|
shownStart = totalLines - summary.outputLines + 1;
|
|
shownEnd = totalLines;
|
|
} else {
|
|
shownStart = startLine;
|
|
shownEnd = startLine + summary.outputLines - 1;
|
|
}
|
|
|
|
this.#meta.truncation = {
|
|
direction,
|
|
truncatedBy,
|
|
totalLines,
|
|
totalBytes: summary.totalBytes,
|
|
outputLines: summary.outputLines,
|
|
outputBytes: summary.outputBytes,
|
|
shownRange: { start: shownStart, end: shownEnd },
|
|
artifactId: summary.artifactId,
|
|
nextOffset: direction === "head" ? shownEnd + 1 : undefined,
|
|
};
|
|
|
|
return this;
|
|
}
|
|
|
|
/** Add truncation info from truncated output text. No-op if truncation not detected. */
|
|
truncationFromText(text: string, options: TruncationTextOptions): this {
|
|
const outputLines = text.length > 0 ? text.split("\n").length : 0;
|
|
const outputBytes = Buffer.byteLength(text, "utf-8");
|
|
const totalLines = options.totalLines ?? outputLines;
|
|
const totalBytes = options.totalBytes ?? outputBytes;
|
|
|
|
const truncated = totalLines > outputLines || totalBytes > outputBytes || false;
|
|
if (!truncated) return this;
|
|
|
|
const truncatedBy: "lines" | "bytes" =
|
|
options.maxBytes && outputBytes >= options.maxBytes
|
|
? "bytes"
|
|
: totalBytes > outputBytes
|
|
? "bytes"
|
|
: totalLines > outputLines
|
|
? "lines"
|
|
: "bytes";
|
|
|
|
let shownStart: number;
|
|
let shownEnd: number;
|
|
|
|
if (options.direction === "tail") {
|
|
shownStart = totalLines - outputLines + 1;
|
|
shownEnd = totalLines;
|
|
} else {
|
|
shownStart = 1;
|
|
shownEnd = outputLines;
|
|
}
|
|
|
|
this.#meta.truncation = {
|
|
direction: options.direction,
|
|
truncatedBy,
|
|
totalLines,
|
|
totalBytes,
|
|
outputLines,
|
|
outputBytes,
|
|
maxBytes: options.maxBytes,
|
|
shownRange: { start: shownStart, end: shownEnd },
|
|
nextOffset: options.direction === "head" ? shownEnd + 1 : undefined,
|
|
};
|
|
|
|
return this;
|
|
}
|
|
|
|
/** Add match limit notice. No-op if reached <= 0. */
|
|
matchLimit(reached: number, suggestion = reached * 2): this {
|
|
if (reached <= 0) return this;
|
|
this.#meta.limits = { ...this.#meta.limits, matchLimit: { reached, suggestion } };
|
|
return this;
|
|
}
|
|
|
|
/** Add limit notices in one call. */
|
|
limits(limits: { matchLimit?: number; resultLimit?: number; headLimit?: number; columnMax?: number }): this {
|
|
if (limits.matchLimit !== undefined) {
|
|
this.matchLimit(limits.matchLimit);
|
|
}
|
|
if (limits.resultLimit !== undefined) {
|
|
this.resultLimit(limits.resultLimit);
|
|
}
|
|
if (limits.headLimit !== undefined) {
|
|
this.headLimit(limits.headLimit);
|
|
}
|
|
if (limits.columnMax !== undefined) {
|
|
this.columnTruncated(limits.columnMax);
|
|
}
|
|
return this;
|
|
}
|
|
|
|
/** Add result limit notice. No-op if reached <= 0. */
|
|
resultLimit(reached: number, suggestion = reached * 2): this {
|
|
if (reached <= 0) return this;
|
|
this.#meta.limits = { ...this.#meta.limits, resultLimit: { reached, suggestion } };
|
|
return this;
|
|
}
|
|
|
|
/** Add limit notice for head truncation. No-op if reached <= 0. */
|
|
headLimit(reached: number, suggestion = reached * 2): this {
|
|
if (reached <= 0) return this;
|
|
this.#meta.limits = { ...this.#meta.limits, headLimit: { reached, suggestion } };
|
|
return this;
|
|
}
|
|
|
|
/** Add column truncation notice. No-op if maxColumn <= 0. */
|
|
columnTruncated(maxColumn: number): this {
|
|
if (maxColumn <= 0) return this;
|
|
this.#meta.limits = { ...this.#meta.limits, columnTruncated: { maxColumn } };
|
|
return this;
|
|
}
|
|
|
|
/** Add source path info. */
|
|
sourcePath(value: string): this {
|
|
this.#meta.source = { type: "path", value };
|
|
return this;
|
|
}
|
|
|
|
/** Add source URL info. */
|
|
sourceUrl(value: string): this {
|
|
this.#meta.source = { type: "url", value };
|
|
return this;
|
|
}
|
|
|
|
/** Add internal URL source info (skill://, agent://, artifact://). */
|
|
sourceInternal(value: string): this {
|
|
this.#meta.source = { type: "internal", value };
|
|
return this;
|
|
}
|
|
|
|
/** Add LSP diagnostics. No-op if no messages. */
|
|
diagnostics(summary: string, messages: string[]): this {
|
|
if (messages.length === 0) return this;
|
|
this.#meta.diagnostics = { summary, messages };
|
|
return this;
|
|
}
|
|
|
|
/** Get the built OutputMeta, or undefined if empty. */
|
|
get(): OutputMeta | undefined {
|
|
return Object.keys(this.#meta).length > 0 ? this.#meta : undefined;
|
|
}
|
|
}
|
|
|
|
/** Create a new OutputMetaBuilder. */
|
|
export function outputMeta(): OutputMetaBuilder {
|
|
return new OutputMetaBuilder();
|
|
}
|
|
|
|
// =============================================================================
|
|
// Notice formatting
|
|
// =============================================================================
|
|
|
|
export function formatFullOutputReference(artifactId: string): string {
|
|
return `Full output: artifact://${artifactId}`;
|
|
}
|
|
|
|
export function formatTruncationMetaNotice(truncation: TruncationMeta): string {
|
|
const range = truncation.shownRange;
|
|
let notice: string;
|
|
|
|
if (range && range.end >= range.start) {
|
|
notice = `Showing lines ${range.start}-${range.end} of ${truncation.totalLines}`;
|
|
} else {
|
|
notice = `Showing ${truncation.outputLines} of ${truncation.totalLines} lines`;
|
|
}
|
|
|
|
if (truncation.truncatedBy === "bytes") {
|
|
const maxBytes = truncation.maxBytes ?? truncation.outputBytes;
|
|
notice += ` (${formatBytes(maxBytes)} limit)`;
|
|
}
|
|
|
|
if (truncation.nextOffset != null) {
|
|
notice += `. Use offset=${truncation.nextOffset} to continue`;
|
|
}
|
|
|
|
if (truncation.artifactId != null) {
|
|
notice += `. ${formatFullOutputReference(truncation.artifactId)}`;
|
|
}
|
|
|
|
return notice;
|
|
}
|
|
|
|
/**
|
|
* Format styled artifact reference with warning color and brackets.
|
|
* For TUI rendering of truncation warnings.
|
|
*/
|
|
export function formatStyledArtifactReference(artifactId: string, theme: Theme): string {
|
|
return theme.fg("warning", formatFullOutputReference(artifactId));
|
|
}
|
|
|
|
/**
|
|
* Format notices from OutputMeta for LLM consumption.
|
|
* Returns empty string if no notices needed.
|
|
*/
|
|
export function formatOutputNotice(meta: OutputMeta | undefined): string {
|
|
if (!meta) return "";
|
|
|
|
const parts: string[] = [];
|
|
|
|
// Truncation notice
|
|
if (meta.truncation) {
|
|
parts.push(formatTruncationMetaNotice(meta.truncation));
|
|
}
|
|
|
|
// Limit notices
|
|
if (meta.limits?.matchLimit) {
|
|
const l = meta.limits.matchLimit;
|
|
parts.push(`${l.reached} matches limit reached. Use limit=${l.suggestion} for more`);
|
|
}
|
|
if (meta.limits?.resultLimit) {
|
|
const l = meta.limits.resultLimit;
|
|
parts.push(`${l.reached} results limit reached. Use limit=${l.suggestion} for more`);
|
|
}
|
|
if (meta.limits?.headLimit) {
|
|
const l = meta.limits.headLimit;
|
|
parts.push(`${l.reached} results limit reached. Use limit=${l.suggestion} for more`);
|
|
}
|
|
if (meta.limits?.columnTruncated) {
|
|
parts.push(`Some lines truncated to ${meta.limits.columnTruncated.maxColumn} chars`);
|
|
}
|
|
|
|
// Diagnostics
|
|
let diagnosticsNotice = "";
|
|
if (meta.diagnostics && meta.diagnostics.messages.length > 0) {
|
|
const d = meta.diagnostics;
|
|
diagnosticsNotice = `\n\nLSP Diagnostics (${d.summary}):\n ${d.messages.join("\n ")}`;
|
|
}
|
|
|
|
const notice = parts.length ? `\n\n[${parts.join(". ")}]` : "";
|
|
return notice + diagnosticsNotice;
|
|
}
|
|
|
|
/**
|
|
* Format a styled truncation warning message.
|
|
* Returns null if no truncation metadata present.
|
|
*/
|
|
export function formatStyledTruncationWarning(meta: OutputMeta | undefined, theme: Theme): string | null {
|
|
if (!meta?.truncation) return null;
|
|
const message = formatTruncationMetaNotice(meta.truncation);
|
|
return theme.fg("warning", wrapBrackets(message, theme));
|
|
}
|
|
|
|
// =============================================================================
|
|
// Tool wrapper
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Append output notice to tool result content if meta is present.
|
|
*/
|
|
function appendOutputNotice(
|
|
content: Array<{ type: string; text?: string }>,
|
|
meta: OutputMeta | undefined,
|
|
): Array<{ type: string; text?: string }> {
|
|
const notice = formatOutputNotice(meta);
|
|
if (!notice) return content;
|
|
|
|
const result = [...content];
|
|
for (let i = result.length - 1; i >= 0; i--) {
|
|
if (result[i].type === "text" && result[i].text != null) {
|
|
result[i] = { ...result[i], text: result[i].text + notice };
|
|
return result;
|
|
}
|
|
}
|
|
|
|
result.push({ type: "text", text: notice.trim() });
|
|
return result;
|
|
}
|
|
|
|
const kUnwrappedExecute = Symbol("OutputMeta.UnwrappedExecute");
|
|
|
|
async function wrappedExecute(
|
|
this: AgentTool & { [kUnwrappedExecute]: AgentToolExecFn },
|
|
toolCallId: string,
|
|
params: any,
|
|
signal?: AbortSignal,
|
|
onUpdate?: AgentToolUpdateCallback,
|
|
context?: AgentToolContext,
|
|
): Promise<AgentToolResult> {
|
|
const originalExecute = this[kUnwrappedExecute];
|
|
|
|
try {
|
|
// Append notices from meta
|
|
const result = await originalExecute.call(this, toolCallId, params, signal, onUpdate, context);
|
|
const meta = (result.details as { meta?: OutputMeta } | undefined)?.meta;
|
|
if (meta) {
|
|
return {
|
|
...result,
|
|
content: appendOutputNotice(result.content, meta) as (TextContent | ImageContent)[],
|
|
};
|
|
}
|
|
return result;
|
|
} catch (e) {
|
|
// Re-throw with formatted message so agent-loop sets isError flag
|
|
throw new Error(renderError(e));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wrap a tool to:
|
|
* 1. Automatically append output notices based on details.meta
|
|
* 2. Handle ToolError rendering
|
|
*/
|
|
export function wrapToolWithMetaNotice<T extends AgentTool<any, any, any>>(tool: T): T {
|
|
if (kUnwrappedExecute in tool) {
|
|
return tool;
|
|
}
|
|
|
|
const originalExecute = tool.execute;
|
|
|
|
return Object.defineProperties(tool, {
|
|
[kUnwrappedExecute]: {
|
|
value: originalExecute,
|
|
enumerable: false,
|
|
configurable: true,
|
|
},
|
|
execute: {
|
|
value: wrappedExecute,
|
|
enumerable: false,
|
|
configurable: true,
|
|
writable: true,
|
|
},
|
|
});
|
|
}
|