libero-mcp 0.2.13 → 0.2.15

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.
@@ -301,6 +301,34 @@ function buildToolRegistry(ctx) {
301
301
  return asContent(await tb.list(r));
302
302
  },
303
303
  });
304
+ tools.push({
305
+ name: "timeblock.sweepStale",
306
+ description: "S43 过期扫描:列出某用户当天过期未确认的 timeblock,分两组返回。" +
307
+ "to_unverified:end_time 已过 30min 仍 planned(该升级 unverified)。" +
308
+ "to_ask:end_time 已过 60min 仍 unverified/planned(该主动问用户)。" +
309
+ "⚠️ 纯查询,不改 db——caller(cron-session LLM)需对 to_unverified 显式调 timeblock.update(status=unverified),对 to_ask 调 cronjob create 发追问。" +
310
+ "⚠️ 仅 cron-session 调用;user_id 必填。",
311
+ inputSchema: {
312
+ user_id: z.string().min(1),
313
+ now: z.string().optional(),
314
+ overdue_min_to_unverified: z.number().optional(),
315
+ overdue_min_to_ask: z.number().optional(),
316
+ },
317
+ handler: async (rawInput) => {
318
+ try {
319
+ const result = await tb.sweepStale({
320
+ user_id: rawInput.user_id,
321
+ now: rawInput.now,
322
+ overdue_min_to_unverified: rawInput.overdue_min_to_unverified,
323
+ overdue_min_to_ask: rawInput.overdue_min_to_ask,
324
+ });
325
+ return asContent(result);
326
+ }
327
+ catch (e) {
328
+ return errorContent(e.message);
329
+ }
330
+ },
331
+ });
304
332
  // ============================================================
305
333
  // stats (2 tools — single-object filters)
306
334
  // ============================================================
@@ -600,6 +628,22 @@ function buildToolRegistry(ctx) {
600
628
  }
601
629
  },
602
630
  });
631
+ tools.push({
632
+ name: "profile.listAllUsers",
633
+ description: "S43 多用户扫描:列出所有 libero 用户(libero_user_id + display_name)。" +
634
+ "用途:cron 扫描(每 30min)跑所有用户的状态升级和主动询问,不靠 LLM 从上下文猜。" +
635
+ "⚠️ 仅 cron-session 调用,普通对话不要用。",
636
+ inputSchema: {},
637
+ handler: async () => {
638
+ try {
639
+ const result = await profile.listAllUsers();
640
+ return asContent({ users: result });
641
+ }
642
+ catch (e) {
643
+ return errorContent(e.message);
644
+ }
645
+ },
646
+ });
603
647
  // ============================================================
604
648
  // reminder (6 tools — S11)
605
649
  // ============================================================
@@ -59,4 +59,7 @@ export class InMemoryIdentityRepo {
59
59
  }
60
60
  return null;
61
61
  }
62
+ async listAllIdentities() {
63
+ return Array.from(this.identities.values());
64
+ }
62
65
  }
@@ -171,5 +171,22 @@ export function createProfileTools(repo, identityRepo) {
171
171
  bound_alias,
172
172
  };
173
173
  },
174
+ /**
175
+ * S43(2026-08-07):列出所有 libero 用户。
176
+ * 用途:扫描 cron 跑所有用户的状态升级和主动询问(不靠 LLM 从上下文猜)。
177
+ *
178
+ * 数据源:identity 表(libero 内部身份)。display_name 取 identity 表的。
179
+ * §4.5 静默折叠声明:返回所有 identity 行;若 identityRepo 未注入则抛(不静默返回空)。
180
+ */
181
+ async listAllUsers() {
182
+ if (!identityRepo) {
183
+ throw new Error("listAllUsers 需要 identityRepo(server-core 启动时未注入,本部署不支持多用户扫描)");
184
+ }
185
+ const identities = await identityRepo.listAllIdentities();
186
+ return identities.map((i) => ({
187
+ libero_user_id: i.libero_user_id,
188
+ display_name: i.display_name,
189
+ }));
190
+ },
174
191
  };
175
192
  }
@@ -76,4 +76,12 @@ export class SqliteIdentityRepo {
76
76
  .get(platform_user_id);
77
77
  return row ? { ...row } : null;
78
78
  }
79
+ async listAllIdentities() {
80
+ const rows = this.db
81
+ .prepare(`SELECT libero_user_id, display_name, created_at, updated_at
82
+ FROM user_identity
83
+ ORDER BY created_at ASC`)
84
+ .all();
85
+ return rows.map((r) => ({ ...r }));
86
+ }
79
87
  }
@@ -164,10 +164,14 @@ export class SqliteTimeBlockRepo {
164
164
  return (await this.getById(id));
165
165
  }
166
166
  async findOverlapping(input) {
167
+ // ⚠️ 时区安全:用 SQLite datetime() 规范化所有时间字段后再比较。
168
+ // datetime() 能解析多种 ISO 格式(含 +08:00 / Z),输出统一 'YYYY-MM-DD HH:MM:SS' UTC。
169
+ // 不能用裸字符串比较('2026-08-07T09:00:00+08:00' vs '2026-08-07T01:00:00Z' 字符串比较会误判)。
167
170
  return this.db
168
171
  .prepare(`SELECT * FROM time_blocks
169
172
  WHERE user_id = ? AND status = 'planned' AND deleted_at IS NULL
170
- AND start_time < ? AND end_time > ?
173
+ AND datetime(start_time) < datetime(?)
174
+ AND datetime(end_time) > datetime(?)
171
175
  ORDER BY start_time`)
172
176
  .all(input.user_id, input.end, input.start);
173
177
  }
@@ -117,5 +117,67 @@ export function createTimeBlockTools(repo, db) {
117
117
  async restore(id) {
118
118
  return repo.restore(id);
119
119
  },
120
+ /**
121
+ * S43(2026-08-07):扫描某用户过期未确认的 timeblock,分组返回。
122
+ *
123
+ * 用途:cron-session 每 30min 调一次,让 cron LLM 显式调 timeblock.update
124
+ * 升级状态 + 主动问。本工具**不**直接改 db(保留 brain/hand 分离 + 用户主权模型)。
125
+ *
126
+ * 判定逻辑(以 now 为基准):
127
+ * - end_time + overdue_min_to_unverified (默认 30min) 仍 planned → to_unverified
128
+ * - end_time + overdue_min_to_ask (默认 60min) 仍 unverified/planned → to_ask
129
+ * - completed / 未过期 / 软删的 → 都不进
130
+ *
131
+ * §4.5 静默折叠声明:纯查询,无副作用,无折叠。
132
+ */
133
+ async sweepStale(input) {
134
+ if (!input.user_id) {
135
+ throw new Error("user_id 为必填参数");
136
+ }
137
+ const nowIso = input.now ?? new Date().toISOString();
138
+ const nowMs = new Date(nowIso).getTime();
139
+ const thresholdUnverifiedMin = input.overdue_min_to_unverified ?? 30;
140
+ const thresholdAskMin = input.overdue_min_to_ask ?? 60;
141
+ const thresholdUnverifiedMs = thresholdUnverifiedMin * 60 * 1000;
142
+ const thresholdAskMs = thresholdAskMin * 60 * 1000;
143
+ // 用 list 拿到该用户所有非软删的块(list 默认会过滤 deleted_at)
144
+ const blocks = await repo.list({
145
+ user_id: input.user_id,
146
+ });
147
+ const to_unverified = [];
148
+ const to_ask = [];
149
+ for (const b of blocks) {
150
+ if (b.deleted_at)
151
+ continue;
152
+ if (b.status === "completed")
153
+ continue;
154
+ const endMs = new Date(b.end_time).getTime();
155
+ if (Number.isNaN(endMs))
156
+ continue;
157
+ const ageMs = nowMs - endMs;
158
+ if (ageMs <= 0)
159
+ continue; // 还没到 end_time
160
+ const overdueUnverified = ageMs >= thresholdUnverifiedMs;
161
+ const overdueAsk = ageMs >= thresholdAskMs;
162
+ if (overdueUnverified && b.status === "planned") {
163
+ to_unverified.push({
164
+ id: b.id,
165
+ title: b.title,
166
+ start_time: b.start_time,
167
+ end_time: b.end_time,
168
+ });
169
+ }
170
+ if (overdueAsk && (b.status === "unverified" || b.status === "planned")) {
171
+ to_ask.push({
172
+ id: b.id,
173
+ title: b.title,
174
+ start_time: b.start_time,
175
+ end_time: b.end_time,
176
+ status: b.status,
177
+ });
178
+ }
179
+ }
180
+ return { to_unverified, to_ask, now: nowIso };
181
+ },
120
182
  };
121
183
  }
@@ -287,69 +287,45 @@ export function renderTimelineCells(input) {
287
287
  i = j;
288
288
  }
289
289
  }
290
- // Build text lines: 表格在前(用户先看到具体内容),矩阵在后(时间分布概览)
291
- // pressure 反馈 2026-07-27:纯色块矩阵对单调数据认不出,表格更直观,应优先展示
290
+ // Build text lines: 明细在前(用户先看具体内容),矩阵在后(时间分布概览)
291
+ // S9 v0.5(2026-08-07):表头移到矩阵上方,不再夹在明细前面
292
292
  const textLines = [];
293
- textLines.push(" 00 10 20 30 40 50");
294
- // ── 表格段(在前)──
295
- // S9 v0.3: details 段按 symbol 分组紧凑展示
296
- // 用户痛点:移动端窄屏,原格式 "{time} {symbol} {title} [planned]" 长标题折行后 [planned] 跑到第三行,诡异
297
- // 新格式:同 symbol 合并到一行,用 · 分隔;移除 [status] 后缀(状态在矩阵方块色已表达)
298
- // S9 v0.4: completed → ✓ 前缀 + ~~划线~~ titleunverified❓ 后缀;插入「📍 现在」分隔线
293
+ // ── 明细段(在前)──
294
+ // S9 v0.3: 按 symbol 分组紧凑展示(合并同 symbol 到一行)
295
+ // S9 v0.4: completed + ~~划线~~;unverified → ❓ 后缀;插入「📍 现在」分隔线
296
+ // S9 v0.5(2026-08-07): 改为每条独占一行(用户反馈"挤在一起乱"
297
+ // 格式:`{status} {symbol} {time} {title}`
298
+ // status: completed → "";unverified "? "planned/timer_*" "(空格占位对齐)
299
299
  const details = input.details ?? [];
300
300
  if (details.length > 0) {
301
301
  textLines.push("📋 明细");
302
302
  const inWin = details.filter((d) => d.in_window);
303
303
  const outWin = details.filter((d) => !d.in_window);
304
- // S9 v0.4: 构造单条 entry,根据 status 加完成态视觉
305
- // - completed: "✓ {time} ~~{title}~~"
306
- // - unverified: "{time} {title} ❓"
307
- // - planned / timer_running / timer_paused: "{time} {title}"
304
+ // S9 v0.5: 每条独占一行的 entry 构造
308
305
  const buildEntry = (d) => {
306
+ let statusMark = " "; // 默认 2 空格占位(对齐 ✓ 和 ?)
307
+ let titlePart = d.title;
309
308
  if (d.status === "completed") {
310
- return `✓ ${d.time} ~~${d.title}~~`;
311
- }
312
- if (d.status === "unverified") {
313
- return `${d.time} ${d.title} ❓`;
309
+ statusMark = "✓ ";
310
+ titlePart = `~~${d.title}~~`;
314
311
  }
315
- return `${d.time} ${d.title}`;
316
- };
317
- // 按 symbol 分组(保留 inWin 内的原始顺序)
318
- const groupBySymbol = (list) => {
319
- const groups = [];
320
- const symbolToGroup = new Map();
321
- for (const d of list) {
322
- mark(d.symbol);
323
- const entry = buildEntry(d);
324
- const idx = symbolToGroup.get(d.symbol);
325
- if (idx === undefined) {
326
- symbolToGroup.set(d.symbol, groups.length);
327
- groups.push({ symbol: d.symbol, entries: [entry] });
328
- }
329
- else {
330
- groups[idx].entries.push(entry);
331
- }
312
+ else if (d.status === "unverified") {
313
+ statusMark = "? ";
332
314
  }
333
- return groups;
315
+ return `${statusMark}${d.symbol} ${d.time} ${titlePart}`;
334
316
  };
335
- // S9 v0.4: 计算「📍 现在」插入点
336
- // 在 inWin 列表中,找到第一条 start_time > now 的明细位置
337
- // 时间从 detail.time "HH:MM-HH:MM" 取 start
317
+ // S9 v0.4: 「📍 现在」分隔线判断(v0.5 保留同规则)
338
318
  const parseDetailStart = (time) => {
339
319
  const startStr = time.split("-")[0].trim();
340
320
  const [h, m] = startStr.split(":").map(Number);
341
321
  return h * 60 + m;
342
322
  };
343
- const nowMs = new Date(input.nowIso).getTime();
344
323
  const nowDate = new Date(input.nowIso);
345
324
  const nowHM = nowDate.getHours() * 60 + nowDate.getMinutes();
346
325
  const nowLabel = `${String(nowDate.getHours()).padStart(2, "0")}:${String(nowDate.getMinutes()).padStart(2, "0")}`;
347
- // 判断是否需要插「📍 现在」
348
- // 仅当 inWin 中存在 start > now 且存在 start <= now 时才插(即跨 now)
349
326
  const inWinAllPast = inWin.every((d) => parseDetailStart(d.time) <= nowHM);
350
327
  const inWinAllFuture = inWin.every((d) => parseDetailStart(d.time) > nowHM);
351
328
  const needNowMarker = inWin.length > 0 && !inWinAllPast && !inWinAllFuture;
352
- // 找到第一个 start > now 的 index(在该位置前插入分隔线)
353
329
  let firstFutureIdx = inWin.length;
354
330
  if (needNowMarker) {
355
331
  for (let i = 0; i < inWin.length; i++) {
@@ -359,30 +335,17 @@ export function renderTimelineCells(input) {
359
335
  }
360
336
  }
361
337
  }
362
- // S9 v0.4: 改写分组渲染——需要保留 inWin 原始顺序,在 firstFutureIdx 处插分隔线
363
- // 旧逻辑 groupBySymbol 一次处理整个 inWin,无法在中间插分隔线
364
- // 新逻辑:先按 firstFutureIdx 切成 past / future 两段,各自 groupBySymbol,中间插「📍 现在」
365
- if (needNowMarker) {
366
- const past = inWin.slice(0, firstFutureIdx);
367
- const future = inWin.slice(firstFutureIdx);
368
- for (const g of groupBySymbol(past)) {
369
- textLines.push(`${g.symbol} ${g.entries.join(" · ")}`);
370
- }
371
- textLines.push(`─── 📍 现在 ${nowLabel} ──`);
372
- for (const g of groupBySymbol(future)) {
373
- textLines.push(`${g.symbol} ${g.entries.join(" · ")}`);
374
- }
375
- }
376
- else {
377
- // 无需分隔线(全过 / 全未到),原逻辑
378
- for (const g of groupBySymbol(inWin)) {
379
- textLines.push(`${g.symbol} ${g.entries.join(" · ")}`);
338
+ // S9 v0.5: 每条独占一行,在 firstFutureIdx 处插分隔线
339
+ for (let i = 0; i < inWin.length; i++) {
340
+ if (needNowMarker && i === firstFutureIdx) {
341
+ textLines.push(`─── 📍 现在 ${nowLabel} ──`);
380
342
  }
343
+ textLines.push(buildEntry(inWin[i]));
381
344
  }
382
345
  if (outWin.length > 0) {
383
346
  textLines.push("(窗口外)");
384
- for (const g of groupBySymbol(outWin)) {
385
- textLines.push(`${g.symbol} ${g.entries.join(" · ")}`);
347
+ for (const d of outWin) {
348
+ textLines.push(buildEntry(d));
386
349
  }
387
350
  }
388
351
  }
@@ -397,6 +360,8 @@ export function renderTimelineCells(input) {
397
360
  };
398
361
  if (boundary)
399
362
  pushEllipsis();
363
+ // S9 v0.5: 时间表头紧贴矩阵上方(之前在明细前,造成"表头夹中间"错觉)
364
+ textLines.push(" 00 10 20 30 40 50");
400
365
  // 2026-07-27 UX 反馈:矩阵上下行距太挤,每行后加一个空行透气
401
366
  for (let k = 0; k < matrixLines.length; k++) {
402
367
  const line = matrixLines[k];
@@ -116,8 +116,41 @@ export function createTimerTools(repo, timeBlockRepo, db) {
116
116
  ended_at: endedAt,
117
117
  });
118
118
  let timeblockId;
119
- // Auto-persist to timeblock when rating is provided
120
- if (options?.rating !== undefined && timeBlockRepo) {
119
+ // S42 (2026-08-07): 回查匹配的 planned timeblock completed
120
+ // 用户场景:先排了 planned timeblock(如「羽毛球 9:00-11:00」),后开计时;
121
+ // 停计时期望自动标完成(带 ✓ + 划线),而不是 create 新的。
122
+ // 匹配规则:同 user_id + 同 title + 时间重叠 + status=planned
123
+ //
124
+ // ⚠️ 时区陷阱:planned timeblock 的 start_time/end_time 可能是 +08:00 格式(用户传入),
125
+ // 而 timer.session 的 started_at 是 Z 格式(nowISO())。findOverlapping 用字符串比较,
126
+ // 混合时区会误判("2026-08-07T09:00:00+08:00" vs "2026-08-07T05:00:00Z" 字符串比较 09 > 05)。
127
+ // 解法:调用前把 started_at/endedAt 都转 ISO UTC(Z 格式),与 line 578-581 其它 list 查询姿势一致。
128
+ if (timeBlockRepo) {
129
+ const startUtc = new Date(active.started_at).toISOString();
130
+ const endUtc = new Date(endedAt).toISOString();
131
+ const candidates = await timeBlockRepo.findOverlapping({
132
+ user_id: active.user_id,
133
+ start: startUtc,
134
+ end: endUtc,
135
+ });
136
+ const match = candidates.find((tb) => tb.status === "planned" &&
137
+ (tb.title === active.event ||
138
+ tb.title.includes(active.event) ||
139
+ active.event.includes(tb.title)));
140
+ if (match) {
141
+ const updated = await timeBlockRepo.update(match.id, {
142
+ end_time: endedAt,
143
+ status: "completed",
144
+ ...(options?.rating !== undefined ? { rating: options.rating } : {}),
145
+ ...(options?.rating_reason !== undefined
146
+ ? { rating_reason: options.rating_reason }
147
+ : {}),
148
+ });
149
+ timeblockId = updated.id;
150
+ }
151
+ }
152
+ // 没匹配到 planned → 走原 create 路径(仅 rating 时)
153
+ if (!timeblockId && options?.rating !== undefined && timeBlockRepo) {
121
154
  const tb = await timeBlockRepo.create({
122
155
  user_id: active.user_id,
123
156
  title: active.event,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libero-mcp",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
4
4
  "description": "AI 时间管理教练 MCP 工具集——时间块记录、统计、矩阵诊断、计时、分类、profile",
5
5
  "license": "MIT",
6
6
  "author": "amosyuan",