libero-mcp 0.2.16 → 0.2.18

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,3 @@
1
+ -- S44 (2026-08-07): managed_by field for代管 forwarding
2
+ -- 代管人 libero_user_id(NULL = 自己接收追问;非 NULL = 追问发给代管人)
3
+ ALTER TABLE user_identity ADD COLUMN managed_by TEXT;
@@ -0,0 +1,3 @@
1
+ -- S44 (2026-08-07): managed_by field for代管 forwarding
2
+ -- 代管人 libero_user_id(NULL = 自己接收追问;非 NULL = 追问发给代管人)
3
+ ALTER TABLE user_identity ADD COLUMN managed_by TEXT;
@@ -646,6 +646,47 @@ function buildToolRegistry(ctx) {
646
646
  }
647
647
  },
648
648
  });
649
+ tools.push({
650
+ name: "profile.getManager",
651
+ description: "S44 代管转发:查某用户的代管人(managed_by + 代管人 display_name)。" +
652
+ "用途:扫描 cron 发现用户没自己渠道时,查 managed_by 把追问发给代管人。" +
653
+ "返回 managed_by=null 表示该用户没代管人(普通独立用户)。",
654
+ inputSchema: {
655
+ user_id: z.string().min(1),
656
+ },
657
+ handler: async (rawInput) => {
658
+ try {
659
+ const result = await profile.getManager({ user_id: rawInput.user_id });
660
+ return asContent(result);
661
+ }
662
+ catch (e) {
663
+ return errorContent(e.message);
664
+ }
665
+ },
666
+ });
667
+ tools.push({
668
+ name: "profile.setManager",
669
+ description: "S44 代管转发:设置或清除某用户的代管人。" +
670
+ "用法:profile.setManager({ user_id: '<被代管人>', managed_by: '<代管人 libero_user_id>' })。" +
671
+ "managed_by=null 清除代管关系;非 null 必须指向存在的 libero_user_id(FK 校验)。" +
672
+ "典型场景:父母给孩子建账户后,profile.setManager({ user_id: '<孩子>', managed_by: '<父母>' })。",
673
+ inputSchema: {
674
+ user_id: z.string().min(1),
675
+ managed_by: z.string().nullable(),
676
+ },
677
+ handler: async (rawInput) => {
678
+ try {
679
+ const result = await profile.setManager({
680
+ user_id: rawInput.user_id,
681
+ managed_by: rawInput.managed_by,
682
+ });
683
+ return asContent(result);
684
+ }
685
+ catch (e) {
686
+ return errorContent(e.message);
687
+ }
688
+ },
689
+ });
649
690
  // ============================================================
650
691
  // reminder (6 tools — S11)
651
692
  // ============================================================
@@ -8,6 +8,7 @@
8
8
  export class InMemoryIdentityRepo {
9
9
  identities = new Map();
10
10
  aliases = new Map(); // key = `${platform}:${platform_user_id}`
11
+ managers = new Map(); // user_id → manager_user_id
11
12
  async createIdentity(input) {
12
13
  if (this.identities.has(input.libero_user_id)) {
13
14
  throw new Error(`identity already exists: libero_user_id=${input.libero_user_id}`);
@@ -60,6 +61,40 @@ export class InMemoryIdentityRepo {
60
61
  return null;
61
62
  }
62
63
  async listAllIdentities() {
63
- return Array.from(this.identities.values());
64
+ return Array.from(this.identities.values()).map((i) => ({
65
+ ...i,
66
+ managed_by: this.managers.get(i.libero_user_id) ?? null,
67
+ }));
68
+ }
69
+ async getManager(libero_user_id) {
70
+ const id = this.identities.get(libero_user_id);
71
+ if (!id) {
72
+ throw new Error(`getManager: libero_user_id not found: ${libero_user_id}`);
73
+ }
74
+ const managedBy = this.managers.get(libero_user_id) ?? null;
75
+ const mgr = managedBy ? this.identities.get(managedBy) : null;
76
+ return {
77
+ libero_user_id,
78
+ managed_by: managedBy,
79
+ manager_display_name: mgr?.display_name ?? null,
80
+ };
81
+ }
82
+ async setManager(input) {
83
+ if (!this.identities.has(input.libero_user_id)) {
84
+ throw new Error(`setManager: libero_user_id not found: ${input.libero_user_id}`);
85
+ }
86
+ if (input.managed_by !== null) {
87
+ if (!this.identities.has(input.managed_by)) {
88
+ throw new Error(`setManager: manager libero_user_id not found: ${input.managed_by}`);
89
+ }
90
+ this.managers.set(input.libero_user_id, input.managed_by);
91
+ }
92
+ else {
93
+ this.managers.delete(input.libero_user_id);
94
+ }
95
+ return {
96
+ libero_user_id: input.libero_user_id,
97
+ managed_by: input.managed_by,
98
+ };
64
99
  }
65
100
  }
@@ -188,5 +188,34 @@ export function createProfileTools(repo, identityRepo) {
188
188
  display_name: i.display_name,
189
189
  }));
190
190
  },
191
+ /**
192
+ * S44(2026-08-07):查某用户的代管人。
193
+ * 用途:扫描 cron 发现用户没自己渠道时,查 managed_by 把追问发给代管人。
194
+ */
195
+ async getManager(input) {
196
+ if (!identityRepo) {
197
+ throw new Error("getManager 需要 identityRepo(本部署不支持代管转发)");
198
+ }
199
+ if (!input.user_id) {
200
+ throw new Error("user_id 为必填参数");
201
+ }
202
+ return identityRepo.getManager(input.user_id);
203
+ },
204
+ /**
205
+ * S44(2026-08-07):设置/清除某用户的代管人。
206
+ * managed_by=null 清除;非 null 必须指向存在的 libero_user_id。
207
+ */
208
+ async setManager(input) {
209
+ if (!identityRepo) {
210
+ throw new Error("setManager 需要 identityRepo(本部署不支持代管转发)");
211
+ }
212
+ if (!input.user_id) {
213
+ throw new Error("user_id 为必填参数");
214
+ }
215
+ return identityRepo.setManager({
216
+ libero_user_id: input.user_id,
217
+ managed_by: input.managed_by,
218
+ });
219
+ },
191
220
  };
192
221
  }
@@ -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,
@@ -78,10 +78,52 @@ export class SqliteIdentityRepo {
78
78
  }
79
79
  async listAllIdentities() {
80
80
  const rows = this.db
81
- .prepare(`SELECT libero_user_id, display_name, created_at, updated_at
81
+ .prepare(`SELECT libero_user_id, display_name, managed_by, created_at, updated_at
82
82
  FROM user_identity
83
83
  ORDER BY created_at ASC`)
84
84
  .all();
85
85
  return rows.map((r) => ({ ...r }));
86
86
  }
87
+ async getManager(libero_user_id) {
88
+ const row = this.db
89
+ .prepare(`SELECT ui.libero_user_id, ui.managed_by, mgr.display_name as manager_display_name
90
+ FROM user_identity ui
91
+ LEFT JOIN user_identity mgr ON mgr.libero_user_id = ui.managed_by
92
+ WHERE ui.libero_user_id = ?`)
93
+ .get(libero_user_id);
94
+ if (!row) {
95
+ throw new Error(`getManager: libero_user_id not found: ${libero_user_id}`);
96
+ }
97
+ return {
98
+ libero_user_id: row.libero_user_id,
99
+ managed_by: row.managed_by,
100
+ manager_display_name: row.manager_display_name,
101
+ };
102
+ }
103
+ async setManager(input) {
104
+ // User existence check
105
+ const user = this.db
106
+ .prepare("SELECT 1 FROM user_identity WHERE libero_user_id = ?")
107
+ .get(input.libero_user_id);
108
+ if (!user) {
109
+ throw new Error(`setManager: libero_user_id not found: ${input.libero_user_id}`);
110
+ }
111
+ // Manager FK check (if non-null)
112
+ if (input.managed_by !== null) {
113
+ const mgr = this.db
114
+ .prepare("SELECT 1 FROM user_identity WHERE libero_user_id = ?")
115
+ .get(input.managed_by);
116
+ if (!mgr) {
117
+ throw new Error(`setManager: manager libero_user_id not found: ${input.managed_by}`);
118
+ }
119
+ }
120
+ const now = new Date().toISOString().replace("T", " ").replace("Z", "");
121
+ this.db
122
+ .prepare("UPDATE user_identity SET managed_by = ?, updated_at = ? WHERE libero_user_id = ?")
123
+ .run(input.managed_by, now, input.libero_user_id);
124
+ return {
125
+ libero_user_id: input.libero_user_id,
126
+ managed_by: input.managed_by,
127
+ };
128
+ }
87
129
  }
@@ -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
@@ -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.16",
3
+ "version": "0.2.18",
4
4
  "description": "AI 时间管理教练 MCP 工具集——时间块记录、统计、矩阵诊断、计时、分类、profile",
5
5
  "license": "MIT",
6
6
  "author": "amosyuan",