dsh-later 1.1.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.
@@ -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)。 */
@@ -23,6 +23,20 @@ export interface PresenceScheduleLike {
23
23
  readonly status?: string;
24
24
  /** 暂停冻结的剩余秒数;仅在 `status === 'paused'` 时有意义。 */
25
25
  readonly remaining_seconds?: number;
26
+ /** 任务类型。决定进度窗口起点按哪个口径推导(见 {@link windowStartOf})。 */
27
+ readonly kind?: string;
28
+ /** `after` 类型的总倒计时秒数(进度起点 = 触发时刻 − 该值)。 */
29
+ readonly after_seconds?: number;
30
+ readonly afterSeconds?: number;
31
+ /** `every` 类型的周期间隔秒数(进度起点 = 触发时刻 − 该值,即本周期起点)。 */
32
+ readonly every_seconds?: number;
33
+ readonly everySeconds?: number;
34
+ /** 宿主/投影直接给的显式总窗口秒数;给了就优先于按 kind 推导。 */
35
+ readonly window_seconds?: number;
36
+ readonly windowSeconds?: number;
37
+ /** `at` 类型的进度起点(任务创建时刻,ISO)。`at` 没有天然周期,只能用它。 */
38
+ readonly created_at?: string;
39
+ readonly createdAt?: number;
26
40
  }
27
41
  /** 由一组活动任务归纳的 badge 概要。 */
28
42
  export interface PresenceSummary {
@@ -32,6 +46,14 @@ export interface PresenceSummary {
32
46
  readonly nextAt?: number;
33
47
  /** 暂停中的任务数(用于文案区分「已冻结」与「等待发送」)。 */
34
48
  readonly pausedCount: number;
49
+ /**
50
+ * 进度环起点(epoch ms):`nextAt` 那条任务的窗口开始时刻。
51
+ *
52
+ * **只跟随 `nextAt` 那条**:环表达的是"距离最近一次触发还剩多少",若取别条
53
+ * 任务的窗口就与 `nextAt` 对不上了。无从推导时缺省,此时调用方应退回恒定
54
+ * 整环({@link progressOf} 返回 `undefined`)。
55
+ */
56
+ readonly progressFrom?: number;
35
57
  }
36
58
  /**
37
59
  * 归纳:取最早触发时刻 + 任务数量。
@@ -49,6 +71,16 @@ export interface PresenceSummary {
49
71
  * 「已到期」或"下次 X 分后"。
50
72
  */
51
73
  export declare function summarizeSchedules(schedules: readonly PresenceScheduleLike[] | undefined): PresenceSummary;
74
+ /**
75
+ * 进度环比例(0–1):`now` 在 `[progressFrom, nextAt]` 中的位置。
76
+ *
77
+ * 返回 `undefined` 表示**无从算起**(缺窗口起点,或窗口已退化),调用方此时应
78
+ * 退回「恒定整环」的旧表现,而不是画一个 0% 的空环 —— 后者是错误信息:
79
+ * 0% 是"刚开始",`undefined` 是"算不出来"。
80
+ *
81
+ * 两端夹住:`overdue` 时 `now > nextAt`,环停在满环而不是绕出去。
82
+ */
83
+ export declare function progressOf(entry: PresenceSummary, now: number): number | undefined;
52
84
  /** 由当前时刻判定 badge 状态。 */
53
85
  export declare function badgeStateFor(nextAt: number | undefined, now: number): PresenceBadgeState;
54
86
  /**
@@ -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 {};
@@ -0,0 +1,75 @@
1
+ /**
2
+ * 定时摘要 store:把 {@link buildScheduleSummary} 的纯计算接到宿主 `sessions`
3
+ * 服务上,得到一个**可订阅的公共数据源**。
4
+ *
5
+ * 为什么需要它(issue #17):dsh-later 自己的侧栏 badge 与外部插件(如
6
+ * dsh-group-status)都需要「全会话定时任务摘要」。两条独立通路会各自订阅
7
+ * `sessions.list`、各自周期 `refresh()`,既浪费 RPC 又容易漂移。本 store 是
8
+ * **唯一**的数据权威:内部负责列表订阅、状态 tick(scheduled→urgent→overdue)
9
+ * 与 `session.list` 读 RPC 补时效;外部只读 {@link ScheduleSummaryStore.getSnapshot}
10
+ * 或订阅变更。
11
+ *
12
+ * 跨插件使用方式(消费方声明 `dsh.client.external: ["dsh-later"]` 后):
13
+ * ```js
14
+ * const later = require('dsh-later/client')
15
+ * const store = later.getScheduleSummaryStore()
16
+ * ```
17
+ *
18
+ * 本模块零 DOM / 零 React 依赖(`document` / `window` 一律经 `globalThis`
19
+ * 结构化探测),宿主 tsc 与浏览器 esbuild 都能编译,`node --test` 也能直接跑。
20
+ * @module dsh-later/schedule-summary-store
21
+ */
22
+ import { type ScheduleSummaryMap, type ScheduleSummarySource } from './schedule-summary.js';
23
+ /** 摘要状态重算周期(本地时钟,零网络)。 */
24
+ export declare const SUMMARY_STATE_TICK_MS = 30000;
25
+ /** `session.list` 重拉周期(读 RPC;仅页面可见时执行)。 */
26
+ export declare const SUMMARY_REFRESH_INTERVAL_MS = 60000;
27
+ /** 宿主 `sessions` 服务的结构子集(只用到列表快照与 refresh)。 */
28
+ export interface ScheduleSummarySessionsLike {
29
+ /** 会话列表 store(快照 + 变更订阅)。 */
30
+ readonly list: {
31
+ getSnapshot(): ScheduleSummarySource;
32
+ subscribe(listener: () => void): () => void;
33
+ };
34
+ /** 重拉会话列表(读 RPC,不写会话日志)。 */
35
+ refresh(): Promise<unknown>;
36
+ }
37
+ /** 可订阅的定时摘要 store。 */
38
+ export interface ScheduleSummaryStore {
39
+ /** 当前摘要(引用稳定;内容变化时才换成新对象)。 */
40
+ getSnapshot(): ScheduleSummaryMap;
41
+ /** 订阅变更;返回退订函数。 */
42
+ subscribe(listener: () => void): () => void;
43
+ /** 立即按最新会话列表快照重算(内容有变化才通知)。 */
44
+ reload(): void;
45
+ /** 释放订阅/定时器(宿主卸载时调用)。 */
46
+ dispose(): void;
47
+ }
48
+ export interface ScheduleSummaryStoreOptions {
49
+ /** 状态重算周期(毫秒);缺省 {@link SUMMARY_STATE_TICK_MS}。 */
50
+ readonly stateTickMs?: number;
51
+ /** 会话列表重拉周期(毫秒);缺省 {@link SUMMARY_REFRESH_INTERVAL_MS}。 */
52
+ readonly refreshIntervalMs?: number;
53
+ /** 当前时刻注入(单测用);缺省 `Date.now`。 */
54
+ readonly now?: () => number;
55
+ /** 是否允许发起 refresh RPC;缺省「页面可见时允许」。 */
56
+ readonly canRefresh?: () => boolean;
57
+ /** 是否自动周期 refresh;缺省 true(单测可关)。 */
58
+ readonly autoRefresh?: boolean;
59
+ }
60
+ /**
61
+ * 创建定时摘要 store(宿主 `apply` 内创建一次,全插件共享)。
62
+ * @param sessions - 宿主 `sessions` 服务。
63
+ * @param options - 周期/时钟/刷新策略覆盖(单测用)。
64
+ * @returns 可订阅 store。
65
+ */
66
+ export declare function createScheduleSummaryStore(sessions: ScheduleSummarySessionsLike, options?: ScheduleSummaryStoreOptions): ScheduleSummaryStore;
67
+ /**
68
+ * 登记/撤销当前 store(由 dsh-later 自身 `apply` 调用;外部插件不要调用)。
69
+ * @param store - 生效的 store;undefined 表示撤销。
70
+ */
71
+ export declare function setActiveScheduleSummaryStore(store: ScheduleSummaryStore | undefined): void;
72
+ /** 读取当前生效的 store;dsh-later 未加载时为 undefined。 */
73
+ export declare function getScheduleSummaryStore(): ScheduleSummaryStore | undefined;
74
+ /** 订阅 store 登记变化(供 React hook 在 store 迟到时仍能重渲染)。 */
75
+ export declare function subscribeScheduleSummaryStore(listener: () => void): () => void;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * 定时任务摘要:dsh-later 对外暴露的**公共数据面**(跨会话 / 跨插件)。
3
+ *
4
+ * 关键事实(本插件「路线 B」的数据源):`userSchedules` 是 client-visible 投影,
5
+ * 宿主在 `session.list` 基线里为**每个**会话携带 projections 块(live 会话走注册
6
+ * 表现值、冷会话走持久化投影缓存),客户端把它合入
7
+ * `SessionSummary.projectionValues`。因此**任何**持有 `sessions` 服务的客户端插件
8
+ * 都能读到全会话的定时任务,本模块只负责把它折叠成稳定形状。
9
+ *
10
+ * 契约形状(issue #17):`Map<SessionId, { count, nextAt, state }>`——即
11
+ * {@link ScheduleSummaryMap}。dsh-later 通过 `dsh-later/client` 导出
12
+ * {@link buildScheduleSummary}(纯函数)、`createScheduleSummaryStore`(可订阅
13
+ * store)与 `useScheduleSummary`(React hook),消费方按需选用。
14
+ *
15
+ * 本模块零 DOM / 零 React 依赖:宿主、浏览器与 `node --test` 都能直接跑。
16
+ * @module dsh-later/schedule-summary
17
+ */
18
+ import { type PresenceBadgeState, type PresenceScheduleLike, type PresenceSummary } from './presence.js';
19
+ /** `userSchedules` 投影值在 `projectionValues` 里的键名。 */
20
+ export declare const SCHEDULE_PROJECTION_KEY = "userSchedules";
21
+ /**
22
+ * 一条会话的定时摘要:数量 + 下次触发 + 视觉状态。
23
+ *
24
+ * `state` 是**快照时刻**由 {@link badgeStateFor} 判定的结果(`scheduled` /
25
+ * `urgent` ≤5min / `overdue` 已到期未派发),因此 store 每次重算/定时 tick 都会
26
+ * 刷新它;暂停项计入 `count`/`pausedCount`,但**不参与** `nextAt`(见 presence 模块
27
+ * 对暂停语义的说明)。
28
+ */
29
+ export interface ScheduleSummaryEntry extends PresenceSummary {
30
+ /** 该会话的 badge 状态(按快照时刻判定)。 */
31
+ readonly state: PresenceBadgeState;
32
+ }
33
+ /** 会话 id → 该会话的定时摘要(**仅**包含有任务的会话)。 */
34
+ export type ScheduleSummaryMap = ReadonlyMap<string, ScheduleSummaryEntry>;
35
+ /** 摘要所需的最小会话列表快照面(`SessionListState` 的结构子集)。 */
36
+ export interface ScheduleSummarySource {
37
+ /** 会话 id → 会话行事实(只用到 `projectionValues`)。 */
38
+ readonly byId: Readonly<Record<string, ScheduleSummaryRowLike | undefined>>;
39
+ }
40
+ /** 会话行事实的结构子集。 */
41
+ export interface ScheduleSummaryRowLike {
42
+ /** 该会话的投影现值(含 `userSchedules`)。 */
43
+ readonly projectionValues?: Readonly<Record<string, unknown>>;
44
+ }
45
+ /**
46
+ * 从 `projectionValues` 里取出任务数组(容错:形状不对一律当作无任务)。
47
+ * @param projectionValues - 会话行的投影现值集合。
48
+ * @returns 任务数组;缺失或形状非法时 undefined。
49
+ */
50
+ export declare function schedulesOfProjection(projectionValues: Readonly<Record<string, unknown>> | undefined): readonly PresenceScheduleLike[] | undefined;
51
+ /**
52
+ * 全会话归纳:把会话列表快照折叠成 {@link ScheduleSummaryMap}。
53
+ * @param source - 会话列表快照(`sessions.list.getSnapshot()`)。
54
+ * @param now - 判定 `state` 的当前时刻(epoch ms)。
55
+ * @returns 会话 id → 摘要;无任务的会话不出现在 map 里。
56
+ */
57
+ export declare function buildScheduleSummary(source: ScheduleSummarySource, now: number): ScheduleSummaryMap;
58
+ /**
59
+ * 两份摘要是否等价(逐条比对五个字段)。store 用它决定是否需要通知订阅者,
60
+ * 避免每次重算都产生新的 Map 引用导致 React 无谓重渲染。
61
+ *
62
+ * `progressFrom` 一并比对:它虽然通常随 `nextAt` 同进同退,但暂停→恢复会让
63
+ * 同一条任务重新拥有窗口起点,此时 `nextAt` 可能未变而窗口已换 —— 漏比会
64
+ * 让进度环停在上一个窗口的比例上。
65
+ * @param a - 旧摘要。
66
+ * @param b - 新摘要。
67
+ * @returns 键集合与每条内容都相同时为 true。
68
+ */
69
+ export declare function sameScheduleSummary(a: ScheduleSummaryMap, b: ScheduleSummaryMap): boolean;
70
+ /** 分组聚合结果(折叠的工作区分组徽标直接用这一份)。 */
71
+ export interface GroupScheduleSummary {
72
+ /** 组内**有任务**的会话数。 */
73
+ readonly sessions: number;
74
+ /** 组内任务总数(含暂停项)。 */
75
+ readonly count: number;
76
+ /** 组内暂停项数量。 */
77
+ readonly pausedCount: number;
78
+ /** 组内最早触发时刻(epoch ms);全部暂停或缺省时为 undefined。 */
79
+ readonly nextAt?: number;
80
+ /** 组内最高优先级状态(overdue > urgent > scheduled);无任务时为 undefined。 */
81
+ readonly state?: PresenceBadgeState;
82
+ }
83
+ /**
84
+ * 按**分组**聚合摘要:给定组内会话 id,折叠出可直接渲染的分组事实。
85
+ *
86
+ * 消费方(如 dsh-group-status 的 `GroupLiveStatus`)只需把组内会话 id 传进来,
87
+ * 无需理解 `nextAt`/暂停语义。
88
+ * @param summary - {@link ScheduleSummaryMap}。
89
+ * @param sessionIds - 组内会话 id(顺序无关;不在摘要里的会被忽略)。
90
+ * @returns 分组级摘要;组内无任务时 `sessions`/`count` 为 0、`state` 缺省。
91
+ */
92
+ export declare function summarizeScheduleGroup(summary: ScheduleSummaryMap, sessionIds: Iterable<string>): GroupScheduleSummary;
@@ -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.1.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",