Add session persistence and improve terminal ergonomics

This commit is contained in:
Adolfo Reyna
2026-05-15 14:25:49 -04:00
parent a04fc0a0b5
commit 2e806c97a0
6 changed files with 180 additions and 23 deletions

View File

@@ -94,6 +94,11 @@ export class Agent {
return [...this.history];
}
loadHistory(history: AgentMessage[]): void {
this.history.length = 0;
this.history.push(...history);
}
reset(): void {
this.history.length = 0;
}

View File

@@ -5,15 +5,16 @@ import { Agent } from "./agent.js";
import { CommandRegistry } from "./commands.js";
import { EventBus } from "./events.js";
import { loadExtensions } from "./extensions.js";
import { ConsoleLogger } from "./logger.js";
import { ConsoleLogger, ToggleLogger } from "./logger.js";
import { OpenAIResponsesClient } from "./model/openai.js";
import { SessionStore } from "./session.js";
import { builtinTools } from "./tools/builtins.js";
import { ToolRegistry } from "./tools/registry.js";
const initialPrompt = process.argv.slice(2).join(" ").trim();
const cwd = process.cwd();
const logger = new ConsoleLogger();
const logger = new ToggleLogger(new ConsoleLogger(), process.env.LOGS !== "off");
const events = new EventBus();
const registry = new ToolRegistry(events);
const commands = new CommandRegistry(events);
@@ -21,6 +22,7 @@ const promptFragments = [
process.env.SYSTEM_PROMPT ??
"You are a helpful agent. Use tools when they are useful, and answer clearly."
];
const sessionStore = new SessionStore(resolve(cwd, ".harness", "session.json"));
for (const event of [
"tool.registered",
@@ -66,17 +68,54 @@ await commands.register({
await commands.register({
name: "reset",
description: "Clear the current conversation history.",
execute() {
async execute() {
currentAgent?.reset();
loadedHistory = [];
await sessionStore.save([]);
return "Conversation reset.";
}
});
await commands.register({
name: "history",
description: "Show how many conversation items are in memory.",
description: "Show current session details.",
execute() {
return `Conversation items in memory: ${currentAgent?.getHistory().length ?? 0}`;
return [
`Conversation items in memory: ${currentAgent?.getHistory().length ?? loadedHistory.length}`,
`Session file: ${sessionStore.getPath()}`,
`Logs: ${logger.isEnabled() ? "on" : "off"}`
].join("\n");
}
});
await commands.register({
name: "new",
description: "Start a new empty conversation.",
async execute() {
currentAgent?.reset();
loadedHistory = [];
await sessionStore.save([]);
return "Started a new conversation.";
}
});
await commands.register({
name: "save",
description: "Save the current conversation immediately.",
async execute() {
await sessionStore.save(currentAgent?.getHistory() ?? loadedHistory);
return `Session saved to ${sessionStore.getPath()}`;
}
});
await commands.register({
name: "logs",
description: "Toggle lifecycle logs on or off.",
execute(args) {
const next =
args === "on" ? true : args === "off" ? false : !logger.isEnabled();
logger.setEnabled(next);
return `Logs ${next ? "enabled" : "disabled"}.`;
}
});
@@ -84,16 +123,20 @@ await loadExtensions(resolve(cwd, "extensions"), registry, commands, events, cwd
const systemPrompt = promptFragments.join("\n\n");
let currentAgent: Agent | undefined;
let loadedHistory = (await sessionStore.load())?.history ?? [];
function getAgent(): Agent {
currentAgent ??= new Agent(
new OpenAIResponsesClient("gpt-5", systemPrompt, events),
registry,
{
cwd,
events
}
);
if (!currentAgent) {
currentAgent = new Agent(
new OpenAIResponsesClient("gpt-5", systemPrompt, events),
registry,
{
cwd,
events
}
);
currentAgent.loadHistory(loadedHistory);
}
return currentAgent;
}
@@ -134,6 +177,8 @@ async function handlePrompt(prompt: string, interactive = false): Promise<void>
if (!interactive) {
const answer = await getAgent().runTurn(prompt);
loadedHistory = getAgent().getHistory();
await sessionStore.save(loadedHistory);
console.log(answer);
return;
}
@@ -143,13 +188,31 @@ async function handlePrompt(prompt: string, interactive = false): Promise<void>
output.write("\x1b[36massistant>\x1b[0m ");
const answer = await getAgent().runTurn(prompt, {
onTextDelta(delta) {
wroteAssistantText = true;
streamedText += delta;
output.write(delta);
let answer: string;
try {
answer = await getAgent().runTurn(prompt, {
onTextDelta(delta) {
wroteAssistantText = true;
streamedText += delta;
output.write(delta);
}
});
loadedHistory = getAgent().getHistory();
await sessionStore.save(loadedHistory);
} catch (error) {
if (!wroteAssistantText) {
output.write(
error instanceof Error && error.message.includes("Missing credentials")
? "Missing OPENAI_API_KEY. Set it before sending model prompts."
: error instanceof Error
? error.message
: "Request failed."
);
}
});
output.write("\n\n");
return;
}
if (!wroteAssistantText) {
output.write(answer);
@@ -168,15 +231,30 @@ if (initialPrompt) {
const rl = createInterface({ input, output });
console.log("Simple Agent Harness");
console.log('Type a message, "/help" for commands, or "/exit" to quit.');
console.log(
`Loaded ${loadedHistory.length} conversation items. Type "/help" for commands or "/exit" to quit.`
);
console.log('End a line with "\\" to continue onto the next line.');
output.write("\x1b[32myou>\x1b[0m ");
let multilineBuffer: string[] = [];
for await (const prompt of rl) {
if (prompt.trim() === "/exit") {
break;
}
await handlePrompt(prompt, true);
const continues = prompt.endsWith("\\");
const line = continues ? prompt.slice(0, -1) : prompt;
multilineBuffer.push(line);
if (continues) {
output.write("\x1b[33m...>\x1b[0m ");
continue;
}
const fullPrompt = multilineBuffer.join("\n");
multilineBuffer = [];
await handlePrompt(fullPrompt, true);
output.write("\x1b[32myou>\x1b[0m ");
}

View File

@@ -17,3 +17,24 @@ export class ConsoleLogger implements Logger {
export class NullLogger implements Logger {
event(): void {}
}
export class ToggleLogger implements Logger {
constructor(
private readonly inner: Logger,
private enabled = true
) {}
event(name: string, data?: LogData): void {
if (this.enabled) {
this.inner.event(name, data);
}
}
setEnabled(enabled: boolean): void {
this.enabled = enabled;
}
isEnabled(): boolean {
return this.enabled;
}
}

40
src/session.ts Normal file
View File

@@ -0,0 +1,40 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import type { AgentMessage } from "./types.js";
export type SessionSnapshot = {
history: AgentMessage[];
updatedAt: string;
};
export class SessionStore {
constructor(private readonly filePath: string) {}
async load(): Promise<SessionSnapshot | undefined> {
try {
return JSON.parse(await readFile(this.filePath, "utf8")) as SessionSnapshot;
} catch {
return undefined;
}
}
async save(history: AgentMessage[]): Promise<void> {
await mkdir(dirname(this.filePath), { recursive: true });
await writeFile(
this.filePath,
JSON.stringify(
{
history,
updatedAt: new Date().toISOString()
} satisfies SessionSnapshot,
null,
2
),
"utf8"
);
}
getPath(): string {
return resolve(this.filePath);
}
}