libero-mcp 0.2.17 → 0.2.19

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.
@@ -0,0 +1,6 @@
1
+ -- S46 (2026-08-07): task difficulty/priority 持久化
2
+ -- 用于"快速启动 + 峰终"智能排程
3
+ -- 难度(easy/medium/hard,默认 medium)
4
+ ALTER TABLE tasks ADD COLUMN difficulty TEXT DEFAULT 'medium';
5
+ -- 优先级(high/medium/low,默认 medium)
6
+ ALTER TABLE tasks ADD COLUMN priority TEXT DEFAULT 'medium';
@@ -0,0 +1,6 @@
1
+ -- S46 (2026-08-07): task difficulty/priority 持久化
2
+ -- 用于"快速启动 + 峰终"智能排程
3
+ -- 难度(easy/medium/hard,默认 medium)
4
+ ALTER TABLE tasks ADD COLUMN difficulty TEXT DEFAULT 'medium';
5
+ -- 优先级(high/medium/low,默认 medium)
6
+ ALTER TABLE tasks ADD COLUMN priority TEXT DEFAULT 'medium';
@@ -824,7 +824,7 @@ function buildToolRegistry(ctx) {
824
824
  // ============================================================
825
825
  tools.push({
826
826
  name: "task.create",
827
- description: "建单个独立任务(不挂任何 timeblock)。⚠️ 拆解请用 decompose.split,本工具一次只能建一个任务、不能建子任务。",
827
+ description: "建单个独立任务(不挂任何 timeblock)。⚠️ 拆解请用 decompose.split,本工具一次只能建一个任务、不能建子任务。S46 支持 difficulty(easy/medium/hard,默认 medium)+ priority(high/medium/low,默认 medium),用于'快速启动+峰终'智能排程。",
828
828
  // S33 v3: 用 z.object().strict() 显式拒 parent_id(zod 默认 strip 不报错,
829
829
  // 严格模式让多余键触发 safeParse 失败 → MCP 返回 isError)。
830
830
  // 底层 createTask 仍保留 parent_id 能力(task.test.ts 单测不破)。
@@ -834,6 +834,8 @@ function buildToolRegistry(ctx) {
834
834
  target_user_id: z.string().min(1).optional(),
835
835
  title: z.string().min(1),
836
836
  est_minutes: z.number().optional(),
837
+ difficulty: z.enum(["easy", "medium", "hard"]).optional(),
838
+ priority: z.enum(["high", "medium", "low"]).optional(),
837
839
  })
838
840
  .strict(),
839
841
  needsUserIdInjection: true,
@@ -868,12 +870,14 @@ function buildToolRegistry(ctx) {
868
870
  });
869
871
  tools.push({
870
872
  name: "task.update",
871
- description: "更新任务(title/est_minutes/status;状态机校验非法流转)",
873
+ description: "更新任务(title/est_minutes/status/difficulty/priority;状态机校验非法流转)",
872
874
  inputSchema: {
873
875
  id: z.string().min(1),
874
876
  title: z.string().optional(),
875
877
  est_minutes: z.number().optional(),
876
878
  status: z.string().optional(),
879
+ difficulty: z.enum(["easy", "medium", "hard"]).optional(),
880
+ priority: z.enum(["high", "medium", "low"]).optional(),
877
881
  },
878
882
  handler: async ({ id, ...rest }) => asContent(await task.updateTask(id, rest)),
879
883
  });
@@ -886,6 +890,16 @@ function buildToolRegistry(ctx) {
886
890
  },
887
891
  handler: async ({ task_id, block_id }) => asContent(await task.linkTimeblock(task_id, block_id)),
888
892
  });
893
+ tools.push({
894
+ name: "task.updateDifficulty",
895
+ description: "S46: 仅更新 task 的 difficulty(easy/medium/hard)或 priority(high/medium/low)。用户主动调整时用,默认 medium。用于'快速启动+峰终'智能排程。",
896
+ inputSchema: {
897
+ task_id: z.string().min(1),
898
+ difficulty: z.enum(["easy", "medium", "hard"]).optional(),
899
+ priority: z.enum(["high", "medium", "low"]).optional(),
900
+ },
901
+ handler: async ({ task_id, difficulty, priority }) => asContent(await task.updateDifficulty(task_id, { difficulty, priority })),
902
+ });
889
903
  tools.push({
890
904
  name: "task.findPlan",
891
905
  description: "按事件名 exact-match 查拆解计划:返回根任务 + 第一块未完成子任务(record 开工时链 task_id,驱动闭环正反馈)",
@@ -298,6 +298,7 @@ export class InMemoryTimelineRepo {
298
298
  end: b.end_time,
299
299
  status: b.status,
300
300
  title: b.title,
301
+ category_id: b.category_id ?? null,
301
302
  category_emoji: cat?.emoji ?? null,
302
303
  category_name: cat?.name ?? null,
303
304
  category_color: cat?.color ?? null,
@@ -404,6 +404,7 @@ export class SqliteTimelineRepo {
404
404
  tb.end_time as end,
405
405
  tb.status as status,
406
406
  tb.title as title,
407
+ tb.category_id as category_id,
407
408
  c.emoji as category_emoji,
408
409
  c.name as category_name,
409
410
  c.color as category_color
@@ -19,6 +19,13 @@ function nowStr() {
19
19
  return (new Date().toISOString().replace("T", " ").replace("Z", "") +
20
20
  String(Math.floor(performance.now() * 100000) % 100000).padStart(5, "0"));
21
21
  }
22
+ // S46: difficulty/priority 归一化(不合法值 → medium)
23
+ function normalizeDiff(v) {
24
+ return v === "easy" || v === "medium" || v === "hard" ? v : "medium";
25
+ }
26
+ function normalizePrio(v) {
27
+ return v === "high" || v === "medium" || v === "low" ? v : "medium";
28
+ }
22
29
  function rowToTask(row) {
23
30
  return {
24
31
  id: row.id,
@@ -30,6 +37,9 @@ function rowToTask(row) {
30
37
  category_id: row.category_id ?? null,
31
38
  linked_timeblock_id: row.linked_timeblock_id ?? null,
32
39
  order_index: row.order_index ?? 0,
40
+ // S46: difficulty/priority(migration 014 加的列;旧库未 migrate 时回退 medium)
41
+ difficulty: row.difficulty ?? "medium",
42
+ priority: row.priority ?? "medium",
33
43
  created_at: row.created_at,
34
44
  updated_at: row.updated_at,
35
45
  deleted_at: row.deleted_at ?? null,
@@ -81,8 +91,11 @@ export function createTaskTools(db) {
81
91
  }
82
92
  const id = crypto.randomUUID();
83
93
  const now = nowStr();
84
- db.prepare(`INSERT INTO tasks (id, user_id, parent_id, title, status, est_minutes, category_id, linked_timeblock_id, order_index, created_at, updated_at, deleted_at)
85
- VALUES (?, ?, ?, ?, 'draft', ?, NULL, NULL, 0, ?, ?, NULL)`).run(id, userId, input.parent_id ?? null, input.title, input.est_minutes ?? null, now, now);
94
+ // S46: difficulty/priority 默认 medium,校验入参合法值
95
+ const difficulty = normalizeDiff(input.difficulty);
96
+ const priority = normalizePrio(input.priority);
97
+ db.prepare(`INSERT INTO tasks (id, user_id, parent_id, title, status, est_minutes, category_id, linked_timeblock_id, order_index, difficulty, priority, created_at, updated_at, deleted_at)
98
+ VALUES (?, ?, ?, ?, 'draft', ?, NULL, NULL, 0, ?, ?, ?, ?, NULL)`).run(id, userId, input.parent_id ?? null, input.title, input.est_minutes ?? null, difficulty, priority, now, now);
86
99
  const row = getTaskRow(id);
87
100
  return {
88
101
  ...rowToTask(row),
@@ -142,6 +155,15 @@ export function createTaskTools(db) {
142
155
  sets.push("status = ?");
143
156
  params.push(patch.status);
144
157
  }
158
+ // S46: difficulty/priority 可独立更新
159
+ if (patch.difficulty !== undefined) {
160
+ sets.push("difficulty = ?");
161
+ params.push(normalizeDiff(patch.difficulty));
162
+ }
163
+ if (patch.priority !== undefined) {
164
+ sets.push("priority = ?");
165
+ params.push(normalizePrio(patch.priority));
166
+ }
145
167
  // Always refresh updated_at
146
168
  sets.push("updated_at = ?");
147
169
  params.push(nowStr());
@@ -168,6 +190,32 @@ export function createTaskTools(db) {
168
190
  const row = getTaskRow(taskId);
169
191
  return rowToTask(row);
170
192
  },
193
+ /** S46: 仅更新 difficulty/priority(用户主动调整) */
194
+ async updateDifficulty(taskId, patch) {
195
+ const existing = getTaskRow(taskId);
196
+ if (!existing) {
197
+ throw new Error("任务不存在");
198
+ }
199
+ if (patch.difficulty === undefined && patch.priority === undefined) {
200
+ throw new Error("至少传一个字段 difficulty 或 priority");
201
+ }
202
+ const sets = [];
203
+ const params = [];
204
+ if (patch.difficulty !== undefined) {
205
+ sets.push("difficulty = ?");
206
+ params.push(normalizeDiff(patch.difficulty));
207
+ }
208
+ if (patch.priority !== undefined) {
209
+ sets.push("priority = ?");
210
+ params.push(normalizePrio(patch.priority));
211
+ }
212
+ sets.push("updated_at = ?");
213
+ params.push(nowStr());
214
+ params.push(taskId);
215
+ db.prepare(`UPDATE tasks SET ${sets.join(", ")} WHERE id = ?`).run(...params);
216
+ const row = getTaskRow(taskId);
217
+ return rowToTask(row);
218
+ },
171
219
  async findPlan(input) {
172
220
  // exact-match a startable root task (aligned/executing) by title
173
221
  const rootRow = db
@@ -241,13 +289,13 @@ export function createTaskTools(db) {
241
289
  const result = db.transaction(() => {
242
290
  const rootId = crypto.randomUUID();
243
291
  const now = nowStr();
244
- db.prepare(`INSERT INTO tasks (id, user_id, parent_id, title, status, est_minutes, category_id, linked_timeblock_id, order_index, created_at, updated_at, deleted_at)
245
- VALUES (?, ?, NULL, ?, 'aligned', NULL, NULL, NULL, 0, ?, ?, NULL)`).run(rootId, plan.user_id, plan.root_title, now, now);
292
+ db.prepare(`INSERT INTO tasks (id, user_id, parent_id, title, status, est_minutes, category_id, linked_timeblock_id, order_index, difficulty, priority, created_at, updated_at, deleted_at)
293
+ VALUES (?, ?, NULL, ?, 'aligned', NULL, NULL, NULL, 0, 'medium', 'medium', ?, ?, NULL)`).run(rootId, plan.user_id, plan.root_title, now, now);
246
294
  const children = plan.children.map((c, i) => {
247
295
  const childId = crypto.randomUUID();
248
296
  const cnow = nowStr();
249
- db.prepare(`INSERT INTO tasks (id, user_id, parent_id, title, status, est_minutes, category_id, linked_timeblock_id, order_index, created_at, updated_at, deleted_at)
250
- VALUES (?, ?, ?, ?, 'draft', ?, NULL, NULL, ?, ?, ?, NULL)`).run(childId, plan.user_id, rootId, c.title, c.est_minutes, i, cnow, cnow);
297
+ db.prepare(`INSERT INTO tasks (id, user_id, parent_id, title, status, est_minutes, category_id, linked_timeblock_id, order_index, difficulty, priority, created_at, updated_at, deleted_at)
298
+ VALUES (?, ?, ?, ?, 'draft', ?, NULL, NULL, ?, 'medium', 'medium', ?, ?, NULL)`).run(childId, plan.user_id, rootId, c.title, c.est_minutes, i, cnow, cnow);
251
299
  return rowToTask(getTaskRow(childId));
252
300
  });
253
301
  return { root: rowToTask(getTaskRow(rootId)), children };
@@ -334,13 +382,13 @@ export function createTaskTools(db) {
334
382
  const result = db.transaction(() => {
335
383
  const rootId = crypto.randomUUID();
336
384
  const now = nowStr();
337
- db.prepare(`INSERT INTO tasks (id, user_id, parent_id, title, status, est_minutes, category_id, linked_timeblock_id, order_index, created_at, updated_at, deleted_at)
338
- VALUES (?, ?, NULL, ?, 'aligned', NULL, NULL, ?, 0, ?, ?, NULL)`).run(rootId, plan.user_id, plan.root_title, plan.timeblock_id, now, now);
385
+ db.prepare(`INSERT INTO tasks (id, user_id, parent_id, title, status, est_minutes, category_id, linked_timeblock_id, order_index, difficulty, priority, created_at, updated_at, deleted_at)
386
+ VALUES (?, ?, NULL, ?, 'aligned', NULL, NULL, ?, 0, 'medium', 'medium', ?, ?, NULL)`).run(rootId, plan.user_id, plan.root_title, plan.timeblock_id, now, now);
339
387
  const children = plan.children.map((c, i) => {
340
388
  const childId = crypto.randomUUID();
341
389
  const cnow = nowStr();
342
- db.prepare(`INSERT INTO tasks (id, user_id, parent_id, title, status, est_minutes, category_id, linked_timeblock_id, order_index, created_at, updated_at, deleted_at)
343
- VALUES (?, ?, ?, ?, 'draft', ?, NULL, NULL, ?, ?, ?, NULL)`).run(childId, plan.user_id, rootId, c.title, c.est_minutes, i, cnow, cnow);
390
+ db.prepare(`INSERT INTO tasks (id, user_id, parent_id, title, status, est_minutes, category_id, linked_timeblock_id, order_index, difficulty, priority, created_at, updated_at, deleted_at)
391
+ VALUES (?, ?, ?, ?, 'draft', ?, NULL, NULL, ?, 'medium', 'medium', ?, ?, NULL)`).run(childId, plan.user_id, rootId, c.title, c.est_minutes, i, cnow, cnow);
344
392
  return {
345
393
  id: childId,
346
394
  title: c.title,
@@ -291,17 +291,20 @@ export function renderTimelineCells(input) {
291
291
  // S9 v0.5(2026-08-07):表头移到矩阵上方,不再夹在明细前面
292
292
  const textLines = [];
293
293
  // ── 明细段(在前)──
294
- // S9 v0.3: 按 symbol 分组紧凑展示(合并同 symbol 到一行)
294
+ // S9 v0.3: 按 symbol 分组紧凑展示
295
295
  // S9 v0.4: completed → ✓ + ~~划线~~;unverified → ❓ 后缀;插入「📍 现在」分隔线
296
- // S9 v0.5(2026-08-07): 改为每条独占一行(用户反馈"挤在一起乱")
297
- // 格式:`{status} {symbol} {time} {title}`
298
- // status: completed "✓ ";unverified → "? ";planned/timer_* → " "(空格占位对齐)
296
+ // S9 v0.5(2026-08-07): 每条独占一行
297
+ // S45(2026-08-07): category + 时间连续 → 合并成组;中间 ≤15min 短休息 → 吸收
298
+ // 组(>1 member 或吸收了休息):组头 + 子任务列表
299
+ // 组(仅 1 member 无休息):退化为 v0.5 单行(避免冗余)
299
300
  const details = input.details ?? [];
300
301
  if (details.length > 0) {
301
302
  textLines.push("📋 明细");
302
303
  const inWin = details.filter((d) => d.in_window);
303
304
  const outWin = details.filter((d) => !d.in_window);
304
- // S9 v0.5: 每条独占一行的 entry 构造
305
+ // S45: inWin 已按 time 排序,可直接分组
306
+ const inWinGroups = groupDetails(inWin);
307
+ // S9 v0.5: 单条 entry 格式
305
308
  const buildEntry = (d) => {
306
309
  let statusMark = " "; // 默认 2 空格占位(对齐 ✓ 和 ?)
307
310
  let titlePart = d.title;
@@ -314,7 +317,28 @@ export function renderTimelineCells(input) {
314
317
  }
315
318
  return `${statusMark}${d.symbol} ${d.time} ${titlePart}`;
316
319
  };
317
- // S9 v0.4: 「📍 现在」分隔线判断(v0.5 保留同规则)
320
+ // S45: 组内子任务 entry(不带组 symbol,状态前缀照旧)
321
+ const buildSubEntry = (d) => {
322
+ let statusMark = " ";
323
+ let titlePart = d.title;
324
+ if (d.status === "completed") {
325
+ statusMark = "✓ ";
326
+ titlePart = `~~${d.title}~~`;
327
+ }
328
+ else if (d.status === "unverified") {
329
+ statusMark = "? ";
330
+ }
331
+ // 子任务缩进 2 空格,时间取自 d.time 的 "HH:MM-HH:MM"
332
+ return ` ${statusMark}${d.time} ${titlePart}`;
333
+ };
334
+ // S45: 组头格式
335
+ const buildGroupHeader = (g) => {
336
+ const emoji = g.category_emoji && g.category_emoji.trim() ? g.category_emoji : "•";
337
+ const name = g.category_name ?? "活动";
338
+ const breakNote = g.break_total_min > 0 ? `(含休息 ${g.break_total_min}min)` : "";
339
+ return `${emoji} ${name} ${g.start_time}-${g.end_time}${breakNote}`;
340
+ };
341
+ // S9 v0.4: 「📍 现在」分隔线判断
318
342
  const parseDetailStart = (time) => {
319
343
  const startStr = time.split("-")[0].trim();
320
344
  const [h, m] = startStr.split(":").map(Number);
@@ -326,26 +350,34 @@ export function renderTimelineCells(input) {
326
350
  const inWinAllPast = inWin.every((d) => parseDetailStart(d.time) <= nowHM);
327
351
  const inWinAllFuture = inWin.every((d) => parseDetailStart(d.time) > nowHM);
328
352
  const needNowMarker = inWin.length > 0 && !inWinAllPast && !inWinAllFuture;
329
- let firstFutureIdx = inWin.length;
330
- if (needNowMarker) {
331
- for (let i = 0; i < inWin.length; i++) {
332
- if (parseDetailStart(inWin[i].time) > nowHM) {
333
- firstFutureIdx = i;
334
- break;
335
- }
353
+ // S45: 渲染组——支持"现在"标识插在第一个未来组之前
354
+ const renderGroup = (g) => {
355
+ const isMulti = g.members.length > 1 || g.absorbed_breaks.length > 0;
356
+ if (!isMulti) {
357
+ // 退化为 v0.5 单行
358
+ return [buildEntry(g.members[0])];
336
359
  }
337
- }
338
- // S9 v0.5: 每条独占一行,在 firstFutureIdx 处插分隔线
339
- for (let i = 0; i < inWin.length; i++) {
340
- if (needNowMarker && i === firstFutureIdx) {
360
+ const lines = [buildGroupHeader(g)];
361
+ for (const m of g.members) {
362
+ lines.push(buildSubEntry(m));
363
+ }
364
+ return lines;
365
+ };
366
+ for (let i = 0; i < inWinGroups.length; i++) {
367
+ const g = inWinGroups[i];
368
+ // 「📍 现在」标识插在第一个未来组之前
369
+ if (needNowMarker && parseDetailStart(`${g.start_time}-${g.end_time}`) > nowHM && i > 0 && !textLines.includes(`─── 📍 现在 ${nowLabel} ──`)) {
341
370
  textLines.push(`─── 📍 现在 ${nowLabel} ──`);
342
371
  }
343
- textLines.push(buildEntry(inWin[i]));
372
+ textLines.push(...renderGroup(g));
344
373
  }
374
+ // 兜底:如果所有组都过去/未来,但 needNowMarker 仍 true(混合)且没插入过 → 不补
375
+ // (理论上不会发生,因为上面循环会处理)
345
376
  if (outWin.length > 0) {
346
377
  textLines.push("(窗口外)");
347
- for (const d of outWin) {
348
- textLines.push(buildEntry(d));
378
+ const outWinGroups = groupDetails(outWin);
379
+ for (const g of outWinGroups) {
380
+ textLines.push(...renderGroup(g));
349
381
  }
350
382
  }
351
383
  }
@@ -389,6 +421,80 @@ export function renderTimelineCells(input) {
389
421
  }
390
422
  return { rows, rendered_text: textLines.join("\n"), legend };
391
423
  }
424
+ const S45_GAP_THRESHOLD_MIN = 15;
425
+ const S45_BREAK_THRESHOLD_MIN = 15;
426
+ function isShortBreak(d) {
427
+ return !!d.is_break && (d.duration_min ?? 999) <= S45_BREAK_THRESHOLD_MIN;
428
+ }
429
+ /**
430
+ * S45: 把已排序的 detail 列表分组成 DetailGroup[]。
431
+ * 输入必须按 start_ms 升序排好。
432
+ */
433
+ export function groupDetails(details) {
434
+ if (details.length === 0)
435
+ return [];
436
+ const groups = [];
437
+ let current = null;
438
+ for (let i = 0; i < details.length; i++) {
439
+ const d = details[i];
440
+ if (current === null) {
441
+ // 首个块 → 开新组(休息块也开自己的"独立组",members 是它自己)
442
+ current = newGroup(d);
443
+ continue;
444
+ }
445
+ const gapMin = ((d.start_ms ?? 0) - (current.members[current.members.length - 1]?.end_ms ?? 0)) /
446
+ (60 * 1000);
447
+ const sameCategory = !!d.category_id &&
448
+ d.category_id === current.category_id &&
449
+ !!current.category_id;
450
+ // 情况 1:当前块是短休息,且后面(i+1)还有同 category 块 → 吸收到当前组
451
+ if (isShortBreak(d) && sameCategoryContinuesAfter(details, i, current.category_id)) {
452
+ current.absorbed_breaks.push(d);
453
+ current.break_total_min += d.duration_min ?? 0;
454
+ // 注意:休息块的 end_ms 不更新 current 的 last_end——组的 last_end 取最后一个非休息块
455
+ continue;
456
+ }
457
+ // 情况 2:同 category + gap ≤15min → 加入组
458
+ if (sameCategory && gapMin >= 0 && gapMin <= S45_GAP_THRESHOLD_MIN) {
459
+ current.members.push(d);
460
+ // 更新组的 end_time
461
+ current.end_time = d.time.split("-")[1]?.trim() ?? current.end_time;
462
+ continue;
463
+ }
464
+ // 情况 3:边界 → 结束当前组,开新组
465
+ groups.push(current);
466
+ current = newGroup(d);
467
+ }
468
+ if (current)
469
+ groups.push(current);
470
+ return groups;
471
+ }
472
+ function newGroup(d) {
473
+ return {
474
+ members: [d],
475
+ absorbed_breaks: [],
476
+ break_total_min: 0,
477
+ start_time: d.time.split("-")[0]?.trim() ?? "",
478
+ end_time: d.time.split("-")[1]?.trim() ?? "",
479
+ category_id: d.category_id ?? null,
480
+ category_name: d.category_name ?? null,
481
+ category_emoji: d.category_emoji ?? null,
482
+ };
483
+ }
484
+ /** 判断 details[i] 之后是否还有同 category_id 的非休息块(前视,用于休息吸收判定) */
485
+ function sameCategoryContinuesAfter(details, i, categoryId) {
486
+ if (!categoryId)
487
+ return false;
488
+ for (let j = i + 1; j < details.length; j++) {
489
+ const next = details[j];
490
+ if (next.is_break)
491
+ continue; // 跳过中间的休息块
492
+ if (next.category_id === categoryId)
493
+ return true;
494
+ return false; // 遇到非同 category 的非休息块 → 不算后续同 category
495
+ }
496
+ return false;
497
+ }
392
498
  export function computeDefaultWindow(now) {
393
499
  const hour = new Date(now);
394
500
  hour.setMinutes(0, 0, 0);
@@ -514,12 +620,23 @@ export function buildTimelineFromEvents(opts) {
514
620
  const sym = resolveCellSymbol(cell, format);
515
621
  const bStart = new Date(b.start).getTime();
516
622
  const bEnd = new Date(b.end).getTime();
623
+ // S45: is_break 判定——标题含「休息」/「break」/「放松」/「空闲」或 category 是闲散
624
+ const titleLower = b.title.toLowerCase();
625
+ const isBreak = /休息|break|放松|空闲|歇|摸鱼|发呆/.test(b.title) ||
626
+ (b.category_name ?? "").includes("闲散");
517
627
  details.push({
518
628
  time: detailTimeRange(b.start, b.end),
519
629
  symbol: sym,
520
630
  title: b.title,
521
631
  status: b.status,
522
632
  in_window: overlaps(bStart, bEnd, wStart, wEnd),
633
+ category_id: b.category_id ?? null,
634
+ category_name: b.category_name ?? null,
635
+ category_emoji: b.category_emoji ?? null,
636
+ is_break: isBreak,
637
+ start_ms: bStart,
638
+ end_ms: bEnd,
639
+ duration_min: Math.round((bEnd - bStart) / 60000),
523
640
  });
524
641
  }
525
642
  for (const t of opts.timers) {
@@ -533,6 +650,14 @@ export function buildTimelineFromEvents(opts) {
533
650
  title: t.event,
534
651
  status: t.status === "running" ? "timer_running" : "timer_paused",
535
652
  in_window: overlaps(startMs, endMs, wStart, wEnd),
653
+ // timer 通常不参与分组(用户主动计时,独立性强)
654
+ category_id: null,
655
+ category_name: null,
656
+ category_emoji: null,
657
+ is_break: false,
658
+ start_ms: startMs,
659
+ end_ms: endMs,
660
+ duration_min: Math.round((endMs - startMs) / 60000),
536
661
  });
537
662
  }
538
663
  // sort: in_window first, then by time string
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libero-mcp",
3
- "version": "0.2.17",
3
+ "version": "0.2.19",
4
4
  "description": "AI 时间管理教练 MCP 工具集——时间块记录、统计、矩阵诊断、计时、分类、profile",
5
5
  "license": "MIT",
6
6
  "author": "amosyuan",