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.
- package/CHANGELOG.md +53 -0
- package/README.en.md +88 -3
- package/README.md +86 -3
- package/lib/client.js +828 -294
- package/lib/commands.js +486 -51
- package/lib/index.js +20 -2
- package/lib/ownership-store.js +50 -3
- package/lib/presence.js +72 -2
- package/lib/runtime.js +39 -5
- package/lib/schedule-summary-store.js +160 -0
- package/lib/schedule-summary.js +113 -0
- 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/presence.d.ts +32 -0
- package/lib/types/runtime.d.ts +10 -1
- package/lib/types/schedule-summary-store.d.ts +75 -0
- package/lib/types/schedule-summary.d.ts +92 -0
- 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/presence.js
CHANGED
|
@@ -30,6 +30,7 @@ export const PRESENCE_URGENT_WINDOW_MS = 5 * 60_000;
|
|
|
30
30
|
*/
|
|
31
31
|
export function summarizeSchedules(schedules) {
|
|
32
32
|
let nextAt;
|
|
33
|
+
let progressFrom;
|
|
33
34
|
let count = 0;
|
|
34
35
|
let pausedCount = 0;
|
|
35
36
|
for (const item of schedules ?? []) {
|
|
@@ -44,12 +45,81 @@ export function summarizeSchedules(schedules) {
|
|
|
44
45
|
pausedCount += 1;
|
|
45
46
|
continue; // 冻结项没有排定的触发时刻,不参与 nextAt
|
|
46
47
|
}
|
|
47
|
-
if (nextAt === undefined || epoch < nextAt)
|
|
48
|
+
if (nextAt === undefined || epoch < nextAt) {
|
|
48
49
|
nextAt = epoch;
|
|
50
|
+
// 窗口起点只跟随被选中的那一条;并列最早时保留先出现者的窗口。
|
|
51
|
+
progressFrom = windowStartOf(item, epoch);
|
|
52
|
+
}
|
|
49
53
|
}
|
|
50
54
|
if (count === 0)
|
|
51
55
|
return { count: 0, pausedCount: 0 };
|
|
52
|
-
|
|
56
|
+
// 字段用条件展开:无进度时不产生 `progressFrom: undefined`,保持既有
|
|
57
|
+
// deepEqual 测试与 wire 形状不变。
|
|
58
|
+
return nextAt === undefined
|
|
59
|
+
? { count, pausedCount }
|
|
60
|
+
: { count, nextAt, pausedCount, ...(progressFrom !== undefined ? { progressFrom } : {}) };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* 推导进度窗口的起点时刻(epoch ms);无从推导时返回 `undefined`。
|
|
64
|
+
*
|
|
65
|
+
* 口径按优先级:
|
|
66
|
+
* 1. `window_seconds` —— 宿主/投影直接给的总窗口,最权威(暂停项的 wire 正是
|
|
67
|
+
* 用它携带"原 after 间隔",见 projection.ts 的 `pausedWireItemOf`)。
|
|
68
|
+
* 2. 按 `kind` 推:`after` 用 `after_seconds`,`every` 用 `every_seconds`。
|
|
69
|
+
* 两者都是"从触发时刻往回推一整个窗口",天然得到本周期起点。
|
|
70
|
+
* 3. `created_at` —— `at` 类型没有周期,只能拿任务创建时刻当起点。
|
|
71
|
+
*
|
|
72
|
+
* **推不出就返回 `undefined`**,绝不拿 `scheduledAt` 兜底:那会让 span 为 0,
|
|
73
|
+
* 进度恒为 100%,把"算不出来"伪装成"已经走完"。
|
|
74
|
+
*/
|
|
75
|
+
function windowStartOf(item, scheduledEpoch) {
|
|
76
|
+
const explicit = toFiniteSeconds(item.window_seconds ?? item.windowSeconds);
|
|
77
|
+
if (explicit !== undefined)
|
|
78
|
+
return scheduledEpoch - explicit * 1000;
|
|
79
|
+
const afterSec = toFiniteSeconds(item.after_seconds ?? item.afterSeconds);
|
|
80
|
+
const everySec = toFiniteSeconds(item.every_seconds ?? item.everySeconds);
|
|
81
|
+
const span = item.kind === 'every' ? everySec : (afterSec ?? everySec);
|
|
82
|
+
if (span !== undefined)
|
|
83
|
+
return scheduledEpoch - span * 1000;
|
|
84
|
+
const created = toEpochMs(item.created_at) ?? toEpochMs(item.createdAt);
|
|
85
|
+
if (created !== null)
|
|
86
|
+
return created;
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
/** 秒数归一:只接受有限正数(0 / 负数 / NaN 一律视为"没给")。 */
|
|
90
|
+
function toFiniteSeconds(value) {
|
|
91
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
|
|
92
|
+
}
|
|
93
|
+
/** epoch ms(number)或 ISO 字符串统一成 epoch ms;无法解析返回 `null`。 */
|
|
94
|
+
function toEpochMs(value) {
|
|
95
|
+
if (typeof value === 'number')
|
|
96
|
+
return Number.isFinite(value) ? value : null;
|
|
97
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
98
|
+
const parsed = Date.parse(value);
|
|
99
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* 进度环比例(0–1):`now` 在 `[progressFrom, nextAt]` 中的位置。
|
|
105
|
+
*
|
|
106
|
+
* 返回 `undefined` 表示**无从算起**(缺窗口起点,或窗口已退化),调用方此时应
|
|
107
|
+
* 退回「恒定整环」的旧表现,而不是画一个 0% 的空环 —— 后者是错误信息:
|
|
108
|
+
* 0% 是"刚开始",`undefined` 是"算不出来"。
|
|
109
|
+
*
|
|
110
|
+
* 两端夹住:`overdue` 时 `now > nextAt`,环停在满环而不是绕出去。
|
|
111
|
+
*/
|
|
112
|
+
export function progressOf(entry, now) {
|
|
113
|
+
const { nextAt, progressFrom } = entry;
|
|
114
|
+
if (nextAt === undefined || progressFrom === undefined)
|
|
115
|
+
return undefined;
|
|
116
|
+
const span = nextAt - progressFrom;
|
|
117
|
+
if (!(span > 0))
|
|
118
|
+
return undefined;
|
|
119
|
+
const ratio = (now - progressFrom) / span;
|
|
120
|
+
if (!Number.isFinite(ratio))
|
|
121
|
+
return undefined;
|
|
122
|
+
return ratio <= 0 ? 0 : ratio >= 1 ? 1 : ratio;
|
|
53
123
|
}
|
|
54
124
|
/** 由当前时刻判定 badge 状态。 */
|
|
55
125
|
export function badgeStateFor(nextAt, now) {
|
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) {
|
|
@@ -0,0 +1,160 @@
|
|
|
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 { buildScheduleSummary, sameScheduleSummary } from './schedule-summary.js';
|
|
23
|
+
/** 摘要状态重算周期(本地时钟,零网络)。 */
|
|
24
|
+
export const SUMMARY_STATE_TICK_MS = 30_000;
|
|
25
|
+
/** `session.list` 重拉周期(读 RPC;仅页面可见时执行)。 */
|
|
26
|
+
export const SUMMARY_REFRESH_INTERVAL_MS = 60_000;
|
|
27
|
+
/** 取 `globalThis.document`(不引 DOM 类型,宿主编译期无 document 也能通过)。 */
|
|
28
|
+
function documentOf() {
|
|
29
|
+
const doc = globalThis.document;
|
|
30
|
+
return typeof doc === 'object' && doc !== null ? doc : undefined;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* 创建定时摘要 store(宿主 `apply` 内创建一次,全插件共享)。
|
|
34
|
+
* @param sessions - 宿主 `sessions` 服务。
|
|
35
|
+
* @param options - 周期/时钟/刷新策略覆盖(单测用)。
|
|
36
|
+
* @returns 可订阅 store。
|
|
37
|
+
*/
|
|
38
|
+
export function createScheduleSummaryStore(sessions, options = {}) {
|
|
39
|
+
const now = options.now ?? Date.now;
|
|
40
|
+
const stateTickMs = options.stateTickMs ?? SUMMARY_STATE_TICK_MS;
|
|
41
|
+
const refreshIntervalMs = options.refreshIntervalMs ?? SUMMARY_REFRESH_INTERVAL_MS;
|
|
42
|
+
const autoRefresh = options.autoRefresh ?? true;
|
|
43
|
+
const canRefresh = options.canRefresh ??
|
|
44
|
+
(() => {
|
|
45
|
+
const doc = documentOf();
|
|
46
|
+
return doc?.visibilityState === undefined || doc.visibilityState === 'visible';
|
|
47
|
+
});
|
|
48
|
+
const listeners = new Set();
|
|
49
|
+
let snapshot = new Map();
|
|
50
|
+
let disposed = false;
|
|
51
|
+
const notify = () => {
|
|
52
|
+
// 快照一份再遍历:订阅者可能在回调里退订。
|
|
53
|
+
for (const listener of [...listeners]) {
|
|
54
|
+
try {
|
|
55
|
+
listener();
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
/* 单个订阅者异常不影响其余订阅者与主流程 */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const reload = () => {
|
|
63
|
+
if (disposed)
|
|
64
|
+
return;
|
|
65
|
+
const next = buildScheduleSummary(sessions.list.getSnapshot(), now());
|
|
66
|
+
if (sameScheduleSummary(snapshot, next))
|
|
67
|
+
return;
|
|
68
|
+
snapshot = next;
|
|
69
|
+
notify();
|
|
70
|
+
};
|
|
71
|
+
const unsubscribeList = sessions.list.subscribe(reload);
|
|
72
|
+
const stateTick = setInterval(reload, stateTickMs);
|
|
73
|
+
let refreshTick;
|
|
74
|
+
let onVisible;
|
|
75
|
+
const doc = documentOf();
|
|
76
|
+
const target = globalThis;
|
|
77
|
+
const refreshNow = () => {
|
|
78
|
+
if (disposed || !canRefresh())
|
|
79
|
+
return;
|
|
80
|
+
try {
|
|
81
|
+
void sessions.refresh().catch(() => undefined);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
/* 刷新失败不影响既有摘要 */
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
if (autoRefresh) {
|
|
88
|
+
onVisible = () => {
|
|
89
|
+
if (!disposed && canRefresh()) {
|
|
90
|
+
refreshNow();
|
|
91
|
+
reload();
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
refreshTick = setInterval(() => {
|
|
95
|
+
if (canRefresh())
|
|
96
|
+
refreshNow();
|
|
97
|
+
}, refreshIntervalMs);
|
|
98
|
+
doc?.addEventListener?.('visibilitychange', onVisible);
|
|
99
|
+
target.addEventListener?.('online', onVisible);
|
|
100
|
+
refreshNow();
|
|
101
|
+
}
|
|
102
|
+
reload();
|
|
103
|
+
const dispose = () => {
|
|
104
|
+
if (disposed)
|
|
105
|
+
return;
|
|
106
|
+
disposed = true;
|
|
107
|
+
clearInterval(stateTick);
|
|
108
|
+
if (refreshTick !== undefined)
|
|
109
|
+
clearInterval(refreshTick);
|
|
110
|
+
if (onVisible !== undefined) {
|
|
111
|
+
doc?.removeEventListener?.('visibilitychange', onVisible);
|
|
112
|
+
target.removeEventListener?.('online', onVisible);
|
|
113
|
+
}
|
|
114
|
+
unsubscribeList();
|
|
115
|
+
listeners.clear();
|
|
116
|
+
};
|
|
117
|
+
return {
|
|
118
|
+
getSnapshot: () => snapshot,
|
|
119
|
+
subscribe: (listener) => {
|
|
120
|
+
listeners.add(listener);
|
|
121
|
+
return () => {
|
|
122
|
+
listeners.delete(listener);
|
|
123
|
+
};
|
|
124
|
+
},
|
|
125
|
+
reload,
|
|
126
|
+
dispose,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/** 当前生效的 store(client 插件 `apply` 挂载时登记;跨插件只读入口)。 */
|
|
130
|
+
let activeStore;
|
|
131
|
+
/** store 登记变化的订阅者(消费方可能在 store 就绪前先渲染)。 */
|
|
132
|
+
const registryListeners = new Set();
|
|
133
|
+
/**
|
|
134
|
+
* 登记/撤销当前 store(由 dsh-later 自身 `apply` 调用;外部插件不要调用)。
|
|
135
|
+
* @param store - 生效的 store;undefined 表示撤销。
|
|
136
|
+
*/
|
|
137
|
+
export function setActiveScheduleSummaryStore(store) {
|
|
138
|
+
if (activeStore === store)
|
|
139
|
+
return;
|
|
140
|
+
activeStore = store;
|
|
141
|
+
for (const listener of [...registryListeners]) {
|
|
142
|
+
try {
|
|
143
|
+
listener();
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
/* 忽略单个订阅者异常 */
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** 读取当前生效的 store;dsh-later 未加载时为 undefined。 */
|
|
151
|
+
export function getScheduleSummaryStore() {
|
|
152
|
+
return activeStore;
|
|
153
|
+
}
|
|
154
|
+
/** 订阅 store 登记变化(供 React hook 在 store 迟到时仍能重渲染)。 */
|
|
155
|
+
export function subscribeScheduleSummaryStore(listener) {
|
|
156
|
+
registryListeners.add(listener);
|
|
157
|
+
return () => {
|
|
158
|
+
registryListeners.delete(listener);
|
|
159
|
+
};
|
|
160
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
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 { badgeStateFor, summarizeSchedules, } from './presence.js';
|
|
19
|
+
/** `userSchedules` 投影值在 `projectionValues` 里的键名。 */
|
|
20
|
+
export const SCHEDULE_PROJECTION_KEY = 'userSchedules';
|
|
21
|
+
/**
|
|
22
|
+
* 从 `projectionValues` 里取出任务数组(容错:形状不对一律当作无任务)。
|
|
23
|
+
* @param projectionValues - 会话行的投影现值集合。
|
|
24
|
+
* @returns 任务数组;缺失或形状非法时 undefined。
|
|
25
|
+
*/
|
|
26
|
+
export function schedulesOfProjection(projectionValues) {
|
|
27
|
+
const value = projectionValues?.[SCHEDULE_PROJECTION_KEY];
|
|
28
|
+
if (typeof value !== 'object' || value === null)
|
|
29
|
+
return undefined;
|
|
30
|
+
const schedules = value.schedules;
|
|
31
|
+
return Array.isArray(schedules) ? schedules : undefined;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* 全会话归纳:把会话列表快照折叠成 {@link ScheduleSummaryMap}。
|
|
35
|
+
* @param source - 会话列表快照(`sessions.list.getSnapshot()`)。
|
|
36
|
+
* @param now - 判定 `state` 的当前时刻(epoch ms)。
|
|
37
|
+
* @returns 会话 id → 摘要;无任务的会话不出现在 map 里。
|
|
38
|
+
*/
|
|
39
|
+
export function buildScheduleSummary(source, now) {
|
|
40
|
+
const summary = new Map();
|
|
41
|
+
for (const [sessionId, row] of Object.entries(source.byId)) {
|
|
42
|
+
if (row === undefined)
|
|
43
|
+
continue;
|
|
44
|
+
const entry = summarizeSchedules(schedulesOfProjection(row.projectionValues));
|
|
45
|
+
if (entry.count === 0)
|
|
46
|
+
continue;
|
|
47
|
+
summary.set(sessionId, { ...entry, state: badgeStateFor(entry.nextAt, now) });
|
|
48
|
+
}
|
|
49
|
+
return summary;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* 两份摘要是否等价(逐条比对五个字段)。store 用它决定是否需要通知订阅者,
|
|
53
|
+
* 避免每次重算都产生新的 Map 引用导致 React 无谓重渲染。
|
|
54
|
+
*
|
|
55
|
+
* `progressFrom` 一并比对:它虽然通常随 `nextAt` 同进同退,但暂停→恢复会让
|
|
56
|
+
* 同一条任务重新拥有窗口起点,此时 `nextAt` 可能未变而窗口已换 —— 漏比会
|
|
57
|
+
* 让进度环停在上一个窗口的比例上。
|
|
58
|
+
* @param a - 旧摘要。
|
|
59
|
+
* @param b - 新摘要。
|
|
60
|
+
* @returns 键集合与每条内容都相同时为 true。
|
|
61
|
+
*/
|
|
62
|
+
export function sameScheduleSummary(a, b) {
|
|
63
|
+
if (a === b)
|
|
64
|
+
return true;
|
|
65
|
+
if (a.size !== b.size)
|
|
66
|
+
return false;
|
|
67
|
+
for (const [sessionId, left] of a) {
|
|
68
|
+
const right = b.get(sessionId);
|
|
69
|
+
if (right === undefined)
|
|
70
|
+
return false;
|
|
71
|
+
if (left.count !== right.count ||
|
|
72
|
+
left.pausedCount !== right.pausedCount ||
|
|
73
|
+
left.nextAt !== right.nextAt ||
|
|
74
|
+
left.progressFrom !== right.progressFrom ||
|
|
75
|
+
left.state !== right.state) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
/** 状态优先级(越大越紧急)。 */
|
|
82
|
+
const STATE_RANK = { scheduled: 0, urgent: 1, overdue: 2 };
|
|
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 function summarizeScheduleGroup(summary, sessionIds) {
|
|
93
|
+
let sessions = 0;
|
|
94
|
+
let count = 0;
|
|
95
|
+
let pausedCount = 0;
|
|
96
|
+
let nextAt;
|
|
97
|
+
let state;
|
|
98
|
+
for (const sessionId of sessionIds) {
|
|
99
|
+
const entry = summary.get(sessionId);
|
|
100
|
+
if (entry === undefined)
|
|
101
|
+
continue;
|
|
102
|
+
sessions += 1;
|
|
103
|
+
count += entry.count;
|
|
104
|
+
pausedCount += entry.pausedCount;
|
|
105
|
+
if (entry.nextAt !== undefined && (nextAt === undefined || entry.nextAt < nextAt))
|
|
106
|
+
nextAt = entry.nextAt;
|
|
107
|
+
if (state === undefined || STATE_RANK[entry.state] > STATE_RANK[state])
|
|
108
|
+
state = entry.state;
|
|
109
|
+
}
|
|
110
|
+
return nextAt === undefined
|
|
111
|
+
? { sessions, count, pausedCount, state }
|
|
112
|
+
: { sessions, count, pausedCount, nextAt, state };
|
|
113
|
+
}
|
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` 对象使用的本地日历字段。
|