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.
@@ -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", { description: "按 id 获取单条时间记录", inputSchema: { id: z.string().min(1) } }, async ({ id }) => asContent(await tb.getById(id)));
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
- export function createProfileTools(repo) {
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-02):Atomic claim — 多 cron 并发场景的"赢者通吃"
56
+ * S40 slice 2 → slice 3(2026-08-03 升级):Atomic claim。
53
57
  *
54
- * 解决问题:nag fan-out 时飞书 cron + 企微 cron 同时启动,都看到 settings=null
55
- * 都发 违反产品叙事「同步响一次」。
58
+ * 历史:slice 2 tool 层「读-检查-写」,集成测试量出 100% race(每次并发都双发)。
59
+ * slice 3 改为:repo 提供 claimNudgeAtomic(用 better-sqlite3 db.transaction 包整段)。
56
60
  *
57
- * Atomic 原子(better-sqlite3 transaction):BEGIN SELECT → 检查 window → UPDATE → COMMIT。
58
- * window_minutes 内只有第一个调用赢(won=true),后续调用返回 won=false 但不写时间戳。
61
+ * 兼容降级:如果 repo 没实现 claimNudgeAtomic(如 InMemoryProfileRepo),
62
+ * 走旧版非原子逻辑——但会在 §4.5 静默折叠意义上「未声明」,所以打 WARN 日志。
59
63
  *
60
- * 调用方(cron prompt)必须看 won:
61
- * won=true → 输出问候
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
- return repo.getById(id);
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libero-mcp",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "AI 时间管理教练 MCP 工具集——时间块记录、统计、矩阵诊断、计时、分类、profile",
5
5
  "license": "MIT",
6
6
  "author": "amosyuan",