dsh-later 1.0.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,531 @@
1
+ import { userScheduleCreate, userScheduleDelete, userScheduleEditPrompt, userScheduleList, resolvePromptLimits, } from './user-tools.js';
2
+ import { DEFAULT_MAX_SCHEDULES } from './user-tools.js';
3
+ import { epochFromLocal, localFieldsOf } from './smart-window.js';
4
+ import { detectTimeZone, formatAbsolute, formatRelative } from './time-utils.js';
5
+ /** 命令名前缀(避免与其他插件命令冲突)。 */
6
+ const PREFIX = 'user-schedule';
7
+ /** 把任意异常稳定渲染为命令错误文本。 */
8
+ function renderError(message) {
9
+ return { kind: 'error', text: message };
10
+ }
11
+ /** 解析 JSON 载荷;非法返回错误描述。 */
12
+ function parsePayload(rawInput) {
13
+ const text = rawInput.trim();
14
+ if (text.length === 0)
15
+ return { ok: false, text: '缺少 JSON 载荷。' };
16
+ try {
17
+ const value = JSON.parse(text);
18
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
19
+ return { ok: false, text: 'JSON 载荷必须是一个对象。' };
20
+ }
21
+ return { ok: true, value };
22
+ }
23
+ catch {
24
+ return { ok: false, text: 'JSON 载荷解析失败。' };
25
+ }
26
+ }
27
+ /** 中文数字 → 数值(支持 十/两/半 由调用方特判)。 */
28
+ const CN_DIGIT = { 零: 0, 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9 };
29
+ function cnToNumber(text) {
30
+ if (/^\d+$/.test(text))
31
+ return Number(text);
32
+ const tenIdx = text.indexOf('十');
33
+ if (tenIdx >= 0) {
34
+ const head = text.slice(0, tenIdx);
35
+ const tail = text.slice(tenIdx + 1);
36
+ const headVal = head === '' ? 1 : CN_DIGIT[head];
37
+ if (headVal === undefined)
38
+ return undefined;
39
+ let total = headVal * 10;
40
+ if (tail !== '') {
41
+ const tailVal = CN_DIGIT[tail];
42
+ if (tailVal === undefined)
43
+ return undefined;
44
+ total += tailVal;
45
+ }
46
+ return total;
47
+ }
48
+ let total = 0;
49
+ for (const ch of text) {
50
+ const d = CN_DIGIT[ch];
51
+ if (d === undefined)
52
+ return undefined;
53
+ total = total * 10 + d;
54
+ }
55
+ return total;
56
+ }
57
+ /** 归一化:全角冒号/数字 → 半角,压缩空白。 */
58
+ function normalizeInput(raw) {
59
+ return raw
60
+ .replace(/[:]/g, ':')
61
+ .replace(/[0-9]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xfee0))
62
+ .replace(/\s+/g, ' ')
63
+ .trim();
64
+ }
65
+ /** 相对时长的中文单位秒数。 */
66
+ const CN_UNIT_SECONDS = {
67
+ 秒: 1, 秒钟: 1, 分: 60, 分钟: 60, 小时: 3600, 个小时: 3600, 钟头: 3600, 天: 86400, 日: 86400, 周: 604800,
68
+ };
69
+ /** 在归一化文本开头尝试匹配一个时间表达式;失败返回 undefined。 */
70
+ function matchTimeExpr(s) {
71
+ // 1. 拉丁相对时长:+30m / +1h30m / +90s / +1w(后面可跟内容)
72
+ const latinRel = /^\+\s*((?:\d+\s*(?:s|m|h|d|w)\s*)+)(?![0-9a-z])/i.exec(s);
73
+ if (latinRel !== null) {
74
+ let total = 0;
75
+ const unitRe = /(\d+)\s*(s|m|h|d|w)/gi;
76
+ for (const match of (latinRel[1] ?? '').matchAll(unitRe)) {
77
+ const n = Number(match[1]);
78
+ const u = (match[2] ?? 'm').toLowerCase();
79
+ total += n * (u === 's' ? 1 : u === 'm' ? 60 : u === 'h' ? 3600 : u === 'd' ? 86400 : 604800);
80
+ }
81
+ if (total <= 0)
82
+ return undefined;
83
+ return { consumed: latinRel[0].length, target: { kind: 'after', afterSeconds: total } };
84
+ }
85
+ // 2. 中文相对时长:30分钟后 / 半小时后 / 两小时后 / 1天以后
86
+ const cnRel = /^(半|\d+|[零一二两三四五六七八九十]+)\s*(个\s*小时|秒钟?|分钟?|小时|钟头|天|日|周)\s*(?:之)?后?/.exec(s);
87
+ if (cnRel !== null) {
88
+ const value = cnRel[1] === '半' ? 0.5 : cnToNumber(cnRel[1] ?? '');
89
+ const unitKey = (cnRel[2] ?? '').replace(/\s+/g, '');
90
+ const unitSeconds = CN_UNIT_SECONDS[unitKey];
91
+ if (value === undefined || value <= 0 || unitSeconds === undefined)
92
+ return undefined;
93
+ return {
94
+ consumed: cnRel[0].length,
95
+ target: { kind: 'after', afterSeconds: Math.round(value * unitSeconds) },
96
+ };
97
+ }
98
+ // 3. 紧凑日期:YYYYMMDD-HHMM / MMDD-HHMM(显式日期,不自动顺延)
99
+ const compactFull = /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(?!\d)/.exec(s);
100
+ if (compactFull !== null) {
101
+ return {
102
+ consumed: compactFull[0].length,
103
+ target: {
104
+ kind: 'at',
105
+ year: Number(compactFull[1]), month: Number(compactFull[2]), day: Number(compactFull[3]),
106
+ hour: Number(compactFull[4]), minute: Number(compactFull[5]),
107
+ },
108
+ };
109
+ }
110
+ const compactShort = /^(\d{2})(\d{2})-(\d{2})(\d{2})(?!\d)/.exec(s);
111
+ if (compactShort !== null) {
112
+ return {
113
+ consumed: compactShort[0].length,
114
+ target: {
115
+ kind: 'at',
116
+ month: Number(compactShort[1]), day: Number(compactShort[2]),
117
+ hour: Number(compactShort[3]), minute: Number(compactShort[4]),
118
+ },
119
+ };
120
+ }
121
+ // 4. 通用组合:[今天|明天|后天|大后天] [日期] 时刻
122
+ let i = 0;
123
+ let dayOffset;
124
+ const dayWords = [['大后天', 3], ['后天', 2], ['明天', 1], ['明日', 1], ['今天', 0], ['今日', 0]];
125
+ for (const [word, offset] of dayWords) {
126
+ if (s.startsWith(word)) {
127
+ dayOffset = offset;
128
+ i += word.length;
129
+ break;
130
+ }
131
+ }
132
+ while (s[i] === ' ')
133
+ i += 1;
134
+ let year;
135
+ let month;
136
+ let day;
137
+ const rest1 = s.slice(i);
138
+ const fullDate = /^(\d{4})[-/年](\d{1,2})[-/月](\d{1,2})[日号]?/.exec(rest1);
139
+ if (fullDate !== null) {
140
+ year = Number(fullDate[1]);
141
+ month = Number(fullDate[2]);
142
+ day = Number(fullDate[3]);
143
+ i += fullDate[0].length;
144
+ }
145
+ else {
146
+ const shortDate = /^(\d{1,2})[月/-](\d{1,2})[日号]?/.exec(rest1);
147
+ if (shortDate !== null && (shortDate[0].includes('月') || shortDate[0].includes('-') || shortDate[0].includes('/'))) {
148
+ month = Number(shortDate[1]);
149
+ day = Number(shortDate[2]);
150
+ i += shortDate[0].length;
151
+ }
152
+ }
153
+ while (s[i] === ' ')
154
+ i += 1;
155
+ // 时刻:15:32 / 15时32分 / 9点 / 9点半 / 9点整
156
+ const rest2 = s.slice(i);
157
+ let hour;
158
+ let minute = 0;
159
+ let timeLen = 0;
160
+ const colonTime = /^(\d{1,2}):(\d{2})/.exec(rest2);
161
+ const cnTime = /^(\d{1,2}|[零一二两三四五六七八九十]+)\s*[点时]\s*(?:(半|\d{1,2}|[一二三四五]+)\s*分?)?\s*整?/.exec(rest2);
162
+ if (colonTime !== null) {
163
+ hour = Number(colonTime[1]);
164
+ minute = Number(colonTime[2]);
165
+ timeLen = colonTime[0].length;
166
+ }
167
+ else if (cnTime !== null) {
168
+ const h = cnToNumber(cnTime[1] ?? '');
169
+ if (h === undefined)
170
+ return undefined;
171
+ hour = h;
172
+ const minRaw = cnTime[2];
173
+ if (minRaw !== undefined) {
174
+ minute = minRaw === '半' ? 30 : (cnToNumber(minRaw) ?? -1);
175
+ if (minute < 0)
176
+ return undefined;
177
+ }
178
+ timeLen = cnTime[0].length;
179
+ }
180
+ else if (dayOffset === undefined && year === undefined && month === undefined) {
181
+ // 裸 HHMM(仅当无任何日期成分;后面必须是空白或结尾)
182
+ const bare = /^(\d{2})(\d{2})(?!\d)(?=\s|$)/.exec(rest2);
183
+ if (bare !== null) {
184
+ hour = Number(bare[1]);
185
+ minute = Number(bare[2]);
186
+ timeLen = bare[0].length;
187
+ }
188
+ }
189
+ if (hour === undefined)
190
+ return undefined;
191
+ i += timeLen;
192
+ return {
193
+ consumed: i,
194
+ target: {
195
+ kind: 'at', hour, minute,
196
+ ...(year !== undefined ? { year } : {}),
197
+ ...(month !== undefined ? { month } : {}),
198
+ ...(day !== undefined ? { day } : {}),
199
+ ...(dayOffset !== undefined ? { dayOffset } : {}),
200
+ },
201
+ };
202
+ }
203
+ /** 把 matchTimeExpr 的原始匹配落定为具体目标(含日期推算与越界校验)。 */
204
+ function resolveTarget(matched, now, timeZone) {
205
+ const t = matched.target;
206
+ if (t.kind === 'after') {
207
+ return { status: 'ok', target: { kind: 'after', afterSeconds: t.afterSeconds } };
208
+ }
209
+ // 范围校验
210
+ if ((t.month !== undefined && (t.month < 1 || t.month > 12)) ||
211
+ (t.day !== undefined && (t.day < 1 || t.day > 31)) ||
212
+ t.hour > 23 || t.minute > 59) {
213
+ return { status: 'error', error: '日期或时间数值无效。' };
214
+ }
215
+ const hhmm = `${String(t.hour).padStart(2, '0')}:${String(t.minute).padStart(2, '0')}`;
216
+ const epochOf = (date) => {
217
+ const e = epochFromLocal(date, `${hhmm}:00`, timeZone);
218
+ return e === undefined || Number.isNaN(e) ? undefined : e;
219
+ };
220
+ const atOk = (date) => ({
221
+ status: 'ok',
222
+ target: { kind: 'at', at: { date, time: `${hhmm}:00`, time_zone: timeZone }, epoch: epochOf(date) },
223
+ });
224
+ // 显式完整日期(含年)
225
+ if (t.year !== undefined && t.month !== undefined && t.day !== undefined) {
226
+ const date = `${String(t.year).padStart(4, '0')}-${String(t.month).padStart(2, '0')}-${String(t.day).padStart(2, '0')}`;
227
+ if (epochOf(date) === undefined)
228
+ return { status: 'error', error: '日期不存在。' };
229
+ if (epochOf(date) <= now)
230
+ return { status: 'error', error: '目标时间已过去。' };
231
+ return atOk(date);
232
+ }
233
+ // 显式月日(今年,已过则明年)
234
+ if (t.month !== undefined && t.day !== undefined) {
235
+ const thisYear = localFieldsOf(now, timeZone).year;
236
+ for (const y of [thisYear, thisYear + 1]) {
237
+ const date = `${y}-${String(t.month).padStart(2, '0')}-${String(t.day).padStart(2, '0')}`;
238
+ const epoch = epochOf(date);
239
+ if (epoch !== undefined && epoch > now)
240
+ return atOk(date);
241
+ }
242
+ return { status: 'error', error: '目标时间已过去。' };
243
+ }
244
+ // 今天/明天/后天/大后天(显式指定,不自动顺延)
245
+ if (t.dayOffset !== undefined) {
246
+ const f = localFieldsOf(now + t.dayOffset * 24 * 3600 * 1000, timeZone);
247
+ const date = `${f.year}-${String(f.month).padStart(2, '0')}-${String(f.day).padStart(2, '0')}`;
248
+ const epoch = epochOf(date);
249
+ if (epoch === undefined)
250
+ return { status: 'error', error: '无法解析目标时间。' };
251
+ if (epoch <= now)
252
+ return { status: 'error', error: '该时刻今天已过去,可改用「明天」或具体日期。' };
253
+ return atOk(date);
254
+ }
255
+ // 仅时刻:从今天起顺延,最多 7 天
256
+ for (let add = 0; add < 8; add += 1) {
257
+ const f = localFieldsOf(now + add * 24 * 3600 * 1000, timeZone);
258
+ const date = `${f.year}-${String(f.month).padStart(2, '0')}-${String(f.day).padStart(2, '0')}`;
259
+ const epoch = epochOf(date);
260
+ if (epoch !== undefined && epoch > now)
261
+ return atOk(date);
262
+ }
263
+ return { status: 'error', error: '无法解析目标时间。' };
264
+ }
265
+ /**
266
+ * 解析 `/schedule` 的完整输入:开头的时间表达式 + 剩余内容。
267
+ *
268
+ * 支持格式(示例):
269
+ * - `+30m` `+1h30m` `+90s` `+1w`
270
+ * - `30分钟后` `半小时后` `两小时后` `1天以后`
271
+ * - `1532` `15:32` `15:32` `15时32分` `9点` `9点半`
272
+ * - `明天 15:32` `后天9点` `大后天 10:00`
273
+ * - `8月21日 15:32` `08-21 15:32`
274
+ * - `2026-08-21 15:32` `0821-1532` `20260821-1532`
275
+ *
276
+ * `limits` 可选:未传时回落到 `DEFAULT_MAX_PROMPT_CHARS=1000`,错误文案里
277
+ * 的字符数与 settings 实际生效值一致。
278
+ */
279
+ export function parseScheduleInput(raw, now, timeZone, limits = { allowLong: false, maxChars: 1000 }) {
280
+ const s = normalizeInput(raw);
281
+ if (s.length === 0)
282
+ return { ok: false, error: '用法:/schedule <时间> <内容>,如 /schedule 1532 检查构建结果' };
283
+ const matched = matchTimeExpr(s);
284
+ if (matched === undefined) {
285
+ return { ok: false, error: '无法识别的时间格式。支持:+30m、1532、15:32、30分钟后、明天9点、8月21日 15:32、2026-08-21 15:32 等。' };
286
+ }
287
+ const resolved = resolveTarget(matched, now, timeZone);
288
+ if (resolved.status === 'error')
289
+ return { ok: false, error: resolved.error };
290
+ const content = s.slice(matched.consumed).trim();
291
+ if (content.length === 0)
292
+ return { ok: false, error: '提醒内容不能为空。' };
293
+ if (content.length > limits.maxChars) {
294
+ return { ok: false, error: `提醒内容不能超过 ${limits.maxChars} 字符。` };
295
+ }
296
+ return { ok: true, target: resolved.target, content };
297
+ }
298
+ /**
299
+ * 兼容旧签名:解析纯时间描述(不含内容)。
300
+ */
301
+ export function parseScheduleSpec(spec, now, timeZone) {
302
+ const s = normalizeInput(spec);
303
+ const matched = matchTimeExpr(s);
304
+ if (matched === undefined) {
305
+ return { ok: false, error: '无法识别的时间格式。支持:+30m、1532、15:32、30分钟后、明天9点 等。' };
306
+ }
307
+ const resolved = resolveTarget(matched, now, timeZone);
308
+ if (resolved.status === 'error')
309
+ return { ok: false, error: resolved.error };
310
+ if (s.slice(matched.consumed).trim().length > 0) {
311
+ return { ok: false, error: '无法识别的时间格式。支持:+30m、1532、15:32、30分钟后、明天9点 等。' };
312
+ }
313
+ if (resolved.target.kind === 'after') {
314
+ return { ok: true, kind: 'after', afterSeconds: resolved.target.afterSeconds };
315
+ }
316
+ return { ok: true, kind: 'at', at: resolved.target.at, epoch: resolved.target.epoch };
317
+ }
318
+ /** 命令工厂:返回三个命令定义。
319
+ *
320
+ * `getSettings` 支持传数字(静态,兼容旧签名)、返回 `{maxSchedules}` 的 getter,
321
+ * `() => { maxSchedules, allowLongPrompts, maxPromptChars }` 的 getter——传 getter
322
+ * 时每次调用读取设置页的**实时**上限(设置保存后即时生效,按需 scope.get(),
323
+ * 与 dsh-smooth-stream 一致)。
324
+ */
325
+ export function userScheduleCommands(ctx, getSettings = DEFAULT_MAX_SCHEDULES, onUserChange, onUserSteer) {
326
+ const resolveSettings = () => {
327
+ if (typeof getSettings === 'function') {
328
+ const value = getSettings();
329
+ if (typeof value === 'number') {
330
+ return { maxSchedules: value, limits: { allowLong: false, maxChars: 1000 } };
331
+ }
332
+ const maxSchedules = value?.maxSchedules ?? DEFAULT_MAX_SCHEDULES;
333
+ const limits = resolvePromptLimits({
334
+ allowLongPrompts: value?.allowLongPrompts,
335
+ maxPromptChars: value?.maxPromptChars,
336
+ });
337
+ return { maxSchedules, limits };
338
+ }
339
+ return { maxSchedules: getSettings, limits: { allowLong: false, maxChars: 1000 } };
340
+ };
341
+ const create = {
342
+ name: `${PREFIX}-create`,
343
+ description: '创建一条会话内定时提醒(GUI 内部通道)。',
344
+ recordInput: false,
345
+ handler: async (invocation) => {
346
+ const parsed = parsePayload(invocation.rawInput);
347
+ if (!parsed.ok)
348
+ return renderError(parsed.text);
349
+ const { maxSchedules, limits } = resolveSettings();
350
+ const result = await userScheduleCreate(parsed.value, invocation.agent, ctx, maxSchedules, undefined, limits);
351
+ if (!result.ok) {
352
+ return { kind: 'error', text: `${result.code}: ${result.message}` };
353
+ }
354
+ try {
355
+ onUserChange?.(invocation.agent);
356
+ }
357
+ catch {
358
+ /* 通知失败不影响命令成功结果 */
359
+ }
360
+ return { kind: 'success', text: JSON.stringify({ ...result }) };
361
+ },
362
+ };
363
+ const list = {
364
+ name: `${PREFIX}-list`,
365
+ description: '列出当前会话用户创建的定时提醒(GUI 内部通道)。',
366
+ recordInput: false,
367
+ handler: async (invocation) => {
368
+ const result = await userScheduleList(invocation.agent, ctx);
369
+ if (!result.ok) {
370
+ return { kind: 'error', text: `${result.code}: ${result.message}` };
371
+ }
372
+ return { kind: 'success', text: JSON.stringify(result.schedules) };
373
+ },
374
+ };
375
+ const del = {
376
+ name: `${PREFIX}-delete`,
377
+ description: '删除一条会话内定时提醒(GUI 内部通道)。',
378
+ recordInput: false,
379
+ handler: async (invocation) => {
380
+ const parsed = parsePayload(invocation.rawInput);
381
+ if (!parsed.ok)
382
+ return renderError(parsed.text);
383
+ const id = parsed.value['id'];
384
+ const result = await userScheduleDelete(id, invocation.agent, ctx);
385
+ if (!result.ok) {
386
+ return { kind: 'error', text: `${result.code}: ${result.message}` };
387
+ }
388
+ if (result.deleted) {
389
+ try {
390
+ onUserChange?.(invocation.agent);
391
+ }
392
+ catch {
393
+ /* 通知失败不影响命令成功结果 */
394
+ }
395
+ }
396
+ return { kind: 'success', text: JSON.stringify({ id: result.id, deleted: result.deleted }) };
397
+ },
398
+ };
399
+ const edit = {
400
+ name: `${PREFIX}-edit`,
401
+ description: '修改一条会话内定时提醒的内容(保留原时刻;GUI 内部通道)。',
402
+ recordInput: false,
403
+ handler: async (invocation) => {
404
+ const parsed = parsePayload(invocation.rawInput);
405
+ if (!parsed.ok)
406
+ return renderError(parsed.text);
407
+ const value = parsed.value;
408
+ const { limits } = resolveSettings();
409
+ const result = await userScheduleEditPrompt(value['id'], value['prompt'], invocation.agent, ctx, limits);
410
+ if (!result.ok) {
411
+ return { kind: 'error', text: `${result.code}: ${result.message}` };
412
+ }
413
+ try {
414
+ onUserChange?.(invocation.agent);
415
+ }
416
+ catch {
417
+ /* 通知失败不影响命令成功结果 */
418
+ }
419
+ return { kind: 'success', text: JSON.stringify(result) };
420
+ },
421
+ };
422
+ // 用户主动插话(GUI「插话发送」按钮):把已到点的 reminder 立即 steer 进 agent。
423
+ // 仅 one-shot 支持;every 类提醒因 batch framing 需要更多 plumbing 暂未开放。
424
+ const steer = {
425
+ name: `${PREFIX}-steer-now`,
426
+ description: '插话发送:把指定 id 的提醒立即推给 agent(仅 one-shot;GUI 内部通道)。',
427
+ recordInput: false,
428
+ handler: async (invocation) => {
429
+ if (onUserSteer === undefined) {
430
+ return { kind: 'error', text: '插话处理器未注入。' };
431
+ }
432
+ const parsed = parsePayload(invocation.rawInput);
433
+ if (!parsed.ok)
434
+ return renderError(parsed.text);
435
+ const id = parsed.value['id'];
436
+ if (typeof id !== 'string' || id.length === 0) {
437
+ return { kind: 'error', text: 'invalid_rule: schedule id 必填。' };
438
+ }
439
+ try {
440
+ const result = await onUserSteer(invocation.agent, id);
441
+ if (!result.ok) {
442
+ return { kind: 'error', text: `${result.code}: ${result.message}` };
443
+ }
444
+ return { kind: 'success', text: JSON.stringify(result) };
445
+ }
446
+ catch (error) {
447
+ return {
448
+ kind: 'error',
449
+ text: `steer_failed: ${error instanceof Error ? error.message : String(error)}`,
450
+ };
451
+ }
452
+ },
453
+ };
454
+ // 人类友好快捷命令:/schedule <时间> <内容>(到点以「上下文注入」提醒注入)与
455
+ // /later <时间> <内容>(到点以「我」的身份发送 = 延迟发送语义,产品取舍见
456
+ // 下方注释)。两者共享同一解析与创建逻辑,仅 delivery 不同。
457
+ const buildQuickHandler = (delivery) => {
458
+ return async (invocation) => {
459
+ const timeZone = detectTimeZone();
460
+ const { maxSchedules, limits } = resolveSettings();
461
+ const parsed = parseScheduleInput(invocation.rawInput, Date.now(), timeZone, limits);
462
+ if (!parsed.ok)
463
+ return renderError(parsed.error);
464
+ const content = parsed.content;
465
+ const input = parsed.target.kind === 'after'
466
+ ? { prompt: content, after_seconds: parsed.target.afterSeconds, time_zone: timeZone }
467
+ : { prompt: content, at: parsed.target.at, time_zone: timeZone };
468
+ // P0-1:delivery 只能经可信通道参数显式声明(人类输入的 /later),
469
+ // 不再注入原始载荷——通用载荷字段一律无效,防止任何通道夹带绕过注入防护。
470
+ const result = await userScheduleCreate(input, invocation.agent, ctx, maxSchedules, { trustedDelivery: delivery }, limits);
471
+ if (!result.ok) {
472
+ return { kind: 'error', text: `${result.code}: ${result.message}` };
473
+ }
474
+ try {
475
+ onUserChange?.(invocation.agent);
476
+ }
477
+ catch {
478
+ /* 通知失败不影响命令成功结果 */
479
+ }
480
+ const epoch = parsed.target.kind === 'after' ? Date.now() + parsed.target.afterSeconds * 1000 : parsed.target.epoch;
481
+ const verb = delivery === 'user' ? '将于' : '已设定提醒:';
482
+ const hint = delivery === 'user' ? '(到点会以你的身份发出)' : '(输入框上方蓝色待发条可随时取消)';
483
+ return {
484
+ kind: 'success',
485
+ text: `${verb}${formatAbsolute(epoch, timeZone)}(${formatRelative(epoch, Date.now())})「${content}」${hint}`,
486
+ };
487
+ };
488
+ };
489
+ const quick = {
490
+ name: 'schedule',
491
+ description: '定时提醒快捷命令:/schedule <时间> <内容>。时间宽容识别:+30m、+1h30m、30分钟后、1532、15:32、9点半、明天9点、后天 10:00、8月21日 15:32、0821-1532、2026-08-21 15:32。',
492
+ // 声明 input → DSH 命令系统在输入框「等待补全」:选中/回车 /schedule 后
493
+ // 保留 `/schedule ` token + hint 提示,等你写完参数回车才执行;带参整行
494
+ // 回车也走命令系统(不会当普通用户消息直接发给模型)。
495
+ input: {
496
+ hint: '<时间> <内容>,如 1532 检查构建结果;也支持 +30m、明天9点 等格式',
497
+ },
498
+ recordInput: false,
499
+ handler: buildQuickHandler('context'),
500
+ };
501
+ // /later:延迟发送语义 —— 到点以「我」的身份把内容当作真实输入发出。
502
+ // 产品取舍(有意为之,与 dsh-sleep-send 对齐):它会让无人真实键入的内容
503
+ // 以用户气泡出现,绕过核心的「注入 vs 人键」信任边界;因此只保留在人类
504
+ // 显式输入 `/later` 的路径,工具/面板一律走安全的 `context`(notice 注入)。
505
+ const later = {
506
+ name: 'later',
507
+ description: '延迟发送:/later <时间> <内容>,到点以你的身份发出该内容(不是提醒,是代发)。时间格式同 /schedule。',
508
+ input: {
509
+ hint: '<时间> <内容>,如 1532 检查构建结果;到点会以你的身份发出',
510
+ },
511
+ recordInput: false,
512
+ handler: buildQuickHandler('user'),
513
+ };
514
+ return [create, list, del, edit, steer, quick, later];
515
+ }
516
+ /** 注册三个命令,返回统一 disposer。 */
517
+ export function registerUserScheduleCommands(ctx, getSettings = DEFAULT_MAX_SCHEDULES, onUserChange, onUserSteer) {
518
+ const disposers = userScheduleCommands(ctx, getSettings, onUserChange, onUserSteer).map((definition) => {
519
+ try {
520
+ return ctx.commands.register(definition);
521
+ }
522
+ catch (error) {
523
+ ctx.logger.warn(`later: 命令 ${definition.name} 注册失败: ${error instanceof Error ? error.message : String(error)}`);
524
+ return () => undefined;
525
+ }
526
+ });
527
+ return () => {
528
+ for (const dispose of disposers.reverse())
529
+ dispose();
530
+ };
531
+ }
package/lib/domain.js ADDED
@@ -0,0 +1,14 @@
1
+ /** 插件名(与 cordis 条目 id / manifest 一致)。 */
2
+ export const name = 'dsh-later';
3
+ /** 会话投影键:GUI 通过 `useProjection` / `faceOf` 读取用户任务列表。 */
4
+ export const PROJECTION_KEY = 'userSchedules';
5
+ /**
6
+ * 伴生所有权事件类型(**历史遗留,仅读兼容**;新写入一律走 ownership-store)。
7
+ * 字面量保留旧名 `session-scheduler/user-schedule`:它是历史会话日志里的
8
+ * 数据键,改名不追溯——读方必须继续认它。
9
+ */
10
+ export const OWNED_EVENT = 'session-scheduler/user-schedule';
11
+ /** 判断事件是否属于本插件所有权流。 */
12
+ export function isOwnedEvent(event) {
13
+ return event.type === OWNED_EVENT;
14
+ }