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.
- package/README.md +58 -0
- package/dist/index.js +84883 -0
- package/package.json +73 -0
- package/src/__tests__/e2e/EventService.test.ts +211 -0
- package/src/__tests__/unit/Event.test.ts +89 -0
- package/src/__tests__/unit/Memory.test.ts +130 -0
- package/src/application/graph/builder.ts +106 -0
- package/src/application/graph/edges.ts +37 -0
- package/src/application/graph/nodes/addEvent.ts +113 -0
- package/src/application/graph/nodes/chat.ts +128 -0
- package/src/application/graph/nodes/extractMemory.ts +135 -0
- package/src/application/graph/nodes/index.ts +8 -0
- package/src/application/graph/nodes/query.ts +194 -0
- package/src/application/graph/nodes/respond.ts +26 -0
- package/src/application/graph/nodes/router.ts +82 -0
- package/src/application/graph/nodes/toolExecutor.ts +79 -0
- package/src/application/graph/nodes/types.ts +2 -0
- package/src/application/index.ts +4 -0
- package/src/application/services/DiaryService.ts +188 -0
- package/src/application/services/EventService.ts +61 -0
- package/src/application/services/index.ts +2 -0
- package/src/application/tools/calendarTool.ts +179 -0
- package/src/application/tools/diaryTool.ts +182 -0
- package/src/application/tools/index.ts +68 -0
- package/src/config/env.ts +33 -0
- package/src/config/index.ts +1 -0
- package/src/domain/entities/DiaryEntry.ts +16 -0
- package/src/domain/entities/Event.ts +13 -0
- package/src/domain/entities/Memory.ts +20 -0
- package/src/domain/index.ts +5 -0
- package/src/domain/interfaces/IDiaryRepository.ts +21 -0
- package/src/domain/interfaces/IEventsRepository.ts +12 -0
- package/src/domain/interfaces/ILanguageModel.ts +23 -0
- package/src/domain/interfaces/IMemoriesRepository.ts +15 -0
- package/src/domain/interfaces/IMemory.ts +19 -0
- package/src/domain/interfaces/index.ts +4 -0
- package/src/domain/state/AgentState.ts +30 -0
- package/src/index.ts +5 -0
- package/src/infrastructure/database/factory.ts +52 -0
- package/src/infrastructure/database/index.ts +21 -0
- package/src/infrastructure/database/sqlite-checkpointer.ts +179 -0
- package/src/infrastructure/database/sqlite-client.ts +69 -0
- package/src/infrastructure/database/sqlite-diary-repository.ts +209 -0
- package/src/infrastructure/database/sqlite-events-repository.ts +167 -0
- package/src/infrastructure/database/sqlite-memories-repository.ts +284 -0
- package/src/infrastructure/database/sqlite-schema.ts +98 -0
- package/src/infrastructure/index.ts +3 -0
- package/src/infrastructure/llm/base.ts +14 -0
- package/src/infrastructure/llm/gemini.ts +139 -0
- package/src/infrastructure/llm/index.ts +22 -0
- package/src/infrastructure/llm/ollama.ts +126 -0
- package/src/infrastructure/llm/openai.ts +148 -0
- package/src/infrastructure/memory/checkpointer.ts +19 -0
- package/src/infrastructure/memory/index.ts +2 -0
- package/src/infrastructure/settings/index.ts +96 -0
- package/src/interface/cli/calendar.ts +120 -0
- package/src/interface/cli/chat.ts +185 -0
- package/src/interface/cli/commands.ts +337 -0
- package/src/interface/cli/printer.ts +65 -0
- package/src/interface/index.ts +1 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { AIMessage, HumanMessage, SystemMessage } from "@langchain/core/messages";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { GraphStateType } from "../../../domain/state/AgentState";
|
|
4
|
+
import type { Event } from "../../../domain/entities/Event";
|
|
5
|
+
import type { ILanguageModel } from "../../../domain/interfaces/ILanguageModel";
|
|
6
|
+
import type { EventService } from "../../services/EventService";
|
|
7
|
+
|
|
8
|
+
const ParsedEventSchema = z.object({
|
|
9
|
+
title: z.string().min(1).describe("The event title/name"),
|
|
10
|
+
datetime: z.string().describe("ISO 8601 datetime string for when the event starts"),
|
|
11
|
+
endTime: z.string().optional().describe("ISO 8601 datetime string for when the event ends"),
|
|
12
|
+
notes: z.string().optional().describe("Additional notes or description"),
|
|
13
|
+
tags: z.array(z.string()).optional().describe("Tags or categories for the event"),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
type ParsedEvent = z.infer<typeof ParsedEventSchema>;
|
|
17
|
+
|
|
18
|
+
const EVENT_PARSER_SYSTEM_PROMPT = `You are an event parser for a calendar assistant. Extract event details from the user's message.
|
|
19
|
+
|
|
20
|
+
Current date and time context will be provided. Use this to interpret relative dates like "tomorrow", "next week", "in 2 hours", etc.
|
|
21
|
+
|
|
22
|
+
You must respond with a JSON object containing:
|
|
23
|
+
- title: A clear, concise name for the event
|
|
24
|
+
- datetime: ISO 8601 format (e.g., "2024-01-15T14:30:00")
|
|
25
|
+
- endTime: (optional) ISO 8601 format for event end
|
|
26
|
+
- notes: (optional) Any additional details mentioned
|
|
27
|
+
- tags: (optional) Relevant categories like ["work", "meeting"] or ["personal", "health"]
|
|
28
|
+
|
|
29
|
+
If the user doesn't specify a time, default to 9:00 AM for morning events, 12:00 PM for afternoon events, or use reasonable defaults.`;
|
|
30
|
+
|
|
31
|
+
export interface AddEventNodeDeps {
|
|
32
|
+
llm: ILanguageModel;
|
|
33
|
+
eventService: EventService;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function addEventNode(
|
|
37
|
+
state: GraphStateType,
|
|
38
|
+
deps: AddEventNodeDeps
|
|
39
|
+
): Promise<Partial<GraphStateType>> {
|
|
40
|
+
const lastMessage = state.messages[state.messages.length - 1];
|
|
41
|
+
|
|
42
|
+
if (!lastMessage || !(lastMessage instanceof HumanMessage)) {
|
|
43
|
+
return {
|
|
44
|
+
response: "I couldn't understand the event request. Please try again.",
|
|
45
|
+
pendingEvent: null,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const userMessage =
|
|
50
|
+
typeof lastMessage.content === "string"
|
|
51
|
+
? lastMessage.content
|
|
52
|
+
: JSON.stringify(lastMessage.content);
|
|
53
|
+
|
|
54
|
+
const now = new Date();
|
|
55
|
+
const contextMessage = `Current date/time: ${now.toISOString()} (${now.toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" })})
|
|
56
|
+
|
|
57
|
+
User request: ${userMessage}`;
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const structuredLlm = deps.llm.withStructuredOutput<ParsedEvent>(ParsedEventSchema);
|
|
61
|
+
|
|
62
|
+
const response = await structuredLlm.generate([
|
|
63
|
+
new SystemMessage(EVENT_PARSER_SYSTEM_PROMPT),
|
|
64
|
+
new HumanMessage(contextMessage),
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
const parsed = ParsedEventSchema.safeParse(JSON.parse(response.content));
|
|
68
|
+
|
|
69
|
+
if (!parsed.success) {
|
|
70
|
+
return {
|
|
71
|
+
response: `I had trouble parsing the event details: ${parsed.error.message}. Could you please rephrase?`,
|
|
72
|
+
pendingEvent: null,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const eventData = parsed.data;
|
|
77
|
+
|
|
78
|
+
console.log("[AddEvent] Saving event:", eventData);
|
|
79
|
+
const newEvent = await deps.eventService.add({
|
|
80
|
+
title: eventData.title,
|
|
81
|
+
datetime: new Date(eventData.datetime),
|
|
82
|
+
endTime: eventData.endTime ? new Date(eventData.endTime) : undefined,
|
|
83
|
+
notes: eventData.notes,
|
|
84
|
+
tags: eventData.tags ?? [],
|
|
85
|
+
});
|
|
86
|
+
console.log("[AddEvent] Event saved:", newEvent);
|
|
87
|
+
|
|
88
|
+
const formattedDate = newEvent.datetime.toLocaleDateString("en-US", {
|
|
89
|
+
weekday: "long",
|
|
90
|
+
year: "numeric",
|
|
91
|
+
month: "long",
|
|
92
|
+
day: "numeric",
|
|
93
|
+
});
|
|
94
|
+
const formattedTime = newEvent.datetime.toLocaleTimeString("en-US", {
|
|
95
|
+
hour: "numeric",
|
|
96
|
+
minute: "2-digit",
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const responseMessage = `I've added "${newEvent.title}" to your calendar for ${formattedDate} at ${formattedTime}.`;
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
pendingEvent: newEvent,
|
|
103
|
+
response: responseMessage,
|
|
104
|
+
messages: [new AIMessage(responseMessage)],
|
|
105
|
+
};
|
|
106
|
+
} catch (error) {
|
|
107
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
108
|
+
return {
|
|
109
|
+
response: `Failed to add the event: ${errorMessage}. Please try again.`,
|
|
110
|
+
pendingEvent: null,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HumanMessage,
|
|
3
|
+
AIMessage,
|
|
4
|
+
SystemMessage,
|
|
5
|
+
ToolMessage,
|
|
6
|
+
BaseMessage,
|
|
7
|
+
} from "@langchain/core/messages";
|
|
8
|
+
import type { GraphStateType } from "../../../domain/state/AgentState";
|
|
9
|
+
import type { ILanguageModel } from "../../../domain/interfaces/ILanguageModel";
|
|
10
|
+
import type { Memory } from "../../../domain/entities/Memory";
|
|
11
|
+
import type { ToolRegistry } from "./types";
|
|
12
|
+
import { calendarToolDefinitions } from "../../tools/calendarTool";
|
|
13
|
+
import { diaryToolDefinitions } from "../../tools/diaryTool";
|
|
14
|
+
|
|
15
|
+
const CHAT_SYSTEM_PROMPT = `You are a helpful, friendly assistant integrated with a calendar/scheduling system. You approach conversations with warmth and emotional awareness.
|
|
16
|
+
|
|
17
|
+
**Therapeutic Communication Style:**
|
|
18
|
+
- Listen actively and acknowledge emotions when the user expresses them
|
|
19
|
+
- Offer gentle encouragement and validation when appropriate
|
|
20
|
+
- If the user seems stressed or overwhelmed, offer brief supportive statements
|
|
21
|
+
- Maintain a calm, reassuring tone without being overly clinical
|
|
22
|
+
- Respect boundaries - don't push for emotional disclosure
|
|
23
|
+
|
|
24
|
+
When relevant memories about the user are provided, use them to personalize your responses and maintain continuity in the conversation.
|
|
25
|
+
|
|
26
|
+
You have access to calendar tools to add events and query the calendar, as well as diary tools to review past journal entries and mood trends.
|
|
27
|
+
|
|
28
|
+
Be concise but warm in your responses. If you notice emotional patterns (from diary entries or current conversation), you may gently acknowledge them when appropriate.`;
|
|
29
|
+
|
|
30
|
+
const allToolDefinitions = [...calendarToolDefinitions, ...diaryToolDefinitions];
|
|
31
|
+
|
|
32
|
+
export interface ChatNodeDeps {
|
|
33
|
+
llm: ILanguageModel;
|
|
34
|
+
tools?: ToolRegistry;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function formatMemoriesContext(memories: Memory[]): string {
|
|
38
|
+
if (memories.length === 0) {
|
|
39
|
+
return "";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const memoryLines = memories.map((m) => `- [${m.type}] ${m.content}`);
|
|
43
|
+
return `\nRelevant context about the user:\n${memoryLines.join("\n")}\n`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function formatTodayEventsContext(events: { title: string; datetime: Date }[]): string {
|
|
47
|
+
if (events.length === 0) {
|
|
48
|
+
return "";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const eventLines = events.map((e) => {
|
|
52
|
+
const time = e.datetime.toLocaleTimeString("en-US", {
|
|
53
|
+
hour: "numeric",
|
|
54
|
+
minute: "2-digit",
|
|
55
|
+
});
|
|
56
|
+
return `- ${e.title} at ${time}`;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return `\nUser's events today:\n${eventLines.join("\n")}\n`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function chatNode(
|
|
63
|
+
state: GraphStateType,
|
|
64
|
+
deps: ChatNodeDeps
|
|
65
|
+
): Promise<Partial<GraphStateType>> {
|
|
66
|
+
const lastMessage = state.messages[state.messages.length - 1];
|
|
67
|
+
|
|
68
|
+
// If last message is a ToolMessage, we need to continue the conversation
|
|
69
|
+
const isToolResponse = lastMessage instanceof ToolMessage;
|
|
70
|
+
|
|
71
|
+
if (!lastMessage || (!isToolResponse && !(lastMessage instanceof HumanMessage))) {
|
|
72
|
+
return {
|
|
73
|
+
response: "I'm here to help! How can I assist you today?",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const memoriesContext = formatMemoriesContext(state.relevantMemories);
|
|
78
|
+
const todayContext = formatTodayEventsContext(state.todayEvents);
|
|
79
|
+
const additionalContext = memoriesContext + todayContext;
|
|
80
|
+
|
|
81
|
+
const systemPrompt = additionalContext
|
|
82
|
+
? `${CHAT_SYSTEM_PROMPT}\n${additionalContext}`
|
|
83
|
+
: CHAT_SYSTEM_PROMPT;
|
|
84
|
+
|
|
85
|
+
// Build messages array with system prompt at the start
|
|
86
|
+
const lcMessages: BaseMessage[] = [new SystemMessage(systemPrompt), ...state.messages];
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
// Pass tool definitions if tools are available
|
|
90
|
+
const toolDefs = deps.tools && deps.tools.size > 0 ? allToolDefinitions : undefined;
|
|
91
|
+
const response = await deps.llm.generate(lcMessages, toolDefs);
|
|
92
|
+
|
|
93
|
+
// Check if LLM returned tool calls
|
|
94
|
+
if (response.toolCalls && response.toolCalls.length > 0) {
|
|
95
|
+
// Convert to OpenAI tool call format for additional_kwargs
|
|
96
|
+
const formattedToolCalls = response.toolCalls.map((tc) => ({
|
|
97
|
+
id: tc.id,
|
|
98
|
+
type: "function" as const,
|
|
99
|
+
function: {
|
|
100
|
+
name: tc.name,
|
|
101
|
+
arguments: JSON.stringify(tc.arguments),
|
|
102
|
+
},
|
|
103
|
+
}));
|
|
104
|
+
|
|
105
|
+
// Return AI message with tool calls attached
|
|
106
|
+
const aiMessage = new AIMessage({
|
|
107
|
+
content: response.content || "",
|
|
108
|
+
additional_kwargs: {
|
|
109
|
+
tool_calls: formattedToolCalls,
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
messages: [aiMessage],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
response: response.content,
|
|
120
|
+
messages: [new AIMessage(response.content)],
|
|
121
|
+
};
|
|
122
|
+
} catch (error) {
|
|
123
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
124
|
+
return {
|
|
125
|
+
response: `I encountered an issue: ${errorMessage}. Let me try again.`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { HumanMessage, AIMessage, SystemMessage } from "@langchain/core/messages";
|
|
3
|
+
import { randomUUID } from "crypto";
|
|
4
|
+
import type { GraphStateType } from "../../../domain/state/AgentState";
|
|
5
|
+
import type { Memory } from "../../../domain/entities/Memory";
|
|
6
|
+
import type { ILanguageModel } from "../../../domain/interfaces/ILanguageModel";
|
|
7
|
+
import type {
|
|
8
|
+
IMemoriesRepository,
|
|
9
|
+
EmbeddingFunction,
|
|
10
|
+
} from "../../../domain/interfaces/IMemoriesRepository";
|
|
11
|
+
|
|
12
|
+
const ExtractedMemorySchema = z.object({
|
|
13
|
+
memories: z
|
|
14
|
+
.array(
|
|
15
|
+
z.object({
|
|
16
|
+
type: z
|
|
17
|
+
.enum([
|
|
18
|
+
"fact",
|
|
19
|
+
"preference",
|
|
20
|
+
"routine",
|
|
21
|
+
"relationship",
|
|
22
|
+
"communication_style",
|
|
23
|
+
"interest",
|
|
24
|
+
])
|
|
25
|
+
.describe(
|
|
26
|
+
"The type of memory: 'fact' for identity info, 'preference' for likes/dislikes, 'routine' for regular patterns, 'relationship' for people in their life, 'communication_style' for how they prefer to interact, 'interest' for topics they care about"
|
|
27
|
+
),
|
|
28
|
+
content: z.string().describe("The memory content - a concise statement of the information"),
|
|
29
|
+
importance: z
|
|
30
|
+
.number()
|
|
31
|
+
.min(1)
|
|
32
|
+
.max(10)
|
|
33
|
+
.describe("Importance score: 1-3 trivial, 4-6 useful, 7-9 important, 10 critical"),
|
|
34
|
+
})
|
|
35
|
+
)
|
|
36
|
+
.describe("List of extracted memories from the conversation"),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
type ExtractedMemories = z.infer<typeof ExtractedMemorySchema>;
|
|
40
|
+
|
|
41
|
+
const MEMORY_EXTRACTOR_SYSTEM_PROMPT = `Extract ONLY persistent facts about the user that will remain true over time.
|
|
42
|
+
|
|
43
|
+
Memory types:
|
|
44
|
+
- "fact": Identity facts ("I'm a college student", "I work at XYZ", "My name is...")
|
|
45
|
+
- "preference": Likes/dislikes ("I prefer morning meetings", "I hate spicy food")
|
|
46
|
+
- "routine": Regular patterns ("I exercise on Mondays", "I usually wake at 7am")
|
|
47
|
+
- "relationship": People in their life ("My friend Sarah is a designer", "My sister lives in NYC")
|
|
48
|
+
- "communication_style": How they prefer to interact ("prefers concise responses", "likes detailed explanations")
|
|
49
|
+
- "interest": Topics they care about ("frequently discusses photography", "interested in AI")
|
|
50
|
+
|
|
51
|
+
DO NOT extract:
|
|
52
|
+
- Specific events: "Meeting at 2 this weekend" (goes to calendar only)
|
|
53
|
+
- Time-bound info: "Traveling next week" (ephemeral)
|
|
54
|
+
- One-time tasks: "Need to call mom tomorrow" (ephemeral)
|
|
55
|
+
- Anything already captured as a calendar event
|
|
56
|
+
|
|
57
|
+
Guidelines:
|
|
58
|
+
- Only output memories that will still be relevant weeks/months from now
|
|
59
|
+
- Be concise - each memory should be a single clear statement
|
|
60
|
+
- Rate importance based on how core this is to the user's identity/preferences
|
|
61
|
+
|
|
62
|
+
You must respond with a JSON object containing a "memories" array. If no memories are worth extracting, return {"memories": []}.`;
|
|
63
|
+
|
|
64
|
+
export interface ExtractMemoryNodeDeps {
|
|
65
|
+
llm: ILanguageModel;
|
|
66
|
+
memoriesRepository: IMemoriesRepository;
|
|
67
|
+
embeddingFn?: EmbeddingFunction;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function extractMemoryNode(
|
|
71
|
+
state: GraphStateType,
|
|
72
|
+
deps: ExtractMemoryNodeDeps
|
|
73
|
+
): Promise<Partial<GraphStateType>> {
|
|
74
|
+
const recentMessages = state.messages.slice(-6);
|
|
75
|
+
|
|
76
|
+
if (recentMessages.length < 2) {
|
|
77
|
+
return {};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const conversationText = recentMessages
|
|
81
|
+
.map((msg) => {
|
|
82
|
+
const role =
|
|
83
|
+
msg instanceof HumanMessage ? "User" : msg instanceof AIMessage ? "Assistant" : "System";
|
|
84
|
+
return `${role}: ${String(msg.content)}`;
|
|
85
|
+
})
|
|
86
|
+
.join("\n");
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const structuredLlm = deps.llm.withStructuredOutput<ExtractedMemories>(ExtractedMemorySchema);
|
|
90
|
+
|
|
91
|
+
const response = await structuredLlm.generate([
|
|
92
|
+
new SystemMessage(MEMORY_EXTRACTOR_SYSTEM_PROMPT),
|
|
93
|
+
new HumanMessage(`Extract memories from this conversation:\n\n${conversationText}`),
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
const parsed = ExtractedMemorySchema.safeParse(JSON.parse(response.content));
|
|
97
|
+
|
|
98
|
+
if (!parsed.success || parsed.data.memories.length === 0) {
|
|
99
|
+
return {};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const now = new Date();
|
|
103
|
+
const savedMemories: Memory[] = [];
|
|
104
|
+
|
|
105
|
+
for (const extractedMemory of parsed.data.memories) {
|
|
106
|
+
const memory: Memory = {
|
|
107
|
+
id: randomUUID(),
|
|
108
|
+
type: extractedMemory.type,
|
|
109
|
+
content: extractedMemory.content,
|
|
110
|
+
source: "conversation",
|
|
111
|
+
importance: extractedMemory.importance,
|
|
112
|
+
lastAccessed: now,
|
|
113
|
+
createdAt: now,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
let embedding: number[] | undefined;
|
|
117
|
+
if (deps.embeddingFn) {
|
|
118
|
+
try {
|
|
119
|
+
embedding = await deps.embeddingFn(extractedMemory.content);
|
|
120
|
+
} catch {
|
|
121
|
+
// Continue without embedding if it fails
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const saved = await deps.memoriesRepository.create(memory, embedding);
|
|
126
|
+
savedMemories.push(saved);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
relevantMemories: [...state.relevantMemories, ...savedMemories],
|
|
131
|
+
};
|
|
132
|
+
} catch {
|
|
133
|
+
return {};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { type AddEventNodeDeps, addEventNode } from "./addEvent";
|
|
2
|
+
export { type ChatNodeDeps, chatNode } from "./chat";
|
|
3
|
+
export { type ExtractMemoryNodeDeps, extractMemoryNode } from "./extractMemory";
|
|
4
|
+
export { type QueryNodeDeps, queryNode } from "./query";
|
|
5
|
+
export { type RouterNodeDeps, routerNode } from "./router";
|
|
6
|
+
export { respondNode } from "./respond";
|
|
7
|
+
export { toolExecutorNode, hasToolCalls, type ToolExecutorNodeDeps } from "./toolExecutor";
|
|
8
|
+
export { type ToolExecutor, type ToolRegistry } from "./types";
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { AIMessage, HumanMessage, SystemMessage } from "@langchain/core/messages";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { Event } from "../../../domain/entities/Event";
|
|
4
|
+
import type { ILanguageModel } from "../../../domain/interfaces/ILanguageModel";
|
|
5
|
+
import type { GraphStateType } from "../../../domain/state/AgentState";
|
|
6
|
+
import type { EventService } from "../../services/EventService";
|
|
7
|
+
|
|
8
|
+
const QueryIntentSchema = z.object({
|
|
9
|
+
queryType: z
|
|
10
|
+
.enum(["today", "tomorrow", "week", "specific_date", "range", "search", "upcoming"])
|
|
11
|
+
.describe("The type of calendar query"),
|
|
12
|
+
searchTerm: z.string().optional().describe("Search term if looking for specific events"),
|
|
13
|
+
specificDate: z.string().optional().describe("ISO 8601 date if querying a specific date"),
|
|
14
|
+
startDate: z.string().optional().describe("Start date for range queries"),
|
|
15
|
+
endDate: z.string().optional().describe("End date for range queries"),
|
|
16
|
+
daysAhead: z.number().optional().describe("Number of days for upcoming events query"),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
type QueryIntent = z.infer<typeof QueryIntentSchema>;
|
|
20
|
+
|
|
21
|
+
const QUERY_PARSER_SYSTEM_PROMPT = `You are a calendar query parser. Analyze the user's question about their calendar and determine what they want to know.
|
|
22
|
+
|
|
23
|
+
Query types:
|
|
24
|
+
- "today": Events happening today
|
|
25
|
+
- "tomorrow": Events happening tomorrow
|
|
26
|
+
- "week": Events this week
|
|
27
|
+
- "specific_date": Events on a specific date
|
|
28
|
+
- "range": Events within a date range
|
|
29
|
+
- "search": Looking for events by name/keyword
|
|
30
|
+
- "upcoming": Next N events or events in next N days
|
|
31
|
+
|
|
32
|
+
Current date/time context will be provided to help interpret relative dates.
|
|
33
|
+
|
|
34
|
+
You must respond with a JSON object containing the query details.`;
|
|
35
|
+
|
|
36
|
+
export interface QueryNodeDeps {
|
|
37
|
+
llm: ILanguageModel;
|
|
38
|
+
eventService: EventService;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function formatEvent(event: Event): string {
|
|
42
|
+
const date = event.datetime.toLocaleDateString("en-US", {
|
|
43
|
+
weekday: "short",
|
|
44
|
+
month: "short",
|
|
45
|
+
day: "numeric",
|
|
46
|
+
});
|
|
47
|
+
const time = event.datetime.toLocaleTimeString("en-US", {
|
|
48
|
+
hour: "numeric",
|
|
49
|
+
minute: "2-digit",
|
|
50
|
+
});
|
|
51
|
+
const endTime = event.endTime
|
|
52
|
+
? ` - ${event.endTime.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}`
|
|
53
|
+
: "";
|
|
54
|
+
|
|
55
|
+
let formatted = `• ${event.title} - ${date} at ${time}${endTime}`;
|
|
56
|
+
if (event.notes) {
|
|
57
|
+
formatted += `\n Notes: ${event.notes}`;
|
|
58
|
+
}
|
|
59
|
+
if (event.tags.length > 0) {
|
|
60
|
+
formatted += `\n Tags: ${event.tags.join(", ")}`;
|
|
61
|
+
}
|
|
62
|
+
return formatted;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatEventList(events: Event[], context: string): string {
|
|
66
|
+
if (events.length === 0) {
|
|
67
|
+
return `No events found ${context}.`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const header =
|
|
71
|
+
events.length === 1
|
|
72
|
+
? `Here's 1 event ${context}:`
|
|
73
|
+
: `Here are ${events.length} events ${context}:`;
|
|
74
|
+
|
|
75
|
+
const formatted = events.map(formatEvent).join("\n\n");
|
|
76
|
+
return `${header}\n\n${formatted}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function queryNode(
|
|
80
|
+
state: GraphStateType,
|
|
81
|
+
deps: QueryNodeDeps
|
|
82
|
+
): Promise<Partial<GraphStateType>> {
|
|
83
|
+
const lastMessage = state.messages[state.messages.length - 1];
|
|
84
|
+
|
|
85
|
+
if (!lastMessage || !(lastMessage instanceof HumanMessage)) {
|
|
86
|
+
return {
|
|
87
|
+
response: "I couldn't understand your question. Please try again.",
|
|
88
|
+
queryResult: null,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const userMessage =
|
|
93
|
+
typeof lastMessage.content === "string"
|
|
94
|
+
? lastMessage.content
|
|
95
|
+
: JSON.stringify(lastMessage.content);
|
|
96
|
+
|
|
97
|
+
const now = new Date();
|
|
98
|
+
const contextMessage = `Current date/time: ${now.toISOString()} (${now.toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" })})
|
|
99
|
+
|
|
100
|
+
User question: ${userMessage}`;
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const structuredLlm = deps.llm.withStructuredOutput<QueryIntent>(QueryIntentSchema);
|
|
104
|
+
|
|
105
|
+
const response = await structuredLlm.generate([
|
|
106
|
+
new SystemMessage(QUERY_PARSER_SYSTEM_PROMPT),
|
|
107
|
+
new HumanMessage(contextMessage),
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
const parsed = QueryIntentSchema.safeParse(JSON.parse(response.content));
|
|
111
|
+
|
|
112
|
+
if (!parsed.success) {
|
|
113
|
+
const todayEvents = await deps.eventService.getToday();
|
|
114
|
+
const responseText = formatEventList(todayEvents, "for today");
|
|
115
|
+
return {
|
|
116
|
+
queryResult: responseText,
|
|
117
|
+
response: responseText,
|
|
118
|
+
todayEvents,
|
|
119
|
+
messages: [new AIMessage(responseText)],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const queryIntent = parsed.data;
|
|
124
|
+
let events: Event[] = [];
|
|
125
|
+
let contextText = "";
|
|
126
|
+
|
|
127
|
+
switch (queryIntent.queryType) {
|
|
128
|
+
case "today": {
|
|
129
|
+
events = await deps.eventService.getToday();
|
|
130
|
+
contextText = "for today";
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
case "tomorrow": {
|
|
134
|
+
const tomorrow = new Date(now);
|
|
135
|
+
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
136
|
+
events = await deps.eventService.getByDate(tomorrow);
|
|
137
|
+
contextText = "for tomorrow";
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
case "week": {
|
|
141
|
+
const weekEnd = new Date(now);
|
|
142
|
+
weekEnd.setDate(weekEnd.getDate() + 7);
|
|
143
|
+
events = await deps.eventService.getByRange(now, weekEnd);
|
|
144
|
+
contextText = "for this week";
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
case "specific_date": {
|
|
148
|
+
if (queryIntent.specificDate) {
|
|
149
|
+
const targetDate = new Date(queryIntent.specificDate);
|
|
150
|
+
events = await deps.eventService.getByDate(targetDate);
|
|
151
|
+
contextText = `for ${targetDate.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" })}`;
|
|
152
|
+
}
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
case "range": {
|
|
156
|
+
if (queryIntent.startDate && queryIntent.endDate) {
|
|
157
|
+
const start = new Date(queryIntent.startDate);
|
|
158
|
+
const end = new Date(queryIntent.endDate);
|
|
159
|
+
events = await deps.eventService.getByRange(start, end);
|
|
160
|
+
contextText = `from ${start.toLocaleDateString("en-US", { month: "short", day: "numeric" })} to ${end.toLocaleDateString("en-US", { month: "short", day: "numeric" })}`;
|
|
161
|
+
}
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
case "search": {
|
|
165
|
+
if (queryIntent.searchTerm) {
|
|
166
|
+
events = await deps.eventService.search(queryIntent.searchTerm);
|
|
167
|
+
contextText = `matching "${queryIntent.searchTerm}"`;
|
|
168
|
+
}
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
case "upcoming": {
|
|
172
|
+
const days = queryIntent.daysAhead ?? 7;
|
|
173
|
+
events = await deps.eventService.getUpcoming(days);
|
|
174
|
+
contextText = `in the next ${days} days`;
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const responseText = formatEventList(events, contextText);
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
queryResult: responseText,
|
|
183
|
+
response: responseText,
|
|
184
|
+
todayEvents: queryIntent.queryType === "today" ? events : state.todayEvents,
|
|
185
|
+
messages: [new AIMessage(responseText)],
|
|
186
|
+
};
|
|
187
|
+
} catch (error) {
|
|
188
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
189
|
+
return {
|
|
190
|
+
response: `Failed to query events: ${errorMessage}. Please try again.`,
|
|
191
|
+
queryResult: null,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { AIMessage } from "@langchain/core/messages";
|
|
2
|
+
import type { GraphStateType } from "../../../domain/state/AgentState";
|
|
3
|
+
|
|
4
|
+
export async function respondNode(state: GraphStateType): Promise<Partial<GraphStateType>> {
|
|
5
|
+
if (!state.response) {
|
|
6
|
+
return {
|
|
7
|
+
response: "I'm not sure how to respond. Could you please rephrase your request?",
|
|
8
|
+
messages: [
|
|
9
|
+
new AIMessage("I'm not sure how to respond. Could you please rephrase your request?"),
|
|
10
|
+
],
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const lastAIMessage = state.messages
|
|
15
|
+
.slice()
|
|
16
|
+
.reverse()
|
|
17
|
+
.find((msg) => msg instanceof AIMessage);
|
|
18
|
+
|
|
19
|
+
if (lastAIMessage && String(lastAIMessage.content) === state.response) {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
messages: [new AIMessage(state.response)],
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { HumanMessage, SystemMessage } from "@langchain/core/messages";
|
|
3
|
+
import type { GraphStateType, Intent } from "../../../domain/state/AgentState";
|
|
4
|
+
import type { ILanguageModel } from "../../../domain/interfaces/ILanguageModel";
|
|
5
|
+
|
|
6
|
+
const IntentSchema = z.object({
|
|
7
|
+
intent: z
|
|
8
|
+
.enum(["add_event", "query", "chat"])
|
|
9
|
+
.describe(
|
|
10
|
+
"The user's intent: 'add_event' for scheduling/creating events, 'query' for asking about existing events or calendar questions, 'chat' for general conversation"
|
|
11
|
+
),
|
|
12
|
+
confidence: z.number().min(0).max(1).describe("Confidence score for the classification"),
|
|
13
|
+
reasoning: z.string().describe("Brief explanation of why this intent was chosen"),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
type IntentClassification = z.infer<typeof IntentSchema>;
|
|
17
|
+
|
|
18
|
+
const ROUTER_SYSTEM_PROMPT = `You are an intent classifier for a calendar/scheduling assistant. Analyze the user's message and classify their intent.
|
|
19
|
+
|
|
20
|
+
Intents:
|
|
21
|
+
- "add_event": User mentions ANY upcoming event, plan, appointment, or schedule - whether as a command OR a casual statement. If the message contains a future event with a time reference, classify as add_event.
|
|
22
|
+
Examples:
|
|
23
|
+
- Commands: "Schedule a meeting tomorrow at 3pm", "Add dentist appointment next week", "Book a call with John"
|
|
24
|
+
- Statements: "I have a flight next Thursday", "I'm going to a conference on Monday", "There's a dentist appointment on Friday"
|
|
25
|
+
- Informal: "Dinner with Sarah at 7pm", "Meeting John tomorrow", "Flight to NYC next week"
|
|
26
|
+
- Mentions: "I need to pick up kids at 3pm", "Got a job interview on Tuesday", "My mom's birthday is on the 15th"
|
|
27
|
+
|
|
28
|
+
- "query": User wants to know about existing events, check their schedule, or ask calendar-related questions
|
|
29
|
+
Examples: "What's on my calendar today?", "When is my next meeting?", "Do I have anything on Friday?", "Show me this month's events", "Check my schedule for next week"
|
|
30
|
+
|
|
31
|
+
- "chat": General conversation NOT related to calendar operations and does NOT mention any specific future events or dates
|
|
32
|
+
Examples: "Hello", "How are you?", "What's the weather like?", "Tell me a joke", "Thanks for your help"
|
|
33
|
+
|
|
34
|
+
IMPORTANT: When in doubt between "add_event" and "chat", if the message mentions ANY specific future date/time with an activity, choose "add_event". Users often mention events casually expecting them to be tracked.
|
|
35
|
+
|
|
36
|
+
You must respond with a JSON object containing: intent, confidence, and reasoning.`;
|
|
37
|
+
|
|
38
|
+
export interface RouterNodeDeps {
|
|
39
|
+
llm: ILanguageModel;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function routerNode(
|
|
43
|
+
state: GraphStateType,
|
|
44
|
+
deps: RouterNodeDeps
|
|
45
|
+
): Promise<Partial<GraphStateType>> {
|
|
46
|
+
const lastMessage = state.messages[state.messages.length - 1];
|
|
47
|
+
|
|
48
|
+
if (!lastMessage || !(lastMessage instanceof HumanMessage)) {
|
|
49
|
+
return { intent: "chat" as Intent };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const userMessage =
|
|
53
|
+
typeof lastMessage.content === "string"
|
|
54
|
+
? lastMessage.content
|
|
55
|
+
: JSON.stringify(lastMessage.content);
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const structuredLlm = deps.llm.withStructuredOutput<IntentClassification>(IntentSchema);
|
|
59
|
+
|
|
60
|
+
const response = await structuredLlm.generate([
|
|
61
|
+
new SystemMessage(ROUTER_SYSTEM_PROMPT),
|
|
62
|
+
new HumanMessage(userMessage),
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
// console.log("[Router] LLM Response:", response.content);
|
|
66
|
+
|
|
67
|
+
const parsed = IntentSchema.safeParse(JSON.parse(response.content));
|
|
68
|
+
|
|
69
|
+
// console.log("[Router] Parse result:", parsed.success ? parsed.data : parsed.error);
|
|
70
|
+
|
|
71
|
+
if (!parsed.success) {
|
|
72
|
+
console.log("[Router] Parse failed, defaulting to chat");
|
|
73
|
+
return { intent: "chat" as Intent };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// console.log("[Router] Final intent:", parsed.data.intent);
|
|
77
|
+
return { intent: parsed.data.intent as Intent };
|
|
78
|
+
} catch (error) {
|
|
79
|
+
console.log("[Router] Error:", error instanceof Error ? error.message : String(error));
|
|
80
|
+
return { intent: "chat" as Intent };
|
|
81
|
+
}
|
|
82
|
+
}
|