openclaw-server 0.1.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/package.json +29 -0
- package/packs/default/faq.yaml +8 -0
- package/packs/default/intents.yaml +19 -0
- package/packs/default/pack.yaml +12 -0
- package/packs/default/policies.yaml +1 -0
- package/packs/default/scenarios.yaml +1 -0
- package/packs/default/synonyms.yaml +1 -0
- package/packs/default/templates.yaml +16 -0
- package/packs/default/tools.yaml +1 -0
- package/readme.md +1219 -0
- package/src/auth.ts +24 -0
- package/src/better-sqlite3.d.ts +17 -0
- package/src/config.ts +63 -0
- package/src/core/matcher.ts +214 -0
- package/src/core/normalizer.test.ts +37 -0
- package/src/core/normalizer.ts +183 -0
- package/src/core/pack-loader.ts +97 -0
- package/src/core/reply-engine.test.ts +76 -0
- package/src/core/reply-engine.ts +256 -0
- package/src/core/request-adapter.ts +65 -0
- package/src/core/session-store.ts +48 -0
- package/src/core/stream-renderer.ts +237 -0
- package/src/core/tool-engine.ts +60 -0
- package/src/debug-log.ts +211 -0
- package/src/index.ts +23 -0
- package/src/openai.ts +79 -0
- package/src/response-api.ts +107 -0
- package/src/routes/admin.ts +32 -0
- package/src/routes/chat-completions.ts +173 -0
- package/src/routes/health.ts +7 -0
- package/src/routes/models.ts +21 -0
- package/src/routes/request-validation.ts +33 -0
- package/src/routes/responses.ts +182 -0
- package/src/routes/tasks.ts +138 -0
- package/src/runtime-stats.ts +80 -0
- package/src/server.test.ts +776 -0
- package/src/server.ts +108 -0
- package/src/tasks/chat-integration.ts +70 -0
- package/src/tasks/service.ts +320 -0
- package/src/tasks/store.test.ts +183 -0
- package/src/tasks/store.ts +602 -0
- package/src/tasks/time-parser.test.ts +94 -0
- package/src/tasks/time-parser.ts +610 -0
- package/src/tasks/timezone.ts +171 -0
- package/src/tasks/types.ts +128 -0
- package/src/types.ts +202 -0
- package/src/weather/chat-integration.ts +56 -0
- package/src/weather/location-catalog.ts +166 -0
- package/src/weather/open-meteo-provider.ts +221 -0
- package/src/weather/parser.test.ts +23 -0
- package/src/weather/parser.ts +102 -0
- package/src/weather/service.test.ts +54 -0
- package/src/weather/service.ts +188 -0
- package/src/weather/types.ts +56 -0
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import Database from "better-sqlite3";
|
|
5
|
+
import { buildDateBounds, formatLocalDateTime } from "./timezone.js";
|
|
6
|
+
import type {
|
|
7
|
+
ConversationDraft,
|
|
8
|
+
ReminderSummary,
|
|
9
|
+
ReminderStatus,
|
|
10
|
+
TaskScope,
|
|
11
|
+
TaskStats,
|
|
12
|
+
TaskStatus,
|
|
13
|
+
TaskSummary,
|
|
14
|
+
} from "./types.js";
|
|
15
|
+
|
|
16
|
+
type TaskRow = {
|
|
17
|
+
id: string;
|
|
18
|
+
user_id: string;
|
|
19
|
+
title: string;
|
|
20
|
+
source_text: string;
|
|
21
|
+
due_at: string;
|
|
22
|
+
timezone: string;
|
|
23
|
+
status: TaskStatus;
|
|
24
|
+
reminder_offset_minutes: number | null;
|
|
25
|
+
created_at: string;
|
|
26
|
+
updated_at: string;
|
|
27
|
+
completed_at: string | null;
|
|
28
|
+
cancelled_at: string | null;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type ReminderRow = {
|
|
32
|
+
id: string;
|
|
33
|
+
task_id: string;
|
|
34
|
+
user_id: string;
|
|
35
|
+
remind_at: string;
|
|
36
|
+
message: string;
|
|
37
|
+
status: ReminderStatus;
|
|
38
|
+
sent_at: string | null;
|
|
39
|
+
acked_at: string | null;
|
|
40
|
+
title: string;
|
|
41
|
+
due_at: string;
|
|
42
|
+
timezone: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type TaskStatsRow = {
|
|
46
|
+
totalTasks: number;
|
|
47
|
+
scheduledTasks: number | null;
|
|
48
|
+
doneTasks: number | null;
|
|
49
|
+
cancelledTasks: number | null;
|
|
50
|
+
overdueTasks: number | null;
|
|
51
|
+
todayTasks: number | null;
|
|
52
|
+
todayDoneTasks: number | null;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
type CountRow = {
|
|
56
|
+
count: number;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function toIsoString(date: Date): string {
|
|
60
|
+
return date.toISOString();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function buildReminderMessage(title: string, dueAt: Date, timeZone: string): string {
|
|
64
|
+
return `提醒你:任务「${title}」时间为 ${formatLocalDateTime(dueAt, timeZone)}。回复:完成 / 延后10分钟 / 取消`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function mapTask(row: TaskRow): TaskSummary {
|
|
68
|
+
return {
|
|
69
|
+
id: row.id,
|
|
70
|
+
userId: row.user_id,
|
|
71
|
+
title: row.title,
|
|
72
|
+
sourceText: row.source_text,
|
|
73
|
+
dueAt: row.due_at,
|
|
74
|
+
dueAtText: formatLocalDateTime(new Date(row.due_at), row.timezone),
|
|
75
|
+
timezone: row.timezone,
|
|
76
|
+
status: row.status,
|
|
77
|
+
reminderOffsetMinutes: row.reminder_offset_minutes,
|
|
78
|
+
createdAt: row.created_at,
|
|
79
|
+
updatedAt: row.updated_at,
|
|
80
|
+
completedAt: row.completed_at,
|
|
81
|
+
cancelledAt: row.cancelled_at,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function mapReminder(row: ReminderRow): ReminderSummary {
|
|
86
|
+
return {
|
|
87
|
+
id: row.id,
|
|
88
|
+
taskId: row.task_id,
|
|
89
|
+
userId: row.user_id,
|
|
90
|
+
title: row.title,
|
|
91
|
+
dueAt: row.due_at,
|
|
92
|
+
dueAtText: formatLocalDateTime(new Date(row.due_at), row.timezone),
|
|
93
|
+
remindAt: row.remind_at,
|
|
94
|
+
remindAtText: formatLocalDateTime(new Date(row.remind_at), row.timezone),
|
|
95
|
+
message: row.message,
|
|
96
|
+
status: row.status,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export class TaskStore {
|
|
101
|
+
private readonly db: Database;
|
|
102
|
+
|
|
103
|
+
constructor(
|
|
104
|
+
dbPath: string,
|
|
105
|
+
private readonly timezone: string,
|
|
106
|
+
) {
|
|
107
|
+
if (dbPath !== ":memory:") {
|
|
108
|
+
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
109
|
+
}
|
|
110
|
+
this.db = new Database(dbPath);
|
|
111
|
+
this.db.pragma("journal_mode = WAL");
|
|
112
|
+
this.db.pragma("foreign_keys = ON");
|
|
113
|
+
this.db.pragma("synchronous = NORMAL");
|
|
114
|
+
this.db.exec(`
|
|
115
|
+
CREATE TABLE IF NOT EXISTS conversation_sessions (
|
|
116
|
+
user_id TEXT PRIMARY KEY,
|
|
117
|
+
status TEXT NOT NULL,
|
|
118
|
+
source_text TEXT NOT NULL,
|
|
119
|
+
title TEXT NOT NULL,
|
|
120
|
+
due_at TEXT NOT NULL,
|
|
121
|
+
reminder_offset_minutes INTEGER,
|
|
122
|
+
created_at TEXT NOT NULL,
|
|
123
|
+
updated_at TEXT NOT NULL
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
127
|
+
id TEXT PRIMARY KEY,
|
|
128
|
+
user_id TEXT NOT NULL,
|
|
129
|
+
title TEXT NOT NULL,
|
|
130
|
+
source_text TEXT NOT NULL,
|
|
131
|
+
due_at TEXT NOT NULL,
|
|
132
|
+
timezone TEXT NOT NULL,
|
|
133
|
+
status TEXT NOT NULL,
|
|
134
|
+
reminder_offset_minutes INTEGER,
|
|
135
|
+
completed_at TEXT,
|
|
136
|
+
cancelled_at TEXT,
|
|
137
|
+
created_at TEXT NOT NULL,
|
|
138
|
+
updated_at TEXT NOT NULL
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
CREATE TABLE IF NOT EXISTS task_reminders (
|
|
142
|
+
id TEXT PRIMARY KEY,
|
|
143
|
+
task_id TEXT NOT NULL,
|
|
144
|
+
user_id TEXT NOT NULL,
|
|
145
|
+
remind_at TEXT NOT NULL,
|
|
146
|
+
message TEXT NOT NULL,
|
|
147
|
+
status TEXT NOT NULL,
|
|
148
|
+
sent_at TEXT,
|
|
149
|
+
acked_at TEXT,
|
|
150
|
+
created_at TEXT NOT NULL,
|
|
151
|
+
updated_at TEXT NOT NULL,
|
|
152
|
+
FOREIGN KEY(task_id) REFERENCES tasks(id)
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
CREATE TABLE IF NOT EXISTS task_events (
|
|
156
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
157
|
+
task_id TEXT NOT NULL,
|
|
158
|
+
event_type TEXT NOT NULL,
|
|
159
|
+
payload_json TEXT NOT NULL,
|
|
160
|
+
created_at TEXT NOT NULL,
|
|
161
|
+
FOREIGN KEY(task_id) REFERENCES tasks(id)
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_user_due ON tasks(user_id, due_at);
|
|
165
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_user_status ON tasks(user_id, status);
|
|
166
|
+
CREATE INDEX IF NOT EXISTS idx_reminders_user_status_due ON task_reminders(user_id, status, remind_at);
|
|
167
|
+
`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
close(): void {
|
|
171
|
+
this.db.close();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
getConversation(userId: string): ConversationDraft | undefined {
|
|
175
|
+
const row = this.db
|
|
176
|
+
.prepare(
|
|
177
|
+
`SELECT
|
|
178
|
+
user_id AS userId,
|
|
179
|
+
status,
|
|
180
|
+
source_text AS sourceText,
|
|
181
|
+
title,
|
|
182
|
+
due_at AS dueAt,
|
|
183
|
+
reminder_offset_minutes AS reminderOffsetMinutes,
|
|
184
|
+
created_at AS createdAt,
|
|
185
|
+
updated_at AS updatedAt
|
|
186
|
+
FROM conversation_sessions
|
|
187
|
+
WHERE user_id = ?`,
|
|
188
|
+
)
|
|
189
|
+
.get(userId) as ConversationDraft | undefined;
|
|
190
|
+
return row;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
upsertConversation(params: {
|
|
194
|
+
userId: string;
|
|
195
|
+
sourceText: string;
|
|
196
|
+
title: string;
|
|
197
|
+
dueAt: Date;
|
|
198
|
+
reminderOffsetMinutes: number | null;
|
|
199
|
+
now: Date;
|
|
200
|
+
}): ConversationDraft {
|
|
201
|
+
const nowIso = toIsoString(params.now);
|
|
202
|
+
const dueAtIso = toIsoString(params.dueAt);
|
|
203
|
+
this.db
|
|
204
|
+
.prepare(
|
|
205
|
+
`INSERT INTO conversation_sessions (
|
|
206
|
+
user_id, status, source_text, title, due_at, reminder_offset_minutes, created_at, updated_at
|
|
207
|
+
) VALUES (?, 'awaiting_reminder', ?, ?, ?, ?, ?, ?)
|
|
208
|
+
ON CONFLICT(user_id) DO UPDATE SET
|
|
209
|
+
status = excluded.status,
|
|
210
|
+
source_text = excluded.source_text,
|
|
211
|
+
title = excluded.title,
|
|
212
|
+
due_at = excluded.due_at,
|
|
213
|
+
reminder_offset_minutes = excluded.reminder_offset_minutes,
|
|
214
|
+
updated_at = excluded.updated_at`,
|
|
215
|
+
)
|
|
216
|
+
.run(
|
|
217
|
+
params.userId,
|
|
218
|
+
params.sourceText,
|
|
219
|
+
params.title,
|
|
220
|
+
dueAtIso,
|
|
221
|
+
params.reminderOffsetMinutes,
|
|
222
|
+
nowIso,
|
|
223
|
+
nowIso,
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
userId: params.userId,
|
|
228
|
+
status: "awaiting_reminder",
|
|
229
|
+
sourceText: params.sourceText,
|
|
230
|
+
title: params.title,
|
|
231
|
+
dueAt: dueAtIso,
|
|
232
|
+
reminderOffsetMinutes: params.reminderOffsetMinutes,
|
|
233
|
+
createdAt: nowIso,
|
|
234
|
+
updatedAt: nowIso,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
clearConversation(userId: string): void {
|
|
239
|
+
this.db.prepare(`DELETE FROM conversation_sessions WHERE user_id = ?`).run(userId);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
createTask(params: {
|
|
243
|
+
userId: string;
|
|
244
|
+
title: string;
|
|
245
|
+
sourceText: string;
|
|
246
|
+
dueAt: Date;
|
|
247
|
+
reminderOffsetMinutes: number | null;
|
|
248
|
+
now: Date;
|
|
249
|
+
}): TaskSummary {
|
|
250
|
+
const id = randomUUID();
|
|
251
|
+
const nowIso = toIsoString(params.now);
|
|
252
|
+
const dueAtIso = toIsoString(params.dueAt);
|
|
253
|
+
this.db
|
|
254
|
+
.prepare(
|
|
255
|
+
`INSERT INTO tasks (
|
|
256
|
+
id, user_id, title, source_text, due_at, timezone, status, reminder_offset_minutes, created_at, updated_at
|
|
257
|
+
) VALUES (?, ?, ?, ?, ?, ?, 'scheduled', ?, ?, ?)`,
|
|
258
|
+
)
|
|
259
|
+
.run(
|
|
260
|
+
id,
|
|
261
|
+
params.userId,
|
|
262
|
+
params.title,
|
|
263
|
+
params.sourceText,
|
|
264
|
+
dueAtIso,
|
|
265
|
+
this.timezone,
|
|
266
|
+
params.reminderOffsetMinutes,
|
|
267
|
+
nowIso,
|
|
268
|
+
nowIso,
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
this.insertTaskEvent(
|
|
272
|
+
id,
|
|
273
|
+
"task.created",
|
|
274
|
+
{
|
|
275
|
+
title: params.title,
|
|
276
|
+
dueAt: dueAtIso,
|
|
277
|
+
reminderOffsetMinutes: params.reminderOffsetMinutes,
|
|
278
|
+
},
|
|
279
|
+
params.now,
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
if (params.reminderOffsetMinutes !== null) {
|
|
283
|
+
this.createReminder({
|
|
284
|
+
taskId: id,
|
|
285
|
+
userId: params.userId,
|
|
286
|
+
title: params.title,
|
|
287
|
+
dueAt: params.dueAt,
|
|
288
|
+
offsetMinutes: params.reminderOffsetMinutes,
|
|
289
|
+
timeZone: this.timezone,
|
|
290
|
+
now: params.now,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return this.getTask(id)!;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
getTask(taskId: string): TaskSummary | undefined {
|
|
298
|
+
const row = this.db.prepare(`SELECT * FROM tasks WHERE id = ?`).get(taskId) as TaskRow | undefined;
|
|
299
|
+
return row ? mapTask(row) : undefined;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private createReminder(params: {
|
|
303
|
+
taskId: string;
|
|
304
|
+
userId: string;
|
|
305
|
+
title: string;
|
|
306
|
+
dueAt: Date;
|
|
307
|
+
offsetMinutes: number;
|
|
308
|
+
timeZone: string;
|
|
309
|
+
now: Date;
|
|
310
|
+
}): ReminderSummary {
|
|
311
|
+
const id = randomUUID();
|
|
312
|
+
const remindAt = new Date(
|
|
313
|
+
Math.max(params.now.getTime(), params.dueAt.getTime() - params.offsetMinutes * 60_000),
|
|
314
|
+
);
|
|
315
|
+
const nowIso = toIsoString(params.now);
|
|
316
|
+
const remindAtIso = toIsoString(remindAt);
|
|
317
|
+
this.db
|
|
318
|
+
.prepare(
|
|
319
|
+
`INSERT INTO task_reminders (
|
|
320
|
+
id, task_id, user_id, remind_at, message, status, created_at, updated_at
|
|
321
|
+
) VALUES (?, ?, ?, ?, ?, 'scheduled', ?, ?)`,
|
|
322
|
+
)
|
|
323
|
+
.run(
|
|
324
|
+
id,
|
|
325
|
+
params.taskId,
|
|
326
|
+
params.userId,
|
|
327
|
+
remindAtIso,
|
|
328
|
+
buildReminderMessage(params.title, params.dueAt, params.timeZone),
|
|
329
|
+
nowIso,
|
|
330
|
+
nowIso,
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
this.insertTaskEvent(
|
|
334
|
+
params.taskId,
|
|
335
|
+
"task.reminder.scheduled",
|
|
336
|
+
{
|
|
337
|
+
remindAt: remindAtIso,
|
|
338
|
+
offsetMinutes: params.offsetMinutes,
|
|
339
|
+
},
|
|
340
|
+
params.now,
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
id,
|
|
345
|
+
taskId: params.taskId,
|
|
346
|
+
userId: params.userId,
|
|
347
|
+
title: params.title,
|
|
348
|
+
dueAt: toIsoString(params.dueAt),
|
|
349
|
+
dueAtText: formatLocalDateTime(params.dueAt, params.timeZone),
|
|
350
|
+
remindAt: remindAtIso,
|
|
351
|
+
remindAtText: formatLocalDateTime(remindAt, params.timeZone),
|
|
352
|
+
message: buildReminderMessage(params.title, params.dueAt, params.timeZone),
|
|
353
|
+
status: "scheduled",
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private insertTaskEvent(taskId: string, eventType: string, payload: unknown, now: Date): void {
|
|
358
|
+
this.db
|
|
359
|
+
.prepare(`INSERT INTO task_events (task_id, event_type, payload_json, created_at) VALUES (?, ?, ?, ?)`)
|
|
360
|
+
.run(taskId, eventType, JSON.stringify(payload), toIsoString(now));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
dispatchDueReminders(now: Date): number {
|
|
364
|
+
const nowIso = toIsoString(now);
|
|
365
|
+
const dueRows = this.db
|
|
366
|
+
.prepare(
|
|
367
|
+
`SELECT task_reminders.id, task_reminders.task_id
|
|
368
|
+
FROM task_reminders
|
|
369
|
+
JOIN tasks ON tasks.id = task_reminders.task_id
|
|
370
|
+
WHERE task_reminders.status = 'scheduled'
|
|
371
|
+
AND tasks.status = 'scheduled'
|
|
372
|
+
AND task_reminders.remind_at <= ?
|
|
373
|
+
ORDER BY task_reminders.remind_at ASC`,
|
|
374
|
+
)
|
|
375
|
+
.all(nowIso) as Array<{ id: string; task_id: string }>;
|
|
376
|
+
|
|
377
|
+
if (dueRows.length === 0) {
|
|
378
|
+
return 0;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
this.db.exec("BEGIN");
|
|
382
|
+
try {
|
|
383
|
+
for (const row of dueRows) {
|
|
384
|
+
this.db
|
|
385
|
+
.prepare(
|
|
386
|
+
`UPDATE task_reminders
|
|
387
|
+
SET status = 'sent', sent_at = ?, updated_at = ?
|
|
388
|
+
WHERE id = ? AND status = 'scheduled'`,
|
|
389
|
+
)
|
|
390
|
+
.run(nowIso, nowIso, row.id);
|
|
391
|
+
this.insertTaskEvent(row.task_id, "task.reminder.sent", { reminderId: row.id }, now);
|
|
392
|
+
}
|
|
393
|
+
this.db.exec("COMMIT");
|
|
394
|
+
} catch (error) {
|
|
395
|
+
try {
|
|
396
|
+
this.db.exec("ROLLBACK");
|
|
397
|
+
} catch {
|
|
398
|
+
// Preserve the original reminder dispatch failure.
|
|
399
|
+
}
|
|
400
|
+
throw error;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return dueRows.length;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
listPendingReminders(userId: string): ReminderSummary[] {
|
|
407
|
+
const rows = this.db
|
|
408
|
+
.prepare(
|
|
409
|
+
`SELECT
|
|
410
|
+
task_reminders.id,
|
|
411
|
+
task_reminders.task_id,
|
|
412
|
+
task_reminders.user_id,
|
|
413
|
+
task_reminders.remind_at,
|
|
414
|
+
task_reminders.message,
|
|
415
|
+
task_reminders.status,
|
|
416
|
+
task_reminders.sent_at,
|
|
417
|
+
task_reminders.acked_at,
|
|
418
|
+
tasks.title,
|
|
419
|
+
tasks.due_at,
|
|
420
|
+
tasks.timezone
|
|
421
|
+
FROM task_reminders
|
|
422
|
+
JOIN tasks ON tasks.id = task_reminders.task_id
|
|
423
|
+
WHERE task_reminders.user_id = ?
|
|
424
|
+
AND task_reminders.status = 'sent'
|
|
425
|
+
AND tasks.status = 'scheduled'
|
|
426
|
+
ORDER BY task_reminders.remind_at ASC`,
|
|
427
|
+
)
|
|
428
|
+
.all(userId) as ReminderRow[];
|
|
429
|
+
return rows.map(mapReminder);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
findActionableTasks(userId: string): TaskSummary[] {
|
|
433
|
+
const rows = this.db
|
|
434
|
+
.prepare(
|
|
435
|
+
`SELECT DISTINCT tasks.*
|
|
436
|
+
FROM tasks
|
|
437
|
+
JOIN task_reminders ON task_reminders.task_id = tasks.id
|
|
438
|
+
WHERE tasks.user_id = ?
|
|
439
|
+
AND tasks.status = 'scheduled'
|
|
440
|
+
AND task_reminders.status = 'sent'
|
|
441
|
+
ORDER BY task_reminders.sent_at DESC, tasks.due_at ASC`,
|
|
442
|
+
)
|
|
443
|
+
.all(userId) as TaskRow[];
|
|
444
|
+
return rows.map(mapTask);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
markTaskDone(taskId: string, now: Date): TaskSummary | undefined {
|
|
448
|
+
const nowIso = toIsoString(now);
|
|
449
|
+
this.db
|
|
450
|
+
.prepare(
|
|
451
|
+
`UPDATE tasks
|
|
452
|
+
SET status = 'done', completed_at = ?, updated_at = ?
|
|
453
|
+
WHERE id = ? AND status = 'scheduled'`,
|
|
454
|
+
)
|
|
455
|
+
.run(nowIso, nowIso, taskId);
|
|
456
|
+
this.db
|
|
457
|
+
.prepare(
|
|
458
|
+
`UPDATE task_reminders
|
|
459
|
+
SET status = 'acked', acked_at = ?, updated_at = ?
|
|
460
|
+
WHERE task_id = ? AND status IN ('scheduled', 'sent')`,
|
|
461
|
+
)
|
|
462
|
+
.run(nowIso, nowIso, taskId);
|
|
463
|
+
this.insertTaskEvent(taskId, "task.done", {}, now);
|
|
464
|
+
return this.getTask(taskId);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
cancelTask(taskId: string, now: Date): TaskSummary | undefined {
|
|
468
|
+
const nowIso = toIsoString(now);
|
|
469
|
+
this.db
|
|
470
|
+
.prepare(
|
|
471
|
+
`UPDATE tasks
|
|
472
|
+
SET status = 'cancelled', cancelled_at = ?, updated_at = ?
|
|
473
|
+
WHERE id = ? AND status = 'scheduled'`,
|
|
474
|
+
)
|
|
475
|
+
.run(nowIso, nowIso, taskId);
|
|
476
|
+
this.db
|
|
477
|
+
.prepare(
|
|
478
|
+
`UPDATE task_reminders
|
|
479
|
+
SET status = 'cancelled', updated_at = ?
|
|
480
|
+
WHERE task_id = ? AND status IN ('scheduled', 'sent')`,
|
|
481
|
+
)
|
|
482
|
+
.run(nowIso, taskId);
|
|
483
|
+
this.insertTaskEvent(taskId, "task.cancelled", {}, now);
|
|
484
|
+
return this.getTask(taskId);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
snoozeTask(taskId: string, minutes: number, now: Date): TaskSummary | undefined {
|
|
488
|
+
const task = this.getTask(taskId);
|
|
489
|
+
if (!task || task.status !== "scheduled") {
|
|
490
|
+
return task;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const currentDueAt = new Date(task.dueAt);
|
|
494
|
+
const base = currentDueAt.getTime() > now.getTime() ? currentDueAt : now;
|
|
495
|
+
const nextDueAt = new Date(base.getTime() + minutes * 60_000);
|
|
496
|
+
const nowIso = toIsoString(now);
|
|
497
|
+
const nextDueAtIso = toIsoString(nextDueAt);
|
|
498
|
+
|
|
499
|
+
this.db.prepare(`UPDATE tasks SET due_at = ?, updated_at = ? WHERE id = ?`).run(nextDueAtIso, nowIso, taskId);
|
|
500
|
+
this.db
|
|
501
|
+
.prepare(
|
|
502
|
+
`UPDATE task_reminders
|
|
503
|
+
SET status = 'cancelled', updated_at = ?
|
|
504
|
+
WHERE task_id = ? AND status IN ('scheduled', 'sent')`,
|
|
505
|
+
)
|
|
506
|
+
.run(nowIso, taskId);
|
|
507
|
+
this.insertTaskEvent(taskId, "task.snoozed", { minutes, dueAt: nextDueAtIso }, now);
|
|
508
|
+
|
|
509
|
+
if (task.reminderOffsetMinutes !== null) {
|
|
510
|
+
this.createReminder({
|
|
511
|
+
taskId,
|
|
512
|
+
userId: task.userId,
|
|
513
|
+
title: task.title,
|
|
514
|
+
dueAt: nextDueAt,
|
|
515
|
+
offsetMinutes: task.reminderOffsetMinutes,
|
|
516
|
+
timeZone: task.timezone,
|
|
517
|
+
now,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
return this.getTask(taskId);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
listTasks(userId: string, scope: TaskScope, now: Date): TaskSummary[] {
|
|
525
|
+
const bounds = buildDateBounds(now, this.timezone);
|
|
526
|
+
let query = `SELECT * FROM tasks WHERE user_id = ?`;
|
|
527
|
+
const params: unknown[] = [userId];
|
|
528
|
+
|
|
529
|
+
// Keep the heavy filtering in SQLite so task lists do not grow linearly in JS on each request.
|
|
530
|
+
switch (scope) {
|
|
531
|
+
case "today":
|
|
532
|
+
query += ` AND status = 'scheduled' AND due_at >= ? AND due_at < ?`;
|
|
533
|
+
params.push(bounds.todayStartIso, bounds.tomorrowStartIso);
|
|
534
|
+
break;
|
|
535
|
+
case "tomorrow":
|
|
536
|
+
query += ` AND status = 'scheduled' AND due_at >= ? AND due_at < ?`;
|
|
537
|
+
params.push(bounds.tomorrowStartIso, bounds.dayAfterTomorrowIso);
|
|
538
|
+
break;
|
|
539
|
+
case "week":
|
|
540
|
+
query += ` AND status = 'scheduled' AND due_at >= ? AND due_at < ?`;
|
|
541
|
+
params.push(bounds.todayStartIso, bounds.nextWeekStartIso);
|
|
542
|
+
break;
|
|
543
|
+
case "overdue":
|
|
544
|
+
query += ` AND status = 'scheduled' AND due_at < ?`;
|
|
545
|
+
params.push(bounds.nowIso);
|
|
546
|
+
break;
|
|
547
|
+
case "all":
|
|
548
|
+
break;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
query += ` ORDER BY due_at ASC`;
|
|
552
|
+
const rows = this.db.prepare(query).all(...params) as TaskRow[];
|
|
553
|
+
return rows.map(mapTask);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
getStats(userId: string, now: Date): TaskStats {
|
|
557
|
+
const bounds = buildDateBounds(now, this.timezone);
|
|
558
|
+
const row = this.db
|
|
559
|
+
.prepare(
|
|
560
|
+
`SELECT
|
|
561
|
+
COUNT(*) AS totalTasks,
|
|
562
|
+
SUM(CASE WHEN status = 'scheduled' THEN 1 ELSE 0 END) AS scheduledTasks,
|
|
563
|
+
SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) AS doneTasks,
|
|
564
|
+
SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS cancelledTasks,
|
|
565
|
+
SUM(CASE WHEN status = 'scheduled' AND due_at < ? THEN 1 ELSE 0 END) AS overdueTasks,
|
|
566
|
+
SUM(CASE WHEN due_at >= ? AND due_at < ? THEN 1 ELSE 0 END) AS todayTasks,
|
|
567
|
+
SUM(CASE WHEN completed_at >= ? AND completed_at < ? THEN 1 ELSE 0 END) AS todayDoneTasks
|
|
568
|
+
FROM tasks
|
|
569
|
+
WHERE user_id = ?`,
|
|
570
|
+
)
|
|
571
|
+
.get(
|
|
572
|
+
bounds.nowIso,
|
|
573
|
+
bounds.todayStartIso,
|
|
574
|
+
bounds.tomorrowStartIso,
|
|
575
|
+
bounds.todayStartIso,
|
|
576
|
+
bounds.tomorrowStartIso,
|
|
577
|
+
userId,
|
|
578
|
+
) as TaskStatsRow | undefined;
|
|
579
|
+
const reminderRow = this.db
|
|
580
|
+
.prepare(
|
|
581
|
+
`SELECT COUNT(*) AS count
|
|
582
|
+
FROM task_reminders
|
|
583
|
+
JOIN tasks ON tasks.id = task_reminders.task_id
|
|
584
|
+
WHERE task_reminders.user_id = ?
|
|
585
|
+
AND task_reminders.status = 'sent'
|
|
586
|
+
AND tasks.status = 'scheduled'`,
|
|
587
|
+
)
|
|
588
|
+
.get(userId) as CountRow | undefined;
|
|
589
|
+
|
|
590
|
+
return {
|
|
591
|
+
totalTasks: row?.totalTasks ?? 0,
|
|
592
|
+
scheduledTasks: row?.scheduledTasks ?? 0,
|
|
593
|
+
doneTasks: row?.doneTasks ?? 0,
|
|
594
|
+
cancelledTasks: row?.cancelledTasks ?? 0,
|
|
595
|
+
overdueTasks: row?.overdueTasks ?? 0,
|
|
596
|
+
todayTasks: row?.todayTasks ?? 0,
|
|
597
|
+
todayDoneTasks: row?.todayDoneTasks ?? 0,
|
|
598
|
+
pendingReminders: reminderRow?.count ?? 0,
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
formatLocalDateTime,
|
|
4
|
+
parseReminderPreference,
|
|
5
|
+
parseTaskAction,
|
|
6
|
+
parseTaskDraft,
|
|
7
|
+
parseTaskQuery,
|
|
8
|
+
} from "./time-parser.js";
|
|
9
|
+
|
|
10
|
+
describe("task time parser", () => {
|
|
11
|
+
it("parses Chinese natural language task drafts", () => {
|
|
12
|
+
const now = new Date("2026-03-12T09:00:00+08:00");
|
|
13
|
+
const draft = parseTaskDraft("明天下午3点开会", now, "Asia/Shanghai");
|
|
14
|
+
expect(draft?.kind).toBe("ready");
|
|
15
|
+
if (!draft || draft.kind !== "ready") {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
expect(draft.title).toBe("开会");
|
|
19
|
+
expect(draft.dueAt.toISOString()).toBe("2026-03-13T07:00:00.000Z");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("parses Chinese numerals and colloquial reminder phrases", () => {
|
|
23
|
+
const now = new Date("2026-03-12T09:00:00+08:00");
|
|
24
|
+
|
|
25
|
+
const chineseClockDraft = parseTaskDraft("明天下午五点开会", now, "Asia/Shanghai");
|
|
26
|
+
expect(chineseClockDraft?.kind).toBe("ready");
|
|
27
|
+
if (!chineseClockDraft || chineseClockDraft.kind !== "ready") {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
expect(chineseClockDraft.title).toBe("开会");
|
|
31
|
+
expect(chineseClockDraft.dueAt.toISOString()).toBe("2026-03-13T09:00:00.000Z");
|
|
32
|
+
|
|
33
|
+
const weekdayDraft = parseTaskDraft("周五晚上八点聚餐", now, "Asia/Shanghai");
|
|
34
|
+
expect(weekdayDraft?.kind).toBe("ready");
|
|
35
|
+
if (!weekdayDraft || weekdayDraft.kind !== "ready") {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
expect(weekdayDraft.title).toBe("聚餐");
|
|
39
|
+
expect(weekdayDraft.dueAt.toISOString()).toBe("2026-03-13T12:00:00.000Z");
|
|
40
|
+
|
|
41
|
+
const relativeDraft = parseTaskDraft("半小时后提醒我喝水", now, "Asia/Shanghai");
|
|
42
|
+
expect(relativeDraft?.kind).toBe("ready");
|
|
43
|
+
if (!relativeDraft || relativeDraft.kind !== "ready") {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
expect(relativeDraft.title).toBe("喝水");
|
|
47
|
+
expect(relativeDraft.dueAt.toISOString()).toBe("2026-03-12T01:30:00.000Z");
|
|
48
|
+
|
|
49
|
+
const monthDayDraft = parseTaskDraft("三月十五日下午三点开会", now, "Asia/Shanghai");
|
|
50
|
+
expect(monthDayDraft?.kind).toBe("ready");
|
|
51
|
+
if (!monthDayDraft || monthDayDraft.kind !== "ready") {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
expect(monthDayDraft.title).toBe("开会");
|
|
55
|
+
expect(monthDayDraft.dueAt.toISOString()).toBe("2026-03-15T07:00:00.000Z");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("parses and formats task times in the configured timezone", () => {
|
|
59
|
+
const now = new Date("2026-03-12T09:00:00.000Z");
|
|
60
|
+
const draft = parseTaskDraft("明天下午3点开会", now, "America/Los_Angeles");
|
|
61
|
+
expect(draft?.kind).toBe("ready");
|
|
62
|
+
if (!draft || draft.kind !== "ready") {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
expect(draft.dueAt.toISOString()).toBe("2026-03-13T22:00:00.000Z");
|
|
66
|
+
expect(formatLocalDateTime(draft.dueAt, "America/Los_Angeles")).toBe("2026-03-13 15:00");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("parses reminder preferences, task actions, and task queries", () => {
|
|
70
|
+
expect(parseReminderPreference("提前15分钟提醒")?.offsetMinutes).toBe(15);
|
|
71
|
+
expect(parseReminderPreference("提前十分钟提醒")?.offsetMinutes).toBe(10);
|
|
72
|
+
expect(parseReminderPreference("提前一个半小时提醒")?.offsetMinutes).toBe(90);
|
|
73
|
+
expect(parseReminderPreference("准时提醒")?.offsetMinutes).toBe(0);
|
|
74
|
+
expect(parseReminderPreference("不需要提醒")?.offsetMinutes).toBeNull();
|
|
75
|
+
expect(parseTaskAction("延后两小时")).toEqual({ kind: "snooze", minutes: 120 });
|
|
76
|
+
expect(parseTaskAction("延后半小时")).toEqual({ kind: "snooze", minutes: 30 });
|
|
77
|
+
expect(parseTaskQuery("今天有什么任务")).toBe("today");
|
|
78
|
+
expect(parseTaskQuery("我的任务")).toBe("all");
|
|
79
|
+
expect(parseTaskQuery("我有哪些任务?")).toBe("all");
|
|
80
|
+
expect(parseTaskQuery("查看我的待办")).toBe("all");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("keeps embedded reminder offsets when creating drafts", () => {
|
|
84
|
+
const now = new Date("2026-03-12T09:00:00+08:00");
|
|
85
|
+
const draft = parseTaskDraft("明天下午五点提醒我开会提前一小时提醒", now, "Asia/Shanghai");
|
|
86
|
+
expect(draft?.kind).toBe("ready");
|
|
87
|
+
if (!draft || draft.kind !== "ready") {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
expect(draft.title).toBe("开会");
|
|
91
|
+
expect(draft.reminderOffsetMinutes).toBe(60);
|
|
92
|
+
expect(draft.dueAt.toISOString()).toBe("2026-03-13T09:00:00.000Z");
|
|
93
|
+
});
|
|
94
|
+
});
|