haroo 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +58 -0
  2. package/dist/index.js +84883 -0
  3. package/package.json +73 -0
  4. package/src/__tests__/e2e/EventService.test.ts +211 -0
  5. package/src/__tests__/unit/Event.test.ts +89 -0
  6. package/src/__tests__/unit/Memory.test.ts +130 -0
  7. package/src/application/graph/builder.ts +106 -0
  8. package/src/application/graph/edges.ts +37 -0
  9. package/src/application/graph/nodes/addEvent.ts +113 -0
  10. package/src/application/graph/nodes/chat.ts +128 -0
  11. package/src/application/graph/nodes/extractMemory.ts +135 -0
  12. package/src/application/graph/nodes/index.ts +8 -0
  13. package/src/application/graph/nodes/query.ts +194 -0
  14. package/src/application/graph/nodes/respond.ts +26 -0
  15. package/src/application/graph/nodes/router.ts +82 -0
  16. package/src/application/graph/nodes/toolExecutor.ts +79 -0
  17. package/src/application/graph/nodes/types.ts +2 -0
  18. package/src/application/index.ts +4 -0
  19. package/src/application/services/DiaryService.ts +188 -0
  20. package/src/application/services/EventService.ts +61 -0
  21. package/src/application/services/index.ts +2 -0
  22. package/src/application/tools/calendarTool.ts +179 -0
  23. package/src/application/tools/diaryTool.ts +182 -0
  24. package/src/application/tools/index.ts +68 -0
  25. package/src/config/env.ts +33 -0
  26. package/src/config/index.ts +1 -0
  27. package/src/domain/entities/DiaryEntry.ts +16 -0
  28. package/src/domain/entities/Event.ts +13 -0
  29. package/src/domain/entities/Memory.ts +20 -0
  30. package/src/domain/index.ts +5 -0
  31. package/src/domain/interfaces/IDiaryRepository.ts +21 -0
  32. package/src/domain/interfaces/IEventsRepository.ts +12 -0
  33. package/src/domain/interfaces/ILanguageModel.ts +23 -0
  34. package/src/domain/interfaces/IMemoriesRepository.ts +15 -0
  35. package/src/domain/interfaces/IMemory.ts +19 -0
  36. package/src/domain/interfaces/index.ts +4 -0
  37. package/src/domain/state/AgentState.ts +30 -0
  38. package/src/index.ts +5 -0
  39. package/src/infrastructure/database/factory.ts +52 -0
  40. package/src/infrastructure/database/index.ts +21 -0
  41. package/src/infrastructure/database/sqlite-checkpointer.ts +179 -0
  42. package/src/infrastructure/database/sqlite-client.ts +69 -0
  43. package/src/infrastructure/database/sqlite-diary-repository.ts +209 -0
  44. package/src/infrastructure/database/sqlite-events-repository.ts +167 -0
  45. package/src/infrastructure/database/sqlite-memories-repository.ts +284 -0
  46. package/src/infrastructure/database/sqlite-schema.ts +98 -0
  47. package/src/infrastructure/index.ts +3 -0
  48. package/src/infrastructure/llm/base.ts +14 -0
  49. package/src/infrastructure/llm/gemini.ts +139 -0
  50. package/src/infrastructure/llm/index.ts +22 -0
  51. package/src/infrastructure/llm/ollama.ts +126 -0
  52. package/src/infrastructure/llm/openai.ts +148 -0
  53. package/src/infrastructure/memory/checkpointer.ts +19 -0
  54. package/src/infrastructure/memory/index.ts +2 -0
  55. package/src/infrastructure/settings/index.ts +96 -0
  56. package/src/interface/cli/calendar.ts +120 -0
  57. package/src/interface/cli/chat.ts +185 -0
  58. package/src/interface/cli/commands.ts +337 -0
  59. package/src/interface/cli/printer.ts +65 -0
  60. package/src/interface/index.ts +1 -0
@@ -0,0 +1,148 @@
1
+ import type { LLMResponse, ToolDefinition, ToolCall, ILanguageModel } from "../../domain";
2
+ import type { BaseMessage } from "@langchain/core/messages";
3
+ import { zodToJsonSchema } from "zod-to-json-schema";
4
+ import { BaseLLMAdapter } from "./base";
5
+
6
+ interface OpenAIToolCall {
7
+ id: string;
8
+ type: "function";
9
+ function: {
10
+ name: string;
11
+ arguments: string;
12
+ };
13
+ }
14
+
15
+ interface OpenAIMessage {
16
+ role: string;
17
+ content: string | null;
18
+ tool_calls?: OpenAIToolCall[];
19
+ tool_call_id?: string;
20
+ }
21
+
22
+ interface OpenAIChoice {
23
+ message: OpenAIMessage;
24
+ finish_reason: string;
25
+ }
26
+
27
+ interface OpenAIResponse {
28
+ choices: OpenAIChoice[];
29
+ }
30
+
31
+ export class OpenAIAdapter extends BaseLLMAdapter {
32
+ private apiKey: string;
33
+ private structuredSchema: unknown = null;
34
+
35
+ constructor(model: string = "gpt-5-nano", apiKey?: string) {
36
+ super(model);
37
+ this.apiKey = apiKey ?? process.env.OPENAI_API_KEY ?? "";
38
+ }
39
+
40
+ async generate(messages: BaseMessage[], tools?: ToolDefinition[]): Promise<LLMResponse> {
41
+ if (!this.apiKey) {
42
+ throw new Error("OPENAI_API_KEY is required");
43
+ }
44
+
45
+ const formattedMessages = messages.map((m) => {
46
+ const type = m._getType();
47
+ const baseMsg: OpenAIMessage = {
48
+ role: type === "human" ? "user" : type === "ai" ? "assistant" : type,
49
+ content: typeof m.content === "string" ? m.content : JSON.stringify(m.content),
50
+ };
51
+
52
+ // Handle tool messages
53
+ if (type === "tool") {
54
+ baseMsg.role = "tool";
55
+ baseMsg.tool_call_id = (m as { tool_call_id?: string }).tool_call_id;
56
+ }
57
+
58
+ // Handle AI messages with tool calls
59
+ if (type === "ai" && m.additional_kwargs?.tool_calls) {
60
+ baseMsg.tool_calls = m.additional_kwargs.tool_calls as OpenAIToolCall[];
61
+ }
62
+
63
+ return baseMsg;
64
+ });
65
+
66
+ const requestBody: Record<string, unknown> = {
67
+ model: this.model,
68
+ messages: formattedMessages,
69
+ };
70
+
71
+ // Use function calling for structured output (most reliable approach)
72
+ if (this.structuredSchema && (!tools || tools.length === 0)) {
73
+ const jsonSchema = zodToJsonSchema(this.structuredSchema as any);
74
+ requestBody.tools = [
75
+ {
76
+ type: "function",
77
+ function: {
78
+ name: "structured_response",
79
+ description: "Return structured data matching the schema",
80
+ parameters: jsonSchema,
81
+ },
82
+ },
83
+ ];
84
+ requestBody.tool_choice = {
85
+ type: "function",
86
+ function: { name: "structured_response" },
87
+ };
88
+ } else if (tools && tools.length > 0) {
89
+ // Add tools if provided (regular tool use, not structured output)
90
+ requestBody.tools = tools.map((t) => ({
91
+ type: "function",
92
+ function: {
93
+ name: t.name,
94
+ description: t.description,
95
+ parameters: t.parameters,
96
+ },
97
+ }));
98
+ }
99
+
100
+ const response = await fetch("https://api.openai.com/v1/chat/completions", {
101
+ method: "POST",
102
+ headers: {
103
+ "Content-Type": "application/json",
104
+ Authorization: `Bearer ${this.apiKey}`,
105
+ },
106
+ body: JSON.stringify(requestBody),
107
+ });
108
+
109
+ if (!response.ok) {
110
+ const errorBody = await response.text();
111
+ throw new Error(`OpenAI API error: ${response.statusText} - ${errorBody}`);
112
+ }
113
+
114
+ const data = (await response.json()) as OpenAIResponse;
115
+ const choice = data.choices?.[0];
116
+ const message = choice?.message;
117
+
118
+ // Handle structured output via function calling
119
+ if (this.structuredSchema && message?.tool_calls?.[0]) {
120
+ // Return the function arguments as content (already a JSON string)
121
+ return {
122
+ content: message.tool_calls[0].function.arguments,
123
+ toolCalls: undefined,
124
+ };
125
+ }
126
+
127
+ // Parse tool calls if present (regular tool use)
128
+ let toolCalls: ToolCall[] | undefined;
129
+ if (message?.tool_calls && message.tool_calls.length > 0) {
130
+ toolCalls = message.tool_calls.map((tc) => ({
131
+ id: tc.id,
132
+ name: tc.function.name,
133
+ arguments: JSON.parse(tc.function.arguments),
134
+ }));
135
+ }
136
+
137
+ return {
138
+ content: message?.content ?? "",
139
+ toolCalls,
140
+ };
141
+ }
142
+
143
+ withStructuredOutput<T>(schema: unknown): ILanguageModel {
144
+ const adapter = new OpenAIAdapter(this.model, this.apiKey);
145
+ adapter.structuredSchema = schema;
146
+ return adapter;
147
+ }
148
+ }
@@ -0,0 +1,19 @@
1
+ import type { ICheckpointer } from "../../domain";
2
+ import type { GraphStateType } from "../../domain/state/AgentState";
3
+
4
+ export class InMemoryCheckpointer implements ICheckpointer {
5
+ private store: Map<string, GraphStateType> = new Map();
6
+
7
+ async save(sessionId: string, state: GraphStateType): Promise<void> {
8
+ this.store.set(sessionId, structuredClone(state));
9
+ }
10
+
11
+ async load(sessionId: string): Promise<GraphStateType | null> {
12
+ const state = this.store.get(sessionId);
13
+ return state ? structuredClone(state) : null;
14
+ }
15
+
16
+ async list(): Promise<string[]> {
17
+ return Array.from(this.store.keys());
18
+ }
19
+ }
@@ -0,0 +1,2 @@
1
+ // Re-export factory for convenience
2
+ export { createBackend, createRepositories, createRepositoriesFromEnv } from "../database";
@@ -0,0 +1,96 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
2
+ import { join } from "path";
3
+ import { homedir } from "os";
4
+ import { z } from "zod";
5
+
6
+ const SETTINGS_DIR = join(homedir(), ".log");
7
+ const SETTINGS_PATH = join(SETTINGS_DIR, "settings.json");
8
+
9
+ const UserSettingsSchema = z.object({
10
+ defaultProvider: z.enum(["openai", "gemini", "ollama"]).optional(),
11
+ defaultModel: z.string().optional(),
12
+ });
13
+
14
+ export type UserSettings = z.infer<typeof UserSettingsSchema>;
15
+
16
+ /**
17
+ * Get the path to the settings file.
18
+ */
19
+ export function getSettingsPath(): string {
20
+ return SETTINGS_PATH;
21
+ }
22
+
23
+ /**
24
+ * Load user settings from disk.
25
+ * Returns empty object if file doesn't exist.
26
+ */
27
+ export function getSettings(): UserSettings {
28
+ if (!existsSync(SETTINGS_PATH)) {
29
+ return {};
30
+ }
31
+
32
+ try {
33
+ const content = readFileSync(SETTINGS_PATH, "utf-8");
34
+ const parsed = JSON.parse(content);
35
+ return UserSettingsSchema.parse(parsed);
36
+ } catch {
37
+ // If file is corrupted or invalid, return empty settings
38
+ return {};
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Save user settings to disk.
44
+ * Merges with existing settings.
45
+ */
46
+ export function saveSettings(updates: Partial<UserSettings>): void {
47
+ // Ensure directory exists
48
+ if (!existsSync(SETTINGS_DIR)) {
49
+ mkdirSync(SETTINGS_DIR, { recursive: true });
50
+ }
51
+
52
+ const current = getSettings();
53
+ const merged: UserSettings = { ...current, ...updates };
54
+
55
+ // Remove undefined values
56
+ const cleaned = Object.fromEntries(Object.entries(merged).filter(([, v]) => v !== undefined));
57
+
58
+ writeFileSync(SETTINGS_PATH, JSON.stringify(cleaned, null, 2) + "\n");
59
+ }
60
+
61
+ /**
62
+ * Get the effective provider, checking settings then env then default.
63
+ */
64
+ export function getEffectiveProvider(cliOption?: string): "openai" | "gemini" | "ollama" {
65
+ if (cliOption && ["openai", "gemini", "ollama"].includes(cliOption)) {
66
+ return cliOption as "openai" | "gemini" | "ollama";
67
+ }
68
+
69
+ const settings = getSettings();
70
+ if (settings.defaultProvider) {
71
+ return settings.defaultProvider;
72
+ }
73
+
74
+ const envProvider = process.env.DEFAULT_PROVIDER;
75
+ if (envProvider && ["openai", "gemini", "ollama"].includes(envProvider)) {
76
+ return envProvider as "openai" | "gemini" | "ollama";
77
+ }
78
+
79
+ return "ollama";
80
+ }
81
+
82
+ /**
83
+ * Get the effective model, checking settings then env.
84
+ */
85
+ export function getEffectiveModel(cliOption?: string): string | undefined {
86
+ if (cliOption) {
87
+ return cliOption;
88
+ }
89
+
90
+ const settings = getSettings();
91
+ if (settings.defaultModel) {
92
+ return settings.defaultModel;
93
+ }
94
+
95
+ return process.env.DEFAULT_MODEL;
96
+ }
@@ -0,0 +1,120 @@
1
+ import chalk from "chalk";
2
+ import type { Event } from "../../domain/entities/Event";
3
+
4
+ function formatTime(date: Date): string {
5
+ return date.toLocaleTimeString("en-US", {
6
+ hour: "2-digit",
7
+ minute: "2-digit",
8
+ hour12: true,
9
+ });
10
+ }
11
+
12
+ function formatDate(date: Date): string {
13
+ return date.toLocaleDateString("en-US", {
14
+ weekday: "long",
15
+ year: "numeric",
16
+ month: "long",
17
+ day: "numeric",
18
+ });
19
+ }
20
+
21
+ function formatEventLine(event: Event): string {
22
+ const time = formatTime(event.datetime);
23
+ const endTime = event.endTime ? ` - ${formatTime(event.endTime)}` : "";
24
+ const tags = event.tags.length > 0 ? ` ${chalk.dim(`[${event.tags.join(", ")}]`)}` : "";
25
+
26
+ return ` ${chalk.cyan(time)}${chalk.dim(endTime)} ${chalk.white(event.title)}${tags}`;
27
+ }
28
+
29
+ export function formatTodayEvents(events: Event[]): string {
30
+ const lines: string[] = [];
31
+ const today = new Date();
32
+
33
+ lines.push("");
34
+ lines.push(chalk.bold.blue(` Today: ${formatDate(today)}`));
35
+ lines.push(chalk.dim(` ${"─".repeat(40)}`));
36
+
37
+ if (events.length === 0) {
38
+ lines.push(chalk.dim(" No events scheduled for today"));
39
+ } else {
40
+ const sortedEvents = [...events].sort((a, b) => a.datetime.getTime() - b.datetime.getTime());
41
+
42
+ for (const event of sortedEvents) {
43
+ lines.push(formatEventLine(event));
44
+ if (event.notes) {
45
+ lines.push(chalk.dim(` ${event.notes}`));
46
+ }
47
+ }
48
+ }
49
+
50
+ lines.push("");
51
+ return lines.join("\n");
52
+ }
53
+
54
+ export function formatEventsForRange(events: Event[], startDate: Date, endDate: Date): string {
55
+ const lines: string[] = [];
56
+
57
+ lines.push("");
58
+ lines.push(chalk.bold.blue(` Events: ${formatDate(startDate)} - ${formatDate(endDate)}`));
59
+ lines.push(chalk.dim(` ${"─".repeat(40)}`));
60
+
61
+ if (events.length === 0) {
62
+ lines.push(chalk.dim(" No events in this range"));
63
+ } else {
64
+ const sortedEvents = [...events].sort((a, b) => a.datetime.getTime() - b.datetime.getTime());
65
+
66
+ let currentDateStr = "";
67
+
68
+ for (const event of sortedEvents) {
69
+ const eventDateStr = event.datetime.toDateString();
70
+
71
+ if (eventDateStr !== currentDateStr) {
72
+ currentDateStr = eventDateStr;
73
+ lines.push("");
74
+ lines.push(chalk.yellow(` ${formatDate(event.datetime)}`));
75
+ }
76
+
77
+ lines.push(formatEventLine(event));
78
+ if (event.notes) {
79
+ lines.push(chalk.dim(` ${event.notes}`));
80
+ }
81
+ }
82
+ }
83
+
84
+ lines.push("");
85
+ return lines.join("\n");
86
+ }
87
+
88
+ export function formatUpcomingEvents(events: Event[], days: number): string {
89
+ const lines: string[] = [];
90
+
91
+ lines.push("");
92
+ lines.push(chalk.bold.blue(` Upcoming ${days} days`));
93
+ lines.push(chalk.dim(` ${"─".repeat(40)}`));
94
+
95
+ if (events.length === 0) {
96
+ lines.push(chalk.dim(" No upcoming events"));
97
+ } else {
98
+ const sortedEvents = [...events].sort((a, b) => a.datetime.getTime() - b.datetime.getTime());
99
+
100
+ let currentDateStr = "";
101
+
102
+ for (const event of sortedEvents) {
103
+ const eventDateStr = event.datetime.toDateString();
104
+
105
+ if (eventDateStr !== currentDateStr) {
106
+ currentDateStr = eventDateStr;
107
+ lines.push("");
108
+ lines.push(chalk.yellow(` ${formatDate(event.datetime)}`));
109
+ }
110
+
111
+ lines.push(formatEventLine(event));
112
+ if (event.notes) {
113
+ lines.push(chalk.dim(` ${event.notes}`));
114
+ }
115
+ }
116
+ }
117
+
118
+ lines.push("");
119
+ return lines.join("\n");
120
+ }
@@ -0,0 +1,185 @@
1
+ import * as readline from "node:readline/promises";
2
+ import { stdin as input, stdout as output } from "node:process";
3
+ import { HumanMessage } from "@langchain/core/messages";
4
+ import { randomUUID } from "crypto";
5
+ import { MemorySaver } from "@langchain/langgraph";
6
+
7
+ import type { LLMProvider } from "./commands";
8
+ import { buildGraph, type GraphConfig } from "../../application/graph/builder";
9
+ import { createToolRegistry } from "../../application/tools";
10
+ import { EventService } from "../../application/services";
11
+ import type { RepositoryBundle } from "../../infrastructure/database/factory";
12
+ import { createLLM } from "../../infrastructure/llm";
13
+ import { printer } from "./printer";
14
+ import { formatTodayEvents } from "./calendar";
15
+ import type { GraphStateType } from "../../domain/state/AgentState";
16
+
17
+ const EXIT_COMMANDS = new Set(["exit", "quit", "/exit", "/quit", "/q"]);
18
+ const CLEAR_COMMANDS = new Set(["clear", "/clear", "/c"]);
19
+ const HELP_COMMANDS = new Set(["help", "/help", "/h", "?"]);
20
+ const CALENDAR_COMMANDS = new Set(["/today", "/cal", "/calendar"]);
21
+
22
+ function printHelp(): void {
23
+ printer.blank();
24
+ printer.info("Available commands:");
25
+ printer.system(" /help, /h, ? - Show this help message");
26
+ printer.system(" /today, /cal - Show today's events");
27
+ printer.system(" /clear, /c - Clear conversation history");
28
+ printer.system(" /exit, /quit, /q - Exit the chat");
29
+ printer.blank();
30
+ }
31
+
32
+ export interface ChatLoopOptions {
33
+ provider: LLMProvider;
34
+ threadId?: string;
35
+ model?: string;
36
+ repositories: RepositoryBundle;
37
+ }
38
+
39
+ export async function startChat(options: ChatLoopOptions): Promise<void> {
40
+ const { provider, model, repositories } = options;
41
+ const threadId = options.threadId || randomUUID();
42
+
43
+ const llm = createLLM(provider, model);
44
+ const tools = createToolRegistry({
45
+ eventsRepository: repositories.events,
46
+ diaryRepository: repositories.diary,
47
+ });
48
+ const eventService = new EventService(repositories.events);
49
+
50
+ // Use MemorySaver for in-session state persistence
51
+ // This enables conversation history across turns within a session
52
+ const checkpointer = new MemorySaver();
53
+
54
+ const graphConfig: GraphConfig = {
55
+ llm,
56
+ tools,
57
+ eventService,
58
+ memoriesRepository: repositories.memories,
59
+ checkpointer,
60
+ };
61
+
62
+ const graph = buildGraph(graphConfig);
63
+
64
+ // Track conversation state for persistence to DB
65
+ let lastState: GraphStateType | null = null;
66
+
67
+ const rl = readline.createInterface({ input, output, terminal: true });
68
+
69
+ // Handle SIGINT (Ctrl+C) to save checkpoint before exit
70
+ process.on("SIGINT", async () => {
71
+ printer.blank();
72
+ printer.warn("Session interrupted, saving state...");
73
+ if (lastState) {
74
+ try {
75
+ await repositories.checkpointer.save(threadId, lastState);
76
+ printer.system(`Session saved: ${threadId}`);
77
+ } catch (error) {
78
+ printer.error(
79
+ `Failed to save session: ${error instanceof Error ? error.message : String(error)}`
80
+ );
81
+ }
82
+ }
83
+ rl.close();
84
+ process.exit(0);
85
+ });
86
+
87
+ printer.blank();
88
+ printer.divider();
89
+ printer.success("Interactive Chat Session");
90
+ printer.system(`Thread: ${threadId}`);
91
+ printer.system(`Provider: ${provider}${model ? ` (${model})` : ""}`);
92
+ printer.divider();
93
+
94
+ // Show today's events on start
95
+ const spinner = printer.spinner("Loading today's events...");
96
+ spinner.start();
97
+ try {
98
+ const todayEvents = await eventService.getToday();
99
+ spinner.stop();
100
+ console.log(formatTodayEvents(todayEvents));
101
+ } catch (_error) {
102
+ spinner.stop();
103
+ printer.warn("Could not load today's events");
104
+ }
105
+
106
+ printer.info("Type /help for commands, /exit to quit");
107
+ printer.blank();
108
+
109
+ try {
110
+ while (true) {
111
+ const userInput = await rl.question("You: ");
112
+ const trimmedInput = userInput.trim();
113
+
114
+ if (!trimmedInput) {
115
+ continue;
116
+ }
117
+
118
+ if (EXIT_COMMANDS.has(trimmedInput.toLowerCase())) {
119
+ printer.blank();
120
+ printer.success("Goodbye!");
121
+ break;
122
+ }
123
+
124
+ if (CLEAR_COMMANDS.has(trimmedInput.toLowerCase())) {
125
+ printer.success("Conversation cleared");
126
+ continue;
127
+ }
128
+
129
+ if (HELP_COMMANDS.has(trimmedInput.toLowerCase())) {
130
+ printHelp();
131
+ continue;
132
+ }
133
+
134
+ if (CALENDAR_COMMANDS.has(trimmedInput.toLowerCase())) {
135
+ const calSpinner = printer.spinner("Loading today's events...");
136
+ calSpinner.start();
137
+ try {
138
+ const todayEvents = await eventService.getToday();
139
+ calSpinner.stop();
140
+ console.log(formatTodayEvents(todayEvents));
141
+ } catch (_error) {
142
+ calSpinner.stop();
143
+ printer.error("Could not load today's events");
144
+ }
145
+ continue;
146
+ }
147
+
148
+ const thinking = printer.spinner("Thinking...");
149
+ thinking.start();
150
+
151
+ try {
152
+ // Invoke with thread_id for checkpointer to track state
153
+ const result = (await graph.invoke(
154
+ { messages: [new HumanMessage(trimmedInput)] },
155
+ { configurable: { thread_id: threadId } }
156
+ )) as GraphStateType;
157
+ thinking.stop();
158
+
159
+ lastState = result;
160
+ const assistantContent = result.response || "(No response)";
161
+
162
+ printer.blank();
163
+ printer.assistant(assistantContent);
164
+ printer.blank();
165
+ } catch (error) {
166
+ thinking.stop();
167
+ const errorMessage = error instanceof Error ? error.message : String(error);
168
+ printer.error(`Failed to get response: ${errorMessage}`);
169
+ }
170
+ }
171
+ } finally {
172
+ // Save final state to database checkpointer for cross-session persistence
173
+ if (lastState) {
174
+ try {
175
+ await repositories.checkpointer.save(threadId, lastState);
176
+ printer.system(`Session saved: ${threadId}`);
177
+ } catch (error) {
178
+ printer.error(
179
+ `Failed to save session: ${error instanceof Error ? error.message : String(error)}`
180
+ );
181
+ }
182
+ }
183
+ rl.close();
184
+ }
185
+ }