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

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
node_modules
dist
.env
.harness

View File

@@ -32,7 +32,16 @@ you>
```
Interactive responses stream into the terminal as they are generated, and the
session keeps conversation history until you reset or exit.
session keeps conversation history until you reset it. The current session is
saved to `.harness/session.json` and reloaded the next time the app starts.
For multiline input, end a line with `\` and keep typing:
```text
you> summarize this:\
...> line two\
...> line three
```
You can also keep using one-shot mode when scripting:
@@ -81,8 +90,11 @@ npm run dev -- "/tools"
The core provides:
- `/help` — show available commands
- `/history` — show how many conversation items are currently in memory
- `/history` — show current session details
- `/new` — start a fresh empty conversation
- `/reset` — clear the current conversation history
- `/save` — persist the current conversation immediately
- `/logs [on|off]` — toggle lifecycle traces
- `/exit` — leave the interactive session
Extensions can register their own commands.

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,9 +123,11 @@ 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(
if (!currentAgent) {
currentAgent = new Agent(
new OpenAIResponsesClient("gpt-5", systemPrompt, events),
registry,
{
@@ -94,6 +135,8 @@ function getAgent(): Agent {
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, {
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);
}
}