Add session persistence and improve terminal ergonomics
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
.env
|
.env
|
||||||
|
.harness
|
||||||
|
|||||||
16
README.md
16
README.md
@@ -32,7 +32,16 @@ you>
|
|||||||
```
|
```
|
||||||
|
|
||||||
Interactive responses stream into the terminal as they are generated, and the
|
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:
|
You can also keep using one-shot mode when scripting:
|
||||||
|
|
||||||
@@ -81,8 +90,11 @@ npm run dev -- "/tools"
|
|||||||
The core provides:
|
The core provides:
|
||||||
|
|
||||||
- `/help` — show available commands
|
- `/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
|
- `/reset` — clear the current conversation history
|
||||||
|
- `/save` — persist the current conversation immediately
|
||||||
|
- `/logs [on|off]` — toggle lifecycle traces
|
||||||
- `/exit` — leave the interactive session
|
- `/exit` — leave the interactive session
|
||||||
|
|
||||||
Extensions can register their own commands.
|
Extensions can register their own commands.
|
||||||
|
|||||||
@@ -94,6 +94,11 @@ export class Agent {
|
|||||||
return [...this.history];
|
return [...this.history];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadHistory(history: AgentMessage[]): void {
|
||||||
|
this.history.length = 0;
|
||||||
|
this.history.push(...history);
|
||||||
|
}
|
||||||
|
|
||||||
reset(): void {
|
reset(): void {
|
||||||
this.history.length = 0;
|
this.history.length = 0;
|
||||||
}
|
}
|
||||||
|
|||||||
120
src/index.ts
120
src/index.ts
@@ -5,15 +5,16 @@ import { Agent } from "./agent.js";
|
|||||||
import { CommandRegistry } from "./commands.js";
|
import { CommandRegistry } from "./commands.js";
|
||||||
import { EventBus } from "./events.js";
|
import { EventBus } from "./events.js";
|
||||||
import { loadExtensions } from "./extensions.js";
|
import { loadExtensions } from "./extensions.js";
|
||||||
import { ConsoleLogger } from "./logger.js";
|
import { ConsoleLogger, ToggleLogger } from "./logger.js";
|
||||||
import { OpenAIResponsesClient } from "./model/openai.js";
|
import { OpenAIResponsesClient } from "./model/openai.js";
|
||||||
|
import { SessionStore } from "./session.js";
|
||||||
import { builtinTools } from "./tools/builtins.js";
|
import { builtinTools } from "./tools/builtins.js";
|
||||||
import { ToolRegistry } from "./tools/registry.js";
|
import { ToolRegistry } from "./tools/registry.js";
|
||||||
|
|
||||||
const initialPrompt = process.argv.slice(2).join(" ").trim();
|
const initialPrompt = process.argv.slice(2).join(" ").trim();
|
||||||
|
|
||||||
const cwd = process.cwd();
|
const cwd = process.cwd();
|
||||||
const logger = new ConsoleLogger();
|
const logger = new ToggleLogger(new ConsoleLogger(), process.env.LOGS !== "off");
|
||||||
const events = new EventBus();
|
const events = new EventBus();
|
||||||
const registry = new ToolRegistry(events);
|
const registry = new ToolRegistry(events);
|
||||||
const commands = new CommandRegistry(events);
|
const commands = new CommandRegistry(events);
|
||||||
@@ -21,6 +22,7 @@ const promptFragments = [
|
|||||||
process.env.SYSTEM_PROMPT ??
|
process.env.SYSTEM_PROMPT ??
|
||||||
"You are a helpful agent. Use tools when they are useful, and answer clearly."
|
"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 [
|
for (const event of [
|
||||||
"tool.registered",
|
"tool.registered",
|
||||||
@@ -66,17 +68,54 @@ await commands.register({
|
|||||||
await commands.register({
|
await commands.register({
|
||||||
name: "reset",
|
name: "reset",
|
||||||
description: "Clear the current conversation history.",
|
description: "Clear the current conversation history.",
|
||||||
execute() {
|
async execute() {
|
||||||
currentAgent?.reset();
|
currentAgent?.reset();
|
||||||
|
loadedHistory = [];
|
||||||
|
await sessionStore.save([]);
|
||||||
return "Conversation reset.";
|
return "Conversation reset.";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await commands.register({
|
await commands.register({
|
||||||
name: "history",
|
name: "history",
|
||||||
description: "Show how many conversation items are in memory.",
|
description: "Show current session details.",
|
||||||
execute() {
|
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");
|
const systemPrompt = promptFragments.join("\n\n");
|
||||||
let currentAgent: Agent | undefined;
|
let currentAgent: Agent | undefined;
|
||||||
|
let loadedHistory = (await sessionStore.load())?.history ?? [];
|
||||||
|
|
||||||
function getAgent(): Agent {
|
function getAgent(): Agent {
|
||||||
currentAgent ??= new Agent(
|
if (!currentAgent) {
|
||||||
new OpenAIResponsesClient("gpt-5", systemPrompt, events),
|
currentAgent = new Agent(
|
||||||
registry,
|
new OpenAIResponsesClient("gpt-5", systemPrompt, events),
|
||||||
{
|
registry,
|
||||||
cwd,
|
{
|
||||||
events
|
cwd,
|
||||||
}
|
events
|
||||||
);
|
}
|
||||||
|
);
|
||||||
|
currentAgent.loadHistory(loadedHistory);
|
||||||
|
}
|
||||||
|
|
||||||
return currentAgent;
|
return currentAgent;
|
||||||
}
|
}
|
||||||
@@ -134,6 +177,8 @@ async function handlePrompt(prompt: string, interactive = false): Promise<void>
|
|||||||
|
|
||||||
if (!interactive) {
|
if (!interactive) {
|
||||||
const answer = await getAgent().runTurn(prompt);
|
const answer = await getAgent().runTurn(prompt);
|
||||||
|
loadedHistory = getAgent().getHistory();
|
||||||
|
await sessionStore.save(loadedHistory);
|
||||||
console.log(answer);
|
console.log(answer);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -143,13 +188,31 @@ async function handlePrompt(prompt: string, interactive = false): Promise<void>
|
|||||||
|
|
||||||
output.write("\x1b[36massistant>\x1b[0m ");
|
output.write("\x1b[36massistant>\x1b[0m ");
|
||||||
|
|
||||||
const answer = await getAgent().runTurn(prompt, {
|
let answer: string;
|
||||||
onTextDelta(delta) {
|
|
||||||
wroteAssistantText = true;
|
try {
|
||||||
streamedText += delta;
|
answer = await getAgent().runTurn(prompt, {
|
||||||
output.write(delta);
|
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) {
|
if (!wroteAssistantText) {
|
||||||
output.write(answer);
|
output.write(answer);
|
||||||
@@ -168,15 +231,30 @@ if (initialPrompt) {
|
|||||||
const rl = createInterface({ input, output });
|
const rl = createInterface({ input, output });
|
||||||
|
|
||||||
console.log("Simple Agent Harness");
|
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 ");
|
output.write("\x1b[32myou>\x1b[0m ");
|
||||||
|
|
||||||
|
let multilineBuffer: string[] = [];
|
||||||
for await (const prompt of rl) {
|
for await (const prompt of rl) {
|
||||||
if (prompt.trim() === "/exit") {
|
if (prompt.trim() === "/exit") {
|
||||||
break;
|
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 ");
|
output.write("\x1b[32myou>\x1b[0m ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,3 +17,24 @@ export class ConsoleLogger implements Logger {
|
|||||||
export class NullLogger implements Logger {
|
export class NullLogger implements Logger {
|
||||||
event(): void {}
|
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
40
src/session.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user