dsh-later 1.2.0 → 1.3.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.
- package/CHANGELOG.md +10 -0
- package/README.en.md +42 -3
- package/README.md +42 -3
- package/lib/client.js +565 -255
- package/lib/commands.js +486 -51
- package/lib/index.js +20 -2
- package/lib/ownership-store.js +50 -3
- package/lib/runtime.js +39 -5
- package/lib/smart-window.js +28 -3
- package/lib/types/commands.d.ts +60 -16
- package/lib/types/index.d.ts +2 -0
- package/lib/types/ownership-store.d.ts +30 -1
- package/lib/types/runtime.d.ts +10 -1
- package/lib/types/user-tools.d.ts +9 -1
- package/lib/user-tools.js +13 -4
- package/package.json +5 -4
package/lib/index.js
CHANGED
|
@@ -65,6 +65,19 @@ export const Config = s.object({
|
|
|
65
65
|
* - `classic`:强制使用旧版点阵风格(10×10 fill 方块 crispEdges)。
|
|
66
66
|
*/
|
|
67
67
|
clockIconStyle: s.union([s.const('auto'), s.const('modern'), s.const('classic')]).default('auto'),
|
|
68
|
+
/**
|
|
69
|
+
* 高级语法(**Beta**,默认开启):`/later` 与 `/schedule` 的多次写法。
|
|
70
|
+
*
|
|
71
|
+
* 覆盖三种写法:等间隔重复 `+1h*3`、相对链式序列 `+1h+1h+0.5h`、绝对锚点序列
|
|
72
|
+
* `15:32+16:32` / `9:00+1d`。标 Beta 的理由不是"没测"(38 个单测已覆盖),而是
|
|
73
|
+
* **展开语义还没被真实使用验证过**:一次命令写 10 条任务会不会刷屏 dock、
|
|
74
|
+
* 60 秒地板与 10 条上限的尺度是否合适,都需要一段观察期。
|
|
75
|
+
*
|
|
76
|
+
* 关闭后这些写法被**明确拒绝**并提示来设置页开启——不会退回"静默建成一条乱码"
|
|
77
|
+
* 的旧行为:那是 bug,不属于可回退的功能面。残片守卫(`15:32*3`、`+1h..3h`)
|
|
78
|
+
* 与本开关无关,始终生效。
|
|
79
|
+
*/
|
|
80
|
+
advancedSyntax: s.boolean().default(true),
|
|
68
81
|
}).volatile();
|
|
69
82
|
/**
|
|
70
83
|
* 0.1.5 时代 settings namespace 注册所用的 schema。
|
|
@@ -119,7 +132,8 @@ function registerUserScheduleTools(rootCtx, toolCtx, agent, getSettings, onUserC
|
|
|
119
132
|
name: 'user_schedule_create',
|
|
120
133
|
// description 在每次 register 时按当前 settings 生成:
|
|
121
134
|
// 模型看到的字符上限始终与宿主实际校验一致,避免描述与实现脱节。
|
|
122
|
-
description: '为用户创建一条当前会话内的定时提醒。必须提供非空提示与恰好一个选择器:after_seconds(正整数延时秒)、at(显式偏移时间或 {date,time,time_zone} 本地时间)、every_seconds(≥300 的固定间隔秒)。time_zone 可选,缺省用会话检测时区。创建来源被标记为用户工具,GUI 面板会展示。'
|
|
135
|
+
description: '为用户创建一条当前会话内的定时提醒。必须提供非空提示与恰好一个选择器:after_seconds(正整数延时秒)、at(显式偏移时间或 {date,time,time_zone} 本地时间)、every_seconds(≥300 的固定间隔秒)。time_zone 可选,缺省用会话检测时区。创建来源被标记为用户工具,GUI 面板会展示。' +
|
|
136
|
+
'every_seconds 是**无结束条件**的固定间隔(会一直发下去,直到用户删除);用户说了次数上限(如「每小时提醒我一次,持续 3 次」)时不要用 every_seconds,改为**逐条调用本工具 N 次**、每次用 at 给出对应的绝对时刻(t+1h、t+2h、t+3h)。',
|
|
123
137
|
parameters: {
|
|
124
138
|
prompt: {
|
|
125
139
|
type: 'string',
|
|
@@ -127,7 +141,7 @@ function registerUserScheduleTools(rootCtx, toolCtx, agent, getSettings, onUserC
|
|
|
127
141
|
description: `提醒内容(trim 后非空,≤${resolveSchedulerPromptLimits(getSettings()).maxChars} 字符;超过会返回 invalid_prompt)。`,
|
|
128
142
|
},
|
|
129
143
|
after_seconds: { type: 'number', description: '正整数延时秒数。' },
|
|
130
|
-
every_seconds: { type: 'number', description: '固定间隔秒数,至少 300
|
|
144
|
+
every_seconds: { type: 'number', description: '固定间隔秒数,至少 300;无结束条件(有次数上限请改为逐条创建 at)。' },
|
|
131
145
|
at: {
|
|
132
146
|
description: '绝对目标:显式偏移的 RFC 3339 字符串,或 {date, time, time_zone} 本地日历对象。',
|
|
133
147
|
oneOf: [
|
|
@@ -263,6 +277,8 @@ export function apply(ctx, config) {
|
|
|
263
277
|
confirmDelivery: true,
|
|
264
278
|
onUnconfirmed: 'keep',
|
|
265
279
|
clockIconStyle: 'auto',
|
|
280
|
+
// Beta 功能默认开:语法已经过 38 个单测,关闭它只会把用户推向报错。
|
|
281
|
+
advancedSyntax: true,
|
|
266
282
|
};
|
|
267
283
|
let getSettings = () => startSettings;
|
|
268
284
|
ctx.inject(['settings'], (settingsCtx) => {
|
|
@@ -300,6 +316,8 @@ export function apply(ctx, config) {
|
|
|
300
316
|
clockIconStyle: value?.clockIconStyle === 'modern' || value?.clockIconStyle === 'classic'
|
|
301
317
|
? value.clockIconStyle
|
|
302
318
|
: 'auto',
|
|
319
|
+
// 仅显式 false 关闭;缺失/非法一律按默认开(与 schema 的 default(true) 同源)
|
|
320
|
+
advancedSyntax: value?.advancedSyntax !== false,
|
|
303
321
|
});
|
|
304
322
|
// ── 轨道 1:0.2.0(settings rewrite 第二版)─────────────────────────────
|
|
305
323
|
//
|
package/lib/ownership-store.js
CHANGED
|
@@ -26,6 +26,33 @@
|
|
|
26
26
|
import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync, existsSync, unlinkSync, readdirSync, copyFileSync, } from 'node:fs';
|
|
27
27
|
import { homedir } from 'node:os';
|
|
28
28
|
import { join } from 'node:path';
|
|
29
|
+
/**
|
|
30
|
+
* 校验并收窄任意来源(含磁盘 JSON)的附件块数组。
|
|
31
|
+
*
|
|
32
|
+
* sidecar 是本地文件,可能被外部改写或被旧版本写入半成品;而附件块最终会
|
|
33
|
+
* 拼进**以用户身份代发**的消息 content,故读路径必须视为不可信输入:
|
|
34
|
+
* 只放行 `{type:'image'|'file', attachment:{attachmentId: string}}` 形状的块,
|
|
35
|
+
* 其余一律丢弃(不抛错——单块损坏不应让整份 sidecar 不可读)。
|
|
36
|
+
*/
|
|
37
|
+
export function sanitizeAttachments(value) {
|
|
38
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
39
|
+
return undefined;
|
|
40
|
+
const blocks = [];
|
|
41
|
+
for (const item of value) {
|
|
42
|
+
if (typeof item !== 'object' || item === null)
|
|
43
|
+
continue;
|
|
44
|
+
const block = item;
|
|
45
|
+
if (block.type !== 'image' && block.type !== 'file')
|
|
46
|
+
continue;
|
|
47
|
+
const ref = block.attachment;
|
|
48
|
+
if (typeof ref !== 'object' || ref === null)
|
|
49
|
+
continue;
|
|
50
|
+
if (typeof ref.attachmentId !== 'string')
|
|
51
|
+
continue;
|
|
52
|
+
blocks.push(item);
|
|
53
|
+
}
|
|
54
|
+
return blocks.length > 0 ? blocks : undefined;
|
|
55
|
+
}
|
|
29
56
|
/**
|
|
30
57
|
* 显式状态目录覆盖(测试 / 便携场景)。
|
|
31
58
|
*
|
|
@@ -140,10 +167,23 @@ function readFresh(sessionId) {
|
|
|
140
167
|
throw new Error('unexpected shape');
|
|
141
168
|
}
|
|
142
169
|
// v1 → v2:补空 paused,写回时统一升到 2
|
|
170
|
+
//
|
|
171
|
+
// 附件块在**读路径**收窄(sidecar 是本地文件,视同不可信输入):旧版本
|
|
172
|
+
// 写入的半成品或外部改写在此被滤掉,下游(代发拼 content)只见到合法块。
|
|
173
|
+
const entries = {};
|
|
174
|
+
for (const [id, entry] of Object.entries(parsed.entries)) {
|
|
175
|
+
const attachments = sanitizeAttachments(entry.attachments);
|
|
176
|
+
entries[id] = attachments === undefined ? entry : { ...entry, attachments };
|
|
177
|
+
}
|
|
178
|
+
const paused = {};
|
|
179
|
+
for (const [uid, entry] of Object.entries(parsed.paused ?? {})) {
|
|
180
|
+
const attachments = sanitizeAttachments(entry.attachments);
|
|
181
|
+
paused[uid] = attachments === undefined ? entry : { ...entry, attachments };
|
|
182
|
+
}
|
|
143
183
|
const file = {
|
|
144
184
|
version: 2,
|
|
145
|
-
entries
|
|
146
|
-
paused
|
|
185
|
+
entries,
|
|
186
|
+
paused,
|
|
147
187
|
};
|
|
148
188
|
cache.set(sessionId, { mtimeMs: stat.mtimeMs, size: stat.size, file });
|
|
149
189
|
return file;
|
|
@@ -216,13 +256,15 @@ export function newTaskUid() {
|
|
|
216
256
|
return globalThis.crypto?.randomUUID?.() ?? `uid-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
217
257
|
}
|
|
218
258
|
/** 记录(新增或覆盖)一条所有权;幂等。仅显式传入 `uid` 时写入稳定身份。 */
|
|
219
|
-
export function recordOwnership(sessionId, id, delivery, uid, windowSeconds) {
|
|
259
|
+
export function recordOwnership(sessionId, id, delivery, uid, windowSeconds, attachments) {
|
|
260
|
+
const safeAttachments = sanitizeAttachments(attachments);
|
|
220
261
|
if (typeof sessionId !== 'string' || sessionId.length === 0 || typeof id !== 'string' || id.length === 0) {
|
|
221
262
|
const bare = {
|
|
222
263
|
delivery,
|
|
223
264
|
createdAt: Date.now(),
|
|
224
265
|
...(uid !== undefined ? { uid } : {}),
|
|
225
266
|
...(typeof windowSeconds === 'number' ? { windowSeconds } : {}),
|
|
267
|
+
...(safeAttachments !== undefined ? { attachments: safeAttachments } : {}),
|
|
226
268
|
};
|
|
227
269
|
return bare;
|
|
228
270
|
}
|
|
@@ -230,11 +272,16 @@ export function recordOwnership(sessionId, id, delivery, uid, windowSeconds) {
|
|
|
230
272
|
const entries = { ...file.entries };
|
|
231
273
|
const nextUid = uid ?? entries[id]?.uid;
|
|
232
274
|
const nextWindow = windowSeconds ?? entries[id]?.windowSeconds;
|
|
275
|
+
// 附件与 uid/window 同语义:未显式传入时保留旧条目值,保证「pause 回填 uid」
|
|
276
|
+
// 与「flush 失败回滚」等覆盖写不丢附件;显式传入(含 undefined 清空语义由
|
|
277
|
+
// 调用方用 previousEntry?.attachments 表达)以传入值为准。
|
|
278
|
+
const nextAttachments = attachments !== undefined ? safeAttachments : entries[id]?.attachments;
|
|
233
279
|
const entry = {
|
|
234
280
|
delivery,
|
|
235
281
|
createdAt: Date.now(),
|
|
236
282
|
...(nextUid !== undefined ? { uid: nextUid } : {}),
|
|
237
283
|
...(typeof nextWindow === 'number' ? { windowSeconds: nextWindow } : {}),
|
|
284
|
+
...(nextAttachments !== undefined ? { attachments: nextAttachments } : {}),
|
|
238
285
|
};
|
|
239
286
|
entries[id] = entry;
|
|
240
287
|
writeThrough(sessionId, { version: 2, entries, paused: file.paused ?? {} });
|
package/lib/runtime.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createUserMessage, boundContextSummary } from '@deepseek-ai/dsh-llm';
|
|
|
2
2
|
import { foldScheduleEvents, renderReminderFraming, resolveEveryOccurrence, ScheduleId, } from '@deepseek-ai/dsh-schedule';
|
|
3
3
|
import { renderRecurringBatchFraming as renderEveryReminderBatchFraming, scheduleMessageSource, } from './schedule-compat.js';
|
|
4
4
|
import { OWNED_EVENT } from './domain.js';
|
|
5
|
-
import { findScheduleIdByUid, getPaused, removePaused } from './ownership-store.js';
|
|
5
|
+
import { findScheduleIdByUid, getOwnership, getPaused, removePaused } from './ownership-store.js';
|
|
6
6
|
import { foldUserState } from './user-tools.js';
|
|
7
7
|
import { confirmDelivery, messageIdOf } from './delivery.js';
|
|
8
8
|
import { detectTimeZone, formatHhmm } from './time-utils.js';
|
|
@@ -453,9 +453,14 @@ export class UserScheduleRuntime {
|
|
|
453
453
|
if (paused === undefined)
|
|
454
454
|
return undefined;
|
|
455
455
|
const asUser = paused.delivery === 'user';
|
|
456
|
+
// 附件随留档冻结(pause 时带出),代发/插话时原样拼回,与 buildMessage 同形。
|
|
457
|
+
const attachmentBlocks = paused.attachments ?? [];
|
|
456
458
|
const message = asUser
|
|
457
459
|
? createUserMessage({
|
|
458
|
-
content: [
|
|
460
|
+
content: [
|
|
461
|
+
{ type: 'text', text: paused.prompt },
|
|
462
|
+
...attachmentBlocks,
|
|
463
|
+
],
|
|
459
464
|
source: { kind: 'user' },
|
|
460
465
|
})
|
|
461
466
|
: createUserMessage({
|
|
@@ -494,7 +499,9 @@ export class UserScheduleRuntime {
|
|
|
494
499
|
* 构造注入的消息,按投递形态分流:
|
|
495
500
|
* - `user`(`/later` 显式请求的延迟发送):原样内容 + `source.kind='user'`,
|
|
496
501
|
* 让 GUI 呈现为「我」发出的普通气泡(产品取舍:用户明确要求以本人身份)。
|
|
497
|
-
* 跳过 framing 防护 ——
|
|
502
|
+
* 跳过 framing 防护 —— 这是「代发」语义,不是注入。随命令提交的附件
|
|
503
|
+
* (图片/文件耐久引用,存于 sidecar)按记录顺序拼在文本块之后,
|
|
504
|
+
* 与用户自己发「文字+图」的消息形状一致。
|
|
498
505
|
* - `context`(默认):复用 dsh-schedule 的注入防护 framing + notice 表单,
|
|
499
506
|
* 保持「上下文注入」的可信审计线;多条一次性到期合并为批次 framing(P1-5),
|
|
500
507
|
* 形状与 dsh-schedule 的固定间隔批次一致。
|
|
@@ -506,8 +513,13 @@ export class UserScheduleRuntime {
|
|
|
506
513
|
// 兜底:任一条带 user 标记即按代发处理,与旧语义一致。
|
|
507
514
|
const asUser = decision.delivery === 'user' || records.some((record) => delivery.get(record.id) === 'user');
|
|
508
515
|
if (asUser) {
|
|
516
|
+
const content = [
|
|
517
|
+
{ type: 'text', text: records.map((record) => record.prompt).join('\n') },
|
|
518
|
+
];
|
|
519
|
+
for (const record of records)
|
|
520
|
+
content.push(...this.attachmentsOf(record.id));
|
|
509
521
|
return createUserMessage({
|
|
510
|
-
content
|
|
522
|
+
content,
|
|
511
523
|
source: { kind: 'user' },
|
|
512
524
|
});
|
|
513
525
|
}
|
|
@@ -522,8 +534,13 @@ export class UserScheduleRuntime {
|
|
|
522
534
|
const records = decision.reminders.map((reminder) => reminder.record);
|
|
523
535
|
// 固定间隔批次里任一条以「代发」为准(当前入口不会产生该组合,防御性保留)
|
|
524
536
|
if (records.some((record) => delivery.get(record.id) === 'user')) {
|
|
537
|
+
const content = [
|
|
538
|
+
{ type: 'text', text: records.map((record) => record.prompt).join('\n') },
|
|
539
|
+
];
|
|
540
|
+
for (const record of records)
|
|
541
|
+
content.push(...this.attachmentsOf(record.id));
|
|
525
542
|
return createUserMessage({
|
|
526
|
-
content
|
|
543
|
+
content,
|
|
527
544
|
source: { kind: 'user' },
|
|
528
545
|
});
|
|
529
546
|
}
|
|
@@ -532,6 +549,23 @@ export class UserScheduleRuntime {
|
|
|
532
549
|
source: scheduleMessageSource(boundContextSummary(noticeSummary(records))),
|
|
533
550
|
});
|
|
534
551
|
}
|
|
552
|
+
/**
|
|
553
|
+
* 读某条提醒随命令提交的附件(sidecar 权威;无会话 id / 无条目时为空)。
|
|
554
|
+
*
|
|
555
|
+
* 只在 `user` 代发路径调用——context 注入通道从未声明接受附件(见 commands.ts),
|
|
556
|
+
* 不会把图片拼进防护 framing。sidecar 读路径已做块级收窄(sanitizeAttachments)。
|
|
557
|
+
*/
|
|
558
|
+
attachmentsOf(scheduleId) {
|
|
559
|
+
const sessionId = this.agent.session.header?.id;
|
|
560
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0)
|
|
561
|
+
return [];
|
|
562
|
+
try {
|
|
563
|
+
return getOwnership(sessionId, scheduleId)?.attachments ?? [];
|
|
564
|
+
}
|
|
565
|
+
catch {
|
|
566
|
+
return [];
|
|
567
|
+
}
|
|
568
|
+
}
|
|
535
569
|
}
|
|
536
570
|
/** notice 摘要:每条 `HH:mm · 内容`,最多列 3 条,余者以计数收尾。 */
|
|
537
571
|
function noticeSummary(records) {
|
package/lib/smart-window.js
CHANGED
|
@@ -226,9 +226,34 @@ export function epochFromLocal(date, time, timeZone) {
|
|
|
226
226
|
day > 31) {
|
|
227
227
|
return undefined;
|
|
228
228
|
}
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
|
|
229
|
+
// 「哪一天」必须由**请求的日历日期**决定,不能从某个探测瞬点反推:
|
|
230
|
+
// 旧实现传 `Date.UTC(y,m-1,d)`(UTC 零点)给 resolveWallClock,它再用
|
|
231
|
+
// localFieldsOf 读「当天」——在 UTC 负偏移时区(America/Los_Angeles 等)那个
|
|
232
|
+
// 瞬间读出来的是**前一天**,于是 `2026-11-01 09:00 @LA` 解成 10-31 09:00,
|
|
233
|
+
// 面板自定义日期与 `/later 明天9点` 整体错一天(正偏移 ≤ +11 的时区恰好看不出)。
|
|
234
|
+
// 这里改为直接以请求日期为判据,在候选偏移里挑字段完全吻合的最小瞬点。
|
|
235
|
+
const localEpoch = Date.UTC(year, month - 1, day, hour, minute, second, 0);
|
|
236
|
+
const offsets = new Set();
|
|
237
|
+
for (let delta = -2; delta <= 2; delta += 1) {
|
|
238
|
+
offsets.add(offsetOf(localEpoch + delta * DAY_MS, timeZone));
|
|
239
|
+
}
|
|
240
|
+
const candidates = [];
|
|
241
|
+
for (const offset of offsets) {
|
|
242
|
+
const candidate = localEpoch - offset;
|
|
243
|
+
const f = localFieldsOf(candidate, timeZone);
|
|
244
|
+
if (f.year === year &&
|
|
245
|
+
f.month === month &&
|
|
246
|
+
f.day === day &&
|
|
247
|
+
f.hour === hour &&
|
|
248
|
+
f.minute === minute &&
|
|
249
|
+
f.second === second) {
|
|
250
|
+
candidates.push(candidate);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// 回拨日同一墙钟出现两次 → 取较早的一次(与 dsh-schedule 的 weekly/daily 口径一致);
|
|
254
|
+
// 前拨跳过的墙钟 → 无候选 → undefined,由调用方给出「该时刻不存在」的提示。
|
|
255
|
+
candidates.sort((a, b) => a - b);
|
|
256
|
+
return candidates[0];
|
|
232
257
|
}
|
|
233
258
|
/**
|
|
234
259
|
* 智能模式的一站式目标构造:返回可直接作为 `at` 对象使用的本地日历字段。
|
package/lib/types/commands.d.ts
CHANGED
|
@@ -50,28 +50,60 @@ export type ParsedScheduleSpec = {
|
|
|
50
50
|
ok: false;
|
|
51
51
|
error: string;
|
|
52
52
|
};
|
|
53
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* `parseScheduleInput` 的结果:时间目标 + 剩余内容。
|
|
55
|
+
*
|
|
56
|
+
* `targets` 恒为 1..MAX_OCCURRENCES 个未来时刻:单条写法就是 1 个;
|
|
57
|
+
* `+1h*3` / `+1h+30m` 这类多次写法给出多个,由命令层展开成同等数量的任务。
|
|
58
|
+
* `target` 恒等于 `targets[0]`,为既有调用方保留。
|
|
59
|
+
*/
|
|
54
60
|
export type ParsedScheduleInput = {
|
|
55
61
|
ok: true;
|
|
56
|
-
target:
|
|
57
|
-
|
|
58
|
-
afterSeconds: number;
|
|
59
|
-
} | {
|
|
60
|
-
kind: 'at';
|
|
61
|
-
at: {
|
|
62
|
-
date: string;
|
|
63
|
-
time: string;
|
|
64
|
-
time_zone: string;
|
|
65
|
-
};
|
|
66
|
-
epoch: number;
|
|
67
|
-
};
|
|
62
|
+
target: ResolvedTarget;
|
|
63
|
+
targets: readonly ResolvedTarget[];
|
|
68
64
|
content: string;
|
|
69
65
|
} | {
|
|
70
66
|
ok: false;
|
|
71
67
|
error: string;
|
|
72
68
|
};
|
|
69
|
+
/** 语法开关(来自 settings.advancedSyntax)。 */
|
|
70
|
+
export interface ScheduleSyntaxOptions {
|
|
71
|
+
/**
|
|
72
|
+
* 高级语法(Beta)是否可用。
|
|
73
|
+
*
|
|
74
|
+
* `undefined` 与 `true` 都按开启处理——只有显式 `false` 才关闭,
|
|
75
|
+
* 与 schema 的 `default(true)` 及旧调用方(不传第三参)的行为一致。
|
|
76
|
+
*/
|
|
77
|
+
readonly advancedSyntax?: boolean;
|
|
78
|
+
}
|
|
79
|
+
/** 关闭高级语法时的统一提示:说清去哪儿开,而不是只说"不支持"。 */
|
|
80
|
+
export declare const ADVANCED_SYNTAX_DISABLED: string;
|
|
81
|
+
/** 一次命令最多展开出的任务条数(`+1h*11`、11 段序列一律拒绝)。 */
|
|
82
|
+
export declare const MAX_OCCURRENCES = 10;
|
|
83
|
+
/**
|
|
84
|
+
* 多次写法下相邻两次之间的最小间隔(秒)。
|
|
85
|
+
*
|
|
86
|
+
* 只约束「多次」:单条 `/later +5s` 是既有合法用法(README 演示与测试都依赖它),
|
|
87
|
+
* 不因这道地板失效。`every` 记录在宿主侧另有 ≥300s 口径;多次写法走的是
|
|
88
|
+
* 「展开成 N 条 one-shot」,不经过 `every`,所以这里自带一道 60s 防护,
|
|
89
|
+
* 免得 `+1s*10` 一行命令把 dock 刷成瀑布。
|
|
90
|
+
*/
|
|
91
|
+
export declare const MIN_OCCURRENCE_INTERVAL_SECONDS = 60;
|
|
92
|
+
/** 目标解析结果(时间 → 具体时刻/时长的落定)。 */
|
|
93
|
+
type ResolvedTarget = {
|
|
94
|
+
kind: 'after';
|
|
95
|
+
afterSeconds: number;
|
|
96
|
+
} | {
|
|
97
|
+
kind: 'at';
|
|
98
|
+
at: {
|
|
99
|
+
date: string;
|
|
100
|
+
time: string;
|
|
101
|
+
time_zone: string;
|
|
102
|
+
};
|
|
103
|
+
epoch: number;
|
|
104
|
+
};
|
|
73
105
|
/**
|
|
74
|
-
* 解析 `/schedule` 的完整输入:开头的时间表达式 + 剩余内容。
|
|
106
|
+
* 解析 `/schedule` 与 `/later` 的完整输入:开头的时间表达式 + 剩余内容。
|
|
75
107
|
*
|
|
76
108
|
* 支持格式(示例):
|
|
77
109
|
* - `+30m` `+1h30m` `+90s` `+1w`
|
|
@@ -80,13 +112,22 @@ export type ParsedScheduleInput = {
|
|
|
80
112
|
* - `明天 15:32` `后天9点` `大后天 10:00`
|
|
81
113
|
* - `8月21日 15:32` `08-21 15:32`
|
|
82
114
|
* - `2026-08-21 15:32` `0821-1532` `20260821-1532`
|
|
115
|
+
* - 等间隔重复:`+1h*3` `+30m×2` `两小时后x3次` → t+1h、t+2h、t+3h
|
|
116
|
+
* - 链式序列:`+1h+1h+0.5h` → t+1h、t+2h、t+2.5h(梯度催办)
|
|
117
|
+
*
|
|
118
|
+
* 多次写法一律展开成 N 条 `after` 一次性任务(不是 1 条 `every`),理由见
|
|
119
|
+
* {@link expandOccurrences}。上限 `MAX_OCCURRENCES`,多次写法的最小间隔
|
|
120
|
+
* `MIN_OCCURRENCE_INTERVAL_SECONDS`。
|
|
83
121
|
*
|
|
84
122
|
* `limits` 可选:未传时回落到 `DEFAULT_MAX_PROMPT_CHARS=1000`,错误文案里
|
|
85
123
|
* 的字符数与 settings 实际生效值一致。
|
|
86
124
|
*/
|
|
87
|
-
export declare function parseScheduleInput(raw: string, now: number, timeZone: string, limits?: PromptLimits): ParsedScheduleInput;
|
|
125
|
+
export declare function parseScheduleInput(raw: string, now: number, timeZone: string, limits?: PromptLimits, options?: ScheduleSyntaxOptions): ParsedScheduleInput;
|
|
88
126
|
/**
|
|
89
|
-
*
|
|
127
|
+
* 兼容旧签名:解析纯时间描述(不含内容),**只支持单条**。
|
|
128
|
+
*
|
|
129
|
+
* 多次/序列写法会被明确拒绝:本函数的消费方(GUI 单次通道)一次只能落一条记录,
|
|
130
|
+
* 静默取第一条等于把用户的「3 次」吃掉。
|
|
90
131
|
*/
|
|
91
132
|
export declare function parseScheduleSpec(spec: string, now: number, timeZone: string): ParsedScheduleSpec;
|
|
92
133
|
/** 命令工厂:返回三个命令定义。
|
|
@@ -100,10 +141,13 @@ export declare function userScheduleCommands(ctx: Context, getSettings?: number
|
|
|
100
141
|
maxSchedules: number;
|
|
101
142
|
allowLongPrompts?: boolean;
|
|
102
143
|
maxPromptChars?: number;
|
|
144
|
+
advancedSyntax?: boolean;
|
|
103
145
|
}), onUserChange?: UserChangeNotifier, onUserSteer?: UserSteerHandler): CommandDefinition[];
|
|
104
146
|
/** 注册三个命令,返回统一 disposer。 */
|
|
105
147
|
export declare function registerUserScheduleCommands(ctx: Context, getSettings?: number | (() => number) | (() => {
|
|
106
148
|
maxSchedules: number;
|
|
107
149
|
allowLongPrompts?: boolean;
|
|
108
150
|
maxPromptChars?: number;
|
|
151
|
+
advancedSyntax?: boolean;
|
|
109
152
|
}), onUserChange?: UserChangeNotifier, onUserSteer?: UserSteerHandler): () => void;
|
|
153
|
+
export {};
|
package/lib/types/index.d.ts
CHANGED
|
@@ -45,6 +45,8 @@ export interface SchedulerSettings {
|
|
|
45
45
|
readonly onUnconfirmed: UserScheduleOnUnconfirmed;
|
|
46
46
|
/** 时钟图标风格(auto / modern / classic)。 */
|
|
47
47
|
readonly clockIconStyle: 'auto' | 'modern' | 'classic';
|
|
48
|
+
/** 高级语法(Beta):多次写法是否可用。默认开。 */
|
|
49
|
+
readonly advancedSyntax: boolean;
|
|
48
50
|
}
|
|
49
51
|
/** 插话投递方式。 */
|
|
50
52
|
export type UserScheduleSteerDelivery = 'followup' | 'steer';
|
|
@@ -1,4 +1,23 @@
|
|
|
1
|
+
import type { FileBlock, ImageBlock } from '@deepseek-ai/dsh-llm';
|
|
1
2
|
import type { UserScheduleDelivery } from './domain.js';
|
|
3
|
+
/**
|
|
4
|
+
* 随提醒一起延迟发送的附件块(宿主命令准入后的耐久引用,JSON 可序列化)。
|
|
5
|
+
*
|
|
6
|
+
* `/later` 声明 `input.attachments` 后,命令框架把 composer 附件做耐久化准入
|
|
7
|
+
* (图片→内容寻址存储,文件→staged receipt 解析),以这两种块交给 handler。
|
|
8
|
+
* 块内只持有不透明 `attachmentId` 引用(非路径、非字节),因此可以安全地
|
|
9
|
+
* 落进 sidecar JSON,到点投递时再拼回 user message 的 content。
|
|
10
|
+
*/
|
|
11
|
+
export type ScheduledAttachment = ImageBlock | FileBlock;
|
|
12
|
+
/**
|
|
13
|
+
* 校验并收窄任意来源(含磁盘 JSON)的附件块数组。
|
|
14
|
+
*
|
|
15
|
+
* sidecar 是本地文件,可能被外部改写或被旧版本写入半成品;而附件块最终会
|
|
16
|
+
* 拼进**以用户身份代发**的消息 content,故读路径必须视为不可信输入:
|
|
17
|
+
* 只放行 `{type:'image'|'file', attachment:{attachmentId: string}}` 形状的块,
|
|
18
|
+
* 其余一律丢弃(不抛错——单块损坏不应让整份 sidecar 不可读)。
|
|
19
|
+
*/
|
|
20
|
+
export declare function sanitizeAttachments(value: unknown): readonly ScheduledAttachment[] | undefined;
|
|
2
21
|
/** 单条所有权记录。 */
|
|
3
22
|
export interface OwnedEntry {
|
|
4
23
|
/** 到点注入形态(/later 为 'user',其余 'context')。 */
|
|
@@ -15,6 +34,14 @@ export interface OwnedEntry {
|
|
|
15
34
|
* 若直接当窗口会从 0 重跑;此处保留原 after 间隔供 wire/进度条使用。
|
|
16
35
|
*/
|
|
17
36
|
readonly windowSeconds?: number;
|
|
37
|
+
/**
|
|
38
|
+
* `/later` 随命令提交的附件(到点代发时拼进消息 content)。
|
|
39
|
+
*
|
|
40
|
+
* 为什么不进会话日志:`schedule/change` 荷载被 dsh-schedule 严格解码
|
|
41
|
+
* (hasExactKeys),追加字段会让整份历史拒读(见 domain.ts 的兼容性说明);
|
|
42
|
+
* sidecar 是本插件私有存储,加字段天然向后兼容。
|
|
43
|
+
*/
|
|
44
|
+
readonly attachments?: readonly ScheduledAttachment[];
|
|
18
45
|
}
|
|
19
46
|
/** 暂停留档(仅 kind==='after';不进会话日志)。 */
|
|
20
47
|
export interface PausedEntry {
|
|
@@ -31,6 +58,8 @@ export interface PausedEntry {
|
|
|
31
58
|
/** 暂停前的日志 schedule id(审计/关联)。 */
|
|
32
59
|
readonly lastScheduleId: string;
|
|
33
60
|
readonly pausedAt: number;
|
|
61
|
+
/** 暂停时随条目冻结的附件(resume/插话投递时原样带回)。 */
|
|
62
|
+
readonly attachments?: readonly ScheduledAttachment[];
|
|
34
63
|
}
|
|
35
64
|
/** 读取某会话的全部所有权记录(缓存优先;返回副本语义,调用方不得变更)。 */
|
|
36
65
|
export declare function allOwnership(sessionId: string): Readonly<Record<string, OwnedEntry>>;
|
|
@@ -41,7 +70,7 @@ export declare function getOwnership(sessionId: string, id: string): OwnedEntry
|
|
|
41
70
|
/** 生成稳定插件 uid(pause/resume 跨日志 id 变更)。 */
|
|
42
71
|
export declare function newTaskUid(): string;
|
|
43
72
|
/** 记录(新增或覆盖)一条所有权;幂等。仅显式传入 `uid` 时写入稳定身份。 */
|
|
44
|
-
export declare function recordOwnership(sessionId: string, id: string, delivery: UserScheduleDelivery, uid?: string, windowSeconds?: number): OwnedEntry;
|
|
73
|
+
export declare function recordOwnership(sessionId: string, id: string, delivery: UserScheduleDelivery, uid?: string, windowSeconds?: number, attachments?: readonly ScheduledAttachment[]): OwnedEntry;
|
|
45
74
|
/** 撤销一条所有权;幂等(不存在时无操作)。 */
|
|
46
75
|
export declare function removeOwnership(sessionId: string, id: string): void;
|
|
47
76
|
/** 按 uid 找当前仍 active 的 schedule id(缺失 undefined)。 */
|
package/lib/types/runtime.d.ts
CHANGED
|
@@ -158,11 +158,20 @@ export declare class UserScheduleRuntime {
|
|
|
158
158
|
* 构造注入的消息,按投递形态分流:
|
|
159
159
|
* - `user`(`/later` 显式请求的延迟发送):原样内容 + `source.kind='user'`,
|
|
160
160
|
* 让 GUI 呈现为「我」发出的普通气泡(产品取舍:用户明确要求以本人身份)。
|
|
161
|
-
* 跳过 framing 防护 ——
|
|
161
|
+
* 跳过 framing 防护 —— 这是「代发」语义,不是注入。随命令提交的附件
|
|
162
|
+
* (图片/文件耐久引用,存于 sidecar)按记录顺序拼在文本块之后,
|
|
163
|
+
* 与用户自己发「文字+图」的消息形状一致。
|
|
162
164
|
* - `context`(默认):复用 dsh-schedule 的注入防护 framing + notice 表单,
|
|
163
165
|
* 保持「上下文注入」的可信审计线;多条一次性到期合并为批次 framing(P1-5),
|
|
164
166
|
* 形状与 dsh-schedule 的固定间隔批次一致。
|
|
165
167
|
*/
|
|
166
168
|
private buildMessage;
|
|
169
|
+
/**
|
|
170
|
+
* 读某条提醒随命令提交的附件(sidecar 权威;无会话 id / 无条目时为空)。
|
|
171
|
+
*
|
|
172
|
+
* 只在 `user` 代发路径调用——context 注入通道从未声明接受附件(见 commands.ts),
|
|
173
|
+
* 不会把图片拼进防护 framing。sidecar 读路径已做块级收窄(sanitizeAttachments)。
|
|
174
|
+
*/
|
|
175
|
+
private attachmentsOf;
|
|
167
176
|
}
|
|
168
177
|
export {};
|
|
@@ -21,7 +21,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent';
|
|
|
21
21
|
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session';
|
|
22
22
|
import type { ScheduleRecord, ScheduleView } from '@deepseek-ai/dsh-schedule';
|
|
23
23
|
import type { UserScheduleDelivery } from './domain.js';
|
|
24
|
-
import type { PausedEntry } from './ownership-store.js';
|
|
24
|
+
import type { PausedEntry, ScheduledAttachment } from './ownership-store.js';
|
|
25
25
|
/** 单 session 用户任务上限(PRD:防滥用)。 */
|
|
26
26
|
export declare const DEFAULT_MAX_SCHEDULES = 100;
|
|
27
27
|
/**
|
|
@@ -195,6 +195,14 @@ export declare function runUserScheduleTransaction<T>(agent: Agent, operation: (
|
|
|
195
195
|
export interface UserScheduleCreateOptions {
|
|
196
196
|
/** 可信通道显式声明的投递形态;未提供或非 'user' 一律按安全默认 'context'。 */
|
|
197
197
|
readonly trustedDelivery?: UserScheduleDelivery;
|
|
198
|
+
/**
|
|
199
|
+
* 可信通道(人类显式输入的 `/later`)随命令提交的附件块。
|
|
200
|
+
*
|
|
201
|
+
* 与 `trustedDelivery` 同一道闸门:**只**接受选项传入,原始输入载荷里的
|
|
202
|
+
* `attachments` 字段不具任何效力——模型工具与 GUI JSON 载荷永远带不进附件,
|
|
203
|
+
* 防止任何通道夹带未准入的对象拼进代发消息。
|
|
204
|
+
*/
|
|
205
|
+
readonly attachments?: readonly ScheduledAttachment[];
|
|
198
206
|
}
|
|
199
207
|
/** `user_schedule_create` 核心实现。 */
|
|
200
208
|
export declare function userScheduleCreate(input: unknown, agent: Agent, ctx: Context, maxSchedules?: number, options?: UserScheduleCreateOptions, limits?: PromptLimits): Promise<UserScheduleCreateResult>;
|
package/lib/user-tools.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ScheduleId, ScheduleInputError, foldScheduleEvents, scheduleView, } from '@deepseek-ai/dsh-schedule';
|
|
2
2
|
import { allocateScheduleIdCompat as allocateScheduleId, createAfterRecord as createAfterScheduleRecord, createAtRecord as createAtScheduleRecord, createEveryRecord as createEveryScheduleRecord, toChangePayload, asLegacySchedules, } from './schedule-compat.js';
|
|
3
3
|
import { OWNED_EVENT } from './domain.js';
|
|
4
|
-
import { allOwnership, allPaused, findPausedByScheduleId, findScheduleIdByUid, getOwnership, getPaused, newTaskUid, recordOwnership, recordPaused, removeOwnership, removePaused, } from './ownership-store.js';
|
|
4
|
+
import { allOwnership, allPaused, findPausedByScheduleId, findScheduleIdByUid, getOwnership, getPaused, newTaskUid, recordOwnership, recordPaused, removeOwnership, removePaused, sanitizeAttachments, } from './ownership-store.js';
|
|
5
5
|
import { detectTimeZone } from './time-utils.js';
|
|
6
6
|
import { readInheritedEventCount, readOwnEvents } from './upstream-compat.js';
|
|
7
7
|
/** 单 session 用户任务上限(PRD:防滥用)。 */
|
|
@@ -268,6 +268,9 @@ export async function userScheduleCreate(input, agent, ctx, maxSchedules = DEFAU
|
|
|
268
268
|
return validated;
|
|
269
269
|
// 只认可信通道显式声明的形态;输入载荷中的 delivery 字段不具任何效力。
|
|
270
270
|
const trustedDelivery = options?.trustedDelivery === 'user' ? 'user' : 'context';
|
|
271
|
+
// 附件同理:仅取选项(可信通道)传入的块,并再过一次收窄(防御纵深——
|
|
272
|
+
// 命令框架的准入已保证形状,但 options 是跨模块边界,不假设上游不变式)。
|
|
273
|
+
const trustedAttachments = sanitizeAttachments(options?.attachments);
|
|
271
274
|
return runUserScheduleTransaction(agent, async () => {
|
|
272
275
|
const now = Date.now();
|
|
273
276
|
let folded;
|
|
@@ -299,7 +302,7 @@ export async function userScheduleCreate(input, agent, ctx, maxSchedules = DEFAU
|
|
|
299
302
|
// 所有权写 sidecar(append 之前:投影 fold 在事件到达时按 sidecar 判定归属)。
|
|
300
303
|
// flush 失败则回滚,避免 sidecar 与日志漂移。
|
|
301
304
|
const sessionId = agent.session.header?.id;
|
|
302
|
-
recordOwnership(sessionId, id, trustedDelivery, newTaskUid(), record.kind === 'after' ? record.afterSeconds : undefined);
|
|
305
|
+
recordOwnership(sessionId, id, trustedDelivery, newTaskUid(), record.kind === 'after' ? record.afterSeconds : undefined, trustedAttachments);
|
|
303
306
|
try {
|
|
304
307
|
agent.session.append('schedule/change', {
|
|
305
308
|
version: 1,
|
|
@@ -487,7 +490,10 @@ export async function userScheduleEditPrompt(id, newPrompt, agent, ctx, limits =
|
|
|
487
490
|
const sessionId = agent.session.header?.id;
|
|
488
491
|
const previousEntry = getOwnership(sessionId, scheduleId);
|
|
489
492
|
// 编辑 after 会重置倒计时 → 窗口等于新 afterSeconds,不沿用 resume 旧窗口
|
|
490
|
-
recordOwnership(sessionId, record.id, delivery, previousEntry?.uid, record.kind === 'after' ? record.afterSeconds : undefined
|
|
493
|
+
recordOwnership(sessionId, record.id, delivery, previousEntry?.uid, record.kind === 'after' ? record.afterSeconds : undefined,
|
|
494
|
+
// 附件跨「删旧建新」保留:编辑只换文案,随命令提交的图片/文件仍属于这条
|
|
495
|
+
// 提醒。新 id 无旧条目可回退,必须显式带上。
|
|
496
|
+
previousEntry?.attachments);
|
|
491
497
|
try {
|
|
492
498
|
agent.session.append('schedule/change', { version: 1, operation: 'delete', id: scheduleId });
|
|
493
499
|
agent.session.append('schedule/change', { version: 1, operation: 'create', schedule: toChangePayload(record) });
|
|
@@ -582,6 +588,9 @@ export async function userSchedulePause(id, agent, ctx) {
|
|
|
582
588
|
...(windowSeconds !== undefined ? { originalAfterSeconds: windowSeconds } : {}),
|
|
583
589
|
lastScheduleId: scheduleId,
|
|
584
590
|
pausedAt: now,
|
|
591
|
+
// 附件随条目冻结:pause 会把日志记录删除,留档是暂停项唯一的素材来源,
|
|
592
|
+
// 不带附件会让「暂停→恢复/插话」静默丢图。
|
|
593
|
+
...(ownership?.attachments !== undefined ? { attachments: ownership.attachments } : {}),
|
|
585
594
|
};
|
|
586
595
|
// sidecar 先写(投影 delete 后按 lastScheduleId 挂回 paused 列表)
|
|
587
596
|
recordPaused(sessionId, pausedEntry);
|
|
@@ -651,7 +660,7 @@ export async function userScheduleResume(uid, agent, ctx, maxSchedules = DEFAULT
|
|
|
651
660
|
// removePaused,视图会短暂同时出现「已暂停」+ 新活动行(实测重复行根因)。
|
|
652
661
|
removePaused(sessionId, uid);
|
|
653
662
|
// 保留原 after 窗口:日志 afterSeconds 只是 remaining,进度条需要原窗口才能接着跑
|
|
654
|
-
recordOwnership(sessionId, record.id, paused.delivery, uid, paused.originalAfterSeconds ?? paused.remainingSeconds);
|
|
663
|
+
recordOwnership(sessionId, record.id, paused.delivery, uid, paused.originalAfterSeconds ?? paused.remainingSeconds, paused.attachments);
|
|
655
664
|
try {
|
|
656
665
|
agent.session.append('schedule/change', {
|
|
657
666
|
version: 1,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-later",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "给 DSH 加上定时发送:输入一句话,到点自动以你的身份送出,关掉网页也准时触发。定时发送 / 定时提醒 / 延迟发送。",
|
|
5
5
|
"icon": "./icon.svg",
|
|
6
6
|
"keywords": [
|
|
@@ -16,11 +16,11 @@
|
|
|
16
16
|
"license": "MIT",
|
|
17
17
|
"repository": {
|
|
18
18
|
"type": "git",
|
|
19
|
-
"url": "git+https://github.com/
|
|
19
|
+
"url": "git+https://github.com/dsh-plugins/dsh-later.git"
|
|
20
20
|
},
|
|
21
|
-
"homepage": "https://github.com/
|
|
21
|
+
"homepage": "https://github.com/dsh-plugins/dsh-later",
|
|
22
22
|
"bugs": {
|
|
23
|
-
"url": "https://github.com/
|
|
23
|
+
"url": "https://github.com/dsh-plugins/dsh-later/issues"
|
|
24
24
|
},
|
|
25
25
|
"type": "module",
|
|
26
26
|
"main": "lib/index.js",
|
|
@@ -73,6 +73,7 @@
|
|
|
73
73
|
"@deepseek-ai/dsh-session-projection": "0.2.0-rc.2",
|
|
74
74
|
"@deepseek-ai/dsh-settings": "0.2.0-rc.2",
|
|
75
75
|
"@deepseek-ai/dsh-tools": "0.2.0-rc.2",
|
|
76
|
+
"@deepseek-ai/dsh-client-ui-primitives": "0.2.0-rc.2",
|
|
76
77
|
"@deepseek-ai/schemastery": "^3.18.4",
|
|
77
78
|
"@types/node": "^22.0.0",
|
|
78
79
|
"@types/react": "^19.2.18",
|