libero-mcp 0.2.21 → 0.2.22

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,5 @@
1
+ -- S48 (2026-08-11): time_blocks 加 asked_at 字段
2
+ -- 用于 libero-cron-scan 幂等:每条 unverified/planned 只被扫描追问一次
3
+ -- 写入时机:sweepStale 把条目放进 to_ask 结果前/后,由扫描 caller 标记(或 sweepStale 内部标记)
4
+ -- 复位规则:用户回报(completed / 软删)后这条不再被扫;asked_at 永久保留作审计
5
+ ALTER TABLE time_blocks ADD COLUMN asked_at TEXT;
package/data/schema.sql CHANGED
@@ -26,6 +26,8 @@ CREATE TABLE IF NOT EXISTS time_blocks (
26
26
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
27
27
  updated_at TEXT NOT NULL DEFAULT (datetime('now')),
28
28
  deleted_at TEXT,
29
+ -- status/rating/rating_reason 由后续 migration 加;此处不显式列(IF NOT EXISTS 不重建表)
30
+ -- asked_at(migration 015,S48):扫描追问幂等标记,每条只主动问一次
29
31
  FOREIGN KEY (user_id) REFERENCES user_profile(id)
30
32
  );
31
33
 
@@ -0,0 +1,5 @@
1
+ -- S48 (2026-08-11): time_blocks 加 asked_at 字段
2
+ -- 用于 libero-cron-scan 幂等:每条 unverified/planned 只被扫描追问一次
3
+ -- 写入时机:sweepStale 把条目放进 to_ask 结果前/后,由扫描 caller 标记(或 sweepStale 内部标记)
4
+ -- 复位规则:用户回报(completed / 软删)后这条不再被扫;asked_at 永久保留作审计
5
+ ALTER TABLE time_blocks ADD COLUMN asked_at TEXT;
@@ -26,6 +26,8 @@ CREATE TABLE IF NOT EXISTS time_blocks (
26
26
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
27
27
  updated_at TEXT NOT NULL DEFAULT (datetime('now')),
28
28
  deleted_at TEXT,
29
+ -- status/rating/rating_reason 由后续 migration 加;此处不显式列(IF NOT EXISTS 不重建表)
30
+ -- asked_at(migration 015,S48):扫描追问幂等标记,每条只主动问一次
29
31
  FOREIGN KEY (user_id) REFERENCES user_profile(id)
30
32
  );
31
33
 
@@ -0,0 +1,252 @@
1
+ import { readFileSync, readdirSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import yaml from "js-yaml";
4
+ import { validateEntry } from "../../_schema/entry.schema.js";
5
+ import { validateAnalysisModel } from "../../_schema/analysis-model.schema.js";
6
+ import { validateScenarioPack } from "../../_schema/scenario-pack.schema.js";
7
+ import { validatePersonaRegistry } from "../../_schema/persona.schema.js";
8
+ /** Repository root dir (two levels up from knowledge/src/repo/ → knowledge/) */
9
+ function defaultBasePath() {
10
+ // When running via tsx, import.meta.dirname is the dir of this file
11
+ return join(import.meta.dirname, "..", "..");
12
+ }
13
+ export class FsYamlKnowledgeRepo {
14
+ basePath;
15
+ _all = null;
16
+ _dimensions = null;
17
+ _analysisModels = null;
18
+ _scenarioPacks = null;
19
+ _personas = null;
20
+ constructor(basePath) {
21
+ this.basePath = basePath ?? defaultBasePath();
22
+ }
23
+ /** Load the dimensions registry from _registry/dimensions.yaml */
24
+ loadDimensions() {
25
+ if (this._dimensions)
26
+ return this._dimensions;
27
+ const path = join(this.basePath, "_registry", "dimensions.yaml");
28
+ if (!existsSync(path)) {
29
+ throw new Error(`维度注册表不存在: ${path}`);
30
+ }
31
+ const raw = yaml.load(readFileSync(path, "utf-8"));
32
+ if (!raw || !Array.isArray(raw.dimensions)) {
33
+ throw new Error(`维度注册表格式错误: ${path}`);
34
+ }
35
+ const dims = raw.dimensions.map((d) => d.id);
36
+ this._dimensions = dims;
37
+ return dims;
38
+ }
39
+ /** Collect all .yaml file paths under basePath, excluding _schema/, _registry/ and tests/ */
40
+ collectYamlFiles(dir, root) {
41
+ const files = [];
42
+ const entries = readdirSync(dir, { withFileTypes: true });
43
+ for (const entry of entries) {
44
+ const fullPath = join(dir, entry.name);
45
+ if (entry.isDirectory()) {
46
+ // Skip non-content directories.
47
+ // analysis-models/ 与 scenario-packs/ 是 COACH-MOD 新增内容类型,
48
+ // 用独立 schema(非 knowledgeEntrySchema),不能被 loadAll 当处方条目校验,
49
+ // 否则 validate 报错 / T13 迁移等价性断言崩。它们由 loadAnalysisModels
50
+ // / loadScenarioPacks 单独加载。
51
+ if (entry.name === "_schema" ||
52
+ entry.name === "_registry" ||
53
+ entry.name === "tests" ||
54
+ entry.name === "analysis-models" ||
55
+ entry.name === "scenario-packs")
56
+ continue;
57
+ files.push(...this.collectYamlFiles(fullPath, root));
58
+ }
59
+ else if (entry.isFile() && entry.name.endsWith(".yaml")) {
60
+ files.push(fullPath);
61
+ }
62
+ }
63
+ return files;
64
+ }
65
+ async loadAll() {
66
+ if (this._all)
67
+ return this._all;
68
+ // Load registry first
69
+ const validDimensions = this.loadDimensions();
70
+ const yamlFiles = this.collectYamlFiles(this.basePath, this.basePath);
71
+ const entries = [];
72
+ const seenIds = new Set();
73
+ for (const filePath of yamlFiles) {
74
+ const raw = yaml.load(readFileSync(filePath, "utf-8"));
75
+ const entry = validateEntry(raw);
76
+ // Check dimension is registered
77
+ if (!validDimensions.includes(entry.dimension)) {
78
+ throw new Error(`维度 "${entry.dimension}" 未在 _registry/dimensions.yaml 中登记 ` +
79
+ `(文件: ${filePath})`);
80
+ }
81
+ // Check duplicate ID
82
+ if (seenIds.has(entry.id)) {
83
+ throw new Error(`重复 ID: "${entry.id}" (文件: ${filePath})`);
84
+ }
85
+ seenIds.add(entry.id);
86
+ entries.push(entry);
87
+ }
88
+ this._all = entries;
89
+ return entries;
90
+ }
91
+ async getById(id) {
92
+ const entries = await this.loadAll();
93
+ return entries.find((e) => e.id === id) ?? null;
94
+ }
95
+ async byDimension(dimension) {
96
+ const entries = await this.loadAll();
97
+ return entries.filter((e) => e.dimension === dimension);
98
+ }
99
+ async matchBySignal(signal) {
100
+ const entries = await this.loadAll();
101
+ return entries.filter((e) => {
102
+ const signals = e.triggers.signals ?? [];
103
+ return signals.some((s) => {
104
+ // Quadrant match: exact
105
+ if (signal.quadrant && s.quadrant) {
106
+ if (s.quadrant !== signal.quadrant)
107
+ return false;
108
+ }
109
+ else if (signal.quadrant && !s.quadrant) {
110
+ return false;
111
+ }
112
+ // Daily total minutes: parse threshold
113
+ if (signal.daily_total_minutes && s.daily_total_minutes) {
114
+ const sv = parseThreshold(s.daily_total_minutes);
115
+ const qv = parseThreshold(signal.daily_total_minutes);
116
+ if (sv === null || qv === null) {
117
+ // If we can't parse, fall back to string equality
118
+ if (s.daily_total_minutes !== signal.daily_total_minutes)
119
+ return false;
120
+ }
121
+ else if (!matchesThreshold(s.daily_total_minutes, signal.daily_total_minutes)) {
122
+ return false;
123
+ }
124
+ }
125
+ else if (signal.daily_total_minutes && !s.daily_total_minutes) {
126
+ return false;
127
+ }
128
+ // If neither signal field is being queried, no match
129
+ if (!signal.quadrant && !signal.daily_total_minutes)
130
+ return false;
131
+ return true;
132
+ });
133
+ });
134
+ }
135
+ async matchBySymptom(symptom) {
136
+ const entries = await this.loadAll();
137
+ const lowerSymptom = symptom.toLowerCase();
138
+ return entries.filter((e) => {
139
+ const symptoms = e.triggers.symptoms ?? [];
140
+ return symptoms.some((s) => s.toLowerCase().includes(lowerSymptom));
141
+ });
142
+ }
143
+ /** Collect .yaml files directly under one content subdir (non-recursive). */
144
+ collectDirYamlFiles(subdir) {
145
+ const dir = join(this.basePath, subdir);
146
+ if (!existsSync(dir))
147
+ return [];
148
+ return readdirSync(dir, { withFileTypes: true })
149
+ .filter((e) => e.isFile() && e.name.endsWith(".yaml"))
150
+ .map((e) => join(dir, e.name));
151
+ }
152
+ /** COACH-MOD: load all analysis models from analysis-models/. */
153
+ async loadAnalysisModels() {
154
+ if (this._analysisModels)
155
+ return this._analysisModels;
156
+ const files = this.collectDirYamlFiles("analysis-models");
157
+ const models = [];
158
+ const seenIds = new Set();
159
+ for (const filePath of files) {
160
+ const raw = yaml.load(readFileSync(filePath, "utf-8"));
161
+ const model = validateAnalysisModel(raw);
162
+ if (seenIds.has(model.id)) {
163
+ throw new Error(`重复分析模型 ID: "${model.id}" (文件: ${filePath})`);
164
+ }
165
+ seenIds.add(model.id);
166
+ models.push(model);
167
+ }
168
+ this._analysisModels = models;
169
+ return models;
170
+ }
171
+ /** COACH-MOD: load all scenario packs from scenario-packs/. */
172
+ async loadScenarioPacks() {
173
+ if (this._scenarioPacks)
174
+ return this._scenarioPacks;
175
+ const files = this.collectDirYamlFiles("scenario-packs");
176
+ const packs = [];
177
+ const seenIds = new Set();
178
+ for (const filePath of files) {
179
+ const raw = yaml.load(readFileSync(filePath, "utf-8"));
180
+ const pack = validateScenarioPack(raw);
181
+ if (seenIds.has(pack.id)) {
182
+ throw new Error(`重复场景包 ID: "${pack.id}" (文件: ${filePath})`);
183
+ }
184
+ seenIds.add(pack.id);
185
+ packs.push(pack);
186
+ }
187
+ this._scenarioPacks = packs;
188
+ return packs;
189
+ }
190
+ /** COACH-MOD: load personas from _registry/personas.yaml. */
191
+ async loadPersonas() {
192
+ if (this._personas)
193
+ return this._personas;
194
+ const path = join(this.basePath, "_registry", "personas.yaml");
195
+ if (!existsSync(path)) {
196
+ this._personas = [];
197
+ return [];
198
+ }
199
+ const raw = yaml.load(readFileSync(path, "utf-8"));
200
+ const { personas } = validatePersonaRegistry(raw ?? {});
201
+ const seenIds = new Set();
202
+ for (const p of personas) {
203
+ if (seenIds.has(p.id)) {
204
+ throw new Error(`重复画像 ID: "${p.id}" (文件: ${path})`);
205
+ }
206
+ seenIds.add(p.id);
207
+ }
208
+ this._personas = personas;
209
+ return personas;
210
+ }
211
+ /**
212
+ * COACH-MOD: return the single default analysis model (is_default: true).
213
+ * §4.5 显式声明:0 个或 >1 个默认都抛错(不静默返回 undefined / 第一个),
214
+ * 因为"复盘默认装一个基线"是硬约束(spec §2 ①原则·基线)。
215
+ */
216
+ async getDefaultAnalysisModel() {
217
+ const models = await this.loadAnalysisModels();
218
+ const defaults = models.filter((m) => m.is_default);
219
+ if (defaults.length === 0) {
220
+ throw new Error("没有默认分析模型(需要恰好一个 is_default: true)");
221
+ }
222
+ if (defaults.length > 1) {
223
+ throw new Error(`默认分析模型不唯一:${defaults.map((m) => m.id).join(", ")}(需要恰好一个 is_default: true)`);
224
+ }
225
+ return defaults[0];
226
+ }
227
+ }
228
+ /** Parse a threshold string like ">600" into { op: ">", value: 600 } */
229
+ function parseThreshold(s) {
230
+ const match = s.match(/^([><=]?)\s*(\d+)$/);
231
+ if (!match)
232
+ return null;
233
+ return { op: match[1] || ">=", value: parseInt(match[2], 10) };
234
+ }
235
+ /** Check if the entry's daily_total_minutes matches the query threshold */
236
+ function matchesThreshold(entryValue, queryValue) {
237
+ const pv = parseThreshold(queryValue);
238
+ if (!pv)
239
+ return entryValue === queryValue;
240
+ // For entry values that are plain numbers or threshold expressions
241
+ const entryMatch = entryValue.match(/^(\d+)$/);
242
+ if (entryMatch) {
243
+ const num = parseInt(entryMatch[1], 10);
244
+ switch (pv.op) {
245
+ case ">": return num > pv.value;
246
+ case "<": return num < pv.value;
247
+ case "=": return num === pv.value;
248
+ default: return num >= pv.value;
249
+ }
250
+ }
251
+ return entryValue === queryValue;
252
+ }
@@ -12,6 +12,8 @@ import { createProfileConfirmTools } from "./src/profile/confirm-default.js";
12
12
  import { createReminderTools } from "./src/reminder/index.js";
13
13
  import { createSchedulerTools } from "./src/scheduler/index.js";
14
14
  import { createTaskTools } from "./src/task/index.js";
15
+ import { createCoachModTools } from "./src/coachmod/index.js";
16
+ import { FsYamlKnowledgeRepo } from "../knowledge/src/repo/fs-yaml.js";
15
17
  import { nowLocal, formatTime, parseTime } from "./src/clock/index.js";
16
18
  import { buildServerInstructions } from "./cold-start.js";
17
19
  import { resolveUserId } from "./src/identity/resolve.js";
@@ -51,7 +53,7 @@ export function errorContent(msg) {
51
53
  * preserving declaration order for diagnostic readability.
52
54
  */
53
55
  function buildToolRegistry(ctx) {
54
- const { categories, tb, stats, matrix, timeline, timer, profile, profileConfirm, reminder, scheduler, task, injector, } = ctx;
56
+ const { categories, tb, stats, matrix, timeline, timer, profile, profileConfirm, reminder, scheduler, task, coachmod, injector, } = ctx;
55
57
  // identityRepo is referenced directly inside handlers below (closure scope),
56
58
  // matching the pre-refactor behavior.
57
59
  const identityRepo = ctx.repos.identityRepo;
@@ -305,8 +307,8 @@ function buildToolRegistry(ctx) {
305
307
  name: "timeblock.sweepStale",
306
308
  description: "S43 过期扫描:列出某用户当天过期未确认的 timeblock,分两组返回。" +
307
309
  "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
+ "to_ask:end_time 已过 60min 仍 unverified/planned,**且未被问过**(S48:每条只问一次,asked_at IS NULL)。" +
311
+ "⚠️ 纯查询,不改 db——caller(cron-session LLM)需对 to_unverified 显式调 timeblock.update(status=unverified),对 to_ask 调 cronjob create 发追问,**发完后必须调 timeblock.markAsked(ids) 幂等标记**(否则下次扫描还会再问)。" +
310
312
  "⚠️ 仅 cron-session 调用;user_id 必填。",
311
313
  inputSchema: {
312
314
  user_id: z.string().min(1),
@@ -329,6 +331,69 @@ function buildToolRegistry(ctx) {
329
331
  }
330
332
  },
331
333
  });
334
+ tools.push({
335
+ name: "timeblock.markAsked",
336
+ description: "S48 幂等标记:把给定 timeblock 的 asked_at 设为当前时间。" +
337
+ "libero-cron-scan 发完追问后必须调用,否则同一条 unverified 会被下次扫描重复追问。" +
338
+ "asked_at 永久保留作审计;用户回报(completed / 软删)后这条不再被扫,无需复位。" +
339
+ "返回 { marked: N }(受影响行数)。⚠️ 仅 cron-session 调用。",
340
+ inputSchema: {
341
+ ids: z.array(z.string().min(1)).min(1),
342
+ },
343
+ handler: async (rawInput) => {
344
+ try {
345
+ const result = await tb.markAsked(rawInput.ids);
346
+ return asContent(result);
347
+ }
348
+ catch (e) {
349
+ return errorContent(e.message);
350
+ }
351
+ },
352
+ });
353
+ // ============================================================
354
+ // coachmod (2 tools — COACH-MOD §4,情境化复盘模块装载)
355
+ // ============================================================
356
+ tools.push({
357
+ name: "coachmod.suggest",
358
+ description: "COACH-MOD §4.1 情境化复盘模块装载——粗筛候选。" +
359
+ "按 context_hint + 用户 profile.focus_areas 确定性匹配 scenario-packs,始终附默认基础分析模型。" +
360
+ "脑(coach skill)拿到候选后**语义精选**并提议给用户(必经同意门,不静默装)。" +
361
+ "无候选 → candidates:[] + note(不静默空当正常)。user_id / context_hint 都可选。",
362
+ inputSchema: {
363
+ user_id: z.string().optional(),
364
+ context_hint: z.string().optional(),
365
+ },
366
+ handler: async (rawInput) => {
367
+ try {
368
+ const result = await coachmod.suggest({
369
+ user_id: rawInput.user_id,
370
+ context_hint: rawInput.context_hint,
371
+ });
372
+ return asContent(result);
373
+ }
374
+ catch (e) {
375
+ return errorContent(e.message);
376
+ }
377
+ },
378
+ });
379
+ tools.push({
380
+ name: "coachmod.load",
381
+ description: "COACH-MOD §4.2 情境化复盘模块装载——渐进披露取内容。" +
382
+ "只返回请求的 ids(脑精选后传);scenario-pack id 展开其 recommends(画像+模型+subskill_prompt)。" +
383
+ "不存在的 id 报错(不静默返回空 loaded)。ids 必填。",
384
+ inputSchema: {
385
+ ids: z.array(z.string().min(1)).min(1),
386
+ },
387
+ handler: async (rawInput) => {
388
+ try {
389
+ const result = await coachmod.load({ ids: rawInput.ids });
390
+ return asContent(result);
391
+ }
392
+ catch (e) {
393
+ return errorContent(e.message);
394
+ }
395
+ },
396
+ });
332
397
  // ============================================================
333
398
  // stats (2 tools — single-object filters)
334
399
  // ============================================================
@@ -984,6 +1049,9 @@ export function buildServer(db) {
984
1049
  const reminder = createReminderTools(repos.reminderRepo, undefined, db);
985
1050
  const scheduler = createSchedulerTools(repos.timeblockRepo, repos.reminderRepo, repos.categoryRepo);
986
1051
  const task = createTaskTools(db);
1052
+ // ── COACH-MOD slice 1: knowledge repo + coachmod tools ──
1053
+ const knowledgeRepo = new FsYamlKnowledgeRepo();
1054
+ const coachmod = createCoachModTools(knowledgeRepo, db);
987
1055
  // instructions 必须放在第 2 参 ServerOptions(不是 serverInfo)—— SDK 只从 options 读
988
1056
  const server = new McpServer({ name: "libero-mcp", version: "0.2.3" }, { instructions: buildServerInstructions() });
989
1057
  // ── MC-0a: build registry once, register in a single traversal ──
@@ -1000,6 +1068,7 @@ export function buildServer(db) {
1000
1068
  reminder,
1001
1069
  scheduler,
1002
1070
  task,
1071
+ coachmod,
1003
1072
  injector,
1004
1073
  db,
1005
1074
  };
@@ -139,6 +139,21 @@ export class InMemoryTimeBlockRepo {
139
139
  async userExists(userId) {
140
140
  return this.userIds.has(userId);
141
141
  }
142
+ /** S48(2026-08-11):标记扫描追问已发。幂等保护。 */
143
+ async markAsked(ids, askedAt) {
144
+ if (!ids || ids.length === 0)
145
+ return 0;
146
+ const ts = askedAt ?? new Date().toISOString();
147
+ let changed = 0;
148
+ for (const b of this.blocks) {
149
+ if (ids.includes(b.id) && !b.deleted_at) {
150
+ b.asked_at = ts;
151
+ b.updated_at = ts;
152
+ changed++;
153
+ }
154
+ }
155
+ return changed;
156
+ }
142
157
  async findOverlapping(input) {
143
158
  return this.blocks
144
159
  .filter((b) => b.user_id === input.user_id &&
@@ -147,6 +147,18 @@ export class SqliteTimeBlockRepo {
147
147
  .get(userId);
148
148
  return !!row;
149
149
  }
150
+ /** S48(2026-08-11):标记扫描追问已发。幂等保护:每条只被 cron 主动问一次。返回受影响行数。 */
151
+ async markAsked(ids, askedAt) {
152
+ if (!ids || ids.length === 0)
153
+ return 0;
154
+ const ts = askedAt ?? new Date().toISOString();
155
+ const placeholders = ids.map(() => "?").join(", ");
156
+ const result = this.db
157
+ .prepare(`UPDATE time_blocks SET asked_at = ?, updated_at = ?
158
+ WHERE id IN (${placeholders}) AND deleted_at IS NULL`)
159
+ .run(ts, ts, ...ids);
160
+ return result.changes;
161
+ }
150
162
  async restore(id) {
151
163
  const row = this.db
152
164
  .prepare("SELECT id, deleted_at FROM time_blocks WHERE id = ?")
@@ -118,17 +118,17 @@ export function createTimeBlockTools(repo, db) {
118
118
  return repo.restore(id);
119
119
  },
120
120
  /**
121
- * S43(2026-08-07):扫描某用户过期未确认的 timeblock,分组返回。
121
+ * S43(2026-08-07)+ S48(2026-08-11):扫描某用户过期未确认的 timeblock,分组返回。
122
122
  *
123
123
  * 用途:cron-session 每 30min 调一次,让 cron LLM 显式调 timeblock.update
124
124
  * 升级状态 + 主动问。本工具**不**直接改 db(保留 brain/hand 分离 + 用户主权模型)。
125
125
  *
126
126
  * 判定逻辑(以 now 为基准):
127
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 / 未过期 / 软删的 → 都不进
128
+ * - end_time + overdue_min_to_ask (默认 60min) 仍 unverified/planned,**且 asked_at IS NULL**(S48:每条只问一次)→ to_ask
129
+ * - completed / 未过期 / 软删 / asked_at 已标记的 → 都不进 to_ask
130
130
  *
131
- * §4.5 静默折叠声明:纯查询,无副作用,无折叠。
131
+ * §4.5 静默折叠声明:纯查询,无副作用,无折叠。caller 发完追问后必须调 markAsked 幂等标记。
132
132
  */
133
133
  async sweepStale(input) {
134
134
  if (!input.user_id) {
@@ -167,7 +167,8 @@ export function createTimeBlockTools(repo, db) {
167
167
  end_time: b.end_time,
168
168
  });
169
169
  }
170
- if (overdueAsk && (b.status === "unverified" || b.status === "planned")) {
170
+ // S48 幂等:asked_at 已标记的不再进 to_ask(每条只被扫描 cron 主动问一次)
171
+ if (overdueAsk && (b.status === "unverified" || b.status === "planned") && !b.asked_at) {
171
172
  to_ask.push({
172
173
  id: b.id,
173
174
  title: b.title,
@@ -179,5 +180,16 @@ export function createTimeBlockTools(repo, db) {
179
180
  }
180
181
  return { to_unverified, to_ask, now: nowIso };
181
182
  },
183
+ /**
184
+ * S48(2026-08-11):标记扫描追问已发——幂等保护。
185
+ * caller(cron-scan skill)发完追问后必须调用,写入 asked_at 时间戳。
186
+ * 返回受影响行数(用于 caller 自检)。无副作用之外的折叠(asked_at 永久保留作审计)。
187
+ */
188
+ async markAsked(ids) {
189
+ if (!ids || ids.length === 0)
190
+ return { marked: 0 };
191
+ const marked = await repo.markAsked(ids);
192
+ return { marked };
193
+ },
182
194
  };
183
195
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libero-mcp",
3
- "version": "0.2.21",
3
+ "version": "0.2.22",
4
4
  "description": "AI 时间管理教练 MCP 工具集——时间块记录、统计、矩阵诊断、计时、分类、profile",
5
5
  "license": "MIT",
6
6
  "author": "amosyuan",