libero-mcp 0.1.0 → 0.2.1
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/README.md +48 -12
- package/data/migrations/007_reminders.sql +17 -0
- package/data/migrations/008_timer_target_ms.sql +5 -0
- package/data/migrations/009_tasks.sql +22 -0
- package/data/migrations/010_timer_task_id.sql +4 -0
- package/dist/data/db.js +8 -3
- package/dist/data/libero.db +0 -0
- package/dist/data/migrations/007_reminders.sql +17 -0
- package/dist/data/migrations/008_timer_target_ms.sql +5 -0
- package/dist/data/migrations/009_tasks.sql +22 -0
- package/dist/data/migrations/010_timer_task_id.sql +4 -0
- package/dist/mcp/server-core.js +197 -4
- package/dist/mcp/server.js +8 -4
- package/dist/mcp/src/clock/index.js +16 -0
- package/dist/mcp/src/reminder/hermes-channel.js +40 -0
- package/dist/mcp/src/reminder/index.js +50 -0
- package/dist/mcp/src/reminder/push-channel.js +10 -0
- package/dist/mcp/src/repo/in-memory.js +235 -0
- package/dist/mcp/src/repo/sqlite.js +119 -3
- package/dist/mcp/src/scheduler/index.js +126 -0
- package/dist/mcp/src/task/index.js +354 -0
- package/dist/mcp/src/timer/index.js +82 -8
- package/package.json +26 -4
package/README.md
CHANGED
|
@@ -1,21 +1,57 @@
|
|
|
1
|
-
# libero
|
|
1
|
+
# libero
|
|
2
2
|
|
|
3
|
-
AI
|
|
3
|
+
AI 辅助的精力管理教练:观察实绩 → 发现问题 → 对齐方案 → AI 辅助达成。
|
|
4
4
|
|
|
5
|
-
2.0 = 一组 Skills(脑)+ 一层 MCP 工具(手)+ 一个数据后端(记忆),骑
|
|
5
|
+
> 2.0 = 一组 Skills(脑)+ 一层 MCP 工具(手)+ 一个数据后端(记忆),骑 Hermes / OpenClaw,本地优先。
|
|
6
6
|
|
|
7
7
|
## 快速开始
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
```bash
|
|
10
|
+
npm i -g libero-mcp@latest
|
|
11
|
+
```
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
装完后,在你的 agent 入口(Hermes / OpenClaw)里直接说一句:
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
- `mcp/` — 手(确定性 MCP 工具)
|
|
15
|
-
- `data/` — 本地 DB schema + migrations
|
|
16
|
-
- `eval/` — Skill 评估与压力测试
|
|
17
|
-
- `docs/` — spec / plan / 决策记录
|
|
15
|
+
> 帮我记一下从现在开始写代码
|
|
18
16
|
|
|
19
|
-
|
|
17
|
+
libero 会自动建库、建用户、开始计时。下次想看时间花哪儿了,就问:
|
|
20
18
|
|
|
21
|
-
|
|
19
|
+
> 这周时间都花哪儿了?帮我复盘一下
|
|
20
|
+
|
|
21
|
+
## 能说什么
|
|
22
|
+
|
|
23
|
+
libero 是个教练,不是表格工具——所有功能都通过自然语言触发:
|
|
24
|
+
|
|
25
|
+
| 想做的事 | 这样说 |
|
|
26
|
+
|---|---|
|
|
27
|
+
| 开始一段专注 | "开始计时写代码" / "开个 25 分钟番茄钟读书" |
|
|
28
|
+
| 停下来 | "停一下" / "看看今天计了多久" |
|
|
29
|
+
| 安排日程 | "明天 9 点到 11 点写报告" / "下周二下午开会" |
|
|
30
|
+
| 看时间分布 | "这周时间都花哪儿了" / "看看今天的四象限" |
|
|
31
|
+
| 复盘 | "复盘一下这周" / "帮我分析最近的时间使用" |
|
|
32
|
+
| 拆任务 | "这个任务太大,帮我拆一下" |
|
|
33
|
+
| 安排提醒 | "明天 8 点提醒我打卡" |
|
|
34
|
+
|
|
35
|
+
## 数据存在哪
|
|
36
|
+
|
|
37
|
+
默认在 `~/.libero/libero.db`(SQLite)。想换位置:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
DB_PATH=/your/path.db libero-mcp
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## 升级
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npm update -g libero-mcp
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
DB schema 升级在启动时自动跑,不用手工干预。
|
|
50
|
+
|
|
51
|
+
## 给开发者
|
|
52
|
+
|
|
53
|
+
- 协作宪法:[AGENTS.md](AGENTS.md)
|
|
54
|
+
- 部署细节 / 故障排查:[docs/operations/deploy-to-hermes.md](docs/operations/deploy-to-hermes.md)
|
|
55
|
+
- MCP 工具清单(41 个):见同上文档「仅排查时参考」段
|
|
56
|
+
- Skills 源在 `skills/`(libero-coach / libero-record / libero-onboarding / libero-decompose)
|
|
57
|
+
- 仓库结构 / spec / 决策记录:见 `docs/`
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS reminders (
|
|
2
|
+
id TEXT PRIMARY KEY,
|
|
3
|
+
user_id TEXT NOT NULL,
|
|
4
|
+
timeblock_id TEXT,
|
|
5
|
+
content TEXT NOT NULL,
|
|
6
|
+
remind_at TEXT NOT NULL,
|
|
7
|
+
channel TEXT NOT NULL DEFAULT 'session',
|
|
8
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
9
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
10
|
+
sent_at TEXT,
|
|
11
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
12
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
13
|
+
deleted_at TEXT,
|
|
14
|
+
FOREIGN KEY (user_id) REFERENCES user_profile(id)
|
|
15
|
+
);
|
|
16
|
+
CREATE INDEX IF NOT EXISTS idx_reminders_due
|
|
17
|
+
ON reminders (status, remind_at) WHERE deleted_at IS NULL;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
-- 009_tasks: 任务拆解(P2-3, S29 §3.1)
|
|
2
|
+
-- coaching 制品:低摩擦冷启动 + ≤25min 子任务方案
|
|
3
|
+
|
|
4
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
5
|
+
id TEXT PRIMARY KEY,
|
|
6
|
+
user_id TEXT NOT NULL,
|
|
7
|
+
parent_id TEXT, -- NULL=根任务, 否则子任务(自引用)
|
|
8
|
+
title TEXT NOT NULL,
|
|
9
|
+
status TEXT NOT NULL DEFAULT 'draft', -- draft/aligned/executing/done/abandoned
|
|
10
|
+
est_minutes INTEGER, -- 预估(冷启动不限, 其余≤25min)
|
|
11
|
+
category_id TEXT,
|
|
12
|
+
linked_timeblock_id TEXT, -- 排/执行时关联 time_block
|
|
13
|
+
order_index INTEGER DEFAULT 0, -- 同级顺序
|
|
14
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
15
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
16
|
+
deleted_at TEXT,
|
|
17
|
+
FOREIGN KEY (user_id) REFERENCES user_profile(id),
|
|
18
|
+
FOREIGN KEY (parent_id) REFERENCES tasks(id),
|
|
19
|
+
FOREIGN KEY (category_id) REFERENCES categories(id),
|
|
20
|
+
FOREIGN KEY (linked_timeblock_id) REFERENCES time_blocks(id)
|
|
21
|
+
);
|
|
22
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_user_parent ON tasks (user_id, parent_id);
|
package/dist/data/db.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import Database from "better-sqlite3";
|
|
2
|
-
import { readFileSync, readdirSync } from "node:fs";
|
|
3
|
-
import {
|
|
2
|
+
import { mkdirSync, readFileSync, readdirSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
6
|
let instance = null;
|
|
6
7
|
function runMigrations(db) {
|
|
@@ -33,6 +34,9 @@ export function initDb(dbPath) {
|
|
|
33
34
|
instance.close();
|
|
34
35
|
instance = null;
|
|
35
36
|
}
|
|
37
|
+
// 父目录不存在时自动创建(recursive),避免 SQLITE_CANTOPEN
|
|
38
|
+
// 2026-07-17:hermes 装机后默认 cwd 无 ./data/ → 用户看到"缺少数据库目录"
|
|
39
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
36
40
|
const db = new Database(dbPath);
|
|
37
41
|
db.pragma("journal_mode = WAL");
|
|
38
42
|
db.pragma("foreign_keys = ON");
|
|
@@ -54,7 +58,8 @@ export function closeDb() {
|
|
|
54
58
|
}
|
|
55
59
|
const entry = process.argv[1];
|
|
56
60
|
if (entry && fileURLToPath(import.meta.url) === entry) {
|
|
57
|
-
|
|
61
|
+
// CLI 模式:默认 ~/.libero/libero.db(与 server.ts 保持一致)
|
|
62
|
+
const dbPath = process.env.DB_PATH ?? join(homedir(), ".libero", "libero.db");
|
|
58
63
|
initDb(dbPath);
|
|
59
64
|
console.log(`DB initialized at ${dbPath}`);
|
|
60
65
|
closeDb();
|
|
Binary file
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS reminders (
|
|
2
|
+
id TEXT PRIMARY KEY,
|
|
3
|
+
user_id TEXT NOT NULL,
|
|
4
|
+
timeblock_id TEXT,
|
|
5
|
+
content TEXT NOT NULL,
|
|
6
|
+
remind_at TEXT NOT NULL,
|
|
7
|
+
channel TEXT NOT NULL DEFAULT 'session',
|
|
8
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
9
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
10
|
+
sent_at TEXT,
|
|
11
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
12
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
13
|
+
deleted_at TEXT,
|
|
14
|
+
FOREIGN KEY (user_id) REFERENCES user_profile(id)
|
|
15
|
+
);
|
|
16
|
+
CREATE INDEX IF NOT EXISTS idx_reminders_due
|
|
17
|
+
ON reminders (status, remind_at) WHERE deleted_at IS NULL;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
-- 009_tasks: 任务拆解(P2-3, S29 §3.1)
|
|
2
|
+
-- coaching 制品:低摩擦冷启动 + ≤25min 子任务方案
|
|
3
|
+
|
|
4
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
5
|
+
id TEXT PRIMARY KEY,
|
|
6
|
+
user_id TEXT NOT NULL,
|
|
7
|
+
parent_id TEXT, -- NULL=根任务, 否则子任务(自引用)
|
|
8
|
+
title TEXT NOT NULL,
|
|
9
|
+
status TEXT NOT NULL DEFAULT 'draft', -- draft/aligned/executing/done/abandoned
|
|
10
|
+
est_minutes INTEGER, -- 预估(冷启动不限, 其余≤25min)
|
|
11
|
+
category_id TEXT,
|
|
12
|
+
linked_timeblock_id TEXT, -- 排/执行时关联 time_block
|
|
13
|
+
order_index INTEGER DEFAULT 0, -- 同级顺序
|
|
14
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
15
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
16
|
+
deleted_at TEXT,
|
|
17
|
+
FOREIGN KEY (user_id) REFERENCES user_profile(id),
|
|
18
|
+
FOREIGN KEY (parent_id) REFERENCES tasks(id),
|
|
19
|
+
FOREIGN KEY (category_id) REFERENCES categories(id),
|
|
20
|
+
FOREIGN KEY (linked_timeblock_id) REFERENCES time_blocks(id)
|
|
21
|
+
);
|
|
22
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_user_parent ON tasks (user_id, parent_id);
|
package/dist/mcp/server-core.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { SqliteCategoryRepo, SqliteTimeBlockRepo, SqliteStatsRepo, SqliteTimerRepo, SqliteProfileRepo, } from "./src/repo/sqlite.js";
|
|
3
|
+
import { SqliteCategoryRepo, SqliteTimeBlockRepo, SqliteStatsRepo, SqliteTimerRepo, SqliteProfileRepo, SqliteReminderRepo, } from "./src/repo/sqlite.js";
|
|
4
4
|
import { createCategoryTools } from "./src/categories/index.js";
|
|
5
5
|
import { createTimeBlockTools } from "./src/timeblock/index.js";
|
|
6
6
|
import { createStatsTools } from "./src/stats/index.js";
|
|
7
7
|
import { createMatrixTools } from "./src/matrix/index.js";
|
|
8
8
|
import { createTimerTools } from "./src/timer/index.js";
|
|
9
9
|
import { createProfileTools } from "./src/profile/index.js";
|
|
10
|
+
import { createReminderTools } from "./src/reminder/index.js";
|
|
11
|
+
import { createSchedulerTools } from "./src/scheduler/index.js";
|
|
12
|
+
import { createTaskTools } from "./src/task/index.js";
|
|
10
13
|
import { nowLocal, formatTime, parseTime } from "./src/clock/index.js";
|
|
11
14
|
/** Wrap any tool result as MCP text content (JSON). Void results become {ok:true}. */
|
|
12
15
|
function asContent(result) {
|
|
@@ -31,13 +34,17 @@ export function buildServer(db) {
|
|
|
31
34
|
const statsRepo = new SqliteStatsRepo(db);
|
|
32
35
|
const timerRepo = new SqliteTimerRepo(db);
|
|
33
36
|
const profileRepo = new SqliteProfileRepo(db);
|
|
37
|
+
const reminderRepo = new SqliteReminderRepo(db);
|
|
34
38
|
// ── inject tool factories ──
|
|
35
39
|
const categories = createCategoryTools(categoryRepo);
|
|
36
40
|
const tb = createTimeBlockTools(timeblockRepo);
|
|
37
41
|
const stats = createStatsTools(statsRepo);
|
|
38
42
|
const matrix = createMatrixTools(statsRepo); // matrix reuses StatsRepo
|
|
39
|
-
const timer = createTimerTools(timerRepo);
|
|
43
|
+
const timer = createTimerTools(timerRepo, timeblockRepo);
|
|
40
44
|
const profile = createProfileTools(profileRepo);
|
|
45
|
+
const reminder = createReminderTools(reminderRepo);
|
|
46
|
+
const scheduler = createSchedulerTools(timeblockRepo, reminderRepo, categoryRepo);
|
|
47
|
+
const task = createTaskTools(db);
|
|
41
48
|
const server = new McpServer({ name: "libero-mcp", version: "0.1.0" });
|
|
42
49
|
// ============================================================
|
|
43
50
|
// categories (2 tools)
|
|
@@ -53,13 +60,39 @@ export function buildServer(db) {
|
|
|
53
60
|
user_id: z.string().min(1),
|
|
54
61
|
event: z.string().min(1),
|
|
55
62
|
categoryId: z.string().optional(),
|
|
63
|
+
task_id: z.string().optional(),
|
|
56
64
|
},
|
|
57
|
-
}, async ({ user_id, event, categoryId }) => asContent(await timer.start(user_id, event, categoryId)));
|
|
65
|
+
}, async ({ user_id, event, categoryId, task_id }) => asContent(await timer.start(user_id, event, categoryId, task_id)));
|
|
58
66
|
server.registerTool("timer.pause", { description: "暂停当前计时", inputSchema: { user_id: z.string().min(1) } }, async ({ user_id }) => asContent(await timer.pause(user_id)));
|
|
59
67
|
server.registerTool("timer.resume", { description: "恢复已暂停的计时", inputSchema: { user_id: z.string().min(1) } }, async ({ user_id }) => asContent(await timer.resume(user_id)));
|
|
60
|
-
server.registerTool("timer.stop", {
|
|
68
|
+
server.registerTool("timer.stop", {
|
|
69
|
+
description: "停止计时并返回记录(传 rating 时自动落盘 timeblock)",
|
|
70
|
+
inputSchema: {
|
|
71
|
+
user_id: z.string().min(1),
|
|
72
|
+
rating: z.number().min(1).max(5).optional(),
|
|
73
|
+
rating_reason: z.string().optional(),
|
|
74
|
+
},
|
|
75
|
+
}, async ({ user_id, rating, rating_reason }) => asContent(await timer.stop(user_id, { rating, rating_reason })));
|
|
61
76
|
server.registerTool("timer.status", { description: "查询某用户当前计时状态", inputSchema: { user_id: z.string().min(1) } }, async ({ user_id }) => asContent(await timer.status(user_id)));
|
|
62
77
|
server.registerTool("timer.elapsed", { description: "查询当前计时已过毫秒数", inputSchema: { user_id: z.string().min(1) } }, async ({ user_id }) => asContent(await timer.elapsed(user_id)));
|
|
78
|
+
server.registerTool("timer.statusAll", { description: "查询所有用户的当前计时状态(调试/兜底用)", inputSchema: {} }, async () => asContent(await timer.statusAll()));
|
|
79
|
+
// ── S26: pomodoro v1 tools ──
|
|
80
|
+
server.registerTool("timer.startFocus", {
|
|
81
|
+
description: "开始一个专注计时(番茄钟),带目标时长",
|
|
82
|
+
inputSchema: {
|
|
83
|
+
user_id: z.string().min(1),
|
|
84
|
+
event: z.string().min(1),
|
|
85
|
+
focusMin: z.number().positive().optional(),
|
|
86
|
+
categoryId: z.string().optional(),
|
|
87
|
+
task_id: z.string().optional(),
|
|
88
|
+
},
|
|
89
|
+
}, async ({ user_id, event, focusMin, categoryId, task_id }) => asContent(await timer.startFocus(user_id, event, focusMin, categoryId, task_id)));
|
|
90
|
+
server.registerTool("timer.remaining", {
|
|
91
|
+
description: "查询专注计时剩余时间(负数=超时)",
|
|
92
|
+
inputSchema: {
|
|
93
|
+
user_id: z.string().min(1),
|
|
94
|
+
},
|
|
95
|
+
}, async ({ user_id }) => asContent(await timer.remaining(user_id)));
|
|
63
96
|
// ============================================================
|
|
64
97
|
// timeblock (5 tools — single-object args)
|
|
65
98
|
// ============================================================
|
|
@@ -178,5 +211,165 @@ export function buildServer(db) {
|
|
|
178
211
|
.optional(),
|
|
179
212
|
},
|
|
180
213
|
}, async (input) => asContent(await profile.upsert(input)));
|
|
214
|
+
// ============================================================
|
|
215
|
+
// reminder (6 tools — S11)
|
|
216
|
+
// ============================================================
|
|
217
|
+
server.registerTool("reminder.create", {
|
|
218
|
+
description: "创建一条提醒",
|
|
219
|
+
inputSchema: {
|
|
220
|
+
user_id: z.string().min(1),
|
|
221
|
+
content: z.string().min(1),
|
|
222
|
+
remind_at: z.string().min(1),
|
|
223
|
+
channel: z.string().optional(),
|
|
224
|
+
timeblock_id: z.string().optional(),
|
|
225
|
+
},
|
|
226
|
+
}, async (input) => asContent(await reminder.create(input)));
|
|
227
|
+
server.registerTool("reminder.getDue", {
|
|
228
|
+
description: "获取到期且待发送的提醒列表",
|
|
229
|
+
inputSchema: {
|
|
230
|
+
user_id: z.string().optional(),
|
|
231
|
+
until: z.string().optional(),
|
|
232
|
+
},
|
|
233
|
+
}, async (input) => asContent(await reminder.getDueReminders(input)));
|
|
234
|
+
server.registerTool("reminder.getNextDue", {
|
|
235
|
+
description: "获取下一条待发送提醒的时间",
|
|
236
|
+
inputSchema: {
|
|
237
|
+
user_id: z.string().optional(),
|
|
238
|
+
},
|
|
239
|
+
}, async (input) => asContent(await reminder.getNextDue(input)));
|
|
240
|
+
server.registerTool("reminder.getMissed", {
|
|
241
|
+
description: "获取过期但未送达的提醒(被动补发用)",
|
|
242
|
+
inputSchema: {
|
|
243
|
+
user_id: z.string().min(1),
|
|
244
|
+
},
|
|
245
|
+
}, async ({ user_id }) => asContent(await reminder.getMissedReminders({ user_id })));
|
|
246
|
+
server.registerTool("reminder.markSent", {
|
|
247
|
+
description: "标记提醒为已发送(幂等)",
|
|
248
|
+
inputSchema: { id: z.string().min(1) },
|
|
249
|
+
}, async ({ id }) => asContent(await reminder.markSent(id)));
|
|
250
|
+
server.registerTool("reminder.cancel", {
|
|
251
|
+
description: "取消一条提醒(幂等)",
|
|
252
|
+
inputSchema: { id: z.string().min(1) },
|
|
253
|
+
}, async ({ id }) => asContent(await reminder.cancelReminder(id)));
|
|
254
|
+
// ============================================================
|
|
255
|
+
// scheduler (3 tools — S27, reuses timeblockRepo)
|
|
256
|
+
// ============================================================
|
|
257
|
+
server.registerTool("scheduler.createSchedule", {
|
|
258
|
+
description: "创建一个日程(planned 时间块)",
|
|
259
|
+
inputSchema: {
|
|
260
|
+
user_id: z.string().min(1),
|
|
261
|
+
title: z.string().min(1),
|
|
262
|
+
category_id: z.string().optional(),
|
|
263
|
+
start_time: z.string(),
|
|
264
|
+
end_time: z.string(),
|
|
265
|
+
duration_minutes: z.number().optional(),
|
|
266
|
+
rating: z.number().min(1).max(5).optional(),
|
|
267
|
+
rating_reason: z.string().optional(),
|
|
268
|
+
notes: z.string().optional(),
|
|
269
|
+
},
|
|
270
|
+
}, async (input) => asContent(await scheduler.createSchedule(input)));
|
|
271
|
+
server.registerTool("scheduler.listSchedules", {
|
|
272
|
+
description: "列出用户的日程(仅 planned 时间块)",
|
|
273
|
+
inputSchema: {
|
|
274
|
+
user_id: z.string().min(1),
|
|
275
|
+
from: z.string().optional(),
|
|
276
|
+
to: z.string().optional(),
|
|
277
|
+
},
|
|
278
|
+
}, async (input) => asContent(await scheduler.listSchedules(input)));
|
|
279
|
+
server.registerTool("scheduler.checkConflict", {
|
|
280
|
+
description: "检查时间段是否与已有日程冲突",
|
|
281
|
+
inputSchema: {
|
|
282
|
+
user_id: z.string().min(1),
|
|
283
|
+
start_time: z.string(),
|
|
284
|
+
end_time: z.string(),
|
|
285
|
+
},
|
|
286
|
+
}, async (input) => asContent(await scheduler.checkConflict(input)));
|
|
287
|
+
// ============================================================
|
|
288
|
+
// task (7 tools — S29 P2-3 + 事一 draft/commit, takes raw db)
|
|
289
|
+
// ============================================================
|
|
290
|
+
server.registerTool("task.create", {
|
|
291
|
+
description: "建单个独立任务(不挂任何 timeblock)。⚠️ 拆解请用 decompose.split,本工具一次只能建一个任务、不能建子任务。",
|
|
292
|
+
// S33 v3: 用 z.object().strict() 显式拒 parent_id(zod 默认 strip 不报错,
|
|
293
|
+
// 严格模式让多余键触发 safeParse 失败 → MCP 返回 isError)。
|
|
294
|
+
// 底层 createTask 仍保留 parent_id 能力(task.test.ts 单测不破)。
|
|
295
|
+
inputSchema: z
|
|
296
|
+
.object({
|
|
297
|
+
user_id: z.string().min(1),
|
|
298
|
+
title: z.string().min(1),
|
|
299
|
+
est_minutes: z.number().optional(),
|
|
300
|
+
})
|
|
301
|
+
.strict(),
|
|
302
|
+
}, async (input) => asContent(await task.createTask(input)));
|
|
303
|
+
server.registerTool("task.get", {
|
|
304
|
+
description: "按 id 获取任务(含递归子树)",
|
|
305
|
+
inputSchema: { id: z.string().min(1) },
|
|
306
|
+
}, async ({ id }) => asContent(await task.getTask(id)));
|
|
307
|
+
server.registerTool("task.list", {
|
|
308
|
+
description: "列出任务(parent_id=null 仅根;给定则该父的子;省略则全部)",
|
|
309
|
+
inputSchema: {
|
|
310
|
+
user_id: z.string().min(1),
|
|
311
|
+
parent_id: z.string().nullable().optional(),
|
|
312
|
+
},
|
|
313
|
+
}, async (input) => asContent(await task.listTasks(input)));
|
|
314
|
+
server.registerTool("task.update", {
|
|
315
|
+
description: "更新任务(title/est_minutes/status;状态机校验非法流转)",
|
|
316
|
+
inputSchema: {
|
|
317
|
+
id: z.string().min(1),
|
|
318
|
+
title: z.string().optional(),
|
|
319
|
+
est_minutes: z.number().optional(),
|
|
320
|
+
status: z.string().optional(),
|
|
321
|
+
},
|
|
322
|
+
}, async ({ id, ...rest }) => asContent(await task.updateTask(id, rest)));
|
|
323
|
+
server.registerTool("task.linkTimeblock", {
|
|
324
|
+
description: "把任务关联到一个时间块(子任务开工执行时)",
|
|
325
|
+
inputSchema: {
|
|
326
|
+
task_id: z.string().min(1),
|
|
327
|
+
block_id: z.string().min(1),
|
|
328
|
+
},
|
|
329
|
+
}, async ({ task_id, block_id }) => asContent(await task.linkTimeblock(task_id, block_id)));
|
|
330
|
+
server.registerTool("task.findPlan", {
|
|
331
|
+
description: "按事件名 exact-match 查拆解计划:返回根任务 + 第一块未完成子任务(record 开工时链 task_id,驱动闭环正反馈)",
|
|
332
|
+
inputSchema: {
|
|
333
|
+
user_id: z.string().min(1),
|
|
334
|
+
title: z.string().min(1),
|
|
335
|
+
},
|
|
336
|
+
}, async (input) => asContent(await task.findPlan(input)));
|
|
337
|
+
// S33 v3: task.draftPlan / task.commitPlan 从 MCP 工具列表移除(不再暴露给 mimo)。
|
|
338
|
+
// 拆解唯一入口 = decompose.split(见下方 decompose 段)。
|
|
339
|
+
// 底层 draftPlan/commitPlan 方法保留在 mcp/src/task/index.ts(被 decompose.split 复用 + task.test.ts 单测覆盖)。
|
|
340
|
+
// ============================================================
|
|
341
|
+
// decompose (1 tool — S33 v3:动作分离重构)
|
|
342
|
+
// decompose.split = 拆解唯一入口。状态机强制 draft→confirm→commit。
|
|
343
|
+
// 错误返回 isError content(不 throw),避 hermes 熔断器(v2 实证坑)。
|
|
344
|
+
// ============================================================
|
|
345
|
+
server.registerTool("decompose.split", {
|
|
346
|
+
description: "拆解 timeblock 为结构化子任务树(拆解唯一入口)。第一次调用出方案(不写库),第二次带 confirm_token 落地。状态机强制 draft→confirm→commit。⚠️ 拆子任务请用本工具,不要用 task.create。",
|
|
347
|
+
inputSchema: {
|
|
348
|
+
user_id: z.string().min(1),
|
|
349
|
+
timeblock_id: z.string().min(1),
|
|
350
|
+
children: z
|
|
351
|
+
.array(z.object({
|
|
352
|
+
title: z.string().min(1),
|
|
353
|
+
est_minutes: z.number().optional(),
|
|
354
|
+
}))
|
|
355
|
+
.min(1),
|
|
356
|
+
confirm_token: z.string().optional(),
|
|
357
|
+
},
|
|
358
|
+
}, async (input) => {
|
|
359
|
+
const result = await task.splitDecompose(input);
|
|
360
|
+
// DecomposeErrorResult → isError content(不 throw,避 hermes 熔断器)
|
|
361
|
+
if ("ok" in result && result.ok === false) {
|
|
362
|
+
return {
|
|
363
|
+
content: [
|
|
364
|
+
{
|
|
365
|
+
type: "text",
|
|
366
|
+
text: JSON.stringify(result),
|
|
367
|
+
},
|
|
368
|
+
],
|
|
369
|
+
isError: true,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
return asContent(result);
|
|
373
|
+
});
|
|
181
374
|
return server;
|
|
182
375
|
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -3,14 +3,18 @@
|
|
|
3
3
|
* libero MCP server — stdio transport entry point (S10 §3.3).
|
|
4
4
|
*
|
|
5
5
|
* Usage:
|
|
6
|
-
*
|
|
7
|
-
* DB_PATH=/path/to/libero.db
|
|
8
|
-
* libero-mcp (if package.json bin + tsx is configured)
|
|
6
|
+
* libero-mcp # 默认 DB 在 ~/.libero/libero.db
|
|
7
|
+
* DB_PATH=/path/to/libero.db libero-mcp # 自定义 DB 路径
|
|
9
8
|
*/
|
|
10
9
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
11
12
|
import { initDb, closeDb } from "../data/db.js";
|
|
12
13
|
import { buildServer } from "./server-core.js";
|
|
13
|
-
|
|
14
|
+
// 默认 DB 在用户家目录的 ~/.libero/libero.db(跨平台、不污染 cwd、符合 XDG-ish 约定)
|
|
15
|
+
// 2026-07-17:原默认 "./data/libero.db" 是相对路径,hermes 启动 libero-mcp 时 cwd 无 ./data/
|
|
16
|
+
// → better-sqlite3 抛 SQLITE_CANTOPEN → 用户看到"缺少数据库目录"。改绝对路径根治。
|
|
17
|
+
const DB_PATH = process.env.DB_PATH ?? join(homedir(), ".libero", "libero.db");
|
|
14
18
|
if (!DB_PATH) {
|
|
15
19
|
console.error("FATAL: DB_PATH environment variable not set.");
|
|
16
20
|
process.exit(1);
|
|
@@ -55,3 +55,19 @@ export function parseTime(isoString) {
|
|
|
55
55
|
export function getLocalOffset() {
|
|
56
56
|
return -new Date().getTimezoneOffset();
|
|
57
57
|
}
|
|
58
|
+
/** Convert any ISO string (UTC or with offset) to local timezone ISO string with offset suffix. */
|
|
59
|
+
export function toLocalIso(isoString) {
|
|
60
|
+
const d = new Date(isoString);
|
|
61
|
+
if (isNaN(d.getTime())) {
|
|
62
|
+
throw new Error(`Invalid ISO time string: ${isoString}`);
|
|
63
|
+
}
|
|
64
|
+
const offset = getLocalOffset();
|
|
65
|
+
const sign = offset >= 0 ? "+" : "-";
|
|
66
|
+
const abs = Math.abs(offset);
|
|
67
|
+
const h = String(Math.floor(abs / 60)).padStart(2, "0");
|
|
68
|
+
const m = String(abs % 60).padStart(2, "0");
|
|
69
|
+
// Shift UTC timestamp by local offset to get "fake UTC" representing local time
|
|
70
|
+
const local = new Date(d.getTime() + offset * 60000);
|
|
71
|
+
const iso = local.toISOString().replace("Z", "");
|
|
72
|
+
return `${iso}${sign}${h}:${m}`;
|
|
73
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HermesChannel — delivers reminders via `hermes send -t <target>` (T2).
|
|
3
|
+
*
|
|
4
|
+
* Uses CommandRunner abstraction (default: child_process.spawn, arrays only — no shell injection).
|
|
5
|
+
* DI `run` for testability; env HERMES_BIN overrides the binary path.
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
/** Default runner: spawn `hermes` (HERMES_BIN override or PATH) with array args. */
|
|
9
|
+
function defaultRunner(args) {
|
|
10
|
+
const bin = process.env.HERMES_BIN || "hermes";
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
const child = spawn(bin, args, { stdio: "ignore" });
|
|
13
|
+
child.on("close", (code) => resolve(code ?? 1));
|
|
14
|
+
child.on("error", () => resolve(1));
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
export class HermesChannel {
|
|
18
|
+
defaultTarget;
|
|
19
|
+
run;
|
|
20
|
+
constructor(opts) {
|
|
21
|
+
this.defaultTarget = opts?.defaultTarget;
|
|
22
|
+
this.run = opts?.run ?? defaultRunner;
|
|
23
|
+
}
|
|
24
|
+
async send(r) {
|
|
25
|
+
// LLM 注入优先:只有 platform:address 格式(含 ':')才算有效 direct target。
|
|
26
|
+
// 'session' / '' / 无 ':' → 视为无 direct target,走 defaultTarget 常量兜底。
|
|
27
|
+
const direct = r.channel && r.channel.includes(":") ? r.channel : null;
|
|
28
|
+
const target = direct ?? this.defaultTarget;
|
|
29
|
+
// §4.5 静默折叠:无送达目标 → warn + return false。
|
|
30
|
+
// 吞掉的原因:reminder 可能由无渠道上下文的途径创建(如 session fallback),
|
|
31
|
+
// 或 schema default='session' 且 LLM 未注入有效 channel。
|
|
32
|
+
if (!target) {
|
|
33
|
+
console.warn(`[HermesChannel] no target for reminder ${r.id}`);
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
const code = await this.run(["send", "-t", target, r.content]);
|
|
37
|
+
// 不抛——失败交 reminder-tick 的 releaseOrFail 重试/死信。
|
|
38
|
+
return code === 0;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { nowLocal } from "../clock/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Validate and delegate reminder operations to the repository.
|
|
4
|
+
* ADR-0001: Dependency-injected — the caller provides the repo.
|
|
5
|
+
*
|
|
6
|
+
* @param repo ReminderRepo implementation
|
|
7
|
+
* @param getNow Clock injection for determinism; defaults to clock.nowLocal()
|
|
8
|
+
*/
|
|
9
|
+
export function createReminderTools(repo, getNow) {
|
|
10
|
+
const now = getNow ?? (() => nowLocal());
|
|
11
|
+
const create = async (input) => {
|
|
12
|
+
if (!input.content || input.content.trim() === "") {
|
|
13
|
+
throw new Error("提醒内容不能为空");
|
|
14
|
+
}
|
|
15
|
+
if (input.remind_at <= now()) {
|
|
16
|
+
throw new Error("提醒时间不能早于当前时间");
|
|
17
|
+
}
|
|
18
|
+
const userOk = await repo.userExists(input.user_id);
|
|
19
|
+
if (!userOk) {
|
|
20
|
+
throw new Error("用户不存在");
|
|
21
|
+
}
|
|
22
|
+
return repo.create(input);
|
|
23
|
+
};
|
|
24
|
+
const getDueReminders = async (input) => {
|
|
25
|
+
return repo.getDueReminders({
|
|
26
|
+
user_id: input.user_id,
|
|
27
|
+
until: input.until ?? now(),
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
const getNextDue = async (input) => {
|
|
31
|
+
return repo.getNextDue(input ?? {});
|
|
32
|
+
};
|
|
33
|
+
const getMissedReminders = async (input) => {
|
|
34
|
+
return repo.getMissedReminders({ ...input, now: now() });
|
|
35
|
+
};
|
|
36
|
+
const markSent = async (id) => {
|
|
37
|
+
return repo.markSent(id);
|
|
38
|
+
};
|
|
39
|
+
const cancelReminder = async (id) => {
|
|
40
|
+
return repo.cancelReminder(id);
|
|
41
|
+
};
|
|
42
|
+
return {
|
|
43
|
+
create,
|
|
44
|
+
getDueReminders,
|
|
45
|
+
getNextDue,
|
|
46
|
+
getMissedReminders,
|
|
47
|
+
markSent,
|
|
48
|
+
cancelReminder,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LogChannel — development fallback. Prints to console, always returns true.
|
|
3
|
+
* No real delivery; used when no credentials are configured.
|
|
4
|
+
*/
|
|
5
|
+
export class LogChannel {
|
|
6
|
+
async send(r) {
|
|
7
|
+
console.log(`[reminder] ${r.content} @ ${r.remind_at}`);
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
}
|