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.
Files changed (54) hide show
  1. package/package.json +29 -0
  2. package/packs/default/faq.yaml +8 -0
  3. package/packs/default/intents.yaml +19 -0
  4. package/packs/default/pack.yaml +12 -0
  5. package/packs/default/policies.yaml +1 -0
  6. package/packs/default/scenarios.yaml +1 -0
  7. package/packs/default/synonyms.yaml +1 -0
  8. package/packs/default/templates.yaml +16 -0
  9. package/packs/default/tools.yaml +1 -0
  10. package/readme.md +1219 -0
  11. package/src/auth.ts +24 -0
  12. package/src/better-sqlite3.d.ts +17 -0
  13. package/src/config.ts +63 -0
  14. package/src/core/matcher.ts +214 -0
  15. package/src/core/normalizer.test.ts +37 -0
  16. package/src/core/normalizer.ts +183 -0
  17. package/src/core/pack-loader.ts +97 -0
  18. package/src/core/reply-engine.test.ts +76 -0
  19. package/src/core/reply-engine.ts +256 -0
  20. package/src/core/request-adapter.ts +65 -0
  21. package/src/core/session-store.ts +48 -0
  22. package/src/core/stream-renderer.ts +237 -0
  23. package/src/core/tool-engine.ts +60 -0
  24. package/src/debug-log.ts +211 -0
  25. package/src/index.ts +23 -0
  26. package/src/openai.ts +79 -0
  27. package/src/response-api.ts +107 -0
  28. package/src/routes/admin.ts +32 -0
  29. package/src/routes/chat-completions.ts +173 -0
  30. package/src/routes/health.ts +7 -0
  31. package/src/routes/models.ts +21 -0
  32. package/src/routes/request-validation.ts +33 -0
  33. package/src/routes/responses.ts +182 -0
  34. package/src/routes/tasks.ts +138 -0
  35. package/src/runtime-stats.ts +80 -0
  36. package/src/server.test.ts +776 -0
  37. package/src/server.ts +108 -0
  38. package/src/tasks/chat-integration.ts +70 -0
  39. package/src/tasks/service.ts +320 -0
  40. package/src/tasks/store.test.ts +183 -0
  41. package/src/tasks/store.ts +602 -0
  42. package/src/tasks/time-parser.test.ts +94 -0
  43. package/src/tasks/time-parser.ts +610 -0
  44. package/src/tasks/timezone.ts +171 -0
  45. package/src/tasks/types.ts +128 -0
  46. package/src/types.ts +202 -0
  47. package/src/weather/chat-integration.ts +56 -0
  48. package/src/weather/location-catalog.ts +166 -0
  49. package/src/weather/open-meteo-provider.ts +221 -0
  50. package/src/weather/parser.test.ts +23 -0
  51. package/src/weather/parser.ts +102 -0
  52. package/src/weather/service.test.ts +54 -0
  53. package/src/weather/service.ts +188 -0
  54. package/src/weather/types.ts +56 -0
package/src/server.ts ADDED
@@ -0,0 +1,108 @@
1
+ import express from "express";
2
+ import type { ServerConfig } from "./config.js";
3
+ import { loadPack } from "./core/pack-loader.js";
4
+ import { ReplyEngine } from "./core/reply-engine.js";
5
+ import { SessionStore } from "./core/session-store.js";
6
+ import { registerAdminRoutes } from "./routes/admin.js";
7
+ import { registerChatCompletionsRoute } from "./routes/chat-completions.js";
8
+ import { registerHealthRoute } from "./routes/health.js";
9
+ import { registerModelsRoute } from "./routes/models.js";
10
+ import { registerResponsesRoute } from "./routes/responses.js";
11
+ import { registerTaskRoutes } from "./routes/tasks.js";
12
+ import { RuntimeStats, type RuntimeStatsSnapshot } from "./runtime-stats.js";
13
+ import { TaskBotService } from "./tasks/service.js";
14
+ import { TaskStore } from "./tasks/store.js";
15
+ import type { LoadedPack } from "./types.js";
16
+ import { OpenMeteoWeatherProvider } from "./weather/open-meteo-provider.js";
17
+ import { WeatherService } from "./weather/service.js";
18
+ import type { WeatherProvider } from "./weather/types.js";
19
+
20
+ export type AppContextDeps = {
21
+ weatherProvider?: WeatherProvider;
22
+ };
23
+
24
+ export type AppContext = {
25
+ config: ServerConfig;
26
+ pack: LoadedPack;
27
+ sessionStore: SessionStore;
28
+ replyEngine: ReplyEngine;
29
+ stats: RuntimeStats;
30
+ taskService: TaskBotService;
31
+ weatherService: WeatherService;
32
+ reloadPack: () => Promise<void>;
33
+ snapshotStats: () => RuntimeStatsSnapshot;
34
+ recordAuthFailure: () => void;
35
+ recordCompletion: (params: { stream: boolean; finishReason: "stop" | "tool_calls" }) => void;
36
+ dispose: () => void;
37
+ };
38
+
39
+ export async function createAppContext(
40
+ config: ServerConfig,
41
+ deps: AppContextDeps = {},
42
+ ): Promise<AppContext> {
43
+ const sessionStore = new SessionStore(config.sessionLogPath);
44
+ const stats = new RuntimeStats();
45
+ const initialPack = await loadPack(config.packDir);
46
+ const taskStore = new TaskStore(config.taskDbPath, config.taskTimezone);
47
+ const taskService = new TaskBotService(taskStore, config.taskReminderPollMs, config.taskTimezone);
48
+ const weatherService = new WeatherService(
49
+ deps.weatherProvider ?? new OpenMeteoWeatherProvider(),
50
+ );
51
+ taskService.start();
52
+
53
+ let context: AppContext;
54
+ context = {
55
+ config,
56
+ pack: initialPack,
57
+ sessionStore,
58
+ replyEngine: new ReplyEngine(initialPack, sessionStore),
59
+ stats,
60
+ taskService,
61
+ weatherService,
62
+ recordAuthFailure: (): void => {
63
+ stats.recordAuthFailure();
64
+ },
65
+ recordCompletion: (params: { stream: boolean; finishReason: "stop" | "tool_calls" }): void => {
66
+ stats.recordCompletion(params);
67
+ },
68
+ snapshotStats: (): RuntimeStatsSnapshot => stats.snapshot(context.pack, sessionStore),
69
+ reloadPack: async (): Promise<void> => {
70
+ const nextPack = await loadPack(config.packDir);
71
+ context.pack = nextPack;
72
+ context.replyEngine = new ReplyEngine(nextPack, sessionStore);
73
+ stats.recordReload();
74
+ },
75
+ dispose: (): void => {
76
+ taskService.stop();
77
+ },
78
+ };
79
+
80
+ return context;
81
+ }
82
+
83
+ export function createApp(context: AppContext) {
84
+ const app = express();
85
+ app.disable("x-powered-by");
86
+ app.use(express.json({ limit: "2mb" }));
87
+
88
+ registerHealthRoute(app);
89
+ registerModelsRoute(app, context);
90
+ registerChatCompletionsRoute(app, context);
91
+ registerResponsesRoute(app, context);
92
+ registerTaskRoutes(app, context);
93
+ registerAdminRoutes(app, context);
94
+
95
+ app.use(
96
+ (error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
97
+ const message = error instanceof Error ? error.message : "internal error";
98
+ res.status(500).json({
99
+ error: {
100
+ message,
101
+ type: "api_error",
102
+ },
103
+ });
104
+ },
105
+ );
106
+
107
+ return app;
108
+ }
@@ -0,0 +1,70 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { EngineResult, NormalizedTurn } from "../types.js";
3
+ import type { TaskBotService, TaskMessageInspection } from "./service.js";
4
+ import type { TaskChatResult } from "./types.js";
5
+
6
+ function firstUserText(turn: NormalizedTurn): string {
7
+ return turn.history.find((message) => message.role === "user")?.text ?? turn.userText;
8
+ }
9
+
10
+ export function resolveTaskUserId(turn: NormalizedTurn, explicitUser?: string): string {
11
+ if (explicitUser?.trim()) {
12
+ return explicitUser.trim();
13
+ }
14
+
15
+ const seed = `${turn.model}\n${firstUserText(turn)}`;
16
+ const digest = createHash("sha1").update(seed).digest("hex").slice(0, 16);
17
+ return `chat:${digest}`;
18
+ }
19
+
20
+ export function inspectTaskMessage(params: {
21
+ taskService: TaskBotService;
22
+ turn: NormalizedTurn;
23
+ explicitUser?: string;
24
+ now?: string;
25
+ }): { userId: string; inspection: TaskMessageInspection } {
26
+ const userId = resolveTaskUserId(params.turn, params.explicitUser);
27
+ const inspection = params.taskService.inspectMessage({
28
+ userId,
29
+ text: params.turn.userText,
30
+ now: params.now,
31
+ });
32
+ return { userId, inspection };
33
+ }
34
+
35
+ export function buildTaskEngineResult(params: {
36
+ turn: NormalizedTurn;
37
+ taskResult: TaskChatResult;
38
+ }): EngineResult {
39
+ return {
40
+ model: params.turn.model,
41
+ sessionId: params.turn.sessionId,
42
+ text: params.taskResult.reply,
43
+ finishReason: "stop",
44
+ matchedIntentId: `task.${params.taskResult.intent}`,
45
+ templateId: `task.${params.taskResult.intent}`,
46
+ };
47
+ }
48
+
49
+ export function respondToTaskMessage(params: {
50
+ taskService: TaskBotService;
51
+ turn: NormalizedTurn;
52
+ explicitUser?: string;
53
+ now?: string;
54
+ }): EngineResult | undefined {
55
+ const { userId, inspection } = inspectTaskMessage(params);
56
+ if (!inspection.shouldHandle) {
57
+ return undefined;
58
+ }
59
+
60
+ const taskResult = params.taskService.processMessage({
61
+ userId,
62
+ text: params.turn.userText,
63
+ now: params.now,
64
+ });
65
+
66
+ return buildTaskEngineResult({
67
+ turn: params.turn,
68
+ taskResult,
69
+ });
70
+ }
@@ -0,0 +1,320 @@
1
+ import {
2
+ formatLocalDateTime,
3
+ isConfirmation,
4
+ parseReminderPreference,
5
+ parseTaskAction,
6
+ parseTaskDraft,
7
+ parseTaskQuery,
8
+ summarizeScope,
9
+ } from "./time-parser.js";
10
+ import { TaskStore } from "./store.js";
11
+ import type {
12
+ ParsedTaskAction,
13
+ ParsedTaskDraft,
14
+ ReminderSummary,
15
+ TaskChatResult,
16
+ TaskScope,
17
+ TaskStats,
18
+ TaskSummary,
19
+ } from "./types.js";
20
+
21
+ function renderTaskLine(task: TaskSummary): string {
22
+ return `- ${task.title}(${task.dueAtText})`;
23
+ }
24
+
25
+ function resolveNow(now?: string): Date {
26
+ if (!now) {
27
+ return new Date();
28
+ }
29
+ const parsed = new Date(now);
30
+ if (Number.isNaN(parsed.getTime())) {
31
+ return new Date();
32
+ }
33
+ return parsed;
34
+ }
35
+
36
+ export type TaskMessageInspection = {
37
+ shouldHandle: boolean;
38
+ reason:
39
+ | "conversation"
40
+ | "query"
41
+ | "action"
42
+ | "draft_ready"
43
+ | "draft_missing_time"
44
+ | "draft_missing_title"
45
+ | "no_match";
46
+ queryScope?: TaskScope;
47
+ actionKind?: ParsedTaskAction["kind"];
48
+ draftKind?: ParsedTaskDraft["kind"];
49
+ title?: string;
50
+ dueAtText?: string;
51
+ };
52
+
53
+ export class TaskBotService {
54
+ private timer: NodeJS.Timeout | undefined;
55
+
56
+ constructor(
57
+ private readonly store: TaskStore,
58
+ private readonly pollMs: number,
59
+ private readonly timeZone: string,
60
+ ) {}
61
+
62
+ start(): void {
63
+ if (this.pollMs <= 0 || this.timer) {
64
+ return;
65
+ }
66
+ this.timer = setInterval(() => {
67
+ this.dispatchDueReminders();
68
+ }, this.pollMs);
69
+ this.timer.unref();
70
+ }
71
+
72
+ stop(): void {
73
+ if (this.timer) {
74
+ clearInterval(this.timer);
75
+ this.timer = undefined;
76
+ }
77
+ this.store.close();
78
+ }
79
+
80
+ inspectMessage(params: { userId: string; text: string; now?: string }): TaskMessageInspection {
81
+ const now = resolveNow(params.now);
82
+ const conversation = this.store.getConversation(params.userId);
83
+ if (conversation) {
84
+ return {
85
+ shouldHandle: true,
86
+ reason: "conversation",
87
+ title: conversation.title,
88
+ dueAtText: formatLocalDateTime(new Date(conversation.dueAt), this.timeZone),
89
+ };
90
+ }
91
+
92
+ const scope = parseTaskQuery(params.text);
93
+ if (scope) {
94
+ return {
95
+ shouldHandle: true,
96
+ reason: "query",
97
+ queryScope: scope,
98
+ };
99
+ }
100
+
101
+ const action = parseTaskAction(params.text);
102
+ if (action) {
103
+ return {
104
+ shouldHandle: true,
105
+ reason: "action",
106
+ actionKind: action.kind,
107
+ };
108
+ }
109
+
110
+ const draft = parseTaskDraft(params.text, now, this.timeZone);
111
+ if (!draft) {
112
+ return {
113
+ shouldHandle: false,
114
+ reason: "no_match",
115
+ };
116
+ }
117
+
118
+ return {
119
+ shouldHandle: true,
120
+ reason:
121
+ draft.kind === "ready"
122
+ ? "draft_ready"
123
+ : draft.kind === "missing_time"
124
+ ? "draft_missing_time"
125
+ : "draft_missing_title",
126
+ draftKind: draft.kind,
127
+ title: "title" in draft ? draft.title : undefined,
128
+ dueAtText: draft.kind === "ready" ? formatLocalDateTime(draft.dueAt, this.timeZone) : undefined,
129
+ };
130
+ }
131
+
132
+ shouldHandleMessage(params: { userId: string; text: string; now?: string }): boolean {
133
+ return this.inspectMessage(params).shouldHandle;
134
+ }
135
+
136
+ dispatchDueReminders(now?: string): number {
137
+ return this.store.dispatchDueReminders(resolveNow(now));
138
+ }
139
+
140
+ listTasks(userId: string, scope: TaskScope, now?: string): TaskSummary[] {
141
+ return this.store.listTasks(userId, scope, resolveNow(now));
142
+ }
143
+
144
+ listPendingReminders(userId: string, now?: string): ReminderSummary[] {
145
+ const current = resolveNow(now);
146
+ this.store.dispatchDueReminders(current);
147
+ return this.store.listPendingReminders(userId);
148
+ }
149
+
150
+ getStats(userId: string, now?: string): TaskStats {
151
+ return this.store.getStats(userId, resolveNow(now));
152
+ }
153
+
154
+ processMessage(params: { userId: string; text: string; now?: string }): TaskChatResult {
155
+ const now = resolveNow(params.now);
156
+ this.store.dispatchDueReminders(now);
157
+
158
+ const session = this.store.getConversation(params.userId);
159
+ if (session) {
160
+ if (/^(取消|算了)$/u.test(params.text.trim())) {
161
+ this.store.clearConversation(params.userId);
162
+ return {
163
+ reply: `已取消任务草稿「${session.title}」。`,
164
+ intent: "task_action",
165
+ };
166
+ }
167
+
168
+ const reminder = parseReminderPreference(params.text);
169
+ if (reminder || isConfirmation(params.text)) {
170
+ const offsetMinutes = reminder ? reminder.offsetMinutes : 0;
171
+ const task = this.store.createTask({
172
+ userId: params.userId,
173
+ title: session.title,
174
+ sourceText: session.sourceText,
175
+ dueAt: new Date(session.dueAt),
176
+ reminderOffsetMinutes: offsetMinutes,
177
+ now,
178
+ });
179
+ this.store.clearConversation(params.userId);
180
+ const reminderText =
181
+ offsetMinutes === null
182
+ ? "不提醒"
183
+ : offsetMinutes === 0
184
+ ? "到点提醒"
185
+ : `提前 ${offsetMinutes} 分钟提醒`;
186
+ return {
187
+ reply: `已创建任务「${task.title}」,时间:${task.dueAtText},提醒:${reminderText}。`,
188
+ intent: "task_created",
189
+ task,
190
+ };
191
+ }
192
+
193
+ return {
194
+ reply: `任务草稿「${session.title}」时间为 ${formatLocalDateTime(new Date(session.dueAt), this.timeZone)}。请回复:提前15分钟提醒 / 到点提醒 / 不用提醒 / 取消。`,
195
+ intent: "clarify",
196
+ };
197
+ }
198
+
199
+ const scope = parseTaskQuery(params.text);
200
+ if (scope) {
201
+ const tasks = this.store.listTasks(params.userId, scope, now);
202
+ if (tasks.length === 0) {
203
+ return {
204
+ reply: `${summarizeScope(scope)}还没有待办任务。`,
205
+ intent: "task_query",
206
+ tasks,
207
+ };
208
+ }
209
+ const lines = tasks.slice(0, 5).map(renderTaskLine).join("\n");
210
+ return {
211
+ reply: `${summarizeScope(scope)}共有 ${tasks.length} 个待办任务:\n${lines}`,
212
+ intent: "task_query",
213
+ tasks,
214
+ };
215
+ }
216
+
217
+ const action = parseTaskAction(params.text);
218
+ if (action) {
219
+ const actionable = this.store.findActionableTasks(params.userId);
220
+ if (actionable.length === 0) {
221
+ return {
222
+ reply: "当前没有等待处理的提醒任务。",
223
+ intent: "clarify",
224
+ };
225
+ }
226
+ if (actionable.length > 1) {
227
+ return {
228
+ reply: `当前有多个待处理提醒:${actionable.map((task) => task.title).join("、")}。请先在任务列表中确认后再处理。`,
229
+ intent: "clarify",
230
+ tasks: actionable,
231
+ };
232
+ }
233
+
234
+ const currentTask = actionable[0];
235
+ if (action.kind === "done") {
236
+ const task = this.store.markTaskDone(currentTask.id, now);
237
+ return {
238
+ reply: `任务「${currentTask.title}」已标记完成。`,
239
+ intent: "task_action",
240
+ task: task ?? currentTask,
241
+ };
242
+ }
243
+ if (action.kind === "cancel") {
244
+ const task = this.store.cancelTask(currentTask.id, now);
245
+ return {
246
+ reply: `任务「${currentTask.title}」已取消。`,
247
+ intent: "task_action",
248
+ task: task ?? currentTask,
249
+ };
250
+ }
251
+
252
+ const task = this.store.snoozeTask(currentTask.id, action.minutes, now);
253
+ return {
254
+ reply: `任务「${currentTask.title}」已延后 ${action.minutes} 分钟,新的时间为 ${task?.dueAtText ?? currentTask.dueAtText}。`,
255
+ intent: "task_action",
256
+ task: task ?? currentTask,
257
+ };
258
+ }
259
+
260
+ const draft = parseTaskDraft(params.text, now, this.timeZone);
261
+ if (draft?.kind === "missing_time") {
262
+ return {
263
+ reply: `我识别到了任务「${draft.title}」,但时间还不够明确。请补充具体时间,例如:明天下午3点。`,
264
+ intent: "clarify",
265
+ };
266
+ }
267
+ if (draft?.kind === "missing_title") {
268
+ return {
269
+ reply: "我识别到了时间,但还缺少任务内容。请补充要做什么。",
270
+ intent: "clarify",
271
+ };
272
+ }
273
+ if (draft?.kind === "ready") {
274
+ if (draft.reminderOffsetMinutes !== undefined) {
275
+ const task = this.store.createTask({
276
+ userId: params.userId,
277
+ title: draft.title,
278
+ sourceText: params.text,
279
+ dueAt: draft.dueAt,
280
+ reminderOffsetMinutes: draft.reminderOffsetMinutes,
281
+ now,
282
+ });
283
+ const reminderText =
284
+ draft.reminderOffsetMinutes === null
285
+ ? "不提醒"
286
+ : draft.reminderOffsetMinutes === 0
287
+ ? "到点提醒"
288
+ : `提前 ${draft.reminderOffsetMinutes} 分钟提醒`;
289
+ return {
290
+ reply: `已创建任务「${task.title}」,时间:${task.dueAtText},提醒:${reminderText}。`,
291
+ intent: "task_created",
292
+ task,
293
+ };
294
+ }
295
+
296
+ this.store.upsertConversation({
297
+ userId: params.userId,
298
+ sourceText: params.text,
299
+ title: draft.title,
300
+ dueAt: draft.dueAt,
301
+ reminderOffsetMinutes: null,
302
+ now,
303
+ });
304
+ return {
305
+ reply: `已创建任务草稿「${draft.title}」,时间:${formatLocalDateTime(draft.dueAt, this.timeZone)},需要提醒吗?可回复:提前15分钟提醒 / 到点提醒 / 不用提醒 / 确认。`,
306
+ intent: "draft_created",
307
+ };
308
+ }
309
+
310
+ const reminders = this.store.listPendingReminders(params.userId);
311
+ const reminderHint = reminders.length
312
+ ? ` 当前有 ${reminders.length} 条待处理提醒,可回复:完成 / 延后10分钟 / 取消。`
313
+ : "";
314
+ return {
315
+ reply: `我目前支持创建任务、设置提醒、查询今天/明天/本周任务,以及处理提醒。示例:明天下午3点开会。${reminderHint}`,
316
+ intent: "help",
317
+ reminders,
318
+ };
319
+ }
320
+ }
@@ -0,0 +1,183 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { TaskStore } from "./store.js";
3
+
4
+ function createStore(): TaskStore {
5
+ return new TaskStore(":memory:", "Asia/Shanghai");
6
+ }
7
+
8
+ function createTimeZoneStore(timeZone: string): TaskStore {
9
+ return new TaskStore(":memory:", timeZone);
10
+ }
11
+
12
+ describe("task store", () => {
13
+ const stores: TaskStore[] = [];
14
+
15
+ afterEach(() => {
16
+ for (const store of stores.splice(0)) {
17
+ store.close();
18
+ }
19
+ });
20
+
21
+ it("filters scoped task lists in SQLite instead of in-process", () => {
22
+ const store = createStore();
23
+ stores.push(store);
24
+
25
+ const createdAt = new Date("2026-03-12T09:00:00+08:00");
26
+ const overdue = store.createTask({
27
+ userId: "u1",
28
+ title: "补昨天的日报",
29
+ sourceText: "昨天的日报",
30
+ dueAt: new Date("2026-03-12T08:30:00+08:00"),
31
+ reminderOffsetMinutes: null,
32
+ now: createdAt,
33
+ });
34
+ store.createTask({
35
+ userId: "u1",
36
+ title: "今天站会",
37
+ sourceText: "今天站会",
38
+ dueAt: new Date("2026-03-12T10:00:00+08:00"),
39
+ reminderOffsetMinutes: null,
40
+ now: createdAt,
41
+ });
42
+ store.createTask({
43
+ userId: "u1",
44
+ title: "明天开会",
45
+ sourceText: "明天开会",
46
+ dueAt: new Date("2026-03-13T09:00:00+08:00"),
47
+ reminderOffsetMinutes: null,
48
+ now: createdAt,
49
+ });
50
+ const done = store.createTask({
51
+ userId: "u1",
52
+ title: "已经完成的任务",
53
+ sourceText: "完成任务",
54
+ dueAt: new Date("2026-03-12T11:00:00+08:00"),
55
+ reminderOffsetMinutes: null,
56
+ now: createdAt,
57
+ });
58
+ store.markTaskDone(done.id, new Date("2026-03-12T11:30:00+08:00"));
59
+ const cancelled = store.createTask({
60
+ userId: "u1",
61
+ title: "取消的任务",
62
+ sourceText: "取消任务",
63
+ dueAt: new Date("2026-03-12T16:00:00+08:00"),
64
+ reminderOffsetMinutes: null,
65
+ now: createdAt,
66
+ });
67
+ store.cancelTask(cancelled.id, new Date("2026-03-12T12:00:00+08:00"));
68
+ store.createTask({
69
+ userId: "u2",
70
+ title: "别人的任务",
71
+ sourceText: "别人的任务",
72
+ dueAt: new Date("2026-03-12T15:00:00+08:00"),
73
+ reminderOffsetMinutes: null,
74
+ now: createdAt,
75
+ });
76
+
77
+ expect(
78
+ store
79
+ .listTasks("u1", "today", new Date("2026-03-12T09:30:00+08:00"))
80
+ .map((task) => task.title),
81
+ ).toEqual(["补昨天的日报", "今天站会"]);
82
+ expect(store.listTasks("u1", "tomorrow", createdAt).map((task) => task.title)).toEqual([
83
+ "明天开会",
84
+ ]);
85
+ expect(
86
+ store
87
+ .listTasks("u1", "overdue", new Date("2026-03-12T09:30:00+08:00"))
88
+ .map((task) => task.id),
89
+ ).toEqual([overdue.id]);
90
+ expect(store.listTasks("u1", "all", createdAt).map((task) => task.title)).toEqual([
91
+ "补昨天的日报",
92
+ "今天站会",
93
+ "已经完成的任务",
94
+ "取消的任务",
95
+ "明天开会",
96
+ ]);
97
+ });
98
+
99
+ it("aggregates task stats in SQL and keeps pending reminder counts accurate", () => {
100
+ const store = createStore();
101
+ stores.push(store);
102
+
103
+ const createdAt = new Date("2026-03-12T09:00:00+08:00");
104
+ store.createTask({
105
+ userId: "u1",
106
+ title: "准备上线",
107
+ sourceText: "准备上线",
108
+ dueAt: new Date("2026-03-12T10:00:00+08:00"),
109
+ reminderOffsetMinutes: null,
110
+ now: createdAt,
111
+ });
112
+ const reminderTask = store.createTask({
113
+ userId: "u1",
114
+ title: "客户会议",
115
+ sourceText: "客户会议",
116
+ dueAt: new Date("2026-03-12T12:30:00+08:00"),
117
+ reminderOffsetMinutes: 30,
118
+ now: createdAt,
119
+ });
120
+ const doneTask = store.createTask({
121
+ userId: "u1",
122
+ title: "写周报",
123
+ sourceText: "写周报",
124
+ dueAt: new Date("2026-03-12T11:00:00+08:00"),
125
+ reminderOffsetMinutes: null,
126
+ now: createdAt,
127
+ });
128
+ store.markTaskDone(doneTask.id, new Date("2026-03-12T11:05:00+08:00"));
129
+ const cancelledTask = store.createTask({
130
+ userId: "u1",
131
+ title: "取消预约",
132
+ sourceText: "取消预约",
133
+ dueAt: new Date("2026-03-13T09:00:00+08:00"),
134
+ reminderOffsetMinutes: null,
135
+ now: createdAt,
136
+ });
137
+ store.cancelTask(cancelledTask.id, new Date("2026-03-12T11:10:00+08:00"));
138
+
139
+ expect(store.dispatchDueReminders(new Date("2026-03-12T12:00:00+08:00"))).toBe(1);
140
+
141
+ expect(store.getStats("u1", new Date("2026-03-12T12:00:00+08:00"))).toMatchObject({
142
+ totalTasks: 4,
143
+ scheduledTasks: 2,
144
+ doneTasks: 1,
145
+ cancelledTasks: 1,
146
+ overdueTasks: 1,
147
+ todayTasks: 3,
148
+ todayDoneTasks: 1,
149
+ pendingReminders: 1,
150
+ });
151
+
152
+ store.markTaskDone(reminderTask.id, new Date("2026-03-12T12:10:00+08:00"));
153
+
154
+ expect(store.getStats("u1", new Date("2026-03-12T12:10:00+08:00")).pendingReminders).toBe(0);
155
+ });
156
+
157
+ it("uses the configured timezone for task scope boundaries and rendering", () => {
158
+ const store = createTimeZoneStore("America/Los_Angeles");
159
+ stores.push(store);
160
+
161
+ const now = new Date("2026-03-12T19:00:00.000Z");
162
+ store.createTask({
163
+ userId: "u1",
164
+ title: "本地昨天深夜",
165
+ sourceText: "昨天深夜",
166
+ dueAt: new Date("2026-03-12T06:30:00.000Z"),
167
+ reminderOffsetMinutes: null,
168
+ now,
169
+ });
170
+ store.createTask({
171
+ userId: "u1",
172
+ title: "本地今天凌晨",
173
+ sourceText: "今天凌晨",
174
+ dueAt: new Date("2026-03-12T07:30:00.000Z"),
175
+ reminderOffsetMinutes: null,
176
+ now,
177
+ });
178
+
179
+ const tasks = store.listTasks("u1", "today", now);
180
+ expect(tasks.map((task) => task.title)).toEqual(["本地今天凌晨"]);
181
+ expect(tasks[0]?.dueAtText).toBe("2026-03-12 00:30");
182
+ });
183
+ });