dsh-worktodo 0.1.3 → 0.2.0

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,170 @@
1
+ /**
2
+ * 自然语言日期解析:从一句人话里认出「截止日期 / 时间」,并把认出来的词从标题里摘掉。
3
+ *
4
+ * 设计原则(保守优先):
5
+ * 1. 只认**明确的日期词**,宁可漏也不误吞("买3个苹果" 里的 3 不能变成"3 天后");
6
+ * 2. 出现多个星期几时取**最后**一个("周一例会改到周三" → 周三);
7
+ * 3. 结果的每一处都能追溯到原文片段(matched),人类侧据此显示"从标题识别"并可撤销;
8
+ * 4. 与 `normalizeDue` 的既有说法保持兼容(今天/明天/昨天/+3d/本周)。
9
+ *
10
+ * 人类面板与 worktodo_* 工具共用这一份实现(本文件零依赖,可直接被打进客户端 bundle),
11
+ * 避免两边理解不一致。
12
+ */
13
+ const DAY_MS = 24 * 60 * 60 * 1000;
14
+
15
+ /** 本地时区的 YYYY-MM-DD(与 records.mjs 的 dayKey 同语义)。 */
16
+ function dayKey(stamp) {
17
+ const d = new Date(stamp);
18
+ const pad = (n) => String(n).padStart(2, '0');
19
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
20
+ }
21
+
22
+ /** 所在周的周一(本地时区,与 records.mjs 的 weekStartKey 同语义)。 */
23
+ function weekStartKey(stamp) {
24
+ const d = new Date(stamp);
25
+ const day = (d.getDay() + 6) % 7; // 周一=0
26
+ const monday = new Date(d.getFullYear(), d.getMonth(), d.getDate() - day);
27
+ return dayKey(monday.getTime());
28
+ }
29
+
30
+
31
+ const CN_NUM = { 零: 0, 一: 1, 两: 2, 二: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9, 十: 10 };
32
+ const WEEKDAY = { 一: 1, 二: 2, 三: 3, 四: 4, 五: 5, 六: 6, 日: 7, 天: 7 };
33
+ const PERIOD_HOUR = { 今早: 9, 早上: 9, 早晨: 9, 上午: 9, 中午: 12, 下午: 15, 傍晚: 18, 今晚: 20, 明晚: 20, 晚上: 20, 晚: 20, 夜里: 21 };
34
+
35
+ const toCnNumber = (raw) => {
36
+ if (/^\d+$/.test(raw)) return Number(raw);
37
+ if (raw === '十') return 10;
38
+ if (raw.startsWith('十')) return 10 + (CN_NUM[raw[1]] ?? 0);
39
+ if (raw.includes('十')) {
40
+ const [a, b] = raw.split('十');
41
+ return (CN_NUM[a] ?? 1) * 10 + (CN_NUM[b] ?? 0);
42
+ }
43
+ return CN_NUM[raw] ?? null;
44
+ };
45
+
46
+ /**
47
+ * @param {string} text 用户输入
48
+ * @param {number} [now] 基准时刻
49
+ * @returns {{title:string, due:string|null, dueTime:string|null, matched:string[]}}
50
+ */
51
+ export function parseNaturalDue(text, now = Date.now()) {
52
+ const source = typeof text === 'string' ? text : '';
53
+ const spans = []; // {start, end, kind:'date'|'time'}
54
+ let due = null;
55
+ let dueTime = null;
56
+ let hourFromPeriod = null; // 「今晚」这类只给时段,兜底时间
57
+
58
+ const today = dayKey(now);
59
+ const todayStart = new Date(`${today}T00:00:00`).getTime();
60
+
61
+ // ---------- 1) 绝对日期:2026-10-20 / 10月20日 / 10/20 ----------
62
+ let m = /(\d{4})[-/年](\d{1,2})[-/月](\d{1,2})日?/.exec(source);
63
+ if (m) {
64
+ due = dayKey(new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])).getTime());
65
+ spans.push({ start: m.index, end: m.index + m[0].length, kind: 'date' });
66
+ } else {
67
+ m = /(\d{1,2})月(\d{1,2})日?/.exec(source);
68
+ if (m) {
69
+ // 省略年份:取「最近一次」(已过去就顺延到明年)
70
+ let year = new Date(now).getFullYear();
71
+ let candidate = dayKey(new Date(year, Number(m[1]) - 1, Number(m[2])).getTime());
72
+ if (candidate < today) candidate = dayKey(new Date(year + 1, Number(m[1]) - 1, Number(m[2])).getTime());
73
+ due = candidate;
74
+ spans.push({ start: m.index, end: m.index + m[0].length, kind: 'date' });
75
+ }
76
+ }
77
+
78
+ // ---------- 2) 星期:周X / 星期X / 礼拜X / 下周一(多个取最后一个) ----------
79
+ const weekRe = /(下下|下个|下|本|这)?(?:周|星期|礼拜)([一二三四五六日天1-7])/g;
80
+ for (let w = weekRe.exec(source); w; w = weekRe.exec(source)) {
81
+ const offsetWeeks = w[1] === '下' || w[1] === '下个' ? 1 : w[1] === '下下' ? 2 : 0;
82
+ const target = WEEKDAY[w[2]] ?? Number(w[2]);
83
+ const thisWeekStart = new Date(`${weekStartKey(now)}T00:00:00`).getTime();
84
+ let stamp = thisWeekStart + (target - 1) * DAY_MS + offsetWeeks * 7 * DAY_MS;
85
+ if (offsetWeeks === 0 && stamp < todayStart) stamp += 7 * DAY_MS; // 已过则算下周
86
+ const candidate = dayKey(stamp);
87
+ // 后面的星期几覆盖前面的("改到周三"的语义)
88
+ if (due === null || spans.some((s) => s.kind === 'date' && s.weekday)) {
89
+ spans.filter((s) => s.weekday).forEach((s) => { s.dropped = true; });
90
+ }
91
+ due = candidate;
92
+ spans.push({ start: w.index, end: w.index + w[0].length, kind: 'date', weekday: true });
93
+ }
94
+
95
+ // ---------- 3) 相对天数:今天/明天/后天/大后天/3天后/+3d ----------
96
+ if (due === null) {
97
+ const relWords = { 今天: 0, 今日: 0, 明天: 1, 明日: 1, 后天: 2, 大后天: 3, 昨天: -1, 昨: -1 };
98
+ for (const [word, offset] of Object.entries(relWords)) {
99
+ const at = source.indexOf(word);
100
+ if (at !== -1) {
101
+ due = dayKey(now + offset * DAY_MS);
102
+ spans.push({ start: at, end: at + word.length, kind: 'date' });
103
+ break;
104
+ }
105
+ }
106
+ }
107
+ if (due === null) {
108
+ const plus = /\+(\d{1,3})\s*d(?:ay)?s?/.exec(source);
109
+ const cn = /([0-9零一两二三四五六七八九十]+)\s*天\s*(?:后|以后|之后)/.exec(source);
110
+ if (plus) {
111
+ due = dayKey(now + Number(plus[1]) * DAY_MS);
112
+ spans.push({ start: plus.index, end: plus.index + plus[0].length, kind: 'date' });
113
+ } else if (cn) {
114
+ const n = toCnNumber(cn[1]);
115
+ if (n !== null) {
116
+ due = dayKey(now + n * DAY_MS);
117
+ spans.push({ start: cn.index, end: cn.index + cn[0].length, kind: 'date' });
118
+ }
119
+ }
120
+ }
121
+
122
+ // ---------- 4) 时段词:今晚 / 明早 / 下午 / 早上 ----------
123
+ const periodRe = /(今晚|明晚|明早|早上|早晨|上午|中午|下午|傍晚|晚上|夜里)/;
124
+ const period = periodRe.exec(source);
125
+ if (period) {
126
+ hourFromPeriod = PERIOD_HOUR[period[1]] ?? null;
127
+ if (due === null) {
128
+ if (period[1] === '今晚' || period[1] === '明晚') due = dayKey(now + (period[1] === '明晚' ? 1 : 0) * DAY_MS);
129
+ if (period[1] === '明早') due = dayKey(now + DAY_MS);
130
+ }
131
+ spans.push({ start: period.index, end: period.index + period[1].length, kind: 'time' });
132
+ }
133
+
134
+ // ---------- 5) 具体时间:下午3点 / 3点半 / 15:00 / 上午10点 ----------
135
+ const hm = /(\d{1,2})[::](\d{2})/.exec(source);
136
+ const cnHour = /([0-9零一两二三四五六七八九十]+)\s*[点时](半|[0-9零一两二三四五六七八九十]{1,3}分)?/.exec(source);
137
+ if (hm) {
138
+ const h = Number(hm[1]);
139
+ if (h <= 23 && Number(hm[2]) <= 59) dueTime = `${String(h).padStart(2, '0')}:${hm[2]}`;
140
+ spans.push({ start: hm.index, end: hm.index + hm[0].length, kind: 'time' });
141
+ } else if (cnHour) {
142
+ let h = toCnNumber(cnHour[1]);
143
+ if (h !== null && h <= 23) {
144
+ if (h <= 12 && hourFromPeriod !== null && hourFromPeriod >= 12) h = h === 12 ? 12 : h + 12; // 下午3点 → 15
145
+ const minute = cnHour[2] === '半' ? 30 : cnHour[2] ? (toCnNumber(cnHour[2].replace('分', '')) ?? 0) : 0;
146
+ if (minute <= 59) dueTime = `${String(h).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
147
+ spans.push({ start: cnHour.index, end: cnHour.index + cnHour[0].length, kind: 'time' });
148
+ }
149
+ } else if (hourFromPeriod !== null) {
150
+ dueTime = `${String(hourFromPeriod).padStart(2, '0')}:00`; // 只有时段词 → 用该时段默认钟点
151
+ }
152
+
153
+ // 只认到时间、没认到日期 → 今天(用户决定④)
154
+ if (dueTime && !due) due = today;
155
+
156
+ // ---------- 6) 摘除:把认出来的片段从标题里去掉 ----------
157
+ const kept = spans.filter((s) => !s.dropped).sort((a, b) => a.start - b.start);
158
+ let title = source;
159
+ for (const span of [...kept].reverse()) {
160
+ title = title.slice(0, span.start) + ' ' + title.slice(span.end);
161
+ }
162
+ title = title.replace(/\s+/g, ' ').trim();
163
+
164
+ return {
165
+ title: title || source.trim(),
166
+ due,
167
+ dueTime,
168
+ matched: kept.map((s) => source.slice(s.start, s.end)),
169
+ };
170
+ }
package/src/records.mjs CHANGED
@@ -16,7 +16,7 @@
16
16
  /** 状态:与官方 todo_write 同名同义,便于互相提升。 */
17
17
  export const STATUSES = ['pending', 'in_progress', 'completed'];
18
18
  /** 优先级:只留三档,避免用户在选择上犹豫。 */
19
- export const PRIORITIES = ['high', 'normal', 'low'];
19
+ export const PRIORITIES = ['high', 'normal', 'low', 'none'];
20
20
  /** 来源:人加的 / agent 加的 / 从会话 todos 提升的。 */
21
21
  export const SOURCES = ['human', 'agent', 'session'];
22
22
 
@@ -147,6 +147,7 @@ export function normalizePriority(value) {
147
147
  p0: 'high', urgent: 'high', high: 'high', 高: 'high', 紧急: 'high', 重要: 'high',
148
148
  p1: 'normal', normal: 'normal', medium: 'normal', mid: 'normal', 中: 'normal', 普通: 'normal',
149
149
  p2: 'low', low: 'low', 低: 'low', 次要: 'low',
150
+ none: 'none', no: 'none', 无: 'none', 不设: 'none', 无优先级: 'none',
150
151
  };
151
152
  const resolved = alias[raw];
152
153
  if (!resolved) throw new WorkTodoError('bad-priority', `优先级只能是 ${PRIORITIES.join(' / ')},收到 ${JSON.stringify(value)}`);
@@ -202,6 +203,25 @@ export function normalizeDue(value, now = Date.now()) {
202
203
  * @param {number} now
203
204
  * @returns {string}
204
205
  */
206
+ /**
207
+ * 截止时间(可选,只有配了截止日期才有意义):接受 "14:30" / "9:05" / "1430" / "14"。
208
+ * 空值(undefined/null/''/none)返回 null;写坏了抛错,别悄悄丢掉用户填的时间。
209
+ * @param {unknown} value
210
+ * @returns {string|null} HH:MM
211
+ */
212
+ export function normalizeDueTime(value) {
213
+ if (value === undefined || value === null || value === '') return null;
214
+ if (typeof value !== 'string') throw new WorkTodoError('bad-due-time', '截止时间必须是文本');
215
+ const raw = value.trim().toLowerCase();
216
+ if (!raw || raw === 'none' || raw === 'null' || raw === 'clear') return null;
217
+ const m = /^(\d{1,2})(?::?(\d{2}))?$/.exec(raw);
218
+ if (!m) throw new WorkTodoError('bad-due-time', '截止时间要写成 HH:MM(例如 14:30)');
219
+ const hour = Number(m[1]);
220
+ const minute = m[2] === undefined ? 0 : Number(m[2]);
221
+ if (hour > 23 || minute > 59) throw new WorkTodoError('bad-due-time', '截止时间超出范围(00:00–23:59)');
222
+ return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
223
+ }
224
+
205
225
  export function newId(now = Date.now(), rand = Math.random) {
206
226
  const t = Math.floor(now).toString(36);
207
227
  const r = Math.floor(rand() * 36 ** 4).toString(36).padStart(4, '0');
@@ -220,13 +240,16 @@ export function newId(now = Date.now(), rand = Math.random) {
220
240
  export function createRecord(input = {}, { now = Date.now(), source = 'human', id } = {}) {
221
241
  if (!SOURCES.includes(source)) throw new WorkTodoError('bad-source', `来源只能是 ${SOURCES.join(' / ')}`);
222
242
  const status = normalizeStatus(input.status) ?? 'pending';
243
+ const due = normalizeDue(input.due, now);
223
244
  const record = {
224
245
  id: id ?? newId(now),
225
246
  title: normalizeTitle(input.title),
226
247
  notes: normalizeNotes(input.notes),
227
248
  status,
228
- priority: normalizePriority(input.priority) ?? 'normal',
229
- due: normalizeDue(input.due, now),
249
+ priority: normalizePriority(input.priority) ?? 'none',
250
+ due,
251
+ // 没有截止日期就没有截止时间(避免出现「有时间没日期」的怪状态)
252
+ dueTime: due ? normalizeDueTime(input.dueTime) : null,
230
253
  project: normalizeProject(input.project),
231
254
  order: null,
232
255
  source,
@@ -254,7 +277,16 @@ export function patchRecord(record, patch = {}, now = Date.now()) {
254
277
  if (take('notes')) { next.notes = normalizeNotes(patch.notes); touched = true; }
255
278
  if (take('project')) { next.project = normalizeProject(patch.project); touched = true; }
256
279
  if (take('priority')) { next.priority = normalizePriority(patch.priority) ?? next.priority; touched = true; }
257
- if (take('due')) { next.due = normalizeDue(patch.due, now); touched = true; }
280
+ if (take('due')) {
281
+ next.due = normalizeDue(patch.due, now);
282
+ if (!next.due) next.dueTime = null; // 清掉日期时时间一并清掉
283
+ touched = true;
284
+ }
285
+ if (take('dueTime')) {
286
+ // 只给时间不给日期是无效的:没有日期就存 null
287
+ next.dueTime = next.due ? normalizeDueTime(patch.dueTime) : null;
288
+ touched = true;
289
+ }
258
290
 
259
291
  const status = normalizeStatus(patch.status);
260
292
  if (status && status !== next.status) {
@@ -306,7 +338,8 @@ export function resolveRef(records, ref) {
306
338
  * ------------------------------------------------------------------ */
307
339
 
308
340
  /** 优先级权重,用于排序(高的在前)。 */
309
- const PRIORITY_WEIGHT = { high: 0, normal: 1, low: 2 };
341
+ // 「无」排在最后(未定优先级的不该挡在已定优先级的前面),但仍排在「已完成」之前
342
+ const PRIORITY_WEIGHT = { high: 0, normal: 1, low: 2, none: 3 };
310
343
 
311
344
  /**
312
345
  * 视图排序:未完成在前;同组内 优先级 → 截止日期(无日期排最后)→ 创建时间。
@@ -330,6 +363,11 @@ export function sortForView(records, mode = 'priority') {
330
363
  const pb = PRIORITY_WEIGHT[b.priority] ?? 1;
331
364
  if (pa !== pb) return pa - pb;
332
365
  if (a.due && b.due && a.due !== b.due) return a.due < b.due ? -1 : 1;
366
+ if (a.due === b.due) {
367
+ const ta = a.dueTime ?? '99:99'; // 同一天:有时间的在前,没时间的垫后
368
+ const tb = b.dueTime ?? '99:99';
369
+ if (ta !== tb) return ta < tb ? -1 : 1;
370
+ }
333
371
  if (a.due && !b.due) return -1;
334
372
  if (!a.due && b.due) return 1;
335
373
  return a.createdAt - b.createdAt;
@@ -489,7 +527,7 @@ export function filterRecords(records, filter = 'open', now = Date.now()) {
489
527
  * ------------------------------------------------------------------ */
490
528
 
491
529
  /** 优先级在文本里的记号:让模型一眼看出轻重,且不占多少 token。 */
492
- const PRIORITY_MARK = { high: '!', normal: '', low: '·' };
530
+ const PRIORITY_MARK = { high: '!', normal: '', low: '·', none: '' };
493
531
 
494
532
  /**
495
533
  * 把一条记录渲染成单行文本,例如:
@@ -503,10 +541,11 @@ export function formatLine(record, now = Date.now()) {
503
541
  const box = record.status === 'completed' ? '[x]' : record.status === 'in_progress' ? '[~]' : '[ ]';
504
542
  const bits = [];
505
543
  if (record.priority === 'high') bits.push('高');
544
+ else if (record.priority === 'normal') bits.push('中');
506
545
  else if (record.priority === 'low') bits.push('低');
507
546
  if (record.due) {
508
547
  const overdue = record.status !== 'completed' && record.due < today;
509
- bits.push(`${overdue ? '已逾期 ' : '截止 '}${record.due}`);
548
+ bits.push(`${overdue ? '已逾期 ' : '截止 '}${record.due}${record.dueTime ? ` ${record.dueTime}` : ''}`);
510
549
  }
511
550
  if (record.project) bits.push(`#${record.project}`);
512
551
  const suffix = bits.length ? ` (${bits.join(', ')})` : '';
package/src/schema.mjs CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  dayKey,
20
20
  newId,
21
21
  normalizeDue,
22
+ normalizeDueTime,
22
23
  normalizePriority,
23
24
  normalizeStatus,
24
25
  } from './records.mjs';
@@ -63,7 +64,7 @@ export function repairRecord(raw) {
63
64
  const id = typeof raw.id === 'string' && raw.id.trim() ? raw.id.trim() : newId();
64
65
  // 状态/优先级/日期都过一遍同义词归一:手改成 done / 高 / 2026/10/20 也要能懂
65
66
  const status = STATUSES.includes(raw.status) ? raw.status : coerce(() => normalizeStatus(raw.status), 'pending');
66
- const priority = PRIORITIES.includes(raw.priority) ? raw.priority : coerce(() => normalizePriority(raw.priority), 'normal');
67
+ const priority = PRIORITIES.includes(raw.priority) ? raw.priority : coerce(() => normalizePriority(raw.priority), 'none');
67
68
  const source = SOURCES.includes(raw.source) ? raw.source : 'human';
68
69
  const createdAt = Number.isFinite(raw.createdAt) ? Number(raw.createdAt) : Date.now();
69
70
  const updatedAt = Number.isFinite(raw.updatedAt) ? Number(raw.updatedAt) : createdAt;
@@ -81,6 +82,10 @@ export function repairRecord(raw) {
81
82
  priority,
82
83
  // 相对日期(today/+3d)按「读到的这一刻」折算:手改文件的人想的当然是今天
83
84
  due: typeof raw.due === 'string' ? coerce(() => normalizeDue(raw.due, Date.now()), null) : null,
85
+ // 时间只在有日期时保留(同 createRecord/patchRecord 的约定)
86
+ dueTime: typeof raw.dueTime === 'string' && typeof raw.due === 'string'
87
+ ? coerce(() => normalizeDueTime(raw.dueTime), null)
88
+ : null,
84
89
  project: typeof raw.project === 'string' && raw.project.trim() ? raw.project.trim() : null,
85
90
  order,
86
91
  source,
package/src/store.mjs CHANGED
@@ -209,7 +209,10 @@ export async function openWorkTodoStore({ ctx, config = {}, logger } = {}) {
209
209
 
210
210
  // ctx.get 对未提供的服务返回 undefined,不抛错;storage 服务可能晚于本插件注册,
211
211
  // 但 dsh-base 的层序在 profile 组合之前,所以这里探测到不存在时才回退。
212
- const storageDomain = explicitPath ? undefined : ctx?.get?.('storageDomain') ?? ctx?.storageDomain;
212
+ // ⚠️ 只用 ctx.get 探测,**绝不**退回 `ctx.storageDomain`:直接访问未在 inject 里声明的服务
213
+ // 会被 cordis 守卫抛错("cannot get property … without inject"),而那个异常会让整个插件挂载失败——
214
+ // 实测插件「时好时坏」就是这里(服务尚未注册时 ctx.get 返回 undefined,兜底访问随即抛错)
215
+ const storageDomain = explicitPath ? undefined : ctx?.get?.('storageDomain');
213
216
 
214
217
  if (storageDomain && typeof storageDomain.open === 'function') {
215
218
  try {
package/src/tools.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { parseNaturalDue } from './parse-due.mjs';
2
+
1
3
  /**
2
4
  * 面向模型的 worktodo_* 工具。
3
5
  *
@@ -90,6 +92,7 @@ export async function registerWorkTodoTools({ ctx, service, logger }) {
90
92
  parameters: {
91
93
  title: { type: 'string', required: true, description: '待办内容,一句简短的祈使句,例如「把 v0.1.0 发到 npm」。' },
92
94
  due: { type: 'string', description: '截止日期:YYYY-MM-DD / today / tomorrow / +3d。省略表示没有截止日期。' },
95
+ dueTime: { type: 'string', description: '截止时间 HH:MM(可选,只有给了 due 才有意义;例如 14:30)。' },
93
96
  priority: { type: 'string', enum: ['high', 'normal', 'low'], description: '优先级,默认 normal。真正卡住别人的事才给 high。' },
94
97
  project: { type: 'string', description: '项目或领域标签,例如 dsh-worktodo。省略表示不归类。' },
95
98
  notes: { type: 'string', description: '补充说明:背景、链接、验收标准。默认空。' },
@@ -103,6 +106,7 @@ export async function registerWorkTodoTools({ ctx, service, logger }) {
103
106
  id: { type: 'string', required: true },
104
107
  title: { type: 'string', required: true },
105
108
  due: { type: 'string' },
109
+ dueTime: { type: 'string' },
106
110
  priority: { type: 'string', required: true },
107
111
  project: { type: 'string' },
108
112
  status: { type: 'string', required: true },
@@ -111,15 +115,16 @@ export async function registerWorkTodoTools({ ctx, service, logger }) {
111
115
  },
112
116
  render: (_args, value) => [{
113
117
  type: 'text',
114
- text: `已记入工作待办:${value.title}(${value.id}${value.due ? `,截止 ${value.due}` : ''})`
118
+ text: `已记入工作待办:${value.title}(${value.id}${value.due ? `,截止 ${value.due}${value.dueTime ? ` ${value.dueTime}` : ''}` : ''})`
115
119
  + (value.stats ? `|当前未完成 ${value.stats.open} 条,逾期 ${value.stats.overdue} 条` : ''),
116
120
  }],
117
121
  },
118
122
  execute(args) {
119
- return service.create(args, { source: 'agent' }).then(unwrap).then((result) => withStats(compact({
123
+ return service.create(withParsedDue(args), { source: 'agent' }).then(unwrap).then((result) => withStats(compact({
120
124
  id: result.todo.id,
121
125
  title: result.todo.title,
122
126
  due: result.todo.due ?? undefined,
127
+ dueTime: result.todo.dueTime ?? undefined,
123
128
  priority: result.todo.priority,
124
129
  project: result.todo.project ?? undefined,
125
130
  status: result.todo.status,
@@ -145,6 +150,7 @@ export async function registerWorkTodoTools({ ctx, service, logger }) {
145
150
  properties: {
146
151
  title: { type: 'string', required: true, description: '待办内容(一句简短的祈使句)。' },
147
152
  due: { type: 'string', description: '截止日期:YYYY-MM-DD / today / tomorrow / +3d。' },
153
+ dueTime: { type: 'string', description: '截止时间 HH:MM(可选)。' },
148
154
  priority: { type: 'string', enum: ['high', 'normal', 'low'], description: '默认 normal。' },
149
155
  project: { type: 'string', description: '项目或领域标签。' },
150
156
  notes: { type: 'string', description: '背景、链接、验收标准。' },
@@ -169,6 +175,7 @@ export async function registerWorkTodoTools({ ctx, service, logger }) {
169
175
  id: { type: 'string', required: true },
170
176
  title: { type: 'string', required: true },
171
177
  due: { type: 'string' },
178
+ dueTime: { type: 'string' },
172
179
  priority: { type: 'string', required: true },
173
180
  project: { type: 'string' },
174
181
  status: { type: 'string', required: true },
@@ -204,11 +211,12 @@ export async function registerWorkTodoTools({ ctx, service, logger }) {
204
211
  const items = Array.isArray(args.items) ? args.items : [];
205
212
  for (const [index, item] of items.entries()) {
206
213
  try {
207
- const result = unwrap(await service.create(item ?? {}, { source: 'agent' }));
214
+ const result = unwrap(await service.create(withParsedDue(item ?? {}), { source: 'agent' }));
208
215
  created.push(compact({
209
216
  id: result.todo.id,
210
217
  title: result.todo.title,
211
218
  due: result.todo.due ?? undefined,
219
+ dueTime: result.todo.dueTime ?? undefined,
212
220
  priority: result.todo.priority,
213
221
  project: result.todo.project ?? undefined,
214
222
  status: result.todo.status,
@@ -261,7 +269,7 @@ export async function registerWorkTodoTools({ ctx, service, logger }) {
261
269
  lines: result.todos.length
262
270
  ? result.todos.map((t) => {
263
271
  const box = t.status === 'completed' ? '[x]' : t.status === 'in_progress' ? '[~]' : '[ ]';
264
- const bits = [t.priority === 'high' ? '高' : t.priority === 'low' ? '低' : null, t.due ? `${t.due}${t.due < result.today && t.status !== 'completed' ? '(逾期)' : ''}` : null, t.project ? `#${t.project}` : null].filter(Boolean);
272
+ const bits = [t.priority === 'high' ? '高' : t.priority === 'low' ? '低' : null, t.due ? `${t.due}${t.dueTime ? ` ${t.dueTime}` : ''}${t.due < result.today && t.status !== 'completed' ? '(逾期)' : ''}` : null, t.project ? `#${t.project}` : null].filter(Boolean);
265
273
  return `${box} ${t.id} ${t.title}${bits.length ? ` (${bits.join(', ')})` : ''}`;
266
274
  }).join('\n')
267
275
  : '(没有符合条件的待办)',
@@ -282,7 +290,8 @@ export async function registerWorkTodoTools({ ctx, service, logger }) {
282
290
  parameters: {
283
291
  ref: { type: 'string', required: true, description: '待办的 id(推荐)或标题。' },
284
292
  title: { type: 'string', description: '新标题。' },
285
- due: { type: 'string', description: '新截止日期;传 none 清空。' },
293
+ due: { type: 'string', description: '新截止日期;传 none 清空(清空日期会一并清掉时间)。' },
294
+ dueTime: { type: 'string', description: '新截止时间 HH:MM;传 none 清空。' },
286
295
  priority: { type: 'string', enum: ['high', 'normal', 'low'] },
287
296
  project: { type: 'string', description: '新项目标签;传 none 清空。' },
288
297
  notes: { type: 'string', description: '新备注。' },
@@ -447,4 +456,23 @@ export async function registerWorkTodoTools({ ctx, service, logger }) {
447
456
  }
448
457
  },
449
458
  };
459
+ }/**
460
+ * 让工具也能"说人话":标题里写了「明天下午三点」这类说法时,交给与面板**同一份**解析器
461
+ * (`src/parse-due.mjs`)。显式传了 `due` 就以显式为准,绝不被标题覆盖。
462
+ * @param {object} input
463
+ * @returns {object} 落库用的参数(标题里的日期词会被摘掉)
464
+ */
465
+ export function withParsedDue(input = {}) {
466
+ const explicit = input.due !== undefined && input.due !== null && input.due !== '';
467
+ if (explicit) return input;
468
+ const parsed = parseNaturalDue(String(input.title ?? ''), Date.now());
469
+ if (!parsed.matched.length) return input;
470
+ return {
471
+ ...input,
472
+ title: parsed.title,
473
+ due: parsed.due ?? undefined,
474
+ dueTime: input.dueTime ?? parsed.dueTime ?? undefined,
475
+ };
450
476
  }
477
+
478
+