libero3 0.3.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 (192) hide show
  1. package/COLD_START.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +43 -0
  4. package/data/migrations/001_initial_schema.sql +43 -0
  5. package/data/migrations/002_seed_categories.sql +18 -0
  6. package/data/migrations/003_add_rating.sql +4 -0
  7. package/data/migrations/004_timer_sessions.sql +21 -0
  8. package/data/migrations/005_profile_settings.sql +5 -0
  9. package/data/migrations/006_add_timeblock_status.sql +1 -0
  10. package/data/migrations/007_reminders.sql +17 -0
  11. package/data/migrations/008_timer_target_ms.sql +5 -0
  12. package/data/migrations/009_tasks.sql +22 -0
  13. package/data/migrations/010_timer_task_id.sql +4 -0
  14. package/data/migrations/011_profile_default_confirmed.sql +4 -0
  15. package/data/migrations/012_identity_tables.sql +68 -0
  16. package/data/migrations/013_identity_managed_by.sql +3 -0
  17. package/data/migrations/014_task_difficulty_priority.sql +6 -0
  18. package/data/migrations/015_timeblock_asked_at.sql +5 -0
  19. package/data/migrations/016_intent_columns.sql +8 -0
  20. package/data/migrations/017_ap_wallet.sql +25 -0
  21. package/data/migrations/018_ap_rewards.sql +21 -0
  22. package/data/migrations/019_ap_bounty.sql +10 -0
  23. package/data/migrations/020_chat_turns.sql +10 -0
  24. package/data/migrations/021_portrait.sql +4 -0
  25. package/data/migrations/022_family_emoji_single.sql +3 -0
  26. package/data/migrations/023_ap_balance_view_log.sql +9 -0
  27. package/data/schema.sql +75 -0
  28. package/dist/data/db.js +83 -0
  29. package/dist/data/migrations/001_initial_schema.sql +43 -0
  30. package/dist/data/migrations/002_seed_categories.sql +18 -0
  31. package/dist/data/migrations/003_add_rating.sql +4 -0
  32. package/dist/data/migrations/004_timer_sessions.sql +21 -0
  33. package/dist/data/migrations/005_profile_settings.sql +5 -0
  34. package/dist/data/migrations/006_add_timeblock_status.sql +1 -0
  35. package/dist/data/migrations/007_reminders.sql +17 -0
  36. package/dist/data/migrations/008_timer_target_ms.sql +5 -0
  37. package/dist/data/migrations/009_tasks.sql +22 -0
  38. package/dist/data/migrations/010_timer_task_id.sql +4 -0
  39. package/dist/data/migrations/011_profile_default_confirmed.sql +4 -0
  40. package/dist/data/migrations/012_identity_tables.sql +68 -0
  41. package/dist/data/migrations/013_identity_managed_by.sql +3 -0
  42. package/dist/data/migrations/014_task_difficulty_priority.sql +6 -0
  43. package/dist/data/migrations/015_timeblock_asked_at.sql +5 -0
  44. package/dist/data/migrations/016_intent_columns.sql +8 -0
  45. package/dist/data/migrations/017_ap_wallet.sql +25 -0
  46. package/dist/data/migrations/018_ap_rewards.sql +21 -0
  47. package/dist/data/migrations/019_ap_bounty.sql +10 -0
  48. package/dist/data/migrations/020_chat_turns.sql +10 -0
  49. package/dist/data/migrations/021_portrait.sql +4 -0
  50. package/dist/data/migrations/022_family_emoji_single.sql +3 -0
  51. package/dist/data/migrations/023_ap_balance_view_log.sql +9 -0
  52. package/dist/data/schema.sql +75 -0
  53. package/dist/knowledge/_registry/dimensions.yaml +10 -0
  54. package/dist/knowledge/_registry/personas.yaml +7 -0
  55. package/dist/knowledge/_schema/analysis-model.schema.js +32 -0
  56. package/dist/knowledge/_schema/entry.schema.js +45 -0
  57. package/dist/knowledge/_schema/persona.schema.js +23 -0
  58. package/dist/knowledge/_schema/scenario-pack.schema.js +29 -0
  59. package/dist/knowledge/analysis-models/review-default.yaml +16 -0
  60. package/dist/knowledge/analysis-models/review-summer-plan.yaml +15 -0
  61. package/dist/knowledge/brain-science/brain-deep-warmup.yaml +22 -0
  62. package/dist/knowledge/brain-science/brain-emotion-granularity.yaml +22 -0
  63. package/dist/knowledge/brain-science/brain-env-engineering.yaml +22 -0
  64. package/dist/knowledge/brain-science/brain-extended-mind.yaml +22 -0
  65. package/dist/knowledge/brain-science/brain-friction-barrier.yaml +22 -0
  66. package/dist/knowledge/brain-science/brain-peak-end.yaml +29 -0
  67. package/dist/knowledge/brain-science/brain-pomodoro.yaml +31 -0
  68. package/dist/knowledge/brain-science/brain-revenge-bedtime.yaml +22 -0
  69. package/dist/knowledge/brain-science/brain-single-variable.yaml +15 -0
  70. package/dist/knowledge/brain-science/brain-tiny-habits.yaml +22 -0
  71. package/dist/knowledge/energy/energy-active-recovery.yaml +22 -0
  72. package/dist/knowledge/energy/energy-deliberate-rest.yaml +22 -0
  73. package/dist/knowledge/energy/energy-exercise-embedded.yaml +22 -0
  74. package/dist/knowledge/energy/energy-fixed-wake-time.yaml +22 -0
  75. package/dist/knowledge/energy/energy-four-energy-radar.yaml +22 -0
  76. package/dist/knowledge/energy/energy-intensity-recovery.yaml +22 -0
  77. package/dist/knowledge/scenario-packs/pack-summer-kid.yaml +12 -0
  78. package/dist/knowledge/src/repo/fs-yaml.js +252 -0
  79. package/dist/knowledge/src/repo/types.js +1 -0
  80. package/dist/knowledge/time/time-batch-windows.yaml +22 -0
  81. package/dist/knowledge/time/time-conscious-abandon.yaml +22 -0
  82. package/dist/knowledge/time/time-deep-work-block.yaml +22 -0
  83. package/dist/knowledge/time/time-finite-commitment.yaml +22 -0
  84. package/dist/knowledge/time/time-forced-whitespace.yaml +22 -0
  85. package/dist/knowledge/time/time-gtd-next-action.yaml +22 -0
  86. package/dist/knowledge/time/time-three-tier-list.yaml +22 -0
  87. package/dist/mcp/cold-start.js +43 -0
  88. package/dist/mcp/contract/v1/rest.js +56 -0
  89. package/dist/mcp/rest-server.js +52 -0
  90. package/dist/mcp/server-core.js +1334 -0
  91. package/dist/mcp/server.js +34 -0
  92. package/dist/mcp/src/ap/index.js +14 -0
  93. package/dist/mcp/src/ap/rewards.js +44 -0
  94. package/dist/mcp/src/categories/index.js +12 -0
  95. package/dist/mcp/src/chat/from-env.js +111 -0
  96. package/dist/mcp/src/chat/history.js +35 -0
  97. package/dist/mcp/src/chat/loop.js +131 -0
  98. package/dist/mcp/src/chat/portrait.js +133 -0
  99. package/dist/mcp/src/chat/system-prompt.js +23 -0
  100. package/dist/mcp/src/chat/tool-names.js +27 -0
  101. package/dist/mcp/src/chat/tool-schema.js +20 -0
  102. package/dist/mcp/src/chat/types.js +1 -0
  103. package/dist/mcp/src/clock/index.js +78 -0
  104. package/dist/mcp/src/coachmod/index.js +135 -0
  105. package/dist/mcp/src/host-adapters/hermes-cron.js +185 -0
  106. package/dist/mcp/src/host-adapters/index.js +61 -0
  107. package/dist/mcp/src/host-adapters/types.js +7 -0
  108. package/dist/mcp/src/identity/equivalent-ids.js +41 -0
  109. package/dist/mcp/src/identity/in-memory.js +100 -0
  110. package/dist/mcp/src/identity/resolve.js +60 -0
  111. package/dist/mcp/src/identity/types.js +4 -0
  112. package/dist/mcp/src/matrix/index.js +15 -0
  113. package/dist/mcp/src/profile/confirm-default.js +48 -0
  114. package/dist/mcp/src/profile/index.js +298 -0
  115. package/dist/mcp/src/profile-confirm.js +28 -0
  116. package/dist/mcp/src/profile-helpers.js +109 -0
  117. package/dist/mcp/src/reminder/hermes-channel.js +40 -0
  118. package/dist/mcp/src/reminder/index.js +70 -0
  119. package/dist/mcp/src/reminder/push-channel.js +10 -0
  120. package/dist/mcp/src/repo/factory.js +34 -0
  121. package/dist/mcp/src/repo/in-memory.js +469 -0
  122. package/dist/mcp/src/repo/sqlite-ap.js +188 -0
  123. package/dist/mcp/src/repo/sqlite-helpers.js +39 -0
  124. package/dist/mcp/src/repo/sqlite-identity.js +129 -0
  125. package/dist/mcp/src/repo/sqlite.js +754 -0
  126. package/dist/mcp/src/repo/types.js +4 -0
  127. package/dist/mcp/src/rest/app.js +121 -0
  128. package/dist/mcp/src/rest/invoke-tool.js +98 -0
  129. package/dist/mcp/src/rest/short-token.js +38 -0
  130. package/dist/mcp/src/rest/tokens.js +55 -0
  131. package/dist/mcp/src/scheduler/index.js +319 -0
  132. package/dist/mcp/src/stats/index.js +29 -0
  133. package/dist/mcp/src/stickiness/report.js +174 -0
  134. package/dist/mcp/src/stickiness/types.js +1 -0
  135. package/dist/mcp/src/task/index.js +412 -0
  136. package/dist/mcp/src/timeblock/index.js +206 -0
  137. package/dist/mcp/src/timeline/index.js +705 -0
  138. package/dist/mcp/src/timer/index.js +234 -0
  139. package/dist/mcp/src/user-id-resolver.js +30 -0
  140. package/dist/mcp/src/weixin/app.js +97 -0
  141. package/dist/mcp/src/weixin/custom-send.js +82 -0
  142. package/dist/mcp/src/weixin/handle-text.js +49 -0
  143. package/dist/mcp/src/weixin/reminder-loop.js +39 -0
  144. package/dist/mcp/src/weixin/signature.js +13 -0
  145. package/dist/mcp/src/weixin/tencent-asr.js +101 -0
  146. package/dist/mcp/src/weixin/xml.js +37 -0
  147. package/dist/mcp/weixin-server.js +158 -0
  148. package/knowledge/_registry/dimensions.yaml +10 -0
  149. package/knowledge/_registry/personas.yaml +7 -0
  150. package/knowledge/_schema/analysis-model.schema.ts +36 -0
  151. package/knowledge/_schema/entry.schema.ts +50 -0
  152. package/knowledge/_schema/persona.schema.ts +30 -0
  153. package/knowledge/_schema/scenario-pack.schema.ts +33 -0
  154. package/knowledge/analysis-models/review-default.yaml +16 -0
  155. package/knowledge/analysis-models/review-summer-plan.yaml +15 -0
  156. package/knowledge/brain-science/brain-deep-warmup.yaml +22 -0
  157. package/knowledge/brain-science/brain-emotion-granularity.yaml +22 -0
  158. package/knowledge/brain-science/brain-env-engineering.yaml +22 -0
  159. package/knowledge/brain-science/brain-extended-mind.yaml +22 -0
  160. package/knowledge/brain-science/brain-friction-barrier.yaml +22 -0
  161. package/knowledge/brain-science/brain-peak-end.yaml +29 -0
  162. package/knowledge/brain-science/brain-pomodoro.yaml +31 -0
  163. package/knowledge/brain-science/brain-revenge-bedtime.yaml +22 -0
  164. package/knowledge/brain-science/brain-single-variable.yaml +15 -0
  165. package/knowledge/brain-science/brain-tiny-habits.yaml +22 -0
  166. package/knowledge/energy/energy-active-recovery.yaml +22 -0
  167. package/knowledge/energy/energy-deliberate-rest.yaml +22 -0
  168. package/knowledge/energy/energy-exercise-embedded.yaml +22 -0
  169. package/knowledge/energy/energy-fixed-wake-time.yaml +22 -0
  170. package/knowledge/energy/energy-four-energy-radar.yaml +22 -0
  171. package/knowledge/energy/energy-intensity-recovery.yaml +22 -0
  172. package/knowledge/scenario-packs/pack-summer-kid.yaml +12 -0
  173. package/knowledge/src/repo/fs-yaml.ts +282 -0
  174. package/knowledge/src/repo/types.ts +24 -0
  175. package/knowledge/tests/fixtures/base/_registry/dimensions.yaml +4 -0
  176. package/knowledge/tests/fixtures/base/time/valid-entry.yaml +24 -0
  177. package/knowledge/tests/fixtures/dup/_registry/dimensions.yaml +4 -0
  178. package/knowledge/tests/fixtures/dup/time/entry-a.yaml +13 -0
  179. package/knowledge/tests/fixtures/dup/time/entry-b.yaml +13 -0
  180. package/knowledge/tests/fixtures/unregistered/_registry/dimensions.yaml +7 -0
  181. package/knowledge/tests/fixtures/unregistered/time/anxiety-entry.yaml +13 -0
  182. package/knowledge/tests/repo/fs-yaml-knowledge-repo.test.ts +333 -0
  183. package/knowledge/time/time-batch-windows.yaml +22 -0
  184. package/knowledge/time/time-conscious-abandon.yaml +22 -0
  185. package/knowledge/time/time-deep-work-block.yaml +22 -0
  186. package/knowledge/time/time-finite-commitment.yaml +22 -0
  187. package/knowledge/time/time-forced-whitespace.yaml +22 -0
  188. package/knowledge/time/time-gtd-next-action.yaml +22 -0
  189. package/knowledge/time/time-three-tier-list.yaml +22 -0
  190. package/knowledge/validate.ts +50 -0
  191. package/package.json +76 -0
  192. package/scripts/postinstall-cold-start.js +45 -0
@@ -0,0 +1,754 @@
1
+ import { buildTimeRangeWhere } from "./sqlite-helpers.js";
2
+ import { buildTimelineFromEvents, computeDefaultWindow, } from "../timeline/index.js";
3
+ import { effectiveUserId } from "../profile-helpers.js";
4
+ import { userIdIn } from "../identity/equivalent-ids.js";
5
+ function profileExistsForUser(db, userId) {
6
+ const uid = userIdIn(db, "id", userId);
7
+ const row = db
8
+ .prepare(`SELECT id FROM user_profile WHERE ${uid.clause} AND deleted_at IS NULL`)
9
+ .get(...uid.params);
10
+ return !!row;
11
+ }
12
+ // ============================================================
13
+ // SqliteCategoryRepo (R1-R3)
14
+ // ============================================================
15
+ export class SqliteCategoryRepo {
16
+ db;
17
+ constructor(db) {
18
+ this.db = db;
19
+ }
20
+ async getAll() {
21
+ return this.db
22
+ .prepare("SELECT id, name, emoji, color FROM categories WHERE deleted_at IS NULL ORDER BY id")
23
+ .all();
24
+ }
25
+ async getById(id) {
26
+ const row = this.db
27
+ .prepare("SELECT id, name, emoji, color FROM categories WHERE id = ? AND deleted_at IS NULL")
28
+ .get(id);
29
+ return row ?? null;
30
+ }
31
+ }
32
+ // ============================================================
33
+ // SqliteTimeBlockRepo (R2)
34
+ // ============================================================
35
+ export class SqliteTimeBlockRepo {
36
+ db;
37
+ constructor(db) {
38
+ this.db = db;
39
+ }
40
+ async create(input) {
41
+ const id = crypto.randomUUID();
42
+ const now = new Date().toISOString().replace("T", " ").replace("Z", "");
43
+ const durationMinutes = input.duration_minutes ??
44
+ Math.round((new Date(input.end_time).getTime() -
45
+ new Date(input.start_time).getTime()) /
46
+ 60000);
47
+ this.db
48
+ .prepare(`INSERT INTO time_blocks (id, user_id, title, category_id, start_time, end_time, duration_minutes, rating, rating_reason, notes, status, created_at, updated_at)
49
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
50
+ .run(id, input.user_id, input.title.trim(), input.category_id ?? null, input.start_time, input.end_time, durationMinutes, input.rating ?? null, input.rating_reason ?? null, input.notes ?? null, input.status ?? "completed", now, now);
51
+ return (await this.getById(id));
52
+ }
53
+ async getById(id) {
54
+ const row = this.db
55
+ .prepare("SELECT * FROM time_blocks WHERE id = ? AND deleted_at IS NULL")
56
+ .get(id);
57
+ return row ?? null;
58
+ }
59
+ async list(filters) {
60
+ const uid = userIdIn(this.db, "user_id", filters.user_id);
61
+ const conditions = [uid.clause];
62
+ const params = [...uid.params];
63
+ if (!filters.include_deleted) {
64
+ conditions.push("deleted_at IS NULL");
65
+ }
66
+ const range = buildTimeRangeWhere({
67
+ column: "start_time",
68
+ date: filters.date,
69
+ from: filters.from,
70
+ to: filters.to,
71
+ });
72
+ conditions.push(...range.clauses);
73
+ params.push(...range.params);
74
+ if (filters.category_id) {
75
+ conditions.push("category_id = ?");
76
+ params.push(filters.category_id);
77
+ }
78
+ if (filters.status) {
79
+ conditions.push("status = ?");
80
+ params.push(filters.status);
81
+ }
82
+ let limit = filters.limit ?? 50;
83
+ if (limit > 100)
84
+ limit = 100;
85
+ const offset = filters.offset ?? 0;
86
+ const sql = `SELECT * FROM time_blocks WHERE ${conditions.join(" AND ")} ORDER BY start_time DESC LIMIT ? OFFSET ?`;
87
+ params.push(limit, offset);
88
+ return this.db.prepare(sql).all(...params);
89
+ }
90
+ async update(id, input) {
91
+ const existing = this.db
92
+ .prepare("SELECT * FROM time_blocks WHERE id = ? AND deleted_at IS NULL")
93
+ .get(id);
94
+ if (!existing) {
95
+ throw new Error("时间块不存在");
96
+ }
97
+ const newStart = input.start_time ?? existing.start_time;
98
+ const newEnd = input.end_time ?? existing.end_time;
99
+ let newDuration;
100
+ if (input.duration_minutes !== undefined) {
101
+ newDuration = input.duration_minutes;
102
+ }
103
+ else if (input.start_time !== undefined ||
104
+ input.end_time !== undefined) {
105
+ newDuration = Math.round((new Date(newEnd).getTime() - new Date(newStart).getTime()) / 60000);
106
+ }
107
+ else {
108
+ newDuration = existing.duration_minutes;
109
+ }
110
+ const now = new Date().toISOString().replace("T", " ").replace("Z", "") +
111
+ String(Math.floor(performance.now() * 100000) % 100000).padStart(5, "0");
112
+ this.db
113
+ .prepare(`UPDATE time_blocks SET
114
+ title = ?,
115
+ category_id = ?,
116
+ start_time = ?,
117
+ end_time = ?,
118
+ duration_minutes = ?,
119
+ rating = ?,
120
+ rating_reason = ?,
121
+ notes = ?,
122
+ status = ?,
123
+ updated_at = ?
124
+ WHERE id = ?`)
125
+ .run((input.title ?? existing.title).trim() || existing.title, input.category_id !== undefined
126
+ ? input.category_id
127
+ : existing.category_id, newStart, newEnd, newDuration, input.rating !== undefined ? input.rating : existing.rating, input.rating_reason !== undefined
128
+ ? input.rating_reason
129
+ : existing.rating_reason, input.notes !== undefined ? input.notes : existing.notes, input.status !== undefined ? input.status : existing.status, now, id);
130
+ return (await this.getById(id));
131
+ }
132
+ async remove(id) {
133
+ const exists = this.db
134
+ .prepare("SELECT id, deleted_at FROM time_blocks WHERE id = ?")
135
+ .get(id);
136
+ if (!exists) {
137
+ throw new Error("时间块不存在");
138
+ }
139
+ if (exists.deleted_at !== null) {
140
+ return; // already soft-deleted, idempotent
141
+ }
142
+ const now = new Date().toISOString().replace("T", " ").replace("Z", "");
143
+ this.db
144
+ .prepare("UPDATE time_blocks SET deleted_at = ?, updated_at = ? WHERE id = ?")
145
+ .run(now, now, id);
146
+ }
147
+ async exists(id) {
148
+ const row = this.db
149
+ .prepare("SELECT id FROM time_blocks WHERE id = ?")
150
+ .get(id);
151
+ return !!row;
152
+ }
153
+ async userExists(userId) {
154
+ return profileExistsForUser(this.db, userId);
155
+ }
156
+ /** S48(2026-08-11):标记扫描追问已发。幂等保护:每条只被 cron 主动问一次。返回受影响行数。 */
157
+ async markAsked(ids, askedAt) {
158
+ if (!ids || ids.length === 0)
159
+ return 0;
160
+ const ts = askedAt ?? new Date().toISOString();
161
+ const placeholders = ids.map(() => "?").join(", ");
162
+ const result = this.db
163
+ .prepare(`UPDATE time_blocks SET asked_at = ?, updated_at = ?
164
+ WHERE id IN (${placeholders}) AND deleted_at IS NULL`)
165
+ .run(ts, ts, ...ids);
166
+ return result.changes;
167
+ }
168
+ async restore(id) {
169
+ const row = this.db
170
+ .prepare("SELECT id, deleted_at FROM time_blocks WHERE id = ?")
171
+ .get(id);
172
+ if (!row || row.deleted_at === null) {
173
+ throw new Error("时间块不存在或未被删除");
174
+ }
175
+ const now = new Date().toISOString().replace("T", " ").replace("Z", "");
176
+ this.db
177
+ .prepare(
178
+ // MV-COACH-3 Slice 4: restore 后状态 = unverified(不知道用户执行了没,等用户核实才升 completed)。
179
+ // 原 v0 是 'completed'(当时没状态机概念,恢复即默认已完成)。新模型下 completed 必须用户确认。
180
+ "UPDATE time_blocks SET deleted_at = NULL, status = 'unverified', updated_at = ? WHERE id = ?")
181
+ .run(now, id);
182
+ return (await this.getById(id));
183
+ }
184
+ async findOverlapping(input) {
185
+ // ⚠️ 时区安全:用 SQLite datetime() 规范化所有时间字段后再比较。
186
+ // datetime() 能解析多种 ISO 格式(含 +08:00 / Z),输出统一 'YYYY-MM-DD HH:MM:SS' UTC。
187
+ // 不能用裸字符串比较('2026-08-07T09:00:00+08:00' vs '2026-08-07T01:00:00Z' 字符串比较会误判)。
188
+ const uid = userIdIn(this.db, "user_id", input.user_id);
189
+ return this.db
190
+ .prepare(`SELECT * FROM time_blocks
191
+ WHERE ${uid.clause} AND status = 'planned' AND deleted_at IS NULL
192
+ AND datetime(start_time) < datetime(?)
193
+ AND datetime(end_time) > datetime(?)
194
+ ORDER BY start_time`)
195
+ .all(...uid.params, input.end, input.start);
196
+ }
197
+ async findExactWindow(input) {
198
+ const uid = userIdIn(this.db, "user_id", input.user_id);
199
+ const row = this.db
200
+ .prepare(`SELECT * FROM time_blocks
201
+ WHERE ${uid.clause} AND deleted_at IS NULL
202
+ AND title = ? AND status = ?
203
+ AND datetime(start_time) = datetime(?)
204
+ AND datetime(end_time) = datetime(?)
205
+ LIMIT 1`)
206
+ .get(...uid.params, input.title.trim(), input.status, input.start, input.end);
207
+ return row ?? null;
208
+ }
209
+ }
210
+ // ============================================================
211
+ // SqliteStatsRepo (R2 — stats + matrix)
212
+ // ============================================================
213
+ // -- pure helpers for renderMatrix (copied verbatim from matrix/index.ts) --
214
+ function median(values) {
215
+ if (values.length === 0)
216
+ return 0;
217
+ const sorted = [...values].sort((a, b) => a - b);
218
+ const mid = Math.floor(sorted.length / 2);
219
+ if (sorted.length % 2 === 1) {
220
+ return sorted[mid];
221
+ }
222
+ return (sorted[mid - 1] + sorted[mid]) / 2;
223
+ }
224
+ const QUADRANT_ORDER = {
225
+ "能耗黑洞": 0,
226
+ "可放弃": 1,
227
+ "保持区": 2,
228
+ "亮点区": 3,
229
+ };
230
+ function assignQuadrant(highTime, highRating) {
231
+ if (highTime && highRating)
232
+ return "保持区";
233
+ if (highTime && !highRating)
234
+ return "能耗黑洞";
235
+ if (!highTime && highRating)
236
+ return "亮点区";
237
+ return "可放弃";
238
+ }
239
+ export class SqliteStatsRepo {
240
+ db;
241
+ constructor(db) {
242
+ this.db = db;
243
+ }
244
+ async summarize(filters) {
245
+ const uid = userIdIn(this.db, "tb.user_id", filters.user_id);
246
+ const conditions = [
247
+ uid.clause,
248
+ "tb.deleted_at IS NULL",
249
+ // MV-COACH-3 Slice 4: 只统计实绩。planned/unverified 不算(用户主权模型:
250
+ // 只有用户确认执行过的才进 stats,避免"建了日程就虚增统计")。
251
+ "tb.status = 'completed'",
252
+ ];
253
+ const params = [...uid.params];
254
+ const range = buildTimeRangeWhere({
255
+ column: "tb.start_time",
256
+ from: filters.from,
257
+ to: filters.to,
258
+ });
259
+ conditions.push(...range.clauses);
260
+ params.push(...range.params);
261
+ if (filters.category_id) {
262
+ conditions.push("tb.category_id = ?");
263
+ params.push(filters.category_id);
264
+ }
265
+ const rows = this.db
266
+ .prepare(`SELECT
267
+ COALESCE(tb.category_id, 'cat-other') as category_id,
268
+ COALESCE(c.name, '其他') as category_name,
269
+ COALESCE(c.emoji, '❓') as category_emoji,
270
+ SUM(tb.duration_minutes) as total_minutes,
271
+ COUNT(*) as record_count
272
+ FROM time_blocks tb
273
+ LEFT JOIN categories c ON tb.category_id = c.id
274
+ WHERE ${conditions.join(" AND ")}
275
+ GROUP BY COALESCE(tb.category_id, 'cat-other')
276
+ ORDER BY total_minutes DESC`)
277
+ .all(...params);
278
+ const grandTotal = rows.reduce((sum, r) => sum + r.total_minutes, 0);
279
+ return rows.map((r) => ({
280
+ category_id: r.category_id,
281
+ category_name: r.category_name,
282
+ category_emoji: r.category_emoji,
283
+ total_minutes: r.total_minutes,
284
+ percentage: grandTotal > 0
285
+ ? Math.round((r.total_minutes / grandTotal) * 1000) / 10
286
+ : 0,
287
+ record_count: r.record_count,
288
+ }));
289
+ }
290
+ async dailyBreakdown(filters) {
291
+ const uid = userIdIn(this.db, "tb.user_id", filters.user_id);
292
+ const conditions = [
293
+ uid.clause,
294
+ "tb.deleted_at IS NULL",
295
+ // MV-COACH-3 Slice 4: 只统计实绩。planned/unverified 不算(用户主权模型:
296
+ // 只有用户确认执行过的才进 stats,避免"建了日程就虚增统计")。
297
+ "tb.status = 'completed'",
298
+ ];
299
+ const params = [...uid.params];
300
+ const range = buildTimeRangeWhere({
301
+ column: "tb.start_time",
302
+ from: filters.from,
303
+ to: filters.to,
304
+ });
305
+ conditions.push(...range.clauses);
306
+ params.push(...range.params);
307
+ if (filters.category_id) {
308
+ conditions.push("tb.category_id = ?");
309
+ params.push(filters.category_id);
310
+ }
311
+ const rows = this.db
312
+ .prepare(`SELECT
313
+ DATE(tb.start_time) as date,
314
+ COALESCE(tb.category_id, 'cat-other') as category_id,
315
+ COALESCE(c.name, '其他') as category_name,
316
+ COALESCE(c.emoji, '❓') as category_emoji,
317
+ SUM(tb.duration_minutes) as minutes
318
+ FROM time_blocks tb
319
+ LEFT JOIN categories c ON tb.category_id = c.id
320
+ WHERE ${conditions.join(" AND ")}
321
+ GROUP BY DATE(tb.start_time), COALESCE(tb.category_id, 'cat-other')
322
+ ORDER BY DATE(tb.start_time) ASC, minutes DESC`)
323
+ .all(...params);
324
+ // Aggregate into date-keyed map
325
+ const dayMap = new Map();
326
+ for (const row of rows) {
327
+ let day = dayMap.get(row.date);
328
+ if (!day) {
329
+ day = { date: row.date, total_minutes: 0, categories: [] };
330
+ dayMap.set(row.date, day);
331
+ }
332
+ day.categories.push({
333
+ category_id: row.category_id,
334
+ category_name: row.category_name,
335
+ category_emoji: row.category_emoji,
336
+ minutes: row.minutes,
337
+ });
338
+ day.total_minutes += row.minutes;
339
+ }
340
+ return [...dayMap.values()];
341
+ }
342
+ async renderMatrix(filters) {
343
+ const uid = userIdIn(this.db, "tb.user_id", filters.user_id);
344
+ const conditions = [
345
+ uid.clause,
346
+ "tb.deleted_at IS NULL",
347
+ // MV-COACH-3 Slice 4: 只统计实绩。planned/unverified 不算(用户主权模型:
348
+ // 只有用户确认执行过的才进 stats,避免"建了日程就虚增统计")。
349
+ "tb.status = 'completed'",
350
+ ];
351
+ const params = [...uid.params];
352
+ const range = buildTimeRangeWhere({
353
+ column: "tb.start_time",
354
+ from: filters.from,
355
+ to: filters.to,
356
+ });
357
+ conditions.push(...range.clauses);
358
+ params.push(...range.params);
359
+ const rows = this.db
360
+ .prepare(`SELECT
361
+ COALESCE(tb.category_id, 'cat-other') as category_id,
362
+ COALESCE(c.name, '其他') as category_name,
363
+ COALESCE(c.emoji, '❓') as category_emoji,
364
+ SUM(tb.duration_minutes) as total_minutes,
365
+ AVG(tb.rating) as avg_rating,
366
+ COUNT(*) as record_count
367
+ FROM time_blocks tb
368
+ LEFT JOIN categories c ON tb.category_id = c.id
369
+ WHERE ${conditions.join(" AND ")}
370
+ GROUP BY COALESCE(tb.category_id, 'cat-other')
371
+ HAVING avg_rating IS NOT NULL`)
372
+ .all(...params);
373
+ if (rows.length === 0) {
374
+ return { median_minutes: 0, median_rating: 0, bubbles: [] };
375
+ }
376
+ const minutesValues = rows.map((r) => r.total_minutes);
377
+ const ratingValues = rows.map((r) => r.avg_rating);
378
+ const medianMinutes = median(minutesValues);
379
+ const medianRating = median(ratingValues);
380
+ const bubbles = rows
381
+ .map((r) => {
382
+ const highTime = r.total_minutes >= medianMinutes;
383
+ const highRating = r.avg_rating >= medianRating;
384
+ return {
385
+ category_id: r.category_id,
386
+ category_name: r.category_name,
387
+ category_emoji: r.category_emoji,
388
+ total_minutes: r.total_minutes,
389
+ avg_rating: Math.round(r.avg_rating * 10) / 10,
390
+ record_count: r.record_count,
391
+ quadrant: assignQuadrant(highTime, highRating),
392
+ };
393
+ })
394
+ .sort((a, b) => {
395
+ const orderDiff = QUADRANT_ORDER[a.quadrant] - QUADRANT_ORDER[b.quadrant];
396
+ if (orderDiff !== 0)
397
+ return orderDiff;
398
+ return b.total_minutes - a.total_minutes;
399
+ });
400
+ return {
401
+ median_minutes: medianMinutes,
402
+ median_rating: Math.round(medianRating * 10) / 10,
403
+ bubbles,
404
+ };
405
+ }
406
+ }
407
+ // ============================================================
408
+ // SqliteTimelineRepo (schedule.timeline)
409
+ // ============================================================
410
+ export class SqliteTimelineRepo {
411
+ db;
412
+ constructor(db) {
413
+ this.db = db;
414
+ }
415
+ async renderTimeline(filters) {
416
+ const userId = effectiveUserId(filters);
417
+ const blockUid = userIdIn(this.db, "tb.user_id", userId);
418
+ const timerUid = userIdIn(this.db, "user_id", userId);
419
+ const now = new Date();
420
+ let from;
421
+ let to;
422
+ if (filters.from !== undefined || filters.to !== undefined) {
423
+ const def = computeDefaultWindow(now);
424
+ from = filters.from !== undefined ? new Date(filters.from) : def.from;
425
+ to = filters.to !== undefined ? new Date(filters.to) : def.to;
426
+ }
427
+ else {
428
+ const def = computeDefaultWindow(now);
429
+ from = def.from;
430
+ to = def.to;
431
+ }
432
+ // Expand fetch to calendar-day bounds so details can include 窗口外
433
+ const fetchFrom = new Date(from);
434
+ fetchFrom.setHours(0, 0, 0, 0);
435
+ const fetchTo = new Date(to);
436
+ fetchTo.setHours(23, 59, 59, 999);
437
+ const blocks = this.db
438
+ .prepare(`SELECT
439
+ tb.start_time as start,
440
+ tb.end_time as end,
441
+ tb.status as status,
442
+ tb.title as title,
443
+ tb.category_id as category_id,
444
+ c.emoji as category_emoji,
445
+ c.name as category_name,
446
+ c.color as category_color
447
+ FROM time_blocks tb
448
+ LEFT JOIN categories c ON tb.category_id = c.id
449
+ WHERE ${blockUid.clause}
450
+ AND tb.deleted_at IS NULL
451
+ AND tb.status IN ('planned', 'unverified', 'completed')
452
+ AND datetime(tb.start_time) < datetime(?)
453
+ AND datetime(tb.end_time) > datetime(?)`)
454
+ .all(...blockUid.params, fetchTo.toISOString(), fetchFrom.toISOString());
455
+ const timers = this.db
456
+ .prepare(`SELECT started_at, accumulated_ms, status, event
457
+ FROM timer_sessions
458
+ WHERE ${timerUid.clause} AND status IN ('running', 'paused')`)
459
+ .all(...timerUid.params);
460
+ return buildTimelineFromEvents({
461
+ blocks,
462
+ timers,
463
+ from,
464
+ to,
465
+ now,
466
+ format: filters.format ?? "emoji",
467
+ });
468
+ }
469
+ }
470
+ // ============================================================
471
+ // SqliteTimerRepo (R4 — timer persistence)
472
+ // ============================================================
473
+ export class SqliteTimerRepo {
474
+ db;
475
+ constructor(db) {
476
+ this.db = db;
477
+ }
478
+ async getActive(userId) {
479
+ const uid = userIdIn(this.db, "user_id", userId);
480
+ const row = this.db
481
+ .prepare(`SELECT * FROM timer_sessions WHERE ${uid.clause} AND status IN ('running', 'paused') LIMIT 1`)
482
+ .get(...uid.params);
483
+ return row ?? null;
484
+ }
485
+ async getAllActive() {
486
+ return this.db
487
+ .prepare("SELECT * FROM timer_sessions WHERE status IN ('running', 'paused')")
488
+ .all();
489
+ }
490
+ async create(input) {
491
+ const id = crypto.randomUUID();
492
+ const now = new Date().toISOString();
493
+ this.db
494
+ .prepare(`INSERT INTO timer_sessions (id, user_id, event, category_id, status, started_at, accumulated_ms, target_ms, task_id, created_at, updated_at)
495
+ VALUES (?, ?, ?, ?, 'running', ?, 0, ?, ?, ?, ?)`)
496
+ .run(id, input.user_id, input.event.trim(), input.category_id ?? null, input.started_at, input.target_ms ?? null, input.task_id ?? null, now, now);
497
+ return this.db
498
+ .prepare("SELECT * FROM timer_sessions WHERE id = ?")
499
+ .get(id);
500
+ }
501
+ async update(id, patch) {
502
+ const now = new Date().toISOString();
503
+ const sets = ["updated_at = ?"];
504
+ const params = [now];
505
+ if (patch.status !== undefined) {
506
+ sets.push("status = ?");
507
+ params.push(patch.status);
508
+ }
509
+ if (patch.started_at !== undefined) {
510
+ sets.push("started_at = ?");
511
+ params.push(patch.started_at);
512
+ }
513
+ if (patch.accumulated_ms !== undefined) {
514
+ sets.push("accumulated_ms = ?");
515
+ params.push(patch.accumulated_ms);
516
+ }
517
+ if (patch.ended_at !== undefined) {
518
+ sets.push("ended_at = ?");
519
+ params.push(patch.ended_at);
520
+ }
521
+ if (patch.target_ms !== undefined) {
522
+ sets.push("target_ms = ?");
523
+ params.push(patch.target_ms);
524
+ }
525
+ params.push(id);
526
+ this.db
527
+ .prepare(`UPDATE timer_sessions SET ${sets.join(", ")} WHERE id = ?`)
528
+ .run(...params);
529
+ return this.db
530
+ .prepare("SELECT * FROM timer_sessions WHERE id = ?")
531
+ .get(id);
532
+ }
533
+ async userExists(userId) {
534
+ return profileExistsForUser(this.db, userId);
535
+ }
536
+ }
537
+ // ============================================================
538
+ // SqliteProfileRepo (S13 — profile with structured settings)
539
+ // ============================================================
540
+ export class SqliteProfileRepo {
541
+ db;
542
+ constructor(db) {
543
+ this.db = db;
544
+ }
545
+ async markPortraitDirty(userId) {
546
+ this.db
547
+ .prepare("UPDATE user_profile SET portrait_dirty = 1 WHERE id = ?")
548
+ .run(userId);
549
+ }
550
+ async getById(userId) {
551
+ const row = this.db
552
+ .prepare("SELECT id, display_name, expectations, settings, created_at, updated_at FROM user_profile WHERE id = ? AND deleted_at IS NULL")
553
+ .get(userId);
554
+ if (!row)
555
+ return null;
556
+ return {
557
+ id: row.id,
558
+ display_name: row.display_name,
559
+ expectations: row.expectations,
560
+ settings: row.settings != null ? JSON.parse(row.settings) : null,
561
+ created_at: row.created_at,
562
+ updated_at: row.updated_at,
563
+ };
564
+ }
565
+ async upsert(input) {
566
+ const existing = await this.getById(input.user_id);
567
+ const now = new Date().toISOString().replace("T", " ").replace("Z", "");
568
+ if (existing) {
569
+ // Update: merge top-level fields, retain existing values for unset fields
570
+ const display_name = input.display_name !== undefined
571
+ ? input.display_name
572
+ : existing.display_name;
573
+ const expectations = input.expectations !== undefined
574
+ ? input.expectations
575
+ : existing.expectations;
576
+ // settings: whole-object replace (not deep merge), per S13 §4
577
+ const settings = input.settings !== undefined
578
+ ? JSON.stringify(input.settings)
579
+ : existing.settings != null
580
+ ? JSON.stringify(existing.settings)
581
+ : null;
582
+ this.db
583
+ .prepare(`UPDATE user_profile SET display_name = ?, expectations = ?, settings = ?, updated_at = ? WHERE id = ?`)
584
+ .run(display_name, expectations, settings, now, input.user_id);
585
+ }
586
+ else {
587
+ // Insert
588
+ const display_name = input.display_name ?? "";
589
+ const expectations = input.expectations ?? null;
590
+ const settings = input.settings !== undefined
591
+ ? JSON.stringify(input.settings)
592
+ : null;
593
+ this.db
594
+ .prepare(`INSERT INTO user_profile (id, display_name, expectations, settings, created_at, updated_at)
595
+ VALUES (?, ?, ?, ?, ?, ?)`)
596
+ .run(input.user_id, display_name, expectations, settings, now, now);
597
+ }
598
+ return (await this.getById(input.user_id));
599
+ }
600
+ /**
601
+ * S40 slice 3:Atomic claim nudge。
602
+ *
603
+ * 用 better-sqlite3 的 `db.transaction()` 把「SELECT → 检查 window → UPDATE → 返回」
604
+ * 包成一个原子单元。better-sqlite3 是**同步**的——transaction 期间阻塞 event loop,
605
+ * 第二个调用根本没机会插入。
606
+ *
607
+ * 注意:transaction 返回的函数是同步的,必须在同步上下文调用(不能 await)。
608
+ * 这里包装成 async 只是为了匹配 ProfileRepo interface,内部完全同步执行。
609
+ */
610
+ async claimNudgeAtomic(input) {
611
+ const windowMs = input.window_minutes * 60 * 1000;
612
+ const now = new Date();
613
+ const nowIso = now.toISOString();
614
+ const nowDb = nowIso.replace("T", " ").replace("Z", "");
615
+ const rowToProfile = (r) => ({
616
+ id: r.id,
617
+ display_name: r.display_name,
618
+ expectations: r.expectations,
619
+ settings: r.settings != null ? JSON.parse(r.settings) : null,
620
+ created_at: r.created_at,
621
+ updated_at: r.updated_at,
622
+ });
623
+ const selectStmt = this.db.prepare("SELECT id, display_name, expectations, settings, created_at, updated_at FROM user_profile WHERE id = ? AND deleted_at IS NULL");
624
+ const updateStmt = this.db.prepare(`UPDATE user_profile SET settings = ?, updated_at = ? WHERE id = ?`);
625
+ const insertStmt = this.db.prepare(`INSERT INTO user_profile (id, display_name, expectations, settings, created_at, updated_at)
626
+ VALUES (?, '', NULL, ?, ?, ?)`);
627
+ // transaction 函数(同步)。better-sqlite3 在 transaction() 期间阻塞 event loop,
628
+ // 第二个并发调用必须等第一个 COMMIT 才能进入 BEGIN。
629
+ const txn = this.db.transaction(() => {
630
+ const row = selectStmt.get(input.user_id);
631
+ const existingSettings = row?.settings != null
632
+ ? JSON.parse(row.settings)
633
+ : {};
634
+ const lastNudgeAt = existingSettings.last_inactivity_nudge_at;
635
+ if (typeof lastNudgeAt === "string") {
636
+ const ageMs = now.getTime() - new Date(lastNudgeAt).getTime();
637
+ if (ageMs < windowMs) {
638
+ return { won: false, profile: rowToProfile(row) };
639
+ }
640
+ }
641
+ const merged = {
642
+ ...existingSettings,
643
+ last_inactivity_nudge_at: nowIso,
644
+ };
645
+ const settingsJson = JSON.stringify(merged);
646
+ if (row) {
647
+ updateStmt.run(settingsJson, nowDb, input.user_id);
648
+ }
649
+ else {
650
+ insertStmt.run(input.user_id, settingsJson, nowDb, nowDb);
651
+ }
652
+ const updated = selectStmt.get(input.user_id);
653
+ return { won: true, profile: rowToProfile(updated) };
654
+ });
655
+ // BEGIN IMMEDIATE:立刻拿写锁,防止 deferred 模式下读-写升级冲突
656
+ // better-sqlite3 API:transaction 函数有 .immediate(args) 变种
657
+ return txn.immediate();
658
+ }
659
+ }
660
+ // ============================================================
661
+ // SqliteReminderRepo (S11 — reminder persistence)
662
+ // ============================================================
663
+ export class SqliteReminderRepo {
664
+ db;
665
+ constructor(db) {
666
+ this.db = db;
667
+ }
668
+ async create(input) {
669
+ const id = crypto.randomUUID();
670
+ const now = new Date().toISOString().replace("T", " ").replace("Z", "");
671
+ // Normalize to UTC Z format — ensures getDueReminders string comparison
672
+ // works correctly even when caller passes +08:00 or other offsets.
673
+ const remindAt = new Date(input.remind_at).toISOString();
674
+ this.db
675
+ .prepare(`INSERT INTO reminders (id, user_id, timeblock_id, content, remind_at, channel, status, attempts, sent_at, created_at, updated_at)
676
+ VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, NULL, ?, ?)`)
677
+ .run(id, input.user_id, input.timeblock_id ?? null, input.content, remindAt, input.channel ?? "session", now, now);
678
+ return (await this.getById(id));
679
+ }
680
+ async getById(id) {
681
+ const row = this.db
682
+ .prepare("SELECT * FROM reminders WHERE id = ? AND deleted_at IS NULL")
683
+ .get(id);
684
+ return row ?? null;
685
+ }
686
+ async getDueReminders(input) {
687
+ if (input.user_id) {
688
+ const uid = userIdIn(this.db, "user_id", input.user_id);
689
+ return this.db
690
+ .prepare(`SELECT * FROM reminders WHERE status = 'pending' AND remind_at <= ? AND ${uid.clause} AND deleted_at IS NULL ORDER BY remind_at ASC`)
691
+ .all(input.until, ...uid.params);
692
+ }
693
+ return this.db
694
+ .prepare("SELECT * FROM reminders WHERE status = 'pending' AND remind_at <= ? AND deleted_at IS NULL ORDER BY remind_at ASC")
695
+ .all(input.until);
696
+ }
697
+ async getNextDue(input = {}) {
698
+ if (input.user_id) {
699
+ const uid = userIdIn(this.db, "user_id", input.user_id);
700
+ const row = this.db
701
+ .prepare(`SELECT remind_at FROM reminders WHERE status = 'pending' AND ${uid.clause} AND deleted_at IS NULL ORDER BY remind_at ASC LIMIT 1`)
702
+ .get(...uid.params);
703
+ return row ?? null;
704
+ }
705
+ const row = this.db
706
+ .prepare("SELECT remind_at FROM reminders WHERE status = 'pending' AND deleted_at IS NULL ORDER BY remind_at ASC LIMIT 1")
707
+ .get();
708
+ return row ?? null;
709
+ }
710
+ async getMissedReminders(input) {
711
+ const uid = userIdIn(this.db, "user_id", input.user_id);
712
+ return this.db
713
+ .prepare(`SELECT * FROM reminders WHERE status = 'pending' AND remind_at < ? AND ${uid.clause} AND deleted_at IS NULL ORDER BY remind_at ASC`)
714
+ .all(input.now, ...uid.params);
715
+ }
716
+ async markSent(id) {
717
+ const now = new Date().toISOString().replace("T", " ").replace("Z", "");
718
+ this.db
719
+ .prepare("UPDATE reminders SET status = 'sent', sent_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL")
720
+ .run(now, now, id);
721
+ }
722
+ async cancelReminder(id) {
723
+ const now = new Date().toISOString().replace("T", " ").replace("Z", "");
724
+ this.db
725
+ .prepare("UPDATE reminders SET status = 'cancelled', updated_at = ? WHERE id = ? AND deleted_at IS NULL")
726
+ .run(now, id);
727
+ }
728
+ async userExists(userId) {
729
+ return profileExistsForUser(this.db, userId);
730
+ }
731
+ async claim(id) {
732
+ const result = this.db
733
+ .prepare("UPDATE reminders SET status = 'sending', updated_at = datetime('now') WHERE id = ? AND status = 'pending'")
734
+ .run(id);
735
+ return result.changes > 0;
736
+ }
737
+ async releaseOrFail(id, maxAttempts) {
738
+ const now = new Date().toISOString().replace("T", " ").replace("Z", "");
739
+ // Increment attempts atomically
740
+ this.db
741
+ .prepare("UPDATE reminders SET attempts = attempts + 1, updated_at = ? WHERE id = ? AND deleted_at IS NULL")
742
+ .run(now, id);
743
+ // Check if we should retry
744
+ const row = this.db
745
+ .prepare("SELECT attempts FROM reminders WHERE id = ? AND deleted_at IS NULL")
746
+ .get(id);
747
+ if (row && row.attempts < maxAttempts) {
748
+ this.db
749
+ .prepare("UPDATE reminders SET status = 'pending', updated_at = ? WHERE id = ? AND deleted_at IS NULL")
750
+ .run(now, id);
751
+ }
752
+ // else: stays 'sending' — dead, won't be picked up again
753
+ }
754
+ }