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.
- package/dist/mcp/server-core.js +44 -0
- package/dist/mcp/src/identity/in-memory.js +3 -0
- package/dist/mcp/src/profile/index.js +17 -0
- package/dist/mcp/src/repo/sqlite-identity.js +8 -0
- package/dist/mcp/src/repo/sqlite.js +5 -1
- package/dist/mcp/src/timeblock/index.js +62 -0
- package/dist/mcp/src/timeline/index.js +26 -61
- package/dist/mcp/src/timer/index.js +35 -2
- package/package.json +1 -1
package/dist/mcp/server-core.js
CHANGED
|
@@ -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
|
// ============================================================
|
|
@@ -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 < ?
|
|
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
|
-
//
|
|
290
|
+
// Build text lines: 明细在前(用户先看具体内容),矩阵在后(时间分布概览)
|
|
291
|
+
// S9 v0.5(2026-08-07):表头移到矩阵上方,不再夹在明细前面
|
|
292
292
|
const textLines = [];
|
|
293
|
-
|
|
294
|
-
//
|
|
295
|
-
// S9 v0.
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
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.
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
if (d.status === "unverified") {
|
|
313
|
-
return `${d.time} ${d.title} ❓`;
|
|
309
|
+
statusMark = "✓ ";
|
|
310
|
+
titlePart = `~~${d.title}~~`;
|
|
314
311
|
}
|
|
315
|
-
|
|
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
|
|
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.
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
|
385
|
-
textLines.push(
|
|
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
|
-
//
|
|
120
|
-
|
|
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,
|