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/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
  //
@@ -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: parsed.entries,
146
- paused: parsed.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: [{ type: 'text', text: paused.prompt }],
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: [{ type: 'text', text: records.map((record) => record.prompt).join('\n') }],
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: [{ type: 'text', text: records.map((record) => record.prompt).join('\n') }],
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) {
@@ -226,9 +226,34 @@ export function epochFromLocal(date, time, timeZone) {
226
226
  day > 31) {
227
227
  return undefined;
228
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);
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` 对象使用的本地日历字段。
@@ -50,28 +50,60 @@ export type ParsedScheduleSpec = {
50
50
  ok: false;
51
51
  error: string;
52
52
  };
53
- /** `parseScheduleInput` 的结果:时间目标 + 剩余内容。 */
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
- 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
- };
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 {};
@@ -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)。 */
@@ -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.2.0",
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/WuJiaoJue/dsh-later.git"
19
+ "url": "git+https://github.com/dsh-plugins/dsh-later.git"
20
20
  },
21
- "homepage": "https://github.com/WuJiaoJue/dsh-later",
21
+ "homepage": "https://github.com/dsh-plugins/dsh-later",
22
22
  "bugs": {
23
- "url": "https://github.com/WuJiaoJue/dsh-later/issues"
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",