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,179 @@
1
+ import type { BaseMessage } from "@langchain/core/messages";
2
+ import type { Database } from "bun:sqlite";
3
+ import type { ICheckpointer, CheckpointMetadata } from "../../domain/interfaces/IMemory";
4
+ import type { GraphStateType } from "../../domain/state/AgentState";
5
+
6
+ interface CheckpointRow {
7
+ session_id: string;
8
+ state: string;
9
+ created_at: string;
10
+ updated_at: string;
11
+ }
12
+
13
+ export class SqliteCheckpointer implements ICheckpointer {
14
+ constructor(private readonly db: Database) {}
15
+
16
+ async save(sessionId: string, state: GraphStateType): Promise<void> {
17
+ const serializedState = JSON.stringify(this.serializeState(state));
18
+ const now = new Date().toISOString();
19
+
20
+ this.db
21
+ .prepare(
22
+ `
23
+ INSERT INTO checkpoints (session_id, state, created_at, updated_at)
24
+ VALUES (?, ?, ?, ?)
25
+ ON CONFLICT (session_id)
26
+ DO UPDATE SET
27
+ state = excluded.state,
28
+ updated_at = excluded.updated_at
29
+ `
30
+ )
31
+ .run(sessionId, serializedState, now, now);
32
+ }
33
+
34
+ async load(sessionId: string): Promise<GraphStateType | null> {
35
+ const row = this.db
36
+ .prepare(
37
+ "SELECT session_id, state, created_at, updated_at FROM checkpoints WHERE session_id = ?"
38
+ )
39
+ .get(sessionId) as CheckpointRow | undefined;
40
+
41
+ if (!row) {
42
+ return null;
43
+ }
44
+
45
+ return this.deserializeState(JSON.parse(row.state));
46
+ }
47
+
48
+ async list(): Promise<string[]> {
49
+ const rows = this.db
50
+ .prepare("SELECT session_id FROM checkpoints ORDER BY updated_at DESC")
51
+ .all() as { session_id: string }[];
52
+
53
+ return rows.map((row) => row.session_id);
54
+ }
55
+
56
+ async delete(sessionId: string): Promise<boolean> {
57
+ const result = this.db.prepare("DELETE FROM checkpoints WHERE session_id = ?").run(sessionId);
58
+
59
+ return result.changes > 0;
60
+ }
61
+
62
+ async getMetadata(sessionId: string): Promise<CheckpointMetadata | null> {
63
+ const row = this.db
64
+ .prepare("SELECT created_at, updated_at FROM checkpoints WHERE session_id = ?")
65
+ .get(sessionId) as { created_at: string; updated_at: string } | undefined;
66
+
67
+ if (!row) {
68
+ return null;
69
+ }
70
+
71
+ return {
72
+ createdAt: new Date(row.created_at),
73
+ updatedAt: new Date(row.updated_at),
74
+ };
75
+ }
76
+
77
+ async getSessionsForDate(date: Date): Promise<string[]> {
78
+ const startOfDay = new Date(date.getFullYear(), date.getMonth(), date.getDate());
79
+ const endOfDay = new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1);
80
+
81
+ const rows = this.db
82
+ .prepare(
83
+ `
84
+ SELECT session_id
85
+ FROM checkpoints
86
+ WHERE updated_at >= ? AND updated_at < ?
87
+ ORDER BY updated_at DESC
88
+ `
89
+ )
90
+ .all(startOfDay.toISOString(), endOfDay.toISOString()) as {
91
+ session_id: string;
92
+ }[];
93
+
94
+ return rows.map((row) => row.session_id);
95
+ }
96
+
97
+ private serializeState(state: GraphStateType): Record<string, unknown> {
98
+ const messages = state.messages.map((msg: BaseMessage) => ({
99
+ type: msg._getType(),
100
+ content: msg.content,
101
+ additional_kwargs: msg.additional_kwargs,
102
+ response_metadata: msg.response_metadata,
103
+ id: msg.id,
104
+ name: msg.name,
105
+ }));
106
+
107
+ return {
108
+ messages,
109
+ intent: state.intent,
110
+ pendingEvent: state.pendingEvent,
111
+ queryResult: state.queryResult,
112
+ relevantMemories: state.relevantMemories,
113
+ todayEvents: state.todayEvents,
114
+ response: state.response,
115
+ };
116
+ }
117
+
118
+ private deserializeState(data: unknown): GraphStateType {
119
+ const obj = data as Record<string, unknown>;
120
+
121
+ const {
122
+ HumanMessage,
123
+ AIMessage,
124
+ SystemMessage,
125
+ ToolMessage,
126
+ } = require("@langchain/core/messages");
127
+
128
+ const messageData = obj.messages as Array<{
129
+ type: string;
130
+ content: string;
131
+ additional_kwargs?: Record<string, unknown>;
132
+ id?: string;
133
+ name?: string;
134
+ }>;
135
+
136
+ const messages = messageData.map((msg) => {
137
+ switch (msg.type) {
138
+ case "human":
139
+ return new HumanMessage({
140
+ content: msg.content,
141
+ additional_kwargs: msg.additional_kwargs,
142
+ id: msg.id,
143
+ });
144
+ case "ai":
145
+ return new AIMessage({
146
+ content: msg.content,
147
+ additional_kwargs: msg.additional_kwargs,
148
+ id: msg.id,
149
+ });
150
+ case "system":
151
+ return new SystemMessage({
152
+ content: msg.content,
153
+ additional_kwargs: msg.additional_kwargs,
154
+ id: msg.id,
155
+ });
156
+ case "tool":
157
+ return new ToolMessage({
158
+ content: msg.content,
159
+ additional_kwargs: msg.additional_kwargs,
160
+ id: msg.id,
161
+ name: msg.name,
162
+ tool_call_id: (msg.additional_kwargs?.tool_call_id as string) ?? "",
163
+ });
164
+ default:
165
+ return new HumanMessage({ content: msg.content });
166
+ }
167
+ });
168
+
169
+ return {
170
+ messages,
171
+ intent: obj.intent as GraphStateType["intent"],
172
+ pendingEvent: obj.pendingEvent as GraphStateType["pendingEvent"],
173
+ queryResult: obj.queryResult as GraphStateType["queryResult"],
174
+ relevantMemories: obj.relevantMemories as GraphStateType["relevantMemories"],
175
+ todayEvents: obj.todayEvents as GraphStateType["todayEvents"],
176
+ response: obj.response as GraphStateType["response"],
177
+ };
178
+ }
179
+ }
@@ -0,0 +1,69 @@
1
+ import { Database } from "bun:sqlite";
2
+ import * as sqliteVec from "sqlite-vec";
3
+ import { mkdirSync, existsSync } from "fs";
4
+ import { join } from "path";
5
+ import { homedir } from "os";
6
+
7
+ export interface SqliteClientOptions {
8
+ dbPath?: string;
9
+ }
10
+
11
+ const DEFAULT_DB_DIR = join(homedir(), ".log");
12
+ const DEFAULT_DB_PATH = join(DEFAULT_DB_DIR, "data.db");
13
+
14
+ // macOS requires a custom SQLite library that supports extensions
15
+ // The default Apple SQLite disables extension loading
16
+ function setupMacOSSqlite(): void {
17
+ if (process.platform !== "darwin") return;
18
+
19
+ const possiblePaths = [
20
+ "/opt/homebrew/opt/sqlite/lib/libsqlite3.dylib", // Apple Silicon
21
+ "/usr/local/opt/sqlite/lib/libsqlite3.dylib", // Intel Mac
22
+ ];
23
+
24
+ for (const sqlitePath of possiblePaths) {
25
+ if (existsSync(sqlitePath)) {
26
+ Database.setCustomSQLite(sqlitePath);
27
+ return;
28
+ }
29
+ }
30
+
31
+ console.warn(
32
+ "Warning: Could not find Homebrew SQLite. Vector search may not work.\n" +
33
+ "Install with: brew install sqlite"
34
+ );
35
+ }
36
+
37
+ // Initialize macOS SQLite on module load
38
+ setupMacOSSqlite();
39
+
40
+ /**
41
+ * Creates a SQLite database client with sqlite-vec extension loaded.
42
+ * Default path: ~/.log/data.db
43
+ */
44
+ export function createSqliteClient(options: SqliteClientOptions = {}): Database {
45
+ const dbPath = options.dbPath ?? DEFAULT_DB_PATH;
46
+
47
+ // Ensure directory exists
48
+ const dbDir = join(dbPath, "..");
49
+ if (!existsSync(dbDir)) {
50
+ mkdirSync(dbDir, { recursive: true });
51
+ }
52
+
53
+ const db = new Database(dbPath);
54
+
55
+ // Load sqlite-vec extension
56
+ sqliteVec.load(db);
57
+
58
+ // Enable WAL mode for better concurrent access
59
+ db.exec("PRAGMA journal_mode = WAL");
60
+
61
+ return db;
62
+ }
63
+
64
+ /**
65
+ * Get the default database path.
66
+ */
67
+ export function getDefaultDbPath(): string {
68
+ return DEFAULT_DB_PATH;
69
+ }
@@ -0,0 +1,209 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { z } from "zod";
3
+ import { type DiaryEntry, DiaryEntrySchema } from "../../domain/entities/DiaryEntry";
4
+ import type { IDiaryRepository, MoodTrendEntry } from "../../domain/interfaces/IDiaryRepository";
5
+
6
+ const DiaryRowSchema = z.object({
7
+ id: z.string().uuid(),
8
+ entry_date: z.string(),
9
+ summary: z.string(),
10
+ mood: z.string(),
11
+ mood_score: z.number(),
12
+ therapeutic_advice: z.string(),
13
+ session_ids: z.string().nullable(),
14
+ message_count: z.number(),
15
+ created_at: z.string(),
16
+ updated_at: z.string(),
17
+ });
18
+
19
+ type DiaryRow = z.infer<typeof DiaryRowSchema>;
20
+
21
+ function rowToEntry(row: DiaryRow): DiaryEntry {
22
+ return DiaryEntrySchema.parse({
23
+ id: row.id,
24
+ entryDate: new Date(row.entry_date),
25
+ summary: row.summary,
26
+ mood: row.mood,
27
+ moodScore: row.mood_score,
28
+ therapeuticAdvice: row.therapeutic_advice,
29
+ sessionIds: row.session_ids ? JSON.parse(row.session_ids) : [],
30
+ messageCount: row.message_count,
31
+ createdAt: new Date(row.created_at),
32
+ updatedAt: new Date(row.updated_at),
33
+ });
34
+ }
35
+
36
+ export class SqliteDiaryRepository implements IDiaryRepository {
37
+ constructor(private readonly db: Database) {}
38
+
39
+ async create(entry: DiaryEntry): Promise<DiaryEntry> {
40
+ this.db
41
+ .prepare(
42
+ `
43
+ INSERT INTO diary_entries (
44
+ id, entry_date, summary, mood, mood_score,
45
+ therapeutic_advice, session_ids, message_count, created_at, updated_at
46
+ )
47
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
48
+ `
49
+ )
50
+ .run(
51
+ entry.id,
52
+ entry.entryDate.toISOString().split("T")[0],
53
+ entry.summary,
54
+ entry.mood,
55
+ entry.moodScore,
56
+ entry.therapeuticAdvice,
57
+ JSON.stringify(entry.sessionIds),
58
+ entry.messageCount,
59
+ entry.createdAt.toISOString(),
60
+ entry.updatedAt.toISOString()
61
+ );
62
+
63
+ return entry;
64
+ }
65
+
66
+ async getById(id: string): Promise<DiaryEntry | null> {
67
+ const row = this.db.prepare("SELECT * FROM diary_entries WHERE id = ?").get(id) as
68
+ | DiaryRow
69
+ | undefined;
70
+
71
+ if (!row) {
72
+ return null;
73
+ }
74
+
75
+ return rowToEntry(DiaryRowSchema.parse(row));
76
+ }
77
+
78
+ async getByDate(date: Date): Promise<DiaryEntry | null> {
79
+ const dateStr = date.toISOString().split("T")[0];
80
+
81
+ const row = this.db.prepare("SELECT * FROM diary_entries WHERE entry_date = ?").get(dateStr) as
82
+ | DiaryRow
83
+ | undefined;
84
+
85
+ if (!row) {
86
+ return null;
87
+ }
88
+
89
+ return rowToEntry(DiaryRowSchema.parse(row));
90
+ }
91
+
92
+ async getByDateRange(startDate: Date, endDate: Date): Promise<DiaryEntry[]> {
93
+ const startStr = startDate.toISOString().split("T")[0];
94
+ const endStr = endDate.toISOString().split("T")[0];
95
+
96
+ const rows = this.db
97
+ .prepare(
98
+ `
99
+ SELECT * FROM diary_entries
100
+ WHERE entry_date >= ? AND entry_date <= ?
101
+ ORDER BY entry_date DESC
102
+ `
103
+ )
104
+ .all(startStr, endStr) as DiaryRow[];
105
+
106
+ return rows.map((row) => rowToEntry(DiaryRowSchema.parse(row)));
107
+ }
108
+
109
+ async getRecent(limit = 7): Promise<DiaryEntry[]> {
110
+ const rows = this.db
111
+ .prepare(
112
+ `
113
+ SELECT * FROM diary_entries
114
+ ORDER BY entry_date DESC
115
+ LIMIT ?
116
+ `
117
+ )
118
+ .all(limit) as DiaryRow[];
119
+
120
+ return rows.map((row) => rowToEntry(DiaryRowSchema.parse(row)));
121
+ }
122
+
123
+ async update(
124
+ id: string,
125
+ updates: Partial<Omit<DiaryEntry, "id" | "createdAt">>
126
+ ): Promise<DiaryEntry | null> {
127
+ const setters: string[] = [];
128
+ const values: (string | number | null)[] = [];
129
+
130
+ if (updates.entryDate !== undefined) {
131
+ setters.push("entry_date = ?");
132
+ values.push(updates.entryDate.toISOString().split("T")[0]);
133
+ }
134
+ if (updates.summary !== undefined) {
135
+ setters.push("summary = ?");
136
+ values.push(updates.summary);
137
+ }
138
+ if (updates.mood !== undefined) {
139
+ setters.push("mood = ?");
140
+ values.push(updates.mood);
141
+ }
142
+ if (updates.moodScore !== undefined) {
143
+ setters.push("mood_score = ?");
144
+ values.push(updates.moodScore);
145
+ }
146
+ if (updates.therapeuticAdvice !== undefined) {
147
+ setters.push("therapeutic_advice = ?");
148
+ values.push(updates.therapeuticAdvice);
149
+ }
150
+ if (updates.sessionIds !== undefined) {
151
+ setters.push("session_ids = ?");
152
+ values.push(JSON.stringify(updates.sessionIds));
153
+ }
154
+ if (updates.messageCount !== undefined) {
155
+ setters.push("message_count = ?");
156
+ values.push(updates.messageCount);
157
+ }
158
+
159
+ // Always update updated_at
160
+ setters.push("updated_at = ?");
161
+ values.push(new Date().toISOString());
162
+
163
+ if (setters.length === 1) {
164
+ // Only updated_at was added
165
+ return this.getById(id);
166
+ }
167
+
168
+ values.push(id);
169
+
170
+ const result = this.db
171
+ .prepare(`UPDATE diary_entries SET ${setters.join(", ")} WHERE id = ?`)
172
+ .run(...values);
173
+
174
+ if (result.changes === 0) {
175
+ return null;
176
+ }
177
+
178
+ return this.getById(id);
179
+ }
180
+
181
+ async delete(id: string): Promise<boolean> {
182
+ const result = this.db.prepare("DELETE FROM diary_entries WHERE id = ?").run(id);
183
+
184
+ return result.changes > 0;
185
+ }
186
+
187
+ async getMoodTrend(days = 30): Promise<MoodTrendEntry[]> {
188
+ const startDate = new Date();
189
+ startDate.setDate(startDate.getDate() - days);
190
+ const startStr = startDate.toISOString().split("T")[0];
191
+
192
+ const rows = this.db
193
+ .prepare(
194
+ `
195
+ SELECT entry_date, mood, mood_score
196
+ FROM diary_entries
197
+ WHERE entry_date >= ?
198
+ ORDER BY entry_date ASC
199
+ `
200
+ )
201
+ .all(startStr) as { entry_date: string; mood: string; mood_score: number }[];
202
+
203
+ return rows.map((row) => ({
204
+ date: new Date(row.entry_date),
205
+ mood: row.mood,
206
+ moodScore: row.mood_score,
207
+ }));
208
+ }
209
+ }
@@ -0,0 +1,167 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { z } from "zod";
3
+ import { type Event, EventSchema } from "../../domain/entities/Event";
4
+ import type { IEventsRepository } from "../../domain/interfaces/IEventsRepository";
5
+
6
+ const EventRowSchema = z.object({
7
+ id: z.string().uuid(),
8
+ title: z.string(),
9
+ datetime: z.string(),
10
+ end_time: z.string().nullable(),
11
+ notes: z.string().nullable(),
12
+ tags: z.string().nullable(),
13
+ created_at: z.string(),
14
+ });
15
+
16
+ type EventRow = z.infer<typeof EventRowSchema>;
17
+
18
+ function rowToEvent(row: EventRow): Event {
19
+ return EventSchema.parse({
20
+ id: row.id,
21
+ title: row.title,
22
+ datetime: new Date(row.datetime),
23
+ endTime: row.end_time ? new Date(row.end_time) : undefined,
24
+ notes: row.notes ?? undefined,
25
+ tags: row.tags ? JSON.parse(row.tags) : [],
26
+ createdAt: new Date(row.created_at),
27
+ });
28
+ }
29
+
30
+ export class SqliteEventsRepository implements IEventsRepository {
31
+ constructor(private readonly db: Database) {}
32
+
33
+ async create(event: Event): Promise<Event> {
34
+ const stmt = this.db.prepare(`
35
+ INSERT INTO events (id, title, datetime, end_time, notes, tags, created_at)
36
+ VALUES (?, ?, ?, ?, ?, ?, ?)
37
+ `);
38
+
39
+ stmt.run(
40
+ event.id,
41
+ event.title,
42
+ event.datetime.toISOString(),
43
+ event.endTime?.toISOString() ?? null,
44
+ event.notes ?? null,
45
+ JSON.stringify(event.tags),
46
+ event.createdAt.toISOString()
47
+ );
48
+
49
+ return event;
50
+ }
51
+
52
+ async getById(id: string): Promise<Event | null> {
53
+ const row = this.db.prepare("SELECT * FROM events WHERE id = ?").get(id) as
54
+ | EventRow
55
+ | undefined;
56
+
57
+ if (!row) {
58
+ return null;
59
+ }
60
+
61
+ return rowToEvent(EventRowSchema.parse(row));
62
+ }
63
+
64
+ async getByDateRange(startDate: Date, endDate: Date): Promise<Event[]> {
65
+ const rows = this.db
66
+ .prepare(
67
+ `
68
+ SELECT * FROM events
69
+ WHERE datetime >= ? AND datetime <= ?
70
+ ORDER BY datetime ASC
71
+ `
72
+ )
73
+ .all(startDate.toISOString(), endDate.toISOString()) as EventRow[];
74
+
75
+ return rows.map((row) => rowToEvent(EventRowSchema.parse(row)));
76
+ }
77
+
78
+ async getToday(): Promise<Event[]> {
79
+ const today = new Date();
80
+ const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate());
81
+ const endOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1);
82
+
83
+ return this.getByDateRange(startOfDay, endOfDay);
84
+ }
85
+
86
+ async getUpcoming(limit = 10): Promise<Event[]> {
87
+ const now = new Date();
88
+
89
+ const rows = this.db
90
+ .prepare(
91
+ `
92
+ SELECT * FROM events
93
+ WHERE datetime >= ?
94
+ ORDER BY datetime ASC
95
+ LIMIT ?
96
+ `
97
+ )
98
+ .all(now.toISOString(), limit) as EventRow[];
99
+
100
+ return rows.map((row) => rowToEvent(EventRowSchema.parse(row)));
101
+ }
102
+
103
+ async update(
104
+ id: string,
105
+ updates: Partial<Omit<Event, "id" | "createdAt">>
106
+ ): Promise<Event | null> {
107
+ const setters: string[] = [];
108
+ const values: (string | null)[] = [];
109
+
110
+ if (updates.title !== undefined) {
111
+ setters.push("title = ?");
112
+ values.push(updates.title);
113
+ }
114
+ if (updates.datetime !== undefined) {
115
+ setters.push("datetime = ?");
116
+ values.push(updates.datetime.toISOString());
117
+ }
118
+ if (updates.endTime !== undefined) {
119
+ setters.push("end_time = ?");
120
+ values.push(updates.endTime?.toISOString() ?? null);
121
+ }
122
+ if (updates.notes !== undefined) {
123
+ setters.push("notes = ?");
124
+ values.push(updates.notes ?? null);
125
+ }
126
+ if (updates.tags !== undefined) {
127
+ setters.push("tags = ?");
128
+ values.push(JSON.stringify(updates.tags));
129
+ }
130
+
131
+ if (setters.length === 0) {
132
+ return this.getById(id);
133
+ }
134
+
135
+ values.push(id);
136
+
137
+ const result = this.db
138
+ .prepare(`UPDATE events SET ${setters.join(", ")} WHERE id = ?`)
139
+ .run(...values);
140
+
141
+ if (result.changes === 0) {
142
+ return null;
143
+ }
144
+
145
+ return this.getById(id);
146
+ }
147
+
148
+ async delete(id: string): Promise<boolean> {
149
+ const result = this.db.prepare("DELETE FROM events WHERE id = ?").run(id);
150
+
151
+ return result.changes > 0;
152
+ }
153
+
154
+ async searchByTitle(query: string): Promise<Event[]> {
155
+ const rows = this.db
156
+ .prepare(
157
+ `
158
+ SELECT * FROM events
159
+ WHERE title LIKE ?
160
+ ORDER BY datetime ASC
161
+ `
162
+ )
163
+ .all(`%${query}%`) as EventRow[];
164
+
165
+ return rows.map((row) => rowToEvent(EventRowSchema.parse(row)));
166
+ }
167
+ }