Add session export to HTML, improve tool error handling, and enhance RPC mode documentation

This commit is contained in:
Mario Zechner 2025-11-12 21:55:10 +01:00
parent 68092ccf01
commit 9e3e319f1a
9 changed files with 638 additions and 63 deletions

View file

@ -2,7 +2,7 @@ import { Agent, ProviderTransport, type ThinkingLevel } from "@mariozechner/pi-a
import { getModel, type KnownProvider } from "@mariozechner/pi-ai";
import { ProcessTerminal, TUI } from "@mariozechner/pi-tui";
import chalk from "chalk";
import { readFileSync } from "fs";
import { existsSync, readFileSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { SessionManager } from "./session-manager.js";
@ -38,6 +38,8 @@ interface Args {
resume?: boolean;
help?: boolean;
mode?: Mode;
noSession?: boolean;
session?: string;
messages: string[];
}
@ -68,6 +70,10 @@ function parseArgs(args: string[]): Args {
result.apiKey = args[++i];
} else if (arg === "--system-prompt" && i + 1 < args.length) {
result.systemPrompt = args[++i];
} else if (arg === "--no-session") {
result.noSession = true;
} else if (arg === "--session" && i + 1 < args.length) {
result.session = args[++i];
} else if (!arg.startsWith("-")) {
result.messages.push(arg);
}
@ -90,6 +96,8 @@ ${chalk.bold("Options:")}
--mode <mode> Output mode: text (default), json, or rpc
--continue, -c Continue previous session
--resume, -r Select a session to resume
--session <path> Use specific session file
--no-session Don't save session (ephemeral)
--help, -h Show this help
${chalk.bold("Examples:")}
@ -140,6 +148,23 @@ Guidelines:
Current directory: ${process.cwd()}`;
/**
* Look for AGENT.md or CLAUDE.md in the current directory and return its contents
*/
function loadProjectContext(): string | null {
const candidates = ["AGENT.md", "CLAUDE.md"];
for (const filename of candidates) {
if (existsSync(filename)) {
try {
return readFileSync(filename, "utf-8");
} catch (error) {
console.error(chalk.yellow(`Warning: Could not read ${filename}: ${error}`));
}
}
}
return null;
}
async function selectSession(sessionManager: SessionManager): Promise<string | null> {
return new Promise((resolve) => {
const ui = new TUI(new ProcessTerminal());
@ -241,7 +266,7 @@ async function runRpcMode(agent: Agent, _sessionManager: SessionManager): Promis
});
// Listen for JSON input on stdin
const readline = require("readline");
const readline = await import("readline");
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
@ -277,7 +302,12 @@ export async function main(args: string[]) {
}
// Setup session manager
const sessionManager = new SessionManager(parsed.continue && !parsed.resume);
const sessionManager = new SessionManager(parsed.continue && !parsed.resume, parsed.session);
// Disable session saving if --no-session flag is set
if (parsed.noSession) {
sessionManager.disable();
}
// Handle --resume flag: show session selector
if (parsed.resume) {
@ -398,6 +428,27 @@ export async function main(args: string[]) {
// Start session
sessionManager.startSession(agent.state);
// Inject project context (AGENT.md/CLAUDE.md) if not continuing/resuming
if (!parsed.continue && !parsed.resume) {
const projectContext = loadProjectContext();
if (projectContext) {
// Queue the context as a message that will be injected at the start
await agent.queueMessage({
role: "user",
content: [
{
type: "text",
text: `[Project Context from ${existsSync("AGENT.md") ? "AGENT.md" : "CLAUDE.md"}]\n\n${projectContext}`,
},
],
timestamp: Date.now(),
});
if (shouldPrintMessages) {
console.log(chalk.dim(`Loaded project context from ${existsSync("AGENT.md") ? "AGENT.md" : "CLAUDE.md"}`));
}
}
}
// Subscribe to agent events to save messages and log events
agent.subscribe((event) => {
// Save messages on completion
@ -412,15 +463,14 @@ export async function main(args: string[]) {
});
// Route to appropriate mode
if (isInteractive) {
// No mode flag in interactive - always use TUI
if (mode === "rpc") {
// RPC mode - headless operation
await runRpcMode(agent, sessionManager);
} else if (isInteractive) {
// No messages and not RPC - use TUI
await runInteractiveMode(agent, sessionManager, VERSION);
} else {
// CLI mode with messages
if (mode === "rpc") {
await runRpcMode(agent, sessionManager);
} else {
await runSingleShotMode(agent, sessionManager, parsed.messages, mode);
}
await runSingleShotMode(agent, sessionManager, parsed.messages, mode);
}
}

View file

@ -17,7 +17,6 @@ export interface SessionHeader {
id: string;
timestamp: string;
cwd: string;
systemPrompt: string;
model: string;
thinkingLevel: string;
}
@ -50,11 +49,16 @@ export class SessionManager {
private sessionId!: string;
private sessionFile!: string;
private sessionDir: string;
private enabled: boolean = true;
constructor(continueSession: boolean = false) {
constructor(continueSession: boolean = false, customSessionPath?: string) {
this.sessionDir = this.getSessionDirectory();
if (continueSession) {
if (customSessionPath) {
// Use custom session file path
this.sessionFile = resolve(customSessionPath);
this.loadSessionId();
} else if (continueSession) {
const mostRecent = this.findMostRecentlyModifiedSession();
if (mostRecent) {
this.sessionFile = mostRecent;
@ -67,6 +71,11 @@ export class SessionManager {
}
}
/** Disable session saving (for --no-session mode) */
disable() {
this.enabled = false;
}
private getSessionDirectory(): string {
const cwd = process.cwd();
const safePath = "--" + cwd.replace(/^\//, "").replace(/\//g, "-") + "--";
@ -121,12 +130,12 @@ export class SessionManager {
}
startSession(state: AgentState): void {
if (!this.enabled) return;
const entry: SessionHeader = {
type: "session",
id: this.sessionId,
timestamp: new Date().toISOString(),
cwd: process.cwd(),
systemPrompt: state.systemPrompt,
model: `${state.model.provider}/${state.model.id}`,
thinkingLevel: state.thinkingLevel,
};
@ -134,6 +143,7 @@ export class SessionManager {
}
saveMessage(message: any): void {
if (!this.enabled) return;
const entry: SessionMessageEntry = {
type: "message",
timestamp: new Date().toISOString(),
@ -143,6 +153,7 @@ export class SessionManager {
}
saveEvent(event: AgentEvent): void {
if (!this.enabled) return;
const entry: SessionEventEntry = {
type: "event",
timestamp: new Date().toISOString(),
@ -152,6 +163,7 @@ export class SessionManager {
}
saveThinkingLevelChange(thinkingLevel: string): void {
if (!this.enabled) return;
const entry: ThinkingLevelChangeEntry = {
type: "thinking_level_change",
timestamp: new Date().toISOString(),
@ -161,6 +173,7 @@ export class SessionManager {
}
saveModelChange(model: string): void {
if (!this.enabled) return;
const entry: ModelChangeEntry = {
type: "model_change",
timestamp: new Date().toISOString(),

View file

@ -163,10 +163,7 @@ export const editTool: AgentTool<typeof editSchema> = {
if (signal) {
signal.removeEventListener("abort", onAbort);
}
resolve({
content: [{ type: "text", text: `Error: File not found: ${path}` }],
details: undefined,
});
reject(new Error(`File not found: ${path}`));
return;
}
@ -188,15 +185,11 @@ export const editTool: AgentTool<typeof editSchema> = {
if (signal) {
signal.removeEventListener("abort", onAbort);
}
resolve({
content: [
{
type: "text",
text: `Error: Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`,
},
],
details: undefined,
});
reject(
new Error(
`Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`,
),
);
return;
}
@ -207,15 +200,11 @@ export const editTool: AgentTool<typeof editSchema> = {
if (signal) {
signal.removeEventListener("abort", onAbort);
}
resolve({
content: [
{
type: "text",
text: `Error: Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`,
},
],
details: undefined,
});
reject(
new Error(
`Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`,
),
);
return;
}

View file

@ -27,8 +27,6 @@ const IMAGE_MIME_TYPES: Record<string, string> = {
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
".svg": "image/svg+xml",
};
/**
@ -52,7 +50,7 @@ export const readTool: AgentTool<typeof readSchema> = {
name: "read",
label: "read",
description:
"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp, svg). Images are sent as attachments. For text files, defaults to first 2000 lines. Use offset/limit for large files.",
"Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, defaults to first 2000 lines. Use offset/limit for large files.",
parameters: readSchema,
execute: async (
_toolCallId: string,