libero-mcp 0.2.7 → 0.2.9
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
CHANGED
|
@@ -72,7 +72,7 @@ export function buildServer(db) {
|
|
|
72
72
|
const matrix = createMatrixTools(statsRepo); // matrix reuses StatsRepo
|
|
73
73
|
const timeline = createTimelineTools(timelineRepo);
|
|
74
74
|
const timer = createTimerTools(timerRepo, timeblockRepo, db);
|
|
75
|
-
const profile = createProfileTools(profileRepo);
|
|
75
|
+
const profile = createProfileTools(profileRepo, identityRepo);
|
|
76
76
|
const profileConfirm = createProfileConfirmTools(db);
|
|
77
77
|
const reminder = createReminderTools(reminderRepo, undefined, db);
|
|
78
78
|
const scheduler = createSchedulerTools(timeblockRepo, reminderRepo, categoryRepo);
|
|
@@ -193,7 +193,19 @@ export function buildServer(db) {
|
|
|
193
193
|
return errorContent(r.error);
|
|
194
194
|
return asContent(await tb.create(r));
|
|
195
195
|
});
|
|
196
|
-
server.registerTool("timeblock.getById", {
|
|
196
|
+
server.registerTool("timeblock.getById", {
|
|
197
|
+
description: "按 id 获取单条时间记录。" +
|
|
198
|
+
"S41 多用户:默认按当前 user_id 过滤,跨用户查不到返回 null。",
|
|
199
|
+
inputSchema: {
|
|
200
|
+
id: z.string().min(1),
|
|
201
|
+
user_id: z.string().optional(),
|
|
202
|
+
},
|
|
203
|
+
}, async (rawInput) => {
|
|
204
|
+
const r = injector(rawInput);
|
|
205
|
+
if ("error" in r)
|
|
206
|
+
return errorContent(r.error);
|
|
207
|
+
return asContent(await tb.getById(rawInput.id, r.user_id));
|
|
208
|
+
});
|
|
197
209
|
server.registerTool("timeblock.update", {
|
|
198
210
|
description: "更新一条时间记录(标题/时间/评分/status 等)。" +
|
|
199
211
|
"MV-COACH-3 Slice 4 用户主权状态机:" +
|
|
@@ -470,6 +482,38 @@ export function buildServer(db) {
|
|
|
470
482
|
})),
|
|
471
483
|
});
|
|
472
484
|
});
|
|
485
|
+
server.registerTool("profile.createUser", {
|
|
486
|
+
description: "S41 多用户建档:建立新 libero 用户(父母代管模式)。" +
|
|
487
|
+
"用户体感:「帮我建一个新用户」「我要给我儿子建档」时调用。" +
|
|
488
|
+
"返回新 libero_user_id,建档后该 user_id 立即可用于所有 libero 工具调用。" +
|
|
489
|
+
"可选绑定平台 alias(如果建档时已知 chat_id)。" +
|
|
490
|
+
"⚠️ 不查重(同名不冲突);display_name 必填;platform 和 platform_user_id 必须同时给或同时不给。",
|
|
491
|
+
inputSchema: {
|
|
492
|
+
display_name: z.string().min(1),
|
|
493
|
+
focus_areas: z.array(z.string()).optional(),
|
|
494
|
+
goals: z.string().optional(),
|
|
495
|
+
platform: z
|
|
496
|
+
.enum(["wecom", "feishu", "telegram", "discord", "signal", "unknown"])
|
|
497
|
+
.optional(),
|
|
498
|
+
platform_user_id: z.string().optional(),
|
|
499
|
+
chat_id: z.string().optional(),
|
|
500
|
+
},
|
|
501
|
+
}, async (rawInput) => {
|
|
502
|
+
try {
|
|
503
|
+
const result = await profile.createUser({
|
|
504
|
+
display_name: rawInput.display_name,
|
|
505
|
+
focus_areas: rawInput.focus_areas,
|
|
506
|
+
goals: rawInput.goals,
|
|
507
|
+
platform: rawInput.platform,
|
|
508
|
+
platform_user_id: rawInput.platform_user_id,
|
|
509
|
+
chat_id: rawInput.chat_id,
|
|
510
|
+
});
|
|
511
|
+
return asContent(result);
|
|
512
|
+
}
|
|
513
|
+
catch (e) {
|
|
514
|
+
return errorContent(e.message);
|
|
515
|
+
}
|
|
516
|
+
});
|
|
473
517
|
// ============================================================
|
|
474
518
|
// reminder (6 tools — S11)
|
|
475
519
|
// ============================================================
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
/** Validate and delegate profile operations to the repository.
|
|
2
|
-
* ADR-0001: Dependency-injected — the caller provides the repo.
|
|
3
|
-
|
|
3
|
+
* ADR-0001: Dependency-injected — the caller provides the repo.
|
|
4
|
+
*
|
|
5
|
+
* S41(2026-08-06):新增可选 identityRepo 参数,用于 createUser 工具。
|
|
6
|
+
* 不传 = 不支持 createUser(向后兼容)。 */
|
|
7
|
+
export function createProfileTools(repo, identityRepo) {
|
|
4
8
|
return {
|
|
5
9
|
async get(userId) {
|
|
6
10
|
if (!userId) {
|
|
@@ -49,48 +53,48 @@ export function createProfileTools(repo) {
|
|
|
49
53
|
});
|
|
50
54
|
},
|
|
51
55
|
/**
|
|
52
|
-
* S40 slice 2(2026-08-
|
|
56
|
+
* S40 slice 2 → slice 3(2026-08-03 升级):Atomic claim。
|
|
53
57
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
58
|
+
* 历史:slice 2 是 tool 层「读-检查-写」,集成测试量出 100% race(每次并发都双发)。
|
|
59
|
+
* slice 3 改为:repo 提供 claimNudgeAtomic(用 better-sqlite3 db.transaction 包整段)。
|
|
56
60
|
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
61
|
+
* 兼容降级:如果 repo 没实现 claimNudgeAtomic(如 InMemoryProfileRepo),
|
|
62
|
+
* 走旧版非原子逻辑——但会在 §4.5 静默折叠意义上「未声明」,所以打 WARN 日志。
|
|
59
63
|
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* won=false → 输出 [SILENT](已被别的 cron 抢先)
|
|
63
|
-
*
|
|
64
|
-
* 默认 window = 5 分钟,覆盖 cron 启动间隔差。
|
|
64
|
+
* @4.5 显式声明:sqlite transaction 是真 atomic(阻塞 event loop + 写锁)。
|
|
65
|
+
* 在 in-memory repo 下无 atomic 保护——但 in-memory 只在测试场景,无生产风险。
|
|
65
66
|
*/
|
|
66
67
|
async claimNudge(input) {
|
|
67
68
|
if (!input.user_id) {
|
|
68
69
|
throw new Error("user_id 为必填参数");
|
|
69
70
|
}
|
|
70
71
|
const windowMinutes = input.window_minutes ?? 5;
|
|
72
|
+
// 优先用 repo 提供的 atomic 版本(sqlite production path)
|
|
73
|
+
if (typeof repo.claimNudgeAtomic === "function") {
|
|
74
|
+
return repo.claimNudgeAtomic({
|
|
75
|
+
user_id: input.user_id,
|
|
76
|
+
window_minutes: windowMinutes,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
// 兼容降级(in-memory / 测试):非原子,无并发安全保证
|
|
80
|
+
// ⚠️ §4.5 声明:此分支不是 atomic,仅用于无并发的测试场景
|
|
81
|
+
// eslint-disable-next-line no-console
|
|
82
|
+
console.warn("[claimNudge] repo.claimNudgeAtomic 未实现,降级到非原子路径。" +
|
|
83
|
+
"仅测试场景安全,生产必须用 SqliteProfileRepo。");
|
|
71
84
|
const windowMs = windowMinutes * 60 * 1000;
|
|
72
85
|
const now = new Date();
|
|
73
86
|
const nowIso = now.toISOString();
|
|
74
|
-
// Atomic block: 用 repo 提供的 transaction hook
|
|
75
|
-
// 但 ProfileRepo interface 没有 transaction API —— 通过 repo.upsert 的「读-合并-写」语义实现
|
|
76
|
-
// 真正的 atomic 要靠 sqlite 层,但为兼容 in-memory repo,我们在 tool 层用读后写
|
|
77
|
-
// ⚠️ §4.5 静默折叠声明:这不是 100% atomic(读-写之间有 race window)
|
|
78
|
-
// 但 sqlite 层的默认 statement-level atomic 在实践中足够(< 1ms 窗口)
|
|
79
|
-
// 真 100% atomic 需要 SqliteProfileRepo 暴露 transaction 接口(slice 3 补)
|
|
80
87
|
const existing = await repo.getById(input.user_id);
|
|
81
88
|
const existingSettings = existing?.settings != null
|
|
82
89
|
? existing.settings
|
|
83
90
|
: {};
|
|
84
|
-
// 检查:window 内是否已被写过
|
|
85
91
|
const lastNudgeAt = existingSettings.last_inactivity_nudge_at;
|
|
86
92
|
if (typeof lastNudgeAt === "string") {
|
|
87
93
|
const ageMs = now.getTime() - new Date(lastNudgeAt).getTime();
|
|
88
94
|
if (ageMs < windowMs) {
|
|
89
|
-
// window 内已被 claim,本次输
|
|
90
95
|
return { won: false, profile: existing };
|
|
91
96
|
}
|
|
92
97
|
}
|
|
93
|
-
// 赢,写时间戳
|
|
94
98
|
const merged = {
|
|
95
99
|
...existingSettings,
|
|
96
100
|
last_inactivity_nudge_at: nowIso,
|
|
@@ -101,5 +105,71 @@ export function createProfileTools(repo) {
|
|
|
101
105
|
});
|
|
102
106
|
return { won: true, profile };
|
|
103
107
|
},
|
|
108
|
+
/**
|
|
109
|
+
* S41(2026-08-06):建新 libero 用户(管理员代管模式)。
|
|
110
|
+
*
|
|
111
|
+
* 产品定位:「帮我建一个新用户」「我要给我儿子建档」时调用。
|
|
112
|
+
* 不查重(同名不冲突)、不强制绑 alias、不要求 focus_areas/goals(最小建档)。
|
|
113
|
+
*
|
|
114
|
+
* 必须传 identityRepo 才可用(slice 1:server-core 启动时注入)。
|
|
115
|
+
*
|
|
116
|
+
* 输入校验(§4.5 显式):
|
|
117
|
+
* - display_name 必填非空
|
|
118
|
+
* - platform 和 platform_user_id 必须同时给或同时不给
|
|
119
|
+
*
|
|
120
|
+
* 副作用:建 identity → 建 profile(空白)→ (可选)绑 alias
|
|
121
|
+
* 失败语义:identity 建成功但 profile 建失败 → 抛错,identity 留在表里
|
|
122
|
+
* (可接受:identity 没有 profile 也能用,下次 profile.get 会触发 auto-create;
|
|
123
|
+
* 不做事务回滚是 §3 #4 最小可逆单位原则。)
|
|
124
|
+
*/
|
|
125
|
+
async createUser(input) {
|
|
126
|
+
if (!identityRepo) {
|
|
127
|
+
throw new Error("createUser 需要 identityRepo(server-core 启动时未注入,本部署不支持多用户建档)");
|
|
128
|
+
}
|
|
129
|
+
if (!input.display_name || input.display_name.trim() === "") {
|
|
130
|
+
throw new Error("display_name 为必填参数且不能为空");
|
|
131
|
+
}
|
|
132
|
+
const hasPlatform = !!input.platform;
|
|
133
|
+
const hasPlatformUserId = !!input.platform_user_id;
|
|
134
|
+
if (hasPlatform !== hasPlatformUserId) {
|
|
135
|
+
throw new Error("platform 和 platform_user_id 必须同时给或同时不给(绑定平台 alias 时两者都需要)");
|
|
136
|
+
}
|
|
137
|
+
const libero_user_id = `u_libero_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
138
|
+
await identityRepo.createIdentity({
|
|
139
|
+
libero_user_id,
|
|
140
|
+
display_name: input.display_name,
|
|
141
|
+
});
|
|
142
|
+
const settings = {};
|
|
143
|
+
if (input.focus_areas && input.focus_areas.length > 0) {
|
|
144
|
+
settings.focus_areas = input.focus_areas;
|
|
145
|
+
}
|
|
146
|
+
if (input.goals && input.goals.trim() !== "") {
|
|
147
|
+
settings.goals = input.goals;
|
|
148
|
+
}
|
|
149
|
+
await repo.upsert({
|
|
150
|
+
user_id: libero_user_id,
|
|
151
|
+
display_name: input.display_name,
|
|
152
|
+
settings: Object.keys(settings).length > 0 ? settings : undefined,
|
|
153
|
+
});
|
|
154
|
+
let bound_alias;
|
|
155
|
+
if (hasPlatform && hasPlatformUserId) {
|
|
156
|
+
await identityRepo.bindAlias({
|
|
157
|
+
libero_user_id,
|
|
158
|
+
platform: input.platform,
|
|
159
|
+
platform_user_id: input.platform_user_id,
|
|
160
|
+
chat_id: input.chat_id,
|
|
161
|
+
});
|
|
162
|
+
bound_alias = {
|
|
163
|
+
platform: input.platform,
|
|
164
|
+
platform_user_id: input.platform_user_id,
|
|
165
|
+
chat_id: input.chat_id ?? null,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
libero_user_id,
|
|
170
|
+
display_name: input.display_name,
|
|
171
|
+
bound_alias,
|
|
172
|
+
};
|
|
173
|
+
},
|
|
104
174
|
};
|
|
105
175
|
}
|
|
@@ -552,6 +552,65 @@ export class SqliteProfileRepo {
|
|
|
552
552
|
}
|
|
553
553
|
return (await this.getById(input.user_id));
|
|
554
554
|
}
|
|
555
|
+
/**
|
|
556
|
+
* S40 slice 3:Atomic claim nudge。
|
|
557
|
+
*
|
|
558
|
+
* 用 better-sqlite3 的 `db.transaction()` 把「SELECT → 检查 window → UPDATE → 返回」
|
|
559
|
+
* 包成一个原子单元。better-sqlite3 是**同步**的——transaction 期间阻塞 event loop,
|
|
560
|
+
* 第二个调用根本没机会插入。
|
|
561
|
+
*
|
|
562
|
+
* 注意:transaction 返回的函数是同步的,必须在同步上下文调用(不能 await)。
|
|
563
|
+
* 这里包装成 async 只是为了匹配 ProfileRepo interface,内部完全同步执行。
|
|
564
|
+
*/
|
|
565
|
+
async claimNudgeAtomic(input) {
|
|
566
|
+
const windowMs = input.window_minutes * 60 * 1000;
|
|
567
|
+
const now = new Date();
|
|
568
|
+
const nowIso = now.toISOString();
|
|
569
|
+
const nowDb = nowIso.replace("T", " ").replace("Z", "");
|
|
570
|
+
const rowToProfile = (r) => ({
|
|
571
|
+
id: r.id,
|
|
572
|
+
display_name: r.display_name,
|
|
573
|
+
expectations: r.expectations,
|
|
574
|
+
settings: r.settings != null ? JSON.parse(r.settings) : null,
|
|
575
|
+
created_at: r.created_at,
|
|
576
|
+
updated_at: r.updated_at,
|
|
577
|
+
});
|
|
578
|
+
const selectStmt = this.db.prepare("SELECT id, display_name, expectations, settings, created_at, updated_at FROM user_profile WHERE id = ? AND deleted_at IS NULL");
|
|
579
|
+
const updateStmt = this.db.prepare(`UPDATE user_profile SET settings = ?, updated_at = ? WHERE id = ?`);
|
|
580
|
+
const insertStmt = this.db.prepare(`INSERT INTO user_profile (id, display_name, expectations, settings, created_at, updated_at)
|
|
581
|
+
VALUES (?, '', NULL, ?, ?, ?)`);
|
|
582
|
+
// transaction 函数(同步)。better-sqlite3 在 transaction() 期间阻塞 event loop,
|
|
583
|
+
// 第二个并发调用必须等第一个 COMMIT 才能进入 BEGIN。
|
|
584
|
+
const txn = this.db.transaction(() => {
|
|
585
|
+
const row = selectStmt.get(input.user_id);
|
|
586
|
+
const existingSettings = row?.settings != null
|
|
587
|
+
? JSON.parse(row.settings)
|
|
588
|
+
: {};
|
|
589
|
+
const lastNudgeAt = existingSettings.last_inactivity_nudge_at;
|
|
590
|
+
if (typeof lastNudgeAt === "string") {
|
|
591
|
+
const ageMs = now.getTime() - new Date(lastNudgeAt).getTime();
|
|
592
|
+
if (ageMs < windowMs) {
|
|
593
|
+
return { won: false, profile: rowToProfile(row) };
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
const merged = {
|
|
597
|
+
...existingSettings,
|
|
598
|
+
last_inactivity_nudge_at: nowIso,
|
|
599
|
+
};
|
|
600
|
+
const settingsJson = JSON.stringify(merged);
|
|
601
|
+
if (row) {
|
|
602
|
+
updateStmt.run(settingsJson, nowDb, input.user_id);
|
|
603
|
+
}
|
|
604
|
+
else {
|
|
605
|
+
insertStmt.run(input.user_id, settingsJson, nowDb, nowDb);
|
|
606
|
+
}
|
|
607
|
+
const updated = selectStmt.get(input.user_id);
|
|
608
|
+
return { won: true, profile: rowToProfile(updated) };
|
|
609
|
+
});
|
|
610
|
+
// BEGIN IMMEDIATE:立刻拿写锁,防止 deferred 模式下读-写升级冲突
|
|
611
|
+
// better-sqlite3 API:transaction 函数有 .immediate(args) 变种
|
|
612
|
+
return txn.immediate();
|
|
613
|
+
}
|
|
555
614
|
}
|
|
556
615
|
// ============================================================
|
|
557
616
|
// SqliteReminderRepo (S11 — reminder persistence)
|
|
@@ -28,8 +28,14 @@ export function createTimeBlockTools(repo, db) {
|
|
|
28
28
|
const row = await repo.create({ ...input, user_id: userId });
|
|
29
29
|
return { ...row, target_user_label: labelFor(db, userId) };
|
|
30
30
|
},
|
|
31
|
-
async getById(id) {
|
|
32
|
-
|
|
31
|
+
async getById(id, userId) {
|
|
32
|
+
const row = await repo.getById(id);
|
|
33
|
+
// S41 multi-user isolation: 如果调用方传了 user_id,校验 ownership。
|
|
34
|
+
// 没传 user_id = 旧行为(单用户 / 兼容路径),不阻塞。
|
|
35
|
+
if (row && userId && row.user_id !== userId) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
return row;
|
|
33
39
|
},
|
|
34
40
|
async list(filters) {
|
|
35
41
|
if (!filters.user_id) {
|