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,260 @@
1
+ /**
2
+ * 智能时段计算(纯函数,host 与 client 共享)。
3
+ *
4
+ * 默认时段(v1 硬编码,可经 cordis config 覆盖):
5
+ * 工作时间 09:00–18:00
6
+ * 午休时间 12:00–14:00
7
+ * 晚间时间 18:00–22:00
8
+ * 夜间静默 22:00–次日 09:00(不发送)
9
+ *
10
+ * 计算在**用户显式选定的 IANA 时区**内进行(不自动推断,确定性优先)。
11
+ * 墙钟→epoch 的解析采用 dsh-schedule `resolveLocalInstant` 同款偏移校正法,
12
+ * 避免依赖进程时区。
13
+ * @module dsh-later/smart-window
14
+ */
15
+ /** 默认时段。 */
16
+ export const DEFAULT_SMART_WINDOW = Object.freeze({
17
+ workStart: '09:00',
18
+ workEnd: '18:00',
19
+ lunchStart: '12:00',
20
+ lunchEnd: '14:00',
21
+ eveningEnd: '22:00',
22
+ });
23
+ /** HH:mm(24 小时制)格式。 */
24
+ const HHMM_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
25
+ /** 校验一个 HH:mm 字符串。 */
26
+ export function isValidHhmm(value) {
27
+ return typeof value === 'string' && HHMM_RE.test(value);
28
+ }
29
+ /**
30
+ * 清洗用户/配置层提供的时段字段:非法或缺省的字段逐项回落默认值
31
+ * (host schema 与 client 设置面共用;保证下游 `nextSmartTarget` 永不因脏输入抛错)。
32
+ */
33
+ export function sanitizeSmartWindowConfig(raw) {
34
+ const pick = (key, fallback) => isValidHhmm(raw?.[key]) ? raw[key] : fallback;
35
+ return {
36
+ workStart: pick('workStart', DEFAULT_SMART_WINDOW.workStart),
37
+ workEnd: pick('workEnd', DEFAULT_SMART_WINDOW.workEnd),
38
+ lunchStart: pick('lunchStart', DEFAULT_SMART_WINDOW.lunchStart),
39
+ lunchEnd: pick('lunchEnd', DEFAULT_SMART_WINDOW.lunchEnd),
40
+ eveningEnd: pick('eveningEnd', DEFAULT_SMART_WINDOW.eveningEnd),
41
+ };
42
+ }
43
+ /** 两次提醒之间至少等待的毫秒数(智能模式下)。 */
44
+ export const SMART_MIN_DELAY_MS = 2 * 60 * 1000;
45
+ /** 一小时/一天的毫秒数。 */
46
+ export const MINUTE_MS = 60 * 1000;
47
+ export const HOUR_MS = 60 * MINUTE_MS;
48
+ export const DAY_MS = 24 * HOUR_MS;
49
+ /**
50
+ * 把 "HH:mm" 解析为当日第几分钟;非法返回 undefined。
51
+ */
52
+ export function hhmmToMinutes(value) {
53
+ const match = /^(\d{2}):(\d{2})$/.exec(value.trim());
54
+ if (match === null)
55
+ return undefined;
56
+ const hour = Number(match[1]);
57
+ const minute = Number(match[2]);
58
+ if (hour > 23 || minute > 59)
59
+ return undefined;
60
+ return hour * 60 + minute;
61
+ }
62
+ /** 把当日第几分钟格式化为 "HH:mm"。 */
63
+ export function minutesToHhmm(minutes) {
64
+ const hour = Math.floor(minutes / 60);
65
+ const minute = minutes % 60;
66
+ return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
67
+ }
68
+ /**
69
+ * 可配置时段是否有效:所有边界均为合法 HH:mm 且满足
70
+ * workStart < lunchStart < lunchEnd < workEnd < eveningEnd。
71
+ */
72
+ export function isValidWindow(window) {
73
+ const order = [
74
+ hhmmToMinutes(window.workStart),
75
+ hhmmToMinutes(window.lunchStart),
76
+ hhmmToMinutes(window.lunchEnd),
77
+ hhmmToMinutes(window.workEnd),
78
+ hhmmToMinutes(window.eveningEnd),
79
+ ];
80
+ if (order.some((value) => value === undefined))
81
+ return false;
82
+ return (order[0] < order[1] &&
83
+ order[1] < order[2] &&
84
+ order[2] < order[3] &&
85
+ order[3] < order[4]);
86
+ }
87
+ /** 取整到整分钟(秒、毫秒清零)。 */
88
+ export function floorToMinute(epoch) {
89
+ return Math.floor(epoch / MINUTE_MS) * MINUTE_MS;
90
+ }
91
+ /** 为该时区构建一个 ISO 投影 formatter(含长期偏移)。 */
92
+ function formatterFor(timeZone) {
93
+ return new Intl.DateTimeFormat('en-US-u-ca-iso8601-nu-latn', {
94
+ timeZone,
95
+ year: 'numeric',
96
+ month: '2-digit',
97
+ day: '2-digit',
98
+ hour: '2-digit',
99
+ minute: '2-digit',
100
+ second: '2-digit',
101
+ hourCycle: 'h23',
102
+ });
103
+ }
104
+ /** 把 epoch 投影到指定时区的本地日历字段。 */
105
+ export function localFieldsOf(epoch, timeZone) {
106
+ const formatter = formatterFor(timeZone);
107
+ const parts = Object.fromEntries(formatter.formatToParts(epoch).map((part) => [part.type, part.value]));
108
+ return {
109
+ year: Number(parts['year']),
110
+ month: Number(parts['month']),
111
+ day: Number(parts['day']),
112
+ hour: Number(parts['hour']),
113
+ minute: Number(parts['minute']),
114
+ second: Number(parts['second']),
115
+ };
116
+ }
117
+ /** 该 epoch 在指定时区的 UTC 偏移(毫秒):local − UTC。 */
118
+ function offsetOf(epoch, timeZone) {
119
+ const f = localFieldsOf(epoch, timeZone);
120
+ return Date.UTC(f.year, f.month - 1, f.day, f.hour, f.minute, f.second, 0) - epoch;
121
+ }
122
+ /**
123
+ * 解析一个本地墙钟时刻(hour/min 表示,sec=00)在指定时区内对应的 epoch。
124
+ * 采用「偏移校正」法:把墙钟当 UTC 构造候选,再用采样偏移反推真实 epoch,
125
+ * 并验证映射回原墙钟字段;DST 重叠取最早候选,缺口会导致无候选。
126
+ */
127
+ export function resolveWallClock(epochOnDay, timeZone, hour, minute, second = 0) {
128
+ const base = localFieldsOf(epochOnDay, timeZone);
129
+ const localEpoch = Date.UTC(base.year, base.month - 1, base.day, hour, minute, second, 0);
130
+ const offsets = new Set();
131
+ for (let delta = -2; delta <= 2; delta += 1) {
132
+ offsets.add(offsetOf(localEpoch + delta * DAY_MS, timeZone));
133
+ }
134
+ const candidates = [];
135
+ for (const offset of offsets) {
136
+ const candidate = localEpoch - offset;
137
+ const f = localFieldsOf(candidate, timeZone);
138
+ if (f.year === base.year &&
139
+ f.month === base.month &&
140
+ f.day === base.day &&
141
+ f.hour === hour &&
142
+ f.minute === minute &&
143
+ f.second === second) {
144
+ candidates.push(candidate);
145
+ }
146
+ }
147
+ candidates.sort((a, b) => a - b);
148
+ return candidates[0];
149
+ }
150
+ /** 求「下一个严格晚于 now 的本地 HH:mm 时刻」的 epoch。 */
151
+ function nextMinutesAfter(now, timeZone, minutes) {
152
+ const hour = Math.floor(minutes / 60);
153
+ const minute = minutes % 60;
154
+ let probe = resolveWallClock(now, timeZone, hour, minute);
155
+ if (probe === undefined) {
156
+ // DST 缺口:该墙钟时刻当天不存在,跳到次日
157
+ probe = resolveWallClock(now + DAY_MS, timeZone, hour, minute);
158
+ }
159
+ if (probe === undefined) {
160
+ // 兜底:按纯 UTC 猜测
161
+ return Date.UTC(localFieldsOf(now, timeZone).year, localFieldsOf(now, timeZone).month - 1, localFieldsOf(now, timeZone).day, hour, minute, 0, 0);
162
+ }
163
+ if (probe > now)
164
+ return probe;
165
+ const nextDay = resolveWallClock(probe + DAY_MS, timeZone, hour, minute) ?? probe + DAY_MS;
166
+ return nextDay > now ? nextDay : nextDay + DAY_MS;
167
+ }
168
+ /**
169
+ * 计算下一个智能时段的本地墙钟目标。
170
+ *
171
+ * 规则:
172
+ * - 若当前在午休时段(lunchStart ≤ now < lunchEnd)→ 目标为 now + 2 分钟;
173
+ * - 否则候选为下一个 工作开始 / 午休结束 / 晚间开始(均 > now + 2 分钟),取最早者;
174
+ * - 夜间静默(≥ eveningEnd 或 < workStart)自然被「下一个工作开始」覆盖。
175
+ *
176
+ * @param now - 当前墙钟 epoch 毫秒。
177
+ * @param timeZone - 用户显式选定的 IANA 时区。
178
+ * @param window - 时段配置(默认 {@link DEFAULT_SMART_WINDOW})。
179
+ * @returns 目标 epoch 毫秒(严格位于未来,且至少 SMART_MIN_DELAY_MS 之后)。
180
+ */
181
+ export function nextSmartTarget(now, timeZone, window = DEFAULT_SMART_WINDOW) {
182
+ if (!isValidWindow(window))
183
+ throw new TypeError('smart window configuration is invalid');
184
+ const fields = localFieldsOf(now, timeZone);
185
+ const minutes = fields.hour * 60 + fields.minute;
186
+ const lunchStart = hhmmToMinutes(window.lunchStart);
187
+ const lunchEnd = hhmmToMinutes(window.lunchEnd);
188
+ // 午休优先:直接 now + 2 分钟
189
+ if (minutes >= lunchStart && minutes < lunchEnd) {
190
+ return now + SMART_MIN_DELAY_MS;
191
+ }
192
+ const workStart = hhmmToMinutes(window.workStart);
193
+ const eveningStart = hhmmToMinutes(window.workEnd); // 工作结束 = 晚间开始
194
+ const candidates = [
195
+ nextMinutesAfter(now, timeZone, workStart),
196
+ nextMinutesAfter(now, timeZone, lunchEnd),
197
+ nextMinutesAfter(now, timeZone, eveningStart),
198
+ ].filter((target) => target > now + SMART_MIN_DELAY_MS);
199
+ if (candidates.length === 0) {
200
+ const earliest = Math.min(nextMinutesAfter(now, timeZone, workStart), nextMinutesAfter(now, timeZone, lunchEnd), nextMinutesAfter(now, timeZone, eveningStart));
201
+ return Math.max(earliest, now + SMART_MIN_DELAY_MS);
202
+ }
203
+ return Math.min(...candidates);
204
+ }
205
+ /**
206
+ * 把本地墙钟字符串(date YYYY-MM-DD、time HH:mm[:ss])在指定时区解析为 epoch。
207
+ * 用于自定义时间的预览与合法性校验。解析失败返回 undefined(如 DST 缺口)。
208
+ */
209
+ export function epochFromLocal(date, time, timeZone) {
210
+ const dateMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
211
+ const timeMatch = /^(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(time);
212
+ if (dateMatch === null || timeMatch === null)
213
+ return undefined;
214
+ const year = Number(dateMatch[1]);
215
+ const month = Number(dateMatch[2]);
216
+ const day = Number(dateMatch[3]);
217
+ const hour = Number(timeMatch[1]);
218
+ const minute = Number(timeMatch[2]);
219
+ const second = Number(timeMatch[3] ?? '0');
220
+ if (hour > 23 ||
221
+ minute > 59 ||
222
+ second > 59 ||
223
+ month < 1 ||
224
+ month > 12 ||
225
+ day < 1 ||
226
+ day > 31) {
227
+ return undefined;
228
+ }
229
+ // 以纯 UTC 构造「当日近似 epoch」作为锚点,再用偏移校正解析真实墙钟时刻。
230
+ const approx = Date.UTC(year, month - 1, day, 0, 0, 0, 0);
231
+ return resolveWallClock(approx, timeZone, hour, minute, second);
232
+ }
233
+ /**
234
+ * 智能模式的一站式目标构造:返回可直接作为 `at` 对象使用的本地日历字段。
235
+ * @returns null 当配置非法或无法解析时。
236
+ */
237
+ export function nextSmartAt(now, timeZone, window = DEFAULT_SMART_WINDOW) {
238
+ let target;
239
+ try {
240
+ target = nextSmartTarget(now, timeZone, window);
241
+ }
242
+ catch {
243
+ return null;
244
+ }
245
+ let parts;
246
+ try {
247
+ parts = localFieldsOf(target, timeZone);
248
+ }
249
+ catch {
250
+ return null;
251
+ }
252
+ const date = `${String(parts.year).padStart(4, '0')}-${String(parts.month).padStart(2, '0')}-${String(parts.day).padStart(2, '0')}`;
253
+ const time = `${String(parts.hour).padStart(2, '0')}:${String(parts.minute).padStart(2, '0')}:${String(parts.second).padStart(2, '0')}`;
254
+ return {
255
+ date,
256
+ time,
257
+ time_zone: timeZone,
258
+ epoch: target,
259
+ };
260
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * 时间格式化与通用工具(纯函数,host 与 client 共享)。
3
+ * 文案遵循 docs/ui/08-microcopy.md 的微文案规范。
4
+ * @module dsh-later/time-utils
5
+ */
6
+ /** 校验一个字符串是否为合法 IANA 时区(或 UTC)。 */
7
+ export function isValidTimeZone(value) {
8
+ if (typeof value !== 'string' || value.length === 0)
9
+ return false;
10
+ try {
11
+ const resolved = new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone;
12
+ return resolved === value || value === 'UTC';
13
+ }
14
+ catch {
15
+ return false;
16
+ }
17
+ }
18
+ /** 浏览器默认时区(客户端);host 侧无合理默认则退回 UTC。 */
19
+ export function detectTimeZone() {
20
+ try {
21
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
22
+ return tz?.length ? tz : 'UTC';
23
+ }
24
+ catch {
25
+ return 'UTC';
26
+ }
27
+ }
28
+ /** 常见时区候选(面板下拉用)。 */
29
+ export const COMMON_TIME_ZONES = [
30
+ 'Asia/Shanghai',
31
+ 'Asia/Tokyo',
32
+ 'Asia/Singapore',
33
+ 'Asia/Hong_Kong',
34
+ 'UTC',
35
+ 'America/Los_Angeles',
36
+ 'America/New_York',
37
+ 'Europe/London',
38
+ 'Europe/Paris',
39
+ ];
40
+ /**
41
+ * 相对时间文案:zh "约 X 分钟后";en "in ~X minutes" 等。
42
+ * @param epoch - 目标 epoch 毫秒。
43
+ * @param now - 当前 epoch 毫秒(默认 Date.now())。
44
+ * @param locale - 文案语言(默认 zh,host 侧无浏览器语境保持中文)。
45
+ */
46
+ export function formatRelative(epoch, now = Date.now(), locale = 'zh') {
47
+ const diff = epoch - now;
48
+ if (locale === 'en') {
49
+ if (diff < 0)
50
+ return 'overdue';
51
+ if (diff < MINUTE_MS)
52
+ return 'sending soon';
53
+ const minutes = Math.round(diff / MINUTE_MS);
54
+ if (minutes < 60)
55
+ return `in ~${minutes} min`;
56
+ const hours = Math.floor(minutes / 60);
57
+ const restMinutes = minutes % 60;
58
+ if (hours < 24) {
59
+ return restMinutes === 0 ? `in ~${hours} h` : `in ~${hours} h ${restMinutes} min`;
60
+ }
61
+ const days = Math.floor(hours / 24);
62
+ return `in ~${days} days`;
63
+ }
64
+ if (diff < 0)
65
+ return '已到期';
66
+ if (diff < MINUTE_MS)
67
+ return '即将发送';
68
+ const minutes = Math.round(diff / MINUTE_MS);
69
+ if (minutes < 60)
70
+ return `约 ${minutes} 分钟后`;
71
+ const hours = Math.floor(minutes / 60);
72
+ const restMinutes = minutes % 60;
73
+ if (hours < 24) {
74
+ return restMinutes === 0 ? `约 ${hours} 小时后` : `约 ${hours} 小时 ${restMinutes} 分后`;
75
+ }
76
+ const days = Math.floor(hours / 24);
77
+ return `约 ${days} 天后`;
78
+ }
79
+ /** 毫秒常量(避免依赖 smart-window 再复制)。 */
80
+ export const MINUTE_MS = 60 * 1000;
81
+ export const HOUR_MS = 60 * MINUTE_MS;
82
+ export const DAY_MS = 24 * HOUR_MS;
83
+ /** 周几中文缩写。 */
84
+ const WEEKDAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
85
+ /**
86
+ * 完整时间文案:zh "{M}月{d}日 {周X} HH:mm"、en "Wed, Aug 26, 14:45"(按指定时区)。
87
+ */
88
+ export function formatAbsolute(epoch, timeZone, locale = 'zh') {
89
+ const formatter = new Intl.DateTimeFormat(locale === 'en' ? 'en-US' : 'zh-CN', {
90
+ timeZone,
91
+ month: 'numeric',
92
+ day: 'numeric',
93
+ weekday: 'short',
94
+ hour: '2-digit',
95
+ minute: '2-digit',
96
+ hourCycle: 'h23',
97
+ });
98
+ return formatter.format(epoch).replace(/\s+/g, ' ');
99
+ }
100
+ /** 仅时间部分 "HH:mm"(按指定时区)。 */
101
+ export function formatHhmm(epoch, timeZone) {
102
+ const formatter = new Intl.DateTimeFormat('en-GB-u-ca-iso8601-nu-latn', {
103
+ timeZone,
104
+ hour: '2-digit',
105
+ minute: '2-digit',
106
+ hourCycle: 'h23',
107
+ });
108
+ return formatter.format(epoch);
109
+ }
110
+ /** 周期几第几天(与 formatAbsolute 里的 wkday 词对齐)。 */
111
+ export function weekdayLabel(epoch, timeZone) {
112
+ const weekday = new Intl.DateTimeFormat('en-US-u-ca-iso8601-nu-latn', {
113
+ timeZone,
114
+ weekday: 'short',
115
+ }).format(epoch);
116
+ const map = {
117
+ Sun: '周日',
118
+ Mon: '周一',
119
+ Tue: '周二',
120
+ Wed: '周三',
121
+ Thu: '周四',
122
+ Fri: '周五',
123
+ Sat: '周六',
124
+ };
125
+ return map[weekday] ?? weekday;
126
+ }
127
+ /**
128
+ * 倒计时 "HH:MM:SS"(或剩余不足 1 小时时 "MM:SS")。
129
+ */
130
+ export function formatCountdown(epoch, now = Date.now()) {
131
+ const remain = Math.max(0, epoch - now);
132
+ const totalSeconds = Math.floor(remain / 1000);
133
+ const hours = Math.floor(totalSeconds / 3600);
134
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
135
+ const seconds = totalSeconds % 60;
136
+ const mm = String(minutes).padStart(2, '0');
137
+ const ss = String(seconds).padStart(2, '0');
138
+ if (hours > 0)
139
+ return `${String(hours).padStart(2, '0')}:${mm}:${ss}`;
140
+ return `${mm}:${ss}`;
141
+ }
142
+ /** 未来距离(毫秒);用于紧急度判断。 */
143
+ export function remainingMs(epoch, now = Date.now()) {
144
+ return Math.max(0, epoch - now);
145
+ }
146
+ /**
147
+ * 构造 `at` 对象:{date, time, time_zone}。
148
+ * @param epoch - 目标 epoch 毫秒。
149
+ * @param timeZone - IANA 时区。
150
+ */
151
+ export function atObjectOf(epoch, timeZone) {
152
+ const parts = new Intl.DateTimeFormat('en-US-u-ca-iso8601-nu-latn', {
153
+ timeZone,
154
+ year: 'numeric',
155
+ month: '2-digit',
156
+ day: '2-digit',
157
+ hour: '2-digit',
158
+ minute: '2-digit',
159
+ second: '2-digit',
160
+ hourCycle: 'h23',
161
+ }).formatToParts(epoch);
162
+ const map = {};
163
+ for (const part of parts)
164
+ map[part.type] = part.value;
165
+ return {
166
+ date: `${map['year']}-${map['month']}-${map['day']}`,
167
+ time: `${map['hour']}:${map['minute']}:${map['second']}`,
168
+ time_zone: timeZone,
169
+ };
170
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * 客户端 → host 变更通道:通过 DSH 的 slash-command 一等机制把 GUI 的
3
+ * 创建/删除/列表请求送到宿主,由 handler 复用 `user-tools` 一次性写入会话日志。
4
+ *
5
+ * 为什么不用 Typert Remote:命令 handler 直接携带 `agent`,天然按会话路由,
6
+ * 且 `command/run` / `command/done` 自动落日志(审计友好)。`recordInput: false`
7
+ * 避免把 GUI 的 JSON 载荷重复写进日志(权威载荷在 domain 事件里)。
8
+ *
9
+ * 客户端经 `session.command('/user-schedule-create <json>')` 调用;
10
+ * 返回的 `RemoteResult<{matched}>` 只表达“已受理”,状态以 `userSchedules`
11
+ * 投影回流(commands 框架本身的 admission 语义)。
12
+ * @module dsh-later/commands
13
+ */
14
+ import type { Context } from '@deepseek-ai/cordis';
15
+ import type { Agent } from '@deepseek-ai/dsh-agent';
16
+ import type { CommandDefinition } from '@deepseek-ai/dsh-commands';
17
+ import type { PromptLimits } from './user-tools.js';
18
+ /** 用户创建/删除成功后的通知回调(宿主用它重驱用户调度器)。 */
19
+ export type UserChangeNotifier = (agent: Agent) => void;
20
+ /**
21
+ * 用户主动插话回调(GUI「插话发送」按钮触发)。handler 返回 steer 结果;
22
+ * - `{ ok: true, steered: true }` 表示已成功把消息立即推给 in-flight agent;
23
+ * - `{ ok: false, code }` 表示拒绝(未到点 / 不存在 / every 类型暂不支持)。
24
+ * @module dsh-later/commands
25
+ */
26
+ export type UserSteerHandler = (agent: Agent, id: string) => Promise<{
27
+ readonly ok: true;
28
+ readonly id: string;
29
+ readonly steered: true;
30
+ } | {
31
+ readonly ok: false;
32
+ readonly code: string;
33
+ readonly message: string;
34
+ }>;
35
+ /** `parseScheduleSpec` 的结果。 */
36
+ export type ParsedScheduleSpec = {
37
+ ok: true;
38
+ kind: 'after';
39
+ afterSeconds: number;
40
+ } | {
41
+ ok: true;
42
+ kind: 'at';
43
+ at: {
44
+ date: string;
45
+ time: string;
46
+ time_zone: string;
47
+ };
48
+ epoch: number;
49
+ } | {
50
+ ok: false;
51
+ error: string;
52
+ };
53
+ /** `parseScheduleInput` 的结果:时间目标 + 剩余内容。 */
54
+ export type ParsedScheduleInput = {
55
+ ok: true;
56
+ target: {
57
+ kind: 'after';
58
+ afterSeconds: number;
59
+ } | {
60
+ kind: 'at';
61
+ at: {
62
+ date: string;
63
+ time: string;
64
+ time_zone: string;
65
+ };
66
+ epoch: number;
67
+ };
68
+ content: string;
69
+ } | {
70
+ ok: false;
71
+ error: string;
72
+ };
73
+ /**
74
+ * 解析 `/schedule` 的完整输入:开头的时间表达式 + 剩余内容。
75
+ *
76
+ * 支持格式(示例):
77
+ * - `+30m` `+1h30m` `+90s` `+1w`
78
+ * - `30分钟后` `半小时后` `两小时后` `1天以后`
79
+ * - `1532` `15:32` `15:32` `15时32分` `9点` `9点半`
80
+ * - `明天 15:32` `后天9点` `大后天 10:00`
81
+ * - `8月21日 15:32` `08-21 15:32`
82
+ * - `2026-08-21 15:32` `0821-1532` `20260821-1532`
83
+ *
84
+ * `limits` 可选:未传时回落到 `DEFAULT_MAX_PROMPT_CHARS=1000`,错误文案里
85
+ * 的字符数与 settings 实际生效值一致。
86
+ */
87
+ export declare function parseScheduleInput(raw: string, now: number, timeZone: string, limits?: PromptLimits): ParsedScheduleInput;
88
+ /**
89
+ * 兼容旧签名:解析纯时间描述(不含内容)。
90
+ */
91
+ export declare function parseScheduleSpec(spec: string, now: number, timeZone: string): ParsedScheduleSpec;
92
+ /** 命令工厂:返回三个命令定义。
93
+ *
94
+ * `getSettings` 支持传数字(静态,兼容旧签名)、返回 `{maxSchedules}` 的 getter,
95
+ * `() => { maxSchedules, allowLongPrompts, maxPromptChars }` 的 getter——传 getter
96
+ * 时每次调用读取设置页的**实时**上限(设置保存后即时生效,按需 scope.get(),
97
+ * 与 dsh-smooth-stream 一致)。
98
+ */
99
+ export declare function userScheduleCommands(ctx: Context, getSettings?: number | (() => number) | (() => {
100
+ maxSchedules: number;
101
+ allowLongPrompts?: boolean;
102
+ maxPromptChars?: number;
103
+ }), onUserChange?: UserChangeNotifier, onUserSteer?: UserSteerHandler): CommandDefinition[];
104
+ /** 注册三个命令,返回统一 disposer。 */
105
+ export declare function registerUserScheduleCommands(ctx: Context, getSettings?: number | (() => number) | (() => {
106
+ maxSchedules: number;
107
+ allowLongPrompts?: boolean;
108
+ maxPromptChars?: number;
109
+ }), onUserChange?: UserChangeNotifier, onUserSteer?: UserSteerHandler): () => void;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * dsh-later 领域类型与事件荷载。
3
+ *
4
+ * 兼容性说明(关键设计决策):
5
+ * dsh-schedule 对 `schedule/change` 荷载采用严格解码(`hasExactKeys` 拒绝任何
6
+ * 多余字段)。因此本插件**不会**在 `schedule/change` 里追加 `source` 字段——
7
+ * 那会让 dsh-schedule 自身 fold 抛 `corrupt_schedule_log`,破坏 AC-20 互不干扰。
8
+ * 用户来源**不再**用伴生会话事件记录(2026-09 根治):曾用自有事件
9
+ * `session-scheduler/user-schedule` 写进会话日志,但该类型不在宿主词汇表内,
10
+ * 持久化层在特定时序下会静默丢行、留下永久 seq 缺口,导致整份历史被拒读。
11
+ * 现改为 sidecar 文件(见 ownership-store.ts);该事件类型仅作**读兼容**
12
+ * 保留(旧日志可能仍含此事件,注册 + fold 逻辑不再写入)。
13
+ *
14
+ * @module dsh-later/domain
15
+ */
16
+ import type { SessionEvent } from '@deepseek-ai/dsh-session';
17
+ /** 插件名(与 cordis 条目 id / manifest 一致)。 */
18
+ export declare const name = "dsh-later";
19
+ /** 会话投影键:GUI 通过 `useProjection` / `faceOf` 读取用户任务列表。 */
20
+ export declare const PROJECTION_KEY = "userSchedules";
21
+ /**
22
+ * 伴生所有权事件类型(**历史遗留,仅读兼容**;新写入一律走 ownership-store)。
23
+ * 字面量保留旧名 `session-scheduler/user-schedule`:它是历史会话日志里的
24
+ * 数据键,改名不追溯——读方必须继续认它。
25
+ */
26
+ export declare const OWNED_EVENT = "session-scheduler/user-schedule";
27
+ /**
28
+ * 伴生所有权事件荷载。语义:`add` 声明一个由用户工具创建的 schedule id;
29
+ * `remove` 撤销该声明。它与 `schedule/change` 的 create/delete 成对出现,
30
+ * 但由本插件独立解码——dsh-schedule 永远不会看到它。
31
+ *
32
+ * `delivery`(可选,默认 `context`)标记到点注入的形态:
33
+ * - `context`:框架认可的安全形式(source.kind='plugin',带 notice 表单 + 防护
34
+ * framing,保持「上下文注入」的可信审计线);
35
+ * - `user`:延迟发送的「代发」形式(source.kind='user'),由 `/later` 命令显式
36
+ * 请求,需用户明确表达『到点以我身份发出』,产品取舍见 commands.ts。
37
+ */
38
+ export interface UserScheduleOwnedChange {
39
+ readonly version: 1;
40
+ readonly operation: 'add' | 'remove';
41
+ readonly id: string;
42
+ readonly delivery?: 'context' | 'user';
43
+ }
44
+ /** 到点注入形态(见 {@link UserScheduleOwnedChange})。 */
45
+ export type UserScheduleDelivery = 'context' | 'user';
46
+ /**
47
+ * 投影输出的一条用户任务骨架(不含随墙钟变化的状态字段;
48
+ * `scheduled`/`overdue` 由客户端用 `scheduledAt` 实时推导)。
49
+ * 字段用 snake_case,与 `user_schedule_list` 工具响应保持一致。
50
+ */
51
+ export interface UserScheduleWireItem {
52
+ readonly id: string;
53
+ readonly kind: 'after' | 'at' | 'every';
54
+ readonly prompt: string;
55
+ readonly after_seconds?: number;
56
+ readonly every_seconds?: number;
57
+ readonly scheduled_at: string;
58
+ /** 创建时刻(来自 create 事件的 time;旧日志/未知时缺省)。 */
59
+ readonly created_at?: string;
60
+ readonly delivery_mode: 'session-local';
61
+ }
62
+ /** 投影输出值。 */
63
+ export interface UserScheduleProjectionValue {
64
+ readonly schedules: readonly UserScheduleWireItem[];
65
+ }
66
+ declare module '@deepseek-ai/dsh-session/types' {
67
+ interface SessionEventMap {
68
+ /** 本插件自有的用户所有权记录(dsh-schedule 忽略)。 */
69
+ 'session-scheduler/user-schedule': UserScheduleOwnedChange;
70
+ }
71
+ }
72
+ declare module '@deepseek-ai/dsh-session-projection/types' {
73
+ interface SessionProjectionMap {
74
+ /** 用户创建的、当前活动的定时提醒列表。 */
75
+ userSchedules: UserScheduleProjectionValue;
76
+ }
77
+ interface SessionProjectionStateMap {
78
+ /**
79
+ * 投影内部折叠状态(plain JSON:owned/active 用数组承载,
80
+ * 满足持久化投影缓存的 plain-JSON 前提)。
81
+ */
82
+ userSchedules: UserScheduleProjectionState;
83
+ }
84
+ }
85
+ /**
86
+ * 投影内部折叠状态(plain JSON)。与 `src/projection.ts` 中的同名接口
87
+ * 结构一致;在 domain.ts 里用结构化类型承载是因为合并的
88
+ * `SessionProjectionStateMap` 需要在这里给出 —— 而 projection.ts 依赖
89
+ * domain.ts,反向 import 会形成环。
90
+ */
91
+ export interface UserScheduleProjectionState {
92
+ /** 会话 id(init 时由 header 注入;apply 据此查询所有权 sidecar 缓存)。 */
93
+ readonly sessionId: string;
94
+ readonly owned: readonly string[];
95
+ readonly active: readonly {
96
+ id: string;
97
+ kind: 'after' | 'at' | 'every';
98
+ prompt: string;
99
+ afterSeconds?: number;
100
+ everySeconds?: number;
101
+ scheduledAt: string;
102
+ createdAt?: number;
103
+ }[];
104
+ readonly seedSeq: number;
105
+ }
106
+ /** 判断事件是否属于本插件所有权流。 */
107
+ export declare function isOwnedEvent(event: SessionEvent): event is SessionEvent & {
108
+ data: UserScheduleOwnedChange;
109
+ };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * dsh-later 宿主入口。
3
+ *
4
+ * 挂载职责:
5
+ * 1. 注册 `userSchedules` 会话投影(GUI 读取任务列表的权威状态);
6
+ * 2. 注册 slash 命令(客户端 → host 的创建/删除/列表变更通道);
7
+ * 3. 为每个 root agent 注册用户工具 `user_schedule_create/list/delete`
8
+ * (PRD「用户工具(底层暴露)」,模型亦可调用,与 dsh-schedule 双入口互补)。
9
+ *
10
+ * 依赖顺序:作为 peer 依赖的 dsh-schedule 需先加载(其 agent-runtime 负责
11
+ * 到期 dispatch + followup 注入;本插件只负责「用户接口层」)。
12
+ * @module dsh-later
13
+ */
14
+ import type { Context } from '@deepseek-ai/cordis';
15
+ import s from '@deepseek-ai/schemastery';
16
+ import { name } from './domain.js';
17
+ /** 插件配置 schema(全可选,默认开箱即用;经 cordis config 覆盖)。 */
18
+ export declare const Config: s<Schemastery.ObjectS<{
19
+ /** 单 session 用户任务上限。 */
20
+ maxSchedules: s<number, number>;
21
+ }>, Schemastery.ObjectT<{
22
+ /** 单 session 用户任务上限。 */
23
+ maxSchedules: s<number, number>;
24
+ }>>;
25
+ /** 所需服务(延后激活,缺失时插件不加载)。 */
26
+ export declare const inject: string[];
27
+ /** 宿主持有的 settings 形状(settings getter 返回值与 register* 接受入参)。 */
28
+ export interface SchedulerSettings {
29
+ readonly maxSchedules: number;
30
+ readonly allowLongPrompts: boolean;
31
+ readonly maxPromptChars: number;
32
+ }
33
+ export declare function apply(ctx: Context, config?: {
34
+ maxSchedules?: number;
35
+ }): void;
36
+ export { name };