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,705 @@
1
+ import { effectiveUserId } from "../profile-helpers.js";
2
+ const SLOT_MS = 10 * 60 * 1000;
3
+ const KIND_PRIORITY = {
4
+ timer: 5,
5
+ completed: 4,
6
+ unverified: 3,
7
+ planned: 2,
8
+ empty: 1,
9
+ };
10
+ const FIXED_EMOJI = {
11
+ empty: "⬜",
12
+ planned: "🟦",
13
+ unverified: "❓",
14
+ timer_running: "⏰",
15
+ timer_paused: "⏸️",
16
+ };
17
+ const FIXED_ASCII = {
18
+ empty: "[ ]",
19
+ planned: "[#]",
20
+ unverified: "[?]",
21
+ timer_running: "[T]",
22
+ timer_paused: "[P]",
23
+ };
24
+ const LEGEND_ZH = {
25
+ "⬜": "无安排",
26
+ "🟦": "计划",
27
+ "❓": "未回应",
28
+ "⏰": "计时中",
29
+ "⏸️": "暂停计时",
30
+ "[ ]": "无安排",
31
+ "[#]": "计划",
32
+ "[?]": "未回应",
33
+ "[T]": "计时中",
34
+ "[P]": "暂停计时",
35
+ };
36
+ /** category name → ascii token like [W] / [·](空分类不用 [?],以免和未回应撞车) */
37
+ export function categoryAsciiToken(name) {
38
+ if (!name || !name.trim())
39
+ return "[·]";
40
+ const latin = name.trim().match(/[A-Za-z]/);
41
+ if (latin)
42
+ return `[${latin[0].toUpperCase()}]`;
43
+ return `[${name.trim()[0]}]`;
44
+ }
45
+ /**
46
+ * S9 v0.2: hex 颜色 → 方块 emoji(粗色相映射,6-7 色)
47
+ *
48
+ * 设计意图(见 docs/specs/S9-v0.2-design-plan-colored-tiles.md):
49
+ * - 15 个分类压缩到 7 种方块色——保留视觉区分度,避免 15 色用户认不过来
50
+ * - 用 HSL hue 粗分(不用精确匹配 hex,避免脆弱)
51
+ * - 灰阶 / 深色 → ⬛(视觉暗色,区分显著)
52
+ * - 无效输入 → 🟦(默认蓝,跟原 spec v0.1 兼容)
53
+ */
54
+ export function colorToTile(hex) {
55
+ if (!hex || typeof hex !== "string" || !hex.startsWith("#"))
56
+ return "🟦";
57
+ const r = parseInt(hex.slice(1, 3), 16);
58
+ const g = parseInt(hex.slice(3, 5), 16);
59
+ const b = parseInt(hex.slice(5, 7), 16);
60
+ // 解析失败 → fallback
61
+ if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b))
62
+ return "🟦";
63
+ const { h, s, l } = rgbToHsl(r, g, b);
64
+ // 灰阶(饱和度 < 12%)或过暗(亮度 < 0.35)→ ⬛
65
+ // 设计:睡眠(#34495E l=0.29 s=0.29)、内务(#95A5A6 s=0.09)、闲散(#BDC3C7) 等
66
+ // 深色 / 中性色都归黑,区分显著
67
+ if (s < 0.12 || l < 0.35)
68
+ return "⬛";
69
+ // 色相分段(0-360)
70
+ if (h < 15 || h >= 345)
71
+ return "🟥"; // 红
72
+ if (h < 45)
73
+ return "🟧"; // 橙
74
+ if (h < 70)
75
+ return "🟨"; // 黄
76
+ if (h < 165)
77
+ return "🟩"; // 绿
78
+ if (h < 240)
79
+ return "🟦"; // 蓝(含青)
80
+ if (h < 315)
81
+ return "🟪"; // 紫(240-315)
82
+ return "🟥"; // 粉红归红
83
+ }
84
+ /** RGB → HSL(h: 0-360, s/l: 0-1) */
85
+ function rgbToHsl(r, g, b) {
86
+ const rN = r / 255;
87
+ const gN = g / 255;
88
+ const bN = b / 255;
89
+ const max = Math.max(rN, gN, bN);
90
+ const min = Math.min(rN, gN, bN);
91
+ const l = (max + min) / 2;
92
+ let h = 0;
93
+ let s = 0;
94
+ if (max !== min) {
95
+ const d = max - min;
96
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
97
+ switch (max) {
98
+ case rN:
99
+ h = ((gN - bN) / d + (gN < bN ? 6 : 0)) * 60;
100
+ break;
101
+ case gN:
102
+ h = ((bN - rN) / d + 2) * 60;
103
+ break;
104
+ default:
105
+ h = ((rN - gN) / d + 4) * 60;
106
+ }
107
+ }
108
+ return { h, s, l };
109
+ }
110
+ function timerKey(status) {
111
+ return status === "paused" ? "timer_paused" : "timer_running";
112
+ }
113
+ /** Resolve display symbol for a cell given format. */
114
+ export function resolveCellSymbol(cell, format) {
115
+ if (cell.kind === "completed") {
116
+ if (format === "emoji") {
117
+ if (cell.emoji && cell.emoji.trim())
118
+ return cell.emoji;
119
+ return cell.ascii && cell.ascii !== "[?]" ? cell.ascii : "•";
120
+ }
121
+ if (cell.ascii && cell.ascii !== "[?]")
122
+ return cell.ascii;
123
+ return "[·]";
124
+ }
125
+ if (cell.kind === "timer") {
126
+ const key = timerKey(cell.status);
127
+ return format === "emoji" ? FIXED_EMOJI[key] : FIXED_ASCII[key];
128
+ }
129
+ // S9 v0.2: planned 块带 tile 时优先用分类色方块,否则 fallback 到默认 🟦
130
+ if (cell.kind === "planned") {
131
+ if (format === "emoji") {
132
+ if (cell.tile && cell.tile.trim())
133
+ return cell.tile;
134
+ return FIXED_EMOJI.planned;
135
+ }
136
+ // ascii 用 category 首字母(如 [W]),否则默认 [#]
137
+ return cell.ascii ?? FIXED_ASCII.planned;
138
+ }
139
+ // unverified / empty
140
+ const key = cell.kind;
141
+ return format === "emoji" ? FIXED_EMOJI[key] : FIXED_ASCII[key];
142
+ }
143
+ /**
144
+ * Merge candidates by priority:
145
+ * timer_running > timer_paused > completed > unverified > planned > empty
146
+ * Between two timers, running beats paused.
147
+ */
148
+ export function mergeTimelineCells(candidates) {
149
+ if (candidates.length === 0)
150
+ return { kind: "empty" };
151
+ let best = candidates[0];
152
+ for (let i = 1; i < candidates.length; i++) {
153
+ const c = candidates[i];
154
+ const bp = KIND_PRIORITY[best.kind];
155
+ const cp = KIND_PRIORITY[c.kind];
156
+ if (cp > bp) {
157
+ best = c;
158
+ continue;
159
+ }
160
+ if (cp < bp)
161
+ continue;
162
+ // same kind: for timer, running > paused
163
+ if (c.kind === "timer" && best.kind === "timer") {
164
+ if (c.status === "running" && best.status !== "running")
165
+ best = c;
166
+ }
167
+ }
168
+ return best;
169
+ }
170
+ function legendMeaning(symbol) {
171
+ if (LEGEND_ZH[symbol])
172
+ return LEGEND_ZH[symbol];
173
+ // S9 v0.2: 方块色 = 计划块(按分类上色)
174
+ if (symbol === "🟦" ||
175
+ symbol === "🟪" ||
176
+ symbol === "🟩" ||
177
+ symbol === "🟥" ||
178
+ symbol === "🟧" ||
179
+ symbol === "🟨" ||
180
+ symbol === "⬛") {
181
+ return "计划";
182
+ }
183
+ // completed category emoji / ascii letter
184
+ if (symbol.startsWith("[") && symbol.endsWith("]"))
185
+ return "实绩";
186
+ return "实绩";
187
+ }
188
+ function formatHourLabel(d) {
189
+ const hh = String(d.getHours()).padStart(2, "0");
190
+ return `${hh}:00`;
191
+ }
192
+ function isEmptyRow(cells) {
193
+ return cells.every((c) => c.kind === "empty");
194
+ }
195
+ /**
196
+ * Pure render: table header + matrix (with collapse) + details table.
197
+ * No DB access.
198
+ */
199
+ export function renderTimelineCells(input) {
200
+ const format = input.format;
201
+ const boundary = input.boundaryEllipsis !== false;
202
+ const now = new Date(input.nowIso);
203
+ const nowHourStart = new Date(now);
204
+ nowHourStart.setMinutes(0, 0, 0);
205
+ const nowHourIso = nowHourStart.toISOString();
206
+ const rows = input.hours.map((h) => ({
207
+ hour: h.hour,
208
+ current: h.hourStartIso === nowHourIso,
209
+ cells: h.cells,
210
+ }));
211
+ const legend = {};
212
+ const mark = (sym) => {
213
+ legend[sym] = legendMeaning(sym);
214
+ };
215
+ // Always include empty in legend for orientation
216
+ mark(resolveCellSymbol({ kind: "empty" }, format));
217
+ const matrixLines = [];
218
+ // pressure E-6: 全空窗口不折叠(否则只剩 ··· 废文本,用户看不到时间范围)
219
+ const hasAnyNonEmpty = rows.some((r) => !isEmptyRow(r.cells));
220
+ // Collapse: ≥3 consecutive empty rows → keep 1 empty + ···
221
+ let i = 0;
222
+ while (i < rows.length) {
223
+ if (!isEmptyRow(rows[i].cells)) {
224
+ const row = rows[i];
225
+ const syms = row.cells.map((c) => {
226
+ const s = resolveCellSymbol(c, format);
227
+ mark(s);
228
+ return s;
229
+ });
230
+ // 2026-07-27 UX 反馈:current 行用 → 替代 ▶(更窄、不挤压 emoji 行)
231
+ const hourPart = format === "emoji"
232
+ ? row.current
233
+ ? `→${row.hour}`
234
+ : ` ${row.hour}`
235
+ : row.current
236
+ ? `>${row.hour}`
237
+ : ` ${row.hour}`;
238
+ matrixLines.push({
239
+ type: "row",
240
+ row,
241
+ text: `${hourPart} ${syms.join(" ")}`,
242
+ });
243
+ i++;
244
+ continue;
245
+ }
246
+ // count consecutive empty
247
+ let j = i;
248
+ while (j < rows.length && isEmptyRow(rows[j].cells))
249
+ j++;
250
+ const emptyCount = j - i;
251
+ // pressure E-6: 全空窗口不折叠,逐行展示让用户看到完整时间范围
252
+ if (emptyCount >= 3 && hasAnyNonEmpty) {
253
+ const row = rows[i];
254
+ const syms = row.cells.map((c) => resolveCellSymbol(c, format));
255
+ mark(syms[0]);
256
+ const hourPart = format === "emoji"
257
+ ? row.current
258
+ ? `→${row.hour}`
259
+ : ` ${row.hour}`
260
+ : row.current
261
+ ? `>${row.hour}`
262
+ : ` ${row.hour}`;
263
+ matrixLines.push({
264
+ type: "row",
265
+ row,
266
+ text: `${hourPart} ${syms.join(" ")}`,
267
+ });
268
+ matrixLines.push({ type: "ellipsis" });
269
+ i = j;
270
+ }
271
+ else {
272
+ for (let k = i; k < j; k++) {
273
+ const row = rows[k];
274
+ const syms = row.cells.map((c) => resolveCellSymbol(c, format));
275
+ mark(syms[0]);
276
+ const hourPart = format === "emoji"
277
+ ? row.current
278
+ ? `→${row.hour}`
279
+ : ` ${row.hour}`
280
+ : row.current
281
+ ? `>${row.hour}`
282
+ : ` ${row.hour}`;
283
+ matrixLines.push({
284
+ type: "row",
285
+ row,
286
+ text: `${hourPart} ${syms.join(" ")}`,
287
+ });
288
+ }
289
+ i = j;
290
+ }
291
+ }
292
+ // Build text lines: 明细在前(用户先看具体内容),矩阵在后(时间分布概览)
293
+ // S9 v0.5(2026-08-07):表头移到矩阵上方,不再夹在明细前面
294
+ const textLines = [];
295
+ // ── 明细段(在前)──
296
+ // S9 v0.3: 按 symbol 分组紧凑展示
297
+ // S9 v0.4: completed → ✓ + ~~划线~~;unverified → ❓ 后缀;插入「📍 现在」分隔线
298
+ // S9 v0.5(2026-08-07): 每条独占一行
299
+ // S45(2026-08-07): 同 category + 时间连续 → 合并成组;中间 ≤15min 短休息 → 吸收
300
+ // 组(>1 member 或吸收了休息):组头 + 子任务列表
301
+ // 组(仅 1 member 无休息):退化为 v0.5 单行(避免冗余)
302
+ const details = input.details ?? [];
303
+ if (details.length > 0) {
304
+ textLines.push("📋 明细");
305
+ const inWin = details.filter((d) => d.in_window);
306
+ const outWin = details.filter((d) => !d.in_window);
307
+ // S45: inWin 已按 time 排序,可直接分组
308
+ const inWinGroups = groupDetails(inWin);
309
+ // S9 v0.5: 单条 entry 格式
310
+ const buildEntry = (d) => {
311
+ let statusMark = " "; // 默认 2 空格占位(对齐 ✓ 和 ?)
312
+ let titlePart = d.title;
313
+ if (d.status === "completed") {
314
+ statusMark = "✓ ";
315
+ titlePart = `~~${d.title}~~`;
316
+ }
317
+ else if (d.status === "unverified") {
318
+ statusMark = "? ";
319
+ }
320
+ return `${statusMark}${d.symbol} ${d.time} ${titlePart}`;
321
+ };
322
+ // S45: 组内子任务 entry(不带组 symbol,状态前缀照旧)
323
+ const buildSubEntry = (d) => {
324
+ let statusMark = " ";
325
+ let titlePart = d.title;
326
+ if (d.status === "completed") {
327
+ statusMark = "✓ ";
328
+ titlePart = `~~${d.title}~~`;
329
+ }
330
+ else if (d.status === "unverified") {
331
+ statusMark = "? ";
332
+ }
333
+ // 子任务缩进 2 空格,时间取自 d.time 的 "HH:MM-HH:MM"
334
+ return ` ${statusMark}${d.time} ${titlePart}`;
335
+ };
336
+ // S45: 组头格式
337
+ const buildGroupHeader = (g) => {
338
+ const emoji = g.category_emoji && g.category_emoji.trim() ? g.category_emoji : "•";
339
+ const name = g.category_name ?? "活动";
340
+ const breakNote = g.break_total_min > 0 ? `(含休息 ${g.break_total_min}min)` : "";
341
+ return `${emoji} ${name} ${g.start_time}-${g.end_time}${breakNote}`;
342
+ };
343
+ // S9 v0.4: 「📍 现在」分隔线判断
344
+ const parseDetailStart = (time) => {
345
+ const startStr = time.split("-")[0].trim();
346
+ const [h, m] = startStr.split(":").map(Number);
347
+ return h * 60 + m;
348
+ };
349
+ const nowDate = new Date(input.nowIso);
350
+ const nowHM = nowDate.getHours() * 60 + nowDate.getMinutes();
351
+ const nowLabel = `${String(nowDate.getHours()).padStart(2, "0")}:${String(nowDate.getMinutes()).padStart(2, "0")}`;
352
+ const inWinAllPast = inWin.every((d) => parseDetailStart(d.time) <= nowHM);
353
+ const inWinAllFuture = inWin.every((d) => parseDetailStart(d.time) > nowHM);
354
+ const needNowMarker = inWin.length > 0 && !inWinAllPast && !inWinAllFuture;
355
+ // S45: 渲染组——支持"现在"标识插在第一个未来组之前
356
+ const renderGroup = (g) => {
357
+ const isMulti = g.members.length > 1 || g.absorbed_breaks.length > 0;
358
+ if (!isMulti) {
359
+ // 退化为 v0.5 单行
360
+ return [buildEntry(g.members[0])];
361
+ }
362
+ const lines = [buildGroupHeader(g)];
363
+ for (const m of g.members) {
364
+ lines.push(buildSubEntry(m));
365
+ }
366
+ return lines;
367
+ };
368
+ for (let i = 0; i < inWinGroups.length; i++) {
369
+ const g = inWinGroups[i];
370
+ // 「📍 现在」标识插在第一个未来组之前
371
+ if (needNowMarker && parseDetailStart(`${g.start_time}-${g.end_time}`) > nowHM && i > 0 && !textLines.includes(`─── 📍 现在 ${nowLabel} ──`)) {
372
+ textLines.push(`─── 📍 现在 ${nowLabel} ──`);
373
+ }
374
+ textLines.push(...renderGroup(g));
375
+ }
376
+ // 兜底:如果所有组都过去/未来,但 needNowMarker 仍 true(混合)且没插入过 → 不补
377
+ // (理论上不会发生,因为上面循环会处理)
378
+ if (outWin.length > 0) {
379
+ textLines.push("(窗口外)");
380
+ const outWinGroups = groupDetails(outWin);
381
+ for (const g of outWinGroups) {
382
+ textLines.push(...renderGroup(g));
383
+ }
384
+ }
385
+ }
386
+ else {
387
+ // pressure E-6: details 空 → 给"该时段无安排"提示,不留空段
388
+ textLines.push("📋 明细:该时段无安排");
389
+ }
390
+ // ── 矩阵段(在后,作为时间分布概览)──
391
+ const pushEllipsis = () => {
392
+ if (textLines[textLines.length - 1] !== "···")
393
+ textLines.push("···");
394
+ };
395
+ if (boundary)
396
+ pushEllipsis();
397
+ // S9 v0.5: 时间表头紧贴矩阵上方(之前在明细前,造成"表头夹中间"错觉)
398
+ textLines.push(" 00 10 20 30 40 50");
399
+ // 2026-07-27 UX 反馈:矩阵上下行距太挤,每行后加一个空行透气
400
+ for (let k = 0; k < matrixLines.length; k++) {
401
+ const line = matrixLines[k];
402
+ if (line.type === "ellipsis") {
403
+ pushEllipsis();
404
+ }
405
+ else {
406
+ textLines.push(line.text);
407
+ // 行间留白:仅当下一行也是 row(避免 row→ellipsis/末尾 出现多余空行)
408
+ const next = matrixLines[k + 1];
409
+ if (next && next.type === "row")
410
+ textLines.push("");
411
+ }
412
+ }
413
+ if (boundary)
414
+ pushEllipsis();
415
+ // S9 v0.2: 末尾拼图例(按本次实际出现的色)
416
+ // 设计:图例短一行,只列实际出现过的符号;颜色块统一标"计划·X 类"
417
+ if (Object.keys(legend).length > 1) {
418
+ const legendParts = [];
419
+ for (const [sym, meaning] of Object.entries(legend)) {
420
+ legendParts.push(`${sym} ${meaning}`);
421
+ }
422
+ textLines.push(`图例:${legendParts.join(" · ")}`);
423
+ }
424
+ return { rows, rendered_text: textLines.join("\n"), legend };
425
+ }
426
+ const S45_GAP_THRESHOLD_MIN = 15;
427
+ const S45_BREAK_THRESHOLD_MIN = 15;
428
+ function isShortBreak(d) {
429
+ return !!d.is_break && (d.duration_min ?? 999) <= S45_BREAK_THRESHOLD_MIN;
430
+ }
431
+ /**
432
+ * S45: 把已排序的 detail 列表分组成 DetailGroup[]。
433
+ * 输入必须按 start_ms 升序排好。
434
+ */
435
+ export function groupDetails(details) {
436
+ if (details.length === 0)
437
+ return [];
438
+ const groups = [];
439
+ let current = null;
440
+ for (let i = 0; i < details.length; i++) {
441
+ const d = details[i];
442
+ if (current === null) {
443
+ // 首个块 → 开新组(休息块也开自己的"独立组",members 是它自己)
444
+ current = newGroup(d);
445
+ continue;
446
+ }
447
+ const gapMin = ((d.start_ms ?? 0) - (current.members[current.members.length - 1]?.end_ms ?? 0)) /
448
+ (60 * 1000);
449
+ const sameCategory = !!d.category_id &&
450
+ d.category_id === current.category_id &&
451
+ !!current.category_id;
452
+ // 情况 1:当前块是短休息,且后面(i+1)还有同 category 块 → 吸收到当前组
453
+ if (isShortBreak(d) && sameCategoryContinuesAfter(details, i, current.category_id)) {
454
+ current.absorbed_breaks.push(d);
455
+ current.break_total_min += d.duration_min ?? 0;
456
+ // 注意:休息块的 end_ms 不更新 current 的 last_end——组的 last_end 取最后一个非休息块
457
+ continue;
458
+ }
459
+ // 情况 2:同 category + gap ≤15min → 加入组
460
+ if (sameCategory && gapMin >= 0 && gapMin <= S45_GAP_THRESHOLD_MIN) {
461
+ current.members.push(d);
462
+ // 更新组的 end_time
463
+ current.end_time = d.time.split("-")[1]?.trim() ?? current.end_time;
464
+ continue;
465
+ }
466
+ // 情况 3:边界 → 结束当前组,开新组
467
+ groups.push(current);
468
+ current = newGroup(d);
469
+ }
470
+ if (current)
471
+ groups.push(current);
472
+ return groups;
473
+ }
474
+ function newGroup(d) {
475
+ return {
476
+ members: [d],
477
+ absorbed_breaks: [],
478
+ break_total_min: 0,
479
+ start_time: d.time.split("-")[0]?.trim() ?? "",
480
+ end_time: d.time.split("-")[1]?.trim() ?? "",
481
+ category_id: d.category_id ?? null,
482
+ category_name: d.category_name ?? null,
483
+ category_emoji: d.category_emoji ?? null,
484
+ };
485
+ }
486
+ /** 判断 details[i] 之后是否还有同 category_id 的非休息块(前视,用于休息吸收判定) */
487
+ function sameCategoryContinuesAfter(details, i, categoryId) {
488
+ if (!categoryId)
489
+ return false;
490
+ for (let j = i + 1; j < details.length; j++) {
491
+ const next = details[j];
492
+ if (next.is_break)
493
+ continue; // 跳过中间的休息块
494
+ if (next.category_id === categoryId)
495
+ return true;
496
+ return false; // 遇到非同 category 的非休息块 → 不算后续同 category
497
+ }
498
+ return false;
499
+ }
500
+ export function computeDefaultWindow(now) {
501
+ const hour = new Date(now);
502
+ hour.setMinutes(0, 0, 0);
503
+ const from = new Date(hour.getTime() - 5 * 60 * 60 * 1000);
504
+ const to = new Date(hour.getTime() + 5 * 60 * 60 * 1000);
505
+ return { from, to };
506
+ }
507
+ function cellFromBlock(b) {
508
+ if (b.status === "completed") {
509
+ const ascii = categoryAsciiToken(b.category_name);
510
+ const emoji = b.category_emoji && b.category_emoji.trim()
511
+ ? b.category_emoji
512
+ : "•";
513
+ return {
514
+ kind: "completed",
515
+ emoji,
516
+ ascii,
517
+ label: b.title,
518
+ };
519
+ }
520
+ // S9 v0.2: planned 块按分类上色(不再统一 🟦)
521
+ // unverified 保持 ❓ 不上色(状态优先于分类)
522
+ if (b.status === "planned") {
523
+ const ascii = categoryAsciiToken(b.category_name);
524
+ const tile = colorToTile(b.category_color);
525
+ return {
526
+ kind: "planned",
527
+ tile,
528
+ ascii,
529
+ label: b.title,
530
+ };
531
+ }
532
+ return { kind: b.status, label: b.title };
533
+ }
534
+ function cellFromTimer(t) {
535
+ return {
536
+ kind: "timer",
537
+ status: t.status,
538
+ label: t.event,
539
+ };
540
+ }
541
+ function occupySlots(map, startMs, endMs, cell, mode) {
542
+ let startSlot = Math.floor(startMs / SLOT_MS) * SLOT_MS;
543
+ let endSlot;
544
+ if (mode === "timer-inclusive") {
545
+ endSlot = Math.floor(endMs / SLOT_MS) * SLOT_MS; // inclusive
546
+ }
547
+ else {
548
+ // half-open [start, end): last slot whose start < end
549
+ endSlot = Math.floor((endMs - 1) / SLOT_MS) * SLOT_MS;
550
+ if (endMs <= startMs)
551
+ return;
552
+ }
553
+ if (endSlot < startSlot)
554
+ return;
555
+ // Align startSlot to be >= startMs floor already done
556
+ for (let s = startSlot; s <= endSlot; s += SLOT_MS) {
557
+ if (mode === "half-open") {
558
+ if (s < startMs || s >= endMs)
559
+ continue;
560
+ }
561
+ const arr = map.get(s) ?? [];
562
+ arr.push(cell);
563
+ map.set(s, arr);
564
+ }
565
+ }
566
+ function fmtHm(d) {
567
+ const hh = String(d.getHours()).padStart(2, "0");
568
+ const mm = String(d.getMinutes()).padStart(2, "0");
569
+ return `${hh}:${mm}`;
570
+ }
571
+ function detailTimeRange(startIso, endIso) {
572
+ return `${fmtHm(new Date(startIso))}-${fmtHm(new Date(endIso))}`;
573
+ }
574
+ function overlaps(aStart, aEnd, bStart, bEnd) {
575
+ return aStart < bEnd && aEnd > bStart;
576
+ }
577
+ /** Build full TimelineResult from in-memory events (shared by sqlite/in-memory repos). */
578
+ export function buildTimelineFromEvents(opts) {
579
+ const { from, to, now, format } = opts;
580
+ const slotMap = new Map();
581
+ for (const b of opts.blocks) {
582
+ const cell = cellFromBlock(b);
583
+ occupySlots(slotMap, new Date(b.start).getTime(), new Date(b.end).getTime(), cell, "half-open");
584
+ }
585
+ for (const t of opts.timers) {
586
+ const startMs = new Date(t.started_at).getTime();
587
+ const endMs = t.status === "running"
588
+ ? now.getTime()
589
+ : startMs + t.accumulated_ms;
590
+ occupySlots(slotMap, startMs, endMs, cellFromTimer(t), "timer-inclusive");
591
+ }
592
+ // Build dense hour rows from `from` (inclusive hour) to `to` (exclusive hour end?)
593
+ // Window: from=04:00, to=14:00 → hours 04..13 (10 rows) when to is exclusive end?
594
+ // Design: now=9:32 → 04:00–14:00, 共 10 行. That's hours 04,05,...,13 = 10 hours.
595
+ // So from inclusive, to exclusive (to is the end boundary, not a row).
596
+ const hours = [];
597
+ const cursor = new Date(from);
598
+ cursor.setMinutes(0, 0, 0);
599
+ const endBound = new Date(to);
600
+ endBound.setMinutes(0, 0, 0);
601
+ while (cursor.getTime() < endBound.getTime()) {
602
+ const hourStartMs = cursor.getTime();
603
+ const cells = [];
604
+ for (let m = 0; m < 6; m++) {
605
+ const slotMs = hourStartMs + m * SLOT_MS;
606
+ const cands = slotMap.get(slotMs) ?? [];
607
+ cells.push(mergeTimelineCells(cands));
608
+ }
609
+ hours.push({
610
+ hour: formatHourLabel(cursor),
611
+ hourStartIso: cursor.toISOString(),
612
+ cells,
613
+ });
614
+ cursor.setHours(cursor.getHours() + 1);
615
+ }
616
+ // Details
617
+ const wStart = from.getTime();
618
+ const wEnd = to.getTime();
619
+ const details = [];
620
+ for (const b of opts.blocks) {
621
+ const cell = cellFromBlock(b);
622
+ const sym = resolveCellSymbol(cell, format);
623
+ const bStart = new Date(b.start).getTime();
624
+ const bEnd = new Date(b.end).getTime();
625
+ // S45: is_break 判定——标题含「休息」/「break」/「放松」/「空闲」或 category 是闲散
626
+ const titleLower = b.title.toLowerCase();
627
+ const isBreak = /休息|break|放松|空闲|歇|摸鱼|发呆/.test(b.title) ||
628
+ (b.category_name ?? "").includes("闲散");
629
+ details.push({
630
+ time: detailTimeRange(b.start, b.end),
631
+ symbol: sym,
632
+ title: b.title,
633
+ status: b.status,
634
+ in_window: overlaps(bStart, bEnd, wStart, wEnd),
635
+ category_id: b.category_id ?? null,
636
+ category_name: b.category_name ?? null,
637
+ category_emoji: b.category_emoji ?? null,
638
+ is_break: isBreak,
639
+ start_ms: bStart,
640
+ end_ms: bEnd,
641
+ duration_min: Math.round((bEnd - bStart) / 60000),
642
+ });
643
+ }
644
+ for (const t of opts.timers) {
645
+ const cell = cellFromTimer(t);
646
+ const sym = resolveCellSymbol(cell, format);
647
+ const startMs = new Date(t.started_at).getTime();
648
+ const endMs = t.status === "running" ? now.getTime() : startMs + t.accumulated_ms;
649
+ details.push({
650
+ time: detailTimeRange(t.started_at, new Date(endMs).toISOString()),
651
+ symbol: sym,
652
+ title: t.event,
653
+ status: t.status === "running" ? "timer_running" : "timer_paused",
654
+ in_window: overlaps(startMs, endMs, wStart, wEnd),
655
+ // timer 通常不参与分组(用户主动计时,独立性强)
656
+ category_id: null,
657
+ category_name: null,
658
+ category_emoji: null,
659
+ is_break: false,
660
+ start_ms: startMs,
661
+ end_ms: endMs,
662
+ duration_min: Math.round((endMs - startMs) / 60000),
663
+ });
664
+ }
665
+ // sort: in_window first, then by time string
666
+ details.sort((a, b) => {
667
+ if (a.in_window !== b.in_window)
668
+ return a.in_window ? -1 : 1;
669
+ return a.time.localeCompare(b.time);
670
+ });
671
+ const rendered = renderTimelineCells({
672
+ hours,
673
+ nowIso: now.toISOString(),
674
+ format,
675
+ details,
676
+ boundaryEllipsis: true,
677
+ });
678
+ return {
679
+ window: {
680
+ from: from.toISOString(),
681
+ to: to.toISOString(),
682
+ now: now.toISOString(),
683
+ },
684
+ rows: rendered.rows,
685
+ details,
686
+ rendered_text: rendered.rendered_text,
687
+ legend: rendered.legend,
688
+ };
689
+ }
690
+ export function createTimelineTools(repo) {
691
+ return {
692
+ async renderTimeline(filters) {
693
+ if (!filters.user_id) {
694
+ throw new Error("user_id 为必填参数");
695
+ }
696
+ if (filters.from !== undefined &&
697
+ filters.to !== undefined &&
698
+ filters.from > filters.to) {
699
+ throw new Error("开始时间不能晚于结束时间");
700
+ }
701
+ const userId = effectiveUserId(filters);
702
+ return repo.renderTimeline({ ...filters, user_id: userId });
703
+ },
704
+ };
705
+ }