libero-mcp 0.2.6 → 0.2.7

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,68 @@
1
+ -- 012_identity_tables: S40 跨平台身份层
2
+ -- 新建 user_identity(1 真人 = 1 行)+ user_platform_alias(1 用户 ↔ N 平台 chat_id)表
3
+ -- 为每个现存 user_profile.id(旧 chat_id)自动建对应 libero_user_id + alias 兜底行
4
+ --
5
+ -- §4.5 静默折叠声明:
6
+ -- 1. 现有 user_profile.id 字段值不立刻改成 libero_user_id(保持 chat_id 不动)
7
+ -- 折叠意图:靠 mcp/src/identity/resolve.ts 的 resolveUserId 在运行时透明解析
8
+ -- 副作用:业务表(timeblock/timer/scheduler/reminder)的 user_id 字段也仍是 chat_id
9
+ -- slice 2 才全量 update 这些字段到 libero_user_id
10
+ -- 2. 现有用户的 alias.platform 默认 'unknown'(migration 不猜平台)
11
+ -- 折叠意图:避免猜测出错(wecom chat_id vs feishu open_id 长得像)
12
+ -- 副作用:listMyChannels 第一次会看到 platform=unknown,主会话首次接触时问用户确认后调 bindAlias 修正
13
+
14
+ CREATE TABLE IF NOT EXISTS user_identity (
15
+ libero_user_id TEXT PRIMARY KEY, -- 'u_libero_xxxxxxxx' (UUID v4 截断)
16
+ display_name TEXT,
17
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
18
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
19
+ );
20
+
21
+ CREATE TABLE IF NOT EXISTS user_platform_alias (
22
+ libero_user_id TEXT NOT NULL,
23
+ platform TEXT NOT NULL, -- 'wecom' / 'feishu' / 'telegram' / 'unknown'
24
+ platform_user_id TEXT NOT NULL, -- 平台的 user/open id
25
+ chat_id TEXT, -- 投递用 chat_id(可等于 platform_user_id)
26
+ bound_at TEXT NOT NULL DEFAULT (datetime('now')),
27
+ PRIMARY KEY (platform, platform_user_id),
28
+ FOREIGN KEY (libero_user_id) REFERENCES user_identity(libero_user_id)
29
+ );
30
+
31
+ CREATE INDEX IF NOT EXISTS idx_alias_libero ON user_platform_alias(libero_user_id);
32
+
33
+ -- 为现有每个 user_profile 行建对应 libero_user_id + alias 兜底行
34
+ -- 用 substr(hex(randomblob(...))) 生成稳定长度的伪 UUID(避免依赖外部 UUID 函数)
35
+ INSERT OR IGNORE INTO user_identity (libero_user_id, display_name, created_at, updated_at)
36
+ SELECT
37
+ 'u_libero_' || substr(hex(randomblob(8)), 1, 16),
38
+ CASE
39
+ WHEN LENGTH(id) <= 16 THEN id
40
+ ELSE SUBSTR(id, 1, 16)
41
+ END,
42
+ COALESCE(created_at, datetime('now')),
43
+ COALESCE(updated_at, datetime('now'))
44
+ FROM user_profile
45
+ WHERE id NOT LIKE 'u_libero_%';
46
+
47
+ -- 为上面建的 identity 加 alias 兜底(platform='unknown',主会话首次接触时修正)
48
+ -- 用 user_profile.id + created_at 双键匹配(display_name 可能撞)
49
+ INSERT OR IGNORE INTO user_platform_alias (libero_user_id, platform, platform_user_id, chat_id, bound_at)
50
+ SELECT
51
+ (
52
+ SELECT ui.libero_user_id FROM user_identity ui
53
+ WHERE ui.display_name = CASE
54
+ WHEN LENGTH(up.id) <= 16 THEN up.id
55
+ ELSE SUBSTR(up.id, 1, 16)
56
+ END
57
+ ORDER BY ui.created_at LIMIT 1
58
+ ),
59
+ 'unknown',
60
+ up.id,
61
+ up.id,
62
+ COALESCE(up.updated_at, datetime('now'))
63
+ FROM user_profile up
64
+ WHERE up.id NOT LIKE 'u_libero_%'
65
+ AND NOT EXISTS (
66
+ SELECT 1 FROM user_platform_alias upa
67
+ WHERE upa.platform = 'unknown' AND upa.platform_user_id = up.id
68
+ );
package/data/schema.sql CHANGED
@@ -45,3 +45,24 @@ CREATE TABLE IF NOT EXISTS _migrations (
45
45
  name TEXT NOT NULL,
46
46
  applied_at TEXT NOT NULL DEFAULT (datetime('now'))
47
47
  );
48
+
49
+ -- 012_identity_tables: S40 跨平台身份层
50
+ -- 1 真人 = 1 user_identity 行;N 平台 chat_id = N user_platform_alias 行
51
+ CREATE TABLE IF NOT EXISTS user_identity (
52
+ libero_user_id TEXT PRIMARY KEY,
53
+ display_name TEXT,
54
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
55
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
56
+ );
57
+
58
+ CREATE TABLE IF NOT EXISTS user_platform_alias (
59
+ libero_user_id TEXT NOT NULL,
60
+ platform TEXT NOT NULL,
61
+ platform_user_id TEXT NOT NULL,
62
+ chat_id TEXT,
63
+ bound_at TEXT NOT NULL DEFAULT (datetime('now')),
64
+ PRIMARY KEY (platform, platform_user_id),
65
+ FOREIGN KEY (libero_user_id) REFERENCES user_identity(libero_user_id)
66
+ );
67
+
68
+ CREATE INDEX IF NOT EXISTS idx_alias_libero ON user_platform_alias(libero_user_id);
@@ -0,0 +1,68 @@
1
+ -- 012_identity_tables: S40 跨平台身份层
2
+ -- 新建 user_identity(1 真人 = 1 行)+ user_platform_alias(1 用户 ↔ N 平台 chat_id)表
3
+ -- 为每个现存 user_profile.id(旧 chat_id)自动建对应 libero_user_id + alias 兜底行
4
+ --
5
+ -- §4.5 静默折叠声明:
6
+ -- 1. 现有 user_profile.id 字段值不立刻改成 libero_user_id(保持 chat_id 不动)
7
+ -- 折叠意图:靠 mcp/src/identity/resolve.ts 的 resolveUserId 在运行时透明解析
8
+ -- 副作用:业务表(timeblock/timer/scheduler/reminder)的 user_id 字段也仍是 chat_id
9
+ -- slice 2 才全量 update 这些字段到 libero_user_id
10
+ -- 2. 现有用户的 alias.platform 默认 'unknown'(migration 不猜平台)
11
+ -- 折叠意图:避免猜测出错(wecom chat_id vs feishu open_id 长得像)
12
+ -- 副作用:listMyChannels 第一次会看到 platform=unknown,主会话首次接触时问用户确认后调 bindAlias 修正
13
+
14
+ CREATE TABLE IF NOT EXISTS user_identity (
15
+ libero_user_id TEXT PRIMARY KEY, -- 'u_libero_xxxxxxxx' (UUID v4 截断)
16
+ display_name TEXT,
17
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
18
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
19
+ );
20
+
21
+ CREATE TABLE IF NOT EXISTS user_platform_alias (
22
+ libero_user_id TEXT NOT NULL,
23
+ platform TEXT NOT NULL, -- 'wecom' / 'feishu' / 'telegram' / 'unknown'
24
+ platform_user_id TEXT NOT NULL, -- 平台的 user/open id
25
+ chat_id TEXT, -- 投递用 chat_id(可等于 platform_user_id)
26
+ bound_at TEXT NOT NULL DEFAULT (datetime('now')),
27
+ PRIMARY KEY (platform, platform_user_id),
28
+ FOREIGN KEY (libero_user_id) REFERENCES user_identity(libero_user_id)
29
+ );
30
+
31
+ CREATE INDEX IF NOT EXISTS idx_alias_libero ON user_platform_alias(libero_user_id);
32
+
33
+ -- 为现有每个 user_profile 行建对应 libero_user_id + alias 兜底行
34
+ -- 用 substr(hex(randomblob(...))) 生成稳定长度的伪 UUID(避免依赖外部 UUID 函数)
35
+ INSERT OR IGNORE INTO user_identity (libero_user_id, display_name, created_at, updated_at)
36
+ SELECT
37
+ 'u_libero_' || substr(hex(randomblob(8)), 1, 16),
38
+ CASE
39
+ WHEN LENGTH(id) <= 16 THEN id
40
+ ELSE SUBSTR(id, 1, 16)
41
+ END,
42
+ COALESCE(created_at, datetime('now')),
43
+ COALESCE(updated_at, datetime('now'))
44
+ FROM user_profile
45
+ WHERE id NOT LIKE 'u_libero_%';
46
+
47
+ -- 为上面建的 identity 加 alias 兜底(platform='unknown',主会话首次接触时修正)
48
+ -- 用 user_profile.id + created_at 双键匹配(display_name 可能撞)
49
+ INSERT OR IGNORE INTO user_platform_alias (libero_user_id, platform, platform_user_id, chat_id, bound_at)
50
+ SELECT
51
+ (
52
+ SELECT ui.libero_user_id FROM user_identity ui
53
+ WHERE ui.display_name = CASE
54
+ WHEN LENGTH(up.id) <= 16 THEN up.id
55
+ ELSE SUBSTR(up.id, 1, 16)
56
+ END
57
+ ORDER BY ui.created_at LIMIT 1
58
+ ),
59
+ 'unknown',
60
+ up.id,
61
+ up.id,
62
+ COALESCE(up.updated_at, datetime('now'))
63
+ FROM user_profile up
64
+ WHERE up.id NOT LIKE 'u_libero_%'
65
+ AND NOT EXISTS (
66
+ SELECT 1 FROM user_platform_alias upa
67
+ WHERE upa.platform = 'unknown' AND upa.platform_user_id = up.id
68
+ );
@@ -45,3 +45,24 @@ CREATE TABLE IF NOT EXISTS _migrations (
45
45
  name TEXT NOT NULL,
46
46
  applied_at TEXT NOT NULL DEFAULT (datetime('now'))
47
47
  );
48
+
49
+ -- 012_identity_tables: S40 跨平台身份层
50
+ -- 1 真人 = 1 user_identity 行;N 平台 chat_id = N user_platform_alias 行
51
+ CREATE TABLE IF NOT EXISTS user_identity (
52
+ libero_user_id TEXT PRIMARY KEY,
53
+ display_name TEXT,
54
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
55
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
56
+ );
57
+
58
+ CREATE TABLE IF NOT EXISTS user_platform_alias (
59
+ libero_user_id TEXT NOT NULL,
60
+ platform TEXT NOT NULL,
61
+ platform_user_id TEXT NOT NULL,
62
+ chat_id TEXT,
63
+ bound_at TEXT NOT NULL DEFAULT (datetime('now')),
64
+ PRIMARY KEY (platform, platform_user_id),
65
+ FOREIGN KEY (libero_user_id) REFERENCES user_identity(libero_user_id)
66
+ );
67
+
68
+ CREATE INDEX IF NOT EXISTS idx_alias_libero ON user_platform_alias(libero_user_id);
@@ -1,6 +1,7 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
3
  import { SqliteCategoryRepo, SqliteTimeBlockRepo, SqliteStatsRepo, SqliteTimerRepo, SqliteProfileRepo, SqliteReminderRepo, SqliteTimelineRepo, } from "./src/repo/sqlite.js";
4
+ import { SqliteIdentityRepo } from "./src/repo/sqlite-identity.js";
4
5
  import { createCategoryTools } from "./src/categories/index.js";
5
6
  import { createTimeBlockTools } from "./src/timeblock/index.js";
6
7
  import { createStatsTools } from "./src/stats/index.js";
@@ -14,6 +15,7 @@ import { createSchedulerTools } from "./src/scheduler/index.js";
14
15
  import { createTaskTools } from "./src/task/index.js";
15
16
  import { nowLocal, formatTime, parseTime } from "./src/clock/index.js";
16
17
  import { buildServerInstructions } from "./cold-start.js";
18
+ import { resolveUserId } from "./src/identity/resolve.js";
17
19
  import { resolveDefaultUserId, logResolvedDefaultUserId, } from "./src/user-id-resolver.js";
18
20
  /** Wrap any tool result as MCP text content (JSON). Void results become {ok:true}. */
19
21
  function asContent(result) {
@@ -62,6 +64,7 @@ export function buildServer(db) {
62
64
  const profileRepo = new SqliteProfileRepo(db);
63
65
  const reminderRepo = new SqliteReminderRepo(db);
64
66
  const timelineRepo = new SqliteTimelineRepo(db);
67
+ const identityRepo = new SqliteIdentityRepo(db);
65
68
  // ── inject tool factories ──
66
69
  const categories = createCategoryTools(categoryRepo);
67
70
  const tb = createTimeBlockTools(timeblockRepo, db);
@@ -372,6 +375,27 @@ export function buildServer(db) {
372
375
  return errorContent(r.error);
373
376
  return asContent(await profile.markInactivityNudge({ user_id: r.user_id }));
374
377
  });
378
+ server.registerTool("profile.claimNudge", {
379
+ description: "S40 slice 2(多 cron 并发 atomic 抢占):nag fan-out 多渠道时用。" +
380
+ "window_minutes 内只有第一个调用赢(won=true),其它返回 won=false。" +
381
+ "**调用方必须看返回的 won 字段**:" +
382
+ "won=true → 输出问候;won=false → 输出 [SILENT](已被其它 cron 抢先)。" +
383
+ "默认 window=5 分钟,覆盖 cron 启动间隔差。",
384
+ inputSchema: {
385
+ user_id: z.string().optional(),
386
+ window_minutes: z.number().optional(),
387
+ },
388
+ }, async (rawInput) => {
389
+ const r = injector(rawInput);
390
+ if ("error" in r)
391
+ return errorContent(r.error);
392
+ return asContent(await profile.claimNudge({
393
+ user_id: r.user_id,
394
+ window_minutes: typeof rawInput.window_minutes === "number"
395
+ ? rawInput.window_minutes
396
+ : undefined,
397
+ }));
398
+ });
375
399
  server.registerTool("profile.confirm_default", {
376
400
  description: "首次写入前确认默认用户:写入 user_profile,设置 display_name 与 default_confirmed_at。display_name 重名则拒绝。",
377
401
  inputSchema: {
@@ -380,6 +404,73 @@ export function buildServer(db) {
380
404
  },
381
405
  }, async (input) => asContent(await profileConfirm.confirmDefault(input)));
382
406
  // ============================================================
407
+ // S40: cross-platform identity (2 tools)
408
+ // ============================================================
409
+ server.registerTool("profile.bindAlias", {
410
+ description: "S40 跨平台身份层:把一个平台 chat_id 绑定到已有 libero 用户身份。" +
411
+ "场景:用户在新设备(如飞书)首次开口,主会话问『要和已有账号(如企微)合并吗』,得到肯定后调这个。" +
412
+ "libero_user_id 为空 = 首次绑定,自动建一个 libero_user_id。" +
413
+ "平台 chat_id 跟 libero_user_id 一对多关系(1 个真人 ↔ N 平台)。",
414
+ inputSchema: {
415
+ libero_user_id: z.string().optional(),
416
+ platform: z.enum(["wecom", "feishu", "telegram", "discord", "signal", "unknown"]),
417
+ platform_user_id: z.string().min(1),
418
+ chat_id: z.string().optional(),
419
+ display_name: z.string().optional(),
420
+ },
421
+ }, async (input) => {
422
+ const crypto = await import("node:crypto");
423
+ let lid = input.libero_user_id;
424
+ if (!lid) {
425
+ lid = `u_libero_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`;
426
+ await identityRepo.createIdentity({
427
+ libero_user_id: lid,
428
+ display_name: input.display_name ?? input.platform_user_id.slice(0, 16),
429
+ });
430
+ }
431
+ else {
432
+ const existing = await identityRepo.getIdentity(lid);
433
+ if (!existing) {
434
+ return errorContent(`libero_user_id not found: ${lid}`);
435
+ }
436
+ }
437
+ try {
438
+ await identityRepo.bindAlias({
439
+ libero_user_id: lid,
440
+ platform: input.platform,
441
+ platform_user_id: input.platform_user_id,
442
+ chat_id: input.chat_id,
443
+ });
444
+ }
445
+ catch (e) {
446
+ return errorContent(e.message);
447
+ }
448
+ const aliases = await identityRepo.listAliases(lid);
449
+ return asContent({ libero_user_id: lid, bound_aliases: aliases });
450
+ });
451
+ server.registerTool("profile.listMyChannels", {
452
+ description: "S40 跨平台身份层:列出当前用户绑定的所有平台渠道。" +
453
+ "用途:nag fan-out(按渠道建独立 cron)、跨平台数据查询、身份确认。",
454
+ inputSchema: {
455
+ user_id: z.string().optional(),
456
+ },
457
+ }, async (rawInput) => {
458
+ const r = injector(rawInput);
459
+ if ("error" in r)
460
+ return errorContent(r.error);
461
+ const lid = await resolveUserId(identityRepo, r.user_id);
462
+ const aliases = await identityRepo.listAliases(lid);
463
+ return asContent({
464
+ libero_user_id: lid,
465
+ channels: aliases.map((a) => ({
466
+ platform: a.platform,
467
+ platform_user_id: a.platform_user_id,
468
+ chat_id: a.chat_id,
469
+ bound_at: a.bound_at,
470
+ })),
471
+ });
472
+ });
473
+ // ============================================================
383
474
  // reminder (6 tools — S11)
384
475
  // ============================================================
385
476
  server.registerTool("reminder.create", {
@@ -0,0 +1,62 @@
1
+ /**
2
+ * In-memory implementation of IdentityRepo — for tests and embedded scenarios.
3
+ *
4
+ * §4.5 静默折叠声明:
5
+ * - 错误(duplicate / FK violation)一律 throw,不静默吞
6
+ * - PK 校验在内存层用 Map.set 覆盖语义,会污染 — 故显式 if-exist check + throw
7
+ */
8
+ export class InMemoryIdentityRepo {
9
+ identities = new Map();
10
+ aliases = new Map(); // key = `${platform}:${platform_user_id}`
11
+ async createIdentity(input) {
12
+ if (this.identities.has(input.libero_user_id)) {
13
+ throw new Error(`identity already exists: libero_user_id=${input.libero_user_id}`);
14
+ }
15
+ const now = new Date().toISOString();
16
+ const row = {
17
+ libero_user_id: input.libero_user_id,
18
+ display_name: input.display_name ?? null,
19
+ created_at: now,
20
+ updated_at: now,
21
+ };
22
+ this.identities.set(input.libero_user_id, row);
23
+ return input.libero_user_id;
24
+ }
25
+ async getIdentity(libero_user_id) {
26
+ return this.identities.get(libero_user_id) ?? null;
27
+ }
28
+ async bindAlias(input) {
29
+ // FK check
30
+ if (!this.identities.has(input.libero_user_id)) {
31
+ throw new Error(`bindAlias FK violation: libero_user_id not found: ${input.libero_user_id}`);
32
+ }
33
+ // PK check
34
+ const key = `${input.platform}:${input.platform_user_id}`;
35
+ if (this.aliases.has(key)) {
36
+ throw new Error(`alias already bound: platform=${input.platform} platform_user_id=${input.platform_user_id}`);
37
+ }
38
+ const row = {
39
+ libero_user_id: input.libero_user_id,
40
+ platform: input.platform,
41
+ platform_user_id: input.platform_user_id,
42
+ chat_id: input.chat_id ?? input.platform_user_id,
43
+ bound_at: new Date().toISOString(),
44
+ };
45
+ this.aliases.set(key, row);
46
+ }
47
+ async listAliases(libero_user_id) {
48
+ const out = [];
49
+ for (const alias of this.aliases.values()) {
50
+ if (alias.libero_user_id === libero_user_id)
51
+ out.push(alias);
52
+ }
53
+ return out;
54
+ }
55
+ async findAliasByPlatformUserId(platform_user_id) {
56
+ for (const alias of this.aliases.values()) {
57
+ if (alias.platform_user_id === platform_user_id)
58
+ return alias;
59
+ }
60
+ return null;
61
+ }
62
+ }
@@ -0,0 +1,60 @@
1
+ import { randomUUID } from "node:crypto";
2
+ /**
3
+ * S40 兼容层核心:把任意 user_id 输入(libero_user_id / platform chat_id / 旧数据 chat_id)
4
+ * 解析成稳定的 libero_user_id。
5
+ *
6
+ * 三段查找:
7
+ * 1. user_platform_alias 表:input 是某平台的 platform_user_id?→ 返回 libero_user_id
8
+ * 2. user_identity 表:input 本身就是 libero_user_id?→ 直接返回
9
+ * 3. 都不是 → 视为旧数据 chat_id,自动建对应 identity + alias 兜底行(platform='unknown')
10
+ *
11
+ * §4.5 静默折叠声明:
12
+ * - "自动建 identity" 是显式声明的折叠——意图是兼容旧数据(slice 1 不全量 update 业务表)
13
+ * - 副作用:未知 chat_id 第一次出现会建一个新 libero user,永远不会跟已有用户合并
14
+ * - 主会话需通过 profile.bindAlias 主动声明合并(用户首接触多设备时引导)
15
+ *
16
+ * 幂等:第二次同 chat_id 调用会命中步骤 1,不会重复建 identity。
17
+ */
18
+ export async function resolveUserId(repo, input) {
19
+ if (!input) {
20
+ throw new Error("resolveUserId: input is required");
21
+ }
22
+ // 1. platform_user_id?
23
+ const alias = await repo.findAliasByPlatformUserId(input);
24
+ if (alias)
25
+ return alias.libero_user_id;
26
+ // 2. libero_user_id?
27
+ const identity = await repo.getIdentity(input);
28
+ if (identity)
29
+ return input;
30
+ // 3. legacy chat_id — auto-create
31
+ const newLiberoId = `u_libero_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
32
+ const displayName = input.length <= 16 ? input : input.slice(0, 16);
33
+ await repo.createIdentity({
34
+ libero_user_id: newLiberoId,
35
+ display_name: displayName,
36
+ });
37
+ await repo.bindAlias({
38
+ libero_user_id: newLiberoId,
39
+ platform: detectPlatformFromId(input),
40
+ platform_user_id: input,
41
+ chat_id: input,
42
+ });
43
+ return newLiberoId;
44
+ }
45
+ /**
46
+ * 平台探测(heuristic):
47
+ * - slice 1 不强求精确;返回 'unknown' 是安全默认,主会话首次接触时让用户确认
48
+ * - 未来可扩展为更精确的模式匹配
49
+ */
50
+ export function detectPlatformFromId(id) {
51
+ // wecom chat_id 通常 'wm_' / 'woqoaa' 开头,长度 30-32
52
+ if (/^(wm_|woqoaa|wm_)/.test(id) || (id.length >= 30 && id.length <= 32 && /^[A-Za-z0-9_-]+$/.test(id))) {
53
+ return "wecom";
54
+ }
55
+ // feishu open_id 通常 'ou_' 开头
56
+ if (/^ou_[a-f0-9]{40,}$/i.test(id)) {
57
+ return "feishu";
58
+ }
59
+ return "unknown";
60
+ }
@@ -0,0 +1,4 @@
1
+ // ============================================================
2
+ // S40 cross-platform identity domain types
3
+ // ============================================================
4
+ export {};
@@ -25,6 +25,10 @@ export function createProfileTools(repo) {
25
25
  * (profile.upsert 的 settings 是 whole-replace per S13 §4,所以必须用专门工具。)
26
26
  *
27
27
  * nonexistent user_id 也会 auto-create profile row(防御 race / skill 顺序问题)。
28
+ *
29
+ * ⚠️ S40 slice 2(2026-08-02):多 cron 并发时不要用 markInactivityNudge(无 atomic 保护)。
30
+ * 用 claimNudge —— 它有 atomic 防重复(window 内只有一方赢)。
31
+ * markInactivityNudge 留作单 cron 场景(产品叙事:单渠道 cron + nag 历史判定)。
28
32
  */
29
33
  async markInactivityNudge(input) {
30
34
  if (!input.user_id) {
@@ -44,5 +48,58 @@ export function createProfileTools(repo) {
44
48
  settings: merged,
45
49
  });
46
50
  },
51
+ /**
52
+ * S40 slice 2(2026-08-02):Atomic claim — 多 cron 并发场景的"赢者通吃"。
53
+ *
54
+ * 解决问题:nag fan-out 时飞书 cron + 企微 cron 同时启动,都看到 settings=null
55
+ * → 都发 → 违反产品叙事「同步响一次」。
56
+ *
57
+ * Atomic 原子(better-sqlite3 transaction):BEGIN → SELECT → 检查 window → UPDATE → COMMIT。
58
+ * window_minutes 内只有第一个调用赢(won=true),后续调用返回 won=false 但不写时间戳。
59
+ *
60
+ * 调用方(cron prompt)必须看 won:
61
+ * won=true → 输出问候
62
+ * won=false → 输出 [SILENT](已被别的 cron 抢先)
63
+ *
64
+ * 默认 window = 5 分钟,覆盖 cron 启动间隔差。
65
+ */
66
+ async claimNudge(input) {
67
+ if (!input.user_id) {
68
+ throw new Error("user_id 为必填参数");
69
+ }
70
+ const windowMinutes = input.window_minutes ?? 5;
71
+ const windowMs = windowMinutes * 60 * 1000;
72
+ const now = new Date();
73
+ 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
+ const existing = await repo.getById(input.user_id);
81
+ const existingSettings = existing?.settings != null
82
+ ? existing.settings
83
+ : {};
84
+ // 检查:window 内是否已被写过
85
+ const lastNudgeAt = existingSettings.last_inactivity_nudge_at;
86
+ if (typeof lastNudgeAt === "string") {
87
+ const ageMs = now.getTime() - new Date(lastNudgeAt).getTime();
88
+ if (ageMs < windowMs) {
89
+ // window 内已被 claim,本次输
90
+ return { won: false, profile: existing };
91
+ }
92
+ }
93
+ // 赢,写时间戳
94
+ const merged = {
95
+ ...existingSettings,
96
+ last_inactivity_nudge_at: nowIso,
97
+ };
98
+ const profile = await repo.upsert({
99
+ user_id: input.user_id,
100
+ settings: merged,
101
+ });
102
+ return { won: true, profile };
103
+ },
47
104
  };
48
105
  }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * SqliteIdentityRepo — persists S40 identity layer to user_identity + user_platform_alias.
3
+ *
4
+ * §4.5 静默折叠声明:
5
+ * - FK / PK violation 抛错(依赖 sqlite 的 PRAGMA foreign_keys = ON 或应用层校验)
6
+ * - 应用层显式 if-exist check + throw,不靠 sqlite 静默拒绝
7
+ */
8
+ export class SqliteIdentityRepo {
9
+ db;
10
+ constructor(db) {
11
+ this.db = db;
12
+ }
13
+ async createIdentity(input) {
14
+ const existing = this.db
15
+ .prepare("SELECT 1 FROM user_identity WHERE libero_user_id = ?")
16
+ .get(input.libero_user_id);
17
+ if (existing) {
18
+ throw new Error(`identity already exists: libero_user_id=${input.libero_user_id}`);
19
+ }
20
+ const now = new Date().toISOString().replace("T", " ").replace("Z", "");
21
+ this.db
22
+ .prepare(`INSERT INTO user_identity (libero_user_id, display_name, created_at, updated_at)
23
+ VALUES (?, ?, ?, ?)`)
24
+ .run(input.libero_user_id, input.display_name ?? null, now, now);
25
+ return input.libero_user_id;
26
+ }
27
+ async getIdentity(libero_user_id) {
28
+ const row = this.db
29
+ .prepare("SELECT libero_user_id, display_name, created_at, updated_at FROM user_identity WHERE libero_user_id = ?")
30
+ .get(libero_user_id);
31
+ if (!row)
32
+ return null;
33
+ return {
34
+ libero_user_id: row.libero_user_id,
35
+ display_name: row.display_name,
36
+ created_at: row.created_at,
37
+ updated_at: row.updated_at,
38
+ };
39
+ }
40
+ async bindAlias(input) {
41
+ // FK check
42
+ const owner = this.db
43
+ .prepare("SELECT 1 FROM user_identity WHERE libero_user_id = ?")
44
+ .get(input.libero_user_id);
45
+ if (!owner) {
46
+ throw new Error(`bindAlias FK violation: libero_user_id not found: ${input.libero_user_id}`);
47
+ }
48
+ // PK check
49
+ const existing = this.db
50
+ .prepare("SELECT 1 FROM user_platform_alias WHERE platform = ? AND platform_user_id = ?")
51
+ .get(input.platform, input.platform_user_id);
52
+ if (existing) {
53
+ throw new Error(`alias already bound: platform=${input.platform} platform_user_id=${input.platform_user_id}`);
54
+ }
55
+ const now = new Date().toISOString().replace("T", " ").replace("Z", "");
56
+ this.db
57
+ .prepare(`INSERT INTO user_platform_alias (libero_user_id, platform, platform_user_id, chat_id, bound_at)
58
+ VALUES (?, ?, ?, ?, ?)`)
59
+ .run(input.libero_user_id, input.platform, input.platform_user_id, input.chat_id ?? input.platform_user_id, now);
60
+ }
61
+ async listAliases(libero_user_id) {
62
+ const rows = this.db
63
+ .prepare(`SELECT libero_user_id, platform, platform_user_id, chat_id, bound_at
64
+ FROM user_platform_alias
65
+ WHERE libero_user_id = ?
66
+ ORDER BY bound_at ASC`)
67
+ .all(libero_user_id);
68
+ return rows.map((r) => ({ ...r }));
69
+ }
70
+ async findAliasByPlatformUserId(platform_user_id) {
71
+ const row = this.db
72
+ .prepare(`SELECT libero_user_id, platform, platform_user_id, chat_id, bound_at
73
+ FROM user_platform_alias
74
+ WHERE platform_user_id = ?
75
+ LIMIT 1`)
76
+ .get(platform_user_id);
77
+ return row ? { ...row } : null;
78
+ }
79
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libero-mcp",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "AI 时间管理教练 MCP 工具集——时间块记录、统计、矩阵诊断、计时、分类、profile",
5
5
  "license": "MIT",
6
6
  "author": "amosyuan",