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
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "haroo",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "AI-powered personal logging CLI with memory, diary, and calendar features",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"haroo": "./src/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"keywords": [
|
|
14
|
+
"cli",
|
|
15
|
+
"ai",
|
|
16
|
+
"logging",
|
|
17
|
+
"diary",
|
|
18
|
+
"memory",
|
|
19
|
+
"langchain"
|
|
20
|
+
],
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"engines": {
|
|
23
|
+
"bun": ">=1.0.0"
|
|
24
|
+
},
|
|
25
|
+
"module": "index.ts",
|
|
26
|
+
"scripts": {
|
|
27
|
+
"dev": "bun run src/index.ts",
|
|
28
|
+
"build": "bun build src/index.ts --outdir ./dist --target node",
|
|
29
|
+
"prepublishOnly": "bun run build && bun run test",
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"test": "bun test",
|
|
32
|
+
"test:file": "bun test",
|
|
33
|
+
"test:watch": "bun test --watch",
|
|
34
|
+
"lint": "eslint . --fix && prettier --write .",
|
|
35
|
+
"lint:check": "eslint . && prettier --check .",
|
|
36
|
+
"format": "prettier --write .",
|
|
37
|
+
"prepare": "husky"
|
|
38
|
+
},
|
|
39
|
+
"type": "module",
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/bun": "latest",
|
|
42
|
+
"@types/inquirer": "^9.0.9",
|
|
43
|
+
"@types/node": "^25.0.3",
|
|
44
|
+
"@typescript-eslint/eslint-plugin": "^8.52.0",
|
|
45
|
+
"@typescript-eslint/parser": "^8.52.0",
|
|
46
|
+
"eslint": "^9.39.2",
|
|
47
|
+
"eslint-config-prettier": "^10.1.8",
|
|
48
|
+
"husky": "^9.1.7",
|
|
49
|
+
"lint-staged": "^16.2.7",
|
|
50
|
+
"prettier": "^3.7.4"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"typescript": "^5.9.3"
|
|
54
|
+
},
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"@langchain/core": "^0.3.54",
|
|
57
|
+
"@langchain/langgraph": "^0.3.4",
|
|
58
|
+
"chalk": "^5.6.2",
|
|
59
|
+
"commander": "^14.0.2",
|
|
60
|
+
"dotenv": "^17.2.3",
|
|
61
|
+
"inquirer": "^13.1.0",
|
|
62
|
+
"ora": "^9.0.0",
|
|
63
|
+
"sqlite-vec": "^0.1.7-alpha.2",
|
|
64
|
+
"zod": "^3.23.8",
|
|
65
|
+
"zod-to-json-schema": "^3.25.1"
|
|
66
|
+
},
|
|
67
|
+
"lint-staged": {
|
|
68
|
+
"*.{ts,tsx,js,jsx}": [
|
|
69
|
+
"eslint --fix",
|
|
70
|
+
"prettier --write"
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { EventService } from "../../application/services/EventService";
|
|
3
|
+
import type { Event } from "../../domain/entities/Event";
|
|
4
|
+
import type { IEventsRepository } from "../../domain/interfaces/IEventsRepository";
|
|
5
|
+
|
|
6
|
+
// Mock repository for testing
|
|
7
|
+
class MockEventsRepository implements IEventsRepository {
|
|
8
|
+
private events: Map<string, Event> = new Map();
|
|
9
|
+
|
|
10
|
+
async create(event: Event): Promise<Event> {
|
|
11
|
+
this.events.set(event.id, event);
|
|
12
|
+
return event;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async getById(id: string): Promise<Event | null> {
|
|
16
|
+
return this.events.get(id) || null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async getByDateRange(start: Date, end: Date): Promise<Event[]> {
|
|
20
|
+
const events = Array.from(this.events.values());
|
|
21
|
+
return events.filter((event) => {
|
|
22
|
+
return event.datetime >= start && event.datetime < end;
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async getToday(): Promise<Event[]> {
|
|
27
|
+
const now = new Date();
|
|
28
|
+
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
29
|
+
const endOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
|
30
|
+
return this.getByDateRange(startOfDay, endOfDay);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async searchByTitle(query: string): Promise<Event[]> {
|
|
34
|
+
const events = Array.from(this.events.values());
|
|
35
|
+
return events.filter((event) => event.title.toLowerCase().includes(query.toLowerCase()));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async update(id: string, data: Partial<Event>): Promise<Event | null> {
|
|
39
|
+
const event = this.events.get(id);
|
|
40
|
+
if (!event) return null;
|
|
41
|
+
|
|
42
|
+
const updated = { ...event, ...data };
|
|
43
|
+
this.events.set(id, updated);
|
|
44
|
+
return updated;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async delete(id: string): Promise<boolean> {
|
|
48
|
+
return this.events.delete(id);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async getUpcoming(limit = 10): Promise<Event[]> {
|
|
52
|
+
const now = new Date();
|
|
53
|
+
const events = Array.from(this.events.values());
|
|
54
|
+
return events
|
|
55
|
+
.filter((event) => event.datetime >= now)
|
|
56
|
+
.sort((a, b) => a.datetime.getTime() - b.datetime.getTime())
|
|
57
|
+
.slice(0, limit);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Helper for testing
|
|
61
|
+
clear(): void {
|
|
62
|
+
this.events.clear();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe("EventService E2E", () => {
|
|
67
|
+
let repository: MockEventsRepository;
|
|
68
|
+
let service: EventService;
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
repository = new MockEventsRepository();
|
|
72
|
+
service = new EventService(repository);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("Event lifecycle", () => {
|
|
76
|
+
it("should create, retrieve, update, and delete an event", async () => {
|
|
77
|
+
// Create
|
|
78
|
+
const eventData = {
|
|
79
|
+
title: "Team Meeting",
|
|
80
|
+
datetime: new Date("2024-03-15T10:00:00Z"),
|
|
81
|
+
notes: "Discuss Q1 goals",
|
|
82
|
+
tags: ["work", "meeting"],
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const created = await service.add(eventData);
|
|
86
|
+
expect(created.id).toBeDefined();
|
|
87
|
+
expect(created.title).toBe("Team Meeting");
|
|
88
|
+
expect(created.createdAt).toBeInstanceOf(Date);
|
|
89
|
+
|
|
90
|
+
// Retrieve
|
|
91
|
+
const retrieved = await service.getById(created.id);
|
|
92
|
+
expect(retrieved).not.toBeNull();
|
|
93
|
+
expect(retrieved?.title).toBe("Team Meeting");
|
|
94
|
+
|
|
95
|
+
// Update
|
|
96
|
+
const updated = await service.update(created.id, {
|
|
97
|
+
title: "Updated Team Meeting",
|
|
98
|
+
notes: "Discuss Q1 and Q2 goals",
|
|
99
|
+
});
|
|
100
|
+
expect(updated.title).toBe("Updated Team Meeting");
|
|
101
|
+
expect(updated.notes).toBe("Discuss Q1 and Q2 goals");
|
|
102
|
+
|
|
103
|
+
// Delete
|
|
104
|
+
const deleted = await service.delete(created.id);
|
|
105
|
+
expect(deleted).toBe(true);
|
|
106
|
+
|
|
107
|
+
const afterDelete = await service.getById(created.id);
|
|
108
|
+
expect(afterDelete).toBeNull();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("Date-based queries", () => {
|
|
113
|
+
beforeEach(async () => {
|
|
114
|
+
// Create test events
|
|
115
|
+
await service.add({
|
|
116
|
+
title: "Event 1",
|
|
117
|
+
datetime: new Date("2024-03-15T10:00:00Z"),
|
|
118
|
+
tags: [],
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
await service.add({
|
|
122
|
+
title: "Event 2",
|
|
123
|
+
datetime: new Date("2024-03-15T14:00:00Z"),
|
|
124
|
+
tags: [],
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
await service.add({
|
|
128
|
+
title: "Event 3",
|
|
129
|
+
datetime: new Date("2024-03-16T10:00:00Z"),
|
|
130
|
+
tags: [],
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("should retrieve events by specific date", async () => {
|
|
135
|
+
const events = await service.getByDate(new Date("2024-03-15"));
|
|
136
|
+
expect(events).toHaveLength(2);
|
|
137
|
+
expect(events.every((e) => e.title.startsWith("Event"))).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("should retrieve events by date range", async () => {
|
|
141
|
+
const start = new Date("2024-03-15T00:00:00Z");
|
|
142
|
+
const end = new Date("2024-03-17T00:00:00Z");
|
|
143
|
+
const events = await service.getByRange(start, end);
|
|
144
|
+
expect(events).toHaveLength(3);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("should retrieve upcoming events", async () => {
|
|
148
|
+
// This test depends on current date, so we'll just check it returns an array
|
|
149
|
+
const events = await service.getUpcoming(7);
|
|
150
|
+
expect(Array.isArray(events)).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("Search functionality", () => {
|
|
155
|
+
beforeEach(async () => {
|
|
156
|
+
await service.add({
|
|
157
|
+
title: "Team Meeting",
|
|
158
|
+
datetime: new Date("2024-03-15T10:00:00Z"),
|
|
159
|
+
tags: [],
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await service.add({
|
|
163
|
+
title: "Client Call",
|
|
164
|
+
datetime: new Date("2024-03-15T14:00:00Z"),
|
|
165
|
+
tags: [],
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await service.add({
|
|
169
|
+
title: "Team Building",
|
|
170
|
+
datetime: new Date("2024-03-16T10:00:00Z"),
|
|
171
|
+
tags: [],
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("should search events by title", async () => {
|
|
176
|
+
const results = await service.search("team");
|
|
177
|
+
expect(results).toHaveLength(2);
|
|
178
|
+
expect(results.every((e) => e.title.toLowerCase().includes("team"))).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("should return empty array for non-matching search", async () => {
|
|
182
|
+
const results = await service.search("nonexistent");
|
|
183
|
+
expect(results).toHaveLength(0);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("should be case-insensitive", async () => {
|
|
187
|
+
const results = await service.search("TEAM");
|
|
188
|
+
expect(results).toHaveLength(2);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("Error handling", () => {
|
|
193
|
+
it("should throw error when updating non-existent event", async () => {
|
|
194
|
+
const fakeId = "550e8400-e29b-41d4-a716-446655440000";
|
|
195
|
+
|
|
196
|
+
await expect(service.update(fakeId, { title: "Updated" })).rejects.toThrow(
|
|
197
|
+
`Event with id ${fakeId} not found`
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("should return null when getting non-existent event", async () => {
|
|
202
|
+
const result = await service.getById("550e8400-e29b-41d4-a716-446655440000");
|
|
203
|
+
expect(result).toBeNull();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("should return false when deleting non-existent event", async () => {
|
|
207
|
+
const result = await service.delete("550e8400-e29b-41d4-a716-446655440000");
|
|
208
|
+
expect(result).toBe(false);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { EventSchema } from "../../domain/entities/Event";
|
|
3
|
+
|
|
4
|
+
describe("Event Entity", () => {
|
|
5
|
+
describe("EventSchema validation", () => {
|
|
6
|
+
it("should validate a valid event with required fields", () => {
|
|
7
|
+
const validEvent = {
|
|
8
|
+
id: "550e8400-e29b-41d4-a716-446655440000",
|
|
9
|
+
title: "Team Meeting",
|
|
10
|
+
datetime: new Date("2024-01-15T10:00:00Z"),
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const result = EventSchema.safeParse(validEvent);
|
|
14
|
+
expect(result.success).toBe(true);
|
|
15
|
+
if (result.success) {
|
|
16
|
+
expect(result.data.title).toBe("Team Meeting");
|
|
17
|
+
expect(result.data.tags).toEqual([]);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("should validate an event with all optional fields", () => {
|
|
22
|
+
const fullEvent = {
|
|
23
|
+
id: "550e8400-e29b-41d4-a716-446655440000",
|
|
24
|
+
title: "Conference",
|
|
25
|
+
datetime: new Date("2024-03-20T09:00:00Z"),
|
|
26
|
+
endTime: new Date("2024-03-20T17:00:00Z"),
|
|
27
|
+
notes: "Annual tech conference",
|
|
28
|
+
tags: ["work", "conference", "networking"],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const result = EventSchema.safeParse(fullEvent);
|
|
32
|
+
expect(result.success).toBe(true);
|
|
33
|
+
if (result.success) {
|
|
34
|
+
expect(result.data.tags).toHaveLength(3);
|
|
35
|
+
expect(result.data.notes).toBe("Annual tech conference");
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("should reject event with invalid UUID", () => {
|
|
40
|
+
const invalidEvent = {
|
|
41
|
+
id: "not-a-uuid",
|
|
42
|
+
title: "Meeting",
|
|
43
|
+
datetime: new Date(),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const result = EventSchema.safeParse(invalidEvent);
|
|
47
|
+
expect(result.success).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("should reject event with empty title", () => {
|
|
51
|
+
const invalidEvent = {
|
|
52
|
+
id: "550e8400-e29b-41d4-a716-446655440000",
|
|
53
|
+
title: "",
|
|
54
|
+
datetime: new Date(),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const result = EventSchema.safeParse(invalidEvent);
|
|
58
|
+
expect(result.success).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("should coerce string dates to Date objects", () => {
|
|
62
|
+
const eventWithStringDate = {
|
|
63
|
+
id: "550e8400-e29b-41d4-a716-446655440000",
|
|
64
|
+
title: "Meeting",
|
|
65
|
+
datetime: "2024-01-15T10:00:00Z",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const result = EventSchema.safeParse(eventWithStringDate);
|
|
69
|
+
expect(result.success).toBe(true);
|
|
70
|
+
if (result.success) {
|
|
71
|
+
expect(result.data.datetime).toBeInstanceOf(Date);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("should set default createdAt if not provided", () => {
|
|
76
|
+
const event = {
|
|
77
|
+
id: "550e8400-e29b-41d4-a716-446655440000",
|
|
78
|
+
title: "Meeting",
|
|
79
|
+
datetime: new Date(),
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const result = EventSchema.safeParse(event);
|
|
83
|
+
expect(result.success).toBe(true);
|
|
84
|
+
if (result.success) {
|
|
85
|
+
expect(result.data.createdAt).toBeInstanceOf(Date);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { MemorySchema } from "../../domain/entities/Memory";
|
|
3
|
+
|
|
4
|
+
describe("Memory Entity", () => {
|
|
5
|
+
describe("MemorySchema validation", () => {
|
|
6
|
+
it("should validate a valid memory with all required fields", () => {
|
|
7
|
+
const validMemory = {
|
|
8
|
+
id: "550e8400-e29b-41d4-a716-446655440000",
|
|
9
|
+
type: "fact" as const,
|
|
10
|
+
content: "User prefers dark mode",
|
|
11
|
+
lastAccessed: new Date("2024-01-15T10:00:00Z"),
|
|
12
|
+
createdAt: new Date("2024-01-01T00:00:00Z"),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const result = MemorySchema.safeParse(validMemory);
|
|
16
|
+
expect(result.success).toBe(true);
|
|
17
|
+
if (result.success) {
|
|
18
|
+
expect(result.data.type).toBe("fact");
|
|
19
|
+
expect(result.data.importance).toBe(5); // default value
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("should validate memory with custom importance", () => {
|
|
24
|
+
const memory = {
|
|
25
|
+
id: "550e8400-e29b-41d4-a716-446655440000",
|
|
26
|
+
type: "preference" as const,
|
|
27
|
+
content: "User loves TypeScript",
|
|
28
|
+
importance: 9,
|
|
29
|
+
lastAccessed: new Date(),
|
|
30
|
+
createdAt: new Date(),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const result = MemorySchema.safeParse(memory);
|
|
34
|
+
expect(result.success).toBe(true);
|
|
35
|
+
if (result.success) {
|
|
36
|
+
expect(result.data.importance).toBe(9);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("should validate memory with optional source", () => {
|
|
41
|
+
const memory = {
|
|
42
|
+
id: "550e8400-e29b-41d4-a716-446655440000",
|
|
43
|
+
type: "routine" as const,
|
|
44
|
+
content: "User checks email at 9 AM",
|
|
45
|
+
source: "conversation-2024-01-15",
|
|
46
|
+
lastAccessed: new Date(),
|
|
47
|
+
createdAt: new Date(),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const result = MemorySchema.safeParse(memory);
|
|
51
|
+
expect(result.success).toBe(true);
|
|
52
|
+
if (result.success) {
|
|
53
|
+
expect(result.data.source).toBe("conversation-2024-01-15");
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("should validate all memory types", () => {
|
|
58
|
+
const types = ["fact", "preference", "routine", "relationship"] as const;
|
|
59
|
+
|
|
60
|
+
for (const type of types) {
|
|
61
|
+
const memory = {
|
|
62
|
+
id: "550e8400-e29b-41d4-a716-446655440000",
|
|
63
|
+
type,
|
|
64
|
+
content: `Test ${type}`,
|
|
65
|
+
lastAccessed: new Date(),
|
|
66
|
+
createdAt: new Date(),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const result = MemorySchema.safeParse(memory);
|
|
70
|
+
expect(result.success).toBe(true);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("should reject memory with invalid type", () => {
|
|
75
|
+
const invalidMemory = {
|
|
76
|
+
id: "550e8400-e29b-41d4-a716-446655440000",
|
|
77
|
+
type: "invalid-type",
|
|
78
|
+
content: "Some content",
|
|
79
|
+
lastAccessed: new Date(),
|
|
80
|
+
createdAt: new Date(),
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const result = MemorySchema.safeParse(invalidMemory);
|
|
84
|
+
expect(result.success).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("should reject memory with importance out of range", () => {
|
|
88
|
+
const memoryTooHigh = {
|
|
89
|
+
id: "550e8400-e29b-41d4-a716-446655440000",
|
|
90
|
+
type: "fact" as const,
|
|
91
|
+
content: "Test",
|
|
92
|
+
importance: 11,
|
|
93
|
+
lastAccessed: new Date(),
|
|
94
|
+
createdAt: new Date(),
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const resultHigh = MemorySchema.safeParse(memoryTooHigh);
|
|
98
|
+
expect(resultHigh.success).toBe(false);
|
|
99
|
+
|
|
100
|
+
const memoryTooLow = {
|
|
101
|
+
id: "550e8400-e29b-41d4-a716-446655440000",
|
|
102
|
+
type: "fact" as const,
|
|
103
|
+
content: "Test",
|
|
104
|
+
importance: 0,
|
|
105
|
+
lastAccessed: new Date(),
|
|
106
|
+
createdAt: new Date(),
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const resultLow = MemorySchema.safeParse(memoryTooLow);
|
|
110
|
+
expect(resultLow.success).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("should coerce string dates to Date objects", () => {
|
|
114
|
+
const memory = {
|
|
115
|
+
id: "550e8400-e29b-41d4-a716-446655440000",
|
|
116
|
+
type: "fact" as const,
|
|
117
|
+
content: "Test content",
|
|
118
|
+
lastAccessed: "2024-01-15T10:00:00Z",
|
|
119
|
+
createdAt: "2024-01-01T00:00:00Z",
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const result = MemorySchema.safeParse(memory);
|
|
123
|
+
expect(result.success).toBe(true);
|
|
124
|
+
if (result.success) {
|
|
125
|
+
expect(result.data.lastAccessed).toBeInstanceOf(Date);
|
|
126
|
+
expect(result.data.createdAt).toBeInstanceOf(Date);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { BaseCheckpointSaver } from "@langchain/langgraph";
|
|
2
|
+
import { END, START, StateGraph } from "@langchain/langgraph";
|
|
3
|
+
import type { ILanguageModel } from "../../domain/interfaces/ILanguageModel";
|
|
4
|
+
import type {
|
|
5
|
+
EmbeddingFunction,
|
|
6
|
+
IMemoriesRepository,
|
|
7
|
+
} from "../../domain/interfaces/IMemoriesRepository";
|
|
8
|
+
import { GraphState, type GraphStateType } from "../../domain/state/AgentState";
|
|
9
|
+
import type { EventService } from "../services/EventService";
|
|
10
|
+
import { routeByIntent, shouldExtractMemory } from "./edges";
|
|
11
|
+
import type { ToolRegistry } from "./nodes";
|
|
12
|
+
import {
|
|
13
|
+
addEventNode,
|
|
14
|
+
chatNode,
|
|
15
|
+
extractMemoryNode,
|
|
16
|
+
toolExecutorNode,
|
|
17
|
+
hasToolCalls,
|
|
18
|
+
routerNode,
|
|
19
|
+
respondNode,
|
|
20
|
+
queryNode,
|
|
21
|
+
} from "./nodes";
|
|
22
|
+
|
|
23
|
+
export interface GraphConfig {
|
|
24
|
+
llm: ILanguageModel;
|
|
25
|
+
tools: ToolRegistry;
|
|
26
|
+
eventService: EventService;
|
|
27
|
+
memoriesRepository: IMemoriesRepository;
|
|
28
|
+
embeddingFn?: EmbeddingFunction;
|
|
29
|
+
checkpointer?: BaseCheckpointSaver;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Determines whether to execute tools or proceed to respond.
|
|
34
|
+
* If the chat node returned tool calls, route to toolExecutor.
|
|
35
|
+
*/
|
|
36
|
+
function shouldExecuteTools(state: GraphStateType): "toolExecutor" | "respond" {
|
|
37
|
+
if (hasToolCalls(state)) {
|
|
38
|
+
return "toolExecutor";
|
|
39
|
+
}
|
|
40
|
+
return "respond";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Builds and compiles the LangGraph StateGraph with all nodes and edges.
|
|
45
|
+
*
|
|
46
|
+
* Graph Flow:
|
|
47
|
+
* START → router → [conditional]
|
|
48
|
+
* ├── "add_event" → addEvent → respond → extractMemory → END
|
|
49
|
+
* ├── "query" → query → respond → END
|
|
50
|
+
* └── "chat" → chat → [conditional]
|
|
51
|
+
* ├── hasToolCalls → toolExecutor → chat (loop)
|
|
52
|
+
* └── noToolCalls → respond → extractMemory → END
|
|
53
|
+
*/
|
|
54
|
+
export function buildGraph(config: GraphConfig) {
|
|
55
|
+
const graph = new StateGraph(GraphState)
|
|
56
|
+
// Add all nodes with their dependencies
|
|
57
|
+
.addNode("router", (state: GraphStateType) => routerNode(state, { llm: config.llm }))
|
|
58
|
+
.addNode("addEvent", (state: GraphStateType) =>
|
|
59
|
+
addEventNode(state, { llm: config.llm, eventService: config.eventService })
|
|
60
|
+
)
|
|
61
|
+
.addNode("query", (state: GraphStateType) =>
|
|
62
|
+
queryNode(state, { llm: config.llm, eventService: config.eventService })
|
|
63
|
+
)
|
|
64
|
+
.addNode("chat", (state: GraphStateType) =>
|
|
65
|
+
chatNode(state, { llm: config.llm, tools: config.tools })
|
|
66
|
+
)
|
|
67
|
+
.addNode("toolExecutor", (state: GraphStateType) =>
|
|
68
|
+
toolExecutorNode(state, { tools: config.tools })
|
|
69
|
+
)
|
|
70
|
+
.addNode("respond", respondNode)
|
|
71
|
+
.addNode("extractMemory", (state: GraphStateType) =>
|
|
72
|
+
extractMemoryNode(state, {
|
|
73
|
+
llm: config.llm,
|
|
74
|
+
memoriesRepository: config.memoriesRepository,
|
|
75
|
+
embeddingFn: config.embeddingFn,
|
|
76
|
+
})
|
|
77
|
+
)
|
|
78
|
+
// Define edges
|
|
79
|
+
.addEdge(START, "router")
|
|
80
|
+
.addConditionalEdges("router", routeByIntent)
|
|
81
|
+
.addEdge("addEvent", "respond")
|
|
82
|
+
.addEdge("query", "respond")
|
|
83
|
+
// Chat can trigger tool execution loop
|
|
84
|
+
.addConditionalEdges("chat", shouldExecuteTools)
|
|
85
|
+
.addEdge("toolExecutor", "chat") // Loop back to chat after tool execution
|
|
86
|
+
.addConditionalEdges("respond", shouldExtractMemory)
|
|
87
|
+
.addEdge("extractMemory", END);
|
|
88
|
+
|
|
89
|
+
// Compile with optional checkpointer for state persistence
|
|
90
|
+
return graph.compile({
|
|
91
|
+
checkpointer: config.checkpointer,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Runs the graph with the given initial state.
|
|
97
|
+
* This is a convenience wrapper that builds and invokes the graph.
|
|
98
|
+
*/
|
|
99
|
+
export async function runGraph(
|
|
100
|
+
initialState: GraphStateType,
|
|
101
|
+
config: GraphConfig
|
|
102
|
+
): Promise<GraphStateType> {
|
|
103
|
+
const compiledGraph = buildGraph(config);
|
|
104
|
+
const result = await compiledGraph.invoke(initialState);
|
|
105
|
+
return result as GraphStateType;
|
|
106
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { END } from "@langchain/langgraph";
|
|
2
|
+
import type { GraphStateType } from "../../domain/state/AgentState";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Routes from the router node based on the classified intent.
|
|
6
|
+
* Maps each intent to its corresponding handler node.
|
|
7
|
+
*/
|
|
8
|
+
export function routeByIntent(state: GraphStateType): "addEvent" | "query" | "chat" {
|
|
9
|
+
const intent = state.intent;
|
|
10
|
+
|
|
11
|
+
switch (intent) {
|
|
12
|
+
case "add_event":
|
|
13
|
+
return "addEvent";
|
|
14
|
+
case "query":
|
|
15
|
+
return "query";
|
|
16
|
+
default:
|
|
17
|
+
return "chat";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Determines whether to extract memories after responding.
|
|
23
|
+
* Memory extraction is performed for add_event and chat intents,
|
|
24
|
+
* as these are likely to contain useful information to remember.
|
|
25
|
+
* Query intents are read-only operations and skip memory extraction.
|
|
26
|
+
*/
|
|
27
|
+
export function shouldExtractMemory(state: GraphStateType): "extractMemory" | typeof END {
|
|
28
|
+
const intent = state.intent;
|
|
29
|
+
|
|
30
|
+
// Extract memories for add_event and chat intents
|
|
31
|
+
// Skip for query intents (read-only operations)
|
|
32
|
+
if (intent === "add_event" || intent === "chat") {
|
|
33
|
+
return "extractMemory";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return END;
|
|
37
|
+
}
|