diff --git a/.gitignore b/.gitignore index 9c97bbd..dd4c7b6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules dist .env +.harness diff --git a/README.md b/README.md index 650fbb5..dd4b3f7 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/agent.ts b/src/agent.ts index e1feaec..b05a50c 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -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; } diff --git a/src/index.ts b/src/index.ts index 28b00db..f055f0b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 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 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 "); } diff --git a/src/logger.ts b/src/logger.ts index aa95b68..5593c6c 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -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; + } +} diff --git a/src/session.ts b/src/session.ts new file mode 100644 index 0000000..8f96ac6 --- /dev/null +++ b/src/session.ts @@ -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 { + try { + return JSON.parse(await readFile(this.filePath, "utf8")) as SessionSnapshot; + } catch { + return undefined; + } + } + + async save(history: AgentMessage[]): Promise { + 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); + } +}