dsh-later 1.0.0 → 1.2.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 +247 -0
- package/README.en.md +55 -2
- package/README.md +53 -2
- package/icon.svg +10 -0
- package/lib/bar-geometry.js +139 -0
- package/lib/client.js +1349 -423
- package/lib/command-outcome.js +15 -0
- package/lib/commands.js +56 -8
- package/lib/delivery.js +66 -0
- package/lib/domain.js +6 -0
- package/lib/index.js +152 -29
- package/lib/ownership-store.js +113 -15
- package/lib/presence.js +122 -4
- package/lib/projection.js +149 -20
- package/lib/runtime.js +160 -24
- package/lib/schedule-compat.js +179 -0
- package/lib/schedule-summary-store.js +160 -0
- package/lib/schedule-summary.js +113 -0
- package/lib/types/bar-geometry.d.ts +111 -0
- package/lib/types/command-outcome.d.ts +19 -0
- package/lib/types/delivery.d.ts +51 -0
- package/lib/types/domain.d.ts +32 -0
- package/lib/types/index.d.ts +28 -8
- package/lib/types/ownership-store.d.ts +42 -2
- package/lib/types/presence.d.ts +75 -4
- package/lib/types/projection.d.ts +68 -6
- package/lib/types/runtime.d.ts +58 -1
- package/lib/types/schedule-compat.d.ts +111 -0
- package/lib/types/schedule-summary-store.d.ts +75 -0
- package/lib/types/schedule-summary.d.ts +92 -0
- package/lib/types/upstream-compat.d.ts +123 -0
- package/lib/types/user-tools.d.ts +48 -7
- package/lib/upstream-compat.js +143 -0
- package/lib/user-tools.js +268 -44
- package/package.json +25 -22
package/lib/runtime.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { createUserMessage, boundContextSummary } from '@deepseek-ai/dsh-llm';
|
|
2
|
-
import { foldScheduleEvents,
|
|
2
|
+
import { foldScheduleEvents, renderReminderFraming, resolveEveryOccurrence, ScheduleId, } from '@deepseek-ai/dsh-schedule';
|
|
3
|
+
import { renderRecurringBatchFraming as renderEveryReminderBatchFraming, scheduleMessageSource, } from './schedule-compat.js';
|
|
3
4
|
import { OWNED_EVENT } from './domain.js';
|
|
5
|
+
import { findScheduleIdByUid, getPaused, removePaused } from './ownership-store.js';
|
|
4
6
|
import { foldUserState } from './user-tools.js';
|
|
7
|
+
import { confirmDelivery, messageIdOf } from './delivery.js';
|
|
5
8
|
import { detectTimeZone, formatHhmm } from './time-utils.js';
|
|
6
9
|
/** Node timers 能表示的最大延迟。 */
|
|
7
10
|
const MAX_TIMER_DELAY_MS = 2147483647;
|
|
@@ -54,8 +57,15 @@ export function dueUserDecision(active, now, delivery) {
|
|
|
54
57
|
* 多条到期一次性提醒的合并注入 framing(P1-5)。
|
|
55
58
|
* 形状与 dsh-schedule 的固定间隔批次 framing 完全一致:
|
|
56
59
|
* 动态字段全部经 JSON 转义,保持「非信任提醒内容」的防护语义。
|
|
60
|
+
*
|
|
61
|
+
* ⚠️ 这是**上游文案的本地副本**。0.2.0 起 dsh-schedule 把 one-shot 与 recurring
|
|
62
|
+
* 两条 framing 统一收敛为一句 `SCHEDULED_MESSAGE_FRAMING`,取代了此前
|
|
63
|
+
* 「Present all due reminders … not new user instructions.」的长句。
|
|
64
|
+
* 本插件只对**多条合并**这一种上游不提供的形态自渲染(单条走
|
|
65
|
+
* `renderReminderFraming`,自动跟随上游),故此处需手工对齐,否则同一会话里
|
|
66
|
+
* 单条与批次的防护措辞会不一致。
|
|
57
67
|
*/
|
|
58
|
-
function renderOneShotBatchFraming(records) {
|
|
68
|
+
export function renderOneShotBatchFraming(records) {
|
|
59
69
|
const payload = records.map((record) => ({
|
|
60
70
|
schedule_id: record.id,
|
|
61
71
|
occurrence_at: record.scheduledAt,
|
|
@@ -63,7 +73,7 @@ function renderOneShotBatchFraming(records) {
|
|
|
63
73
|
}));
|
|
64
74
|
return [
|
|
65
75
|
'[SCHEDULE REMINDER BATCH]',
|
|
66
|
-
'
|
|
76
|
+
'This is a scheduled message from the user',
|
|
67
77
|
`reminders_json: ${JSON.stringify(payload)}`,
|
|
68
78
|
].join('\n');
|
|
69
79
|
}
|
|
@@ -71,6 +81,14 @@ function renderOneShotBatchFraming(records) {
|
|
|
71
81
|
export class UserScheduleRuntime {
|
|
72
82
|
ctx;
|
|
73
83
|
agent;
|
|
84
|
+
/**
|
|
85
|
+
* 读取当前生效设置(每次调用按需取值,与工具/命令同源)。
|
|
86
|
+
*
|
|
87
|
+
* 注入而非直接读 ctx.settings:settings 的注册在 index 的 `ctx.inject(['settings'])`
|
|
88
|
+
* 回调里完成,runtime 拿不到那个 scope;把它做成闭包注入,保证「设置 UI 改了立刻生效」
|
|
89
|
+
* 这一既有语义(`applies: 'live'`)在插话路径上同样成立。
|
|
90
|
+
*/
|
|
91
|
+
getSettings;
|
|
74
92
|
stop;
|
|
75
93
|
resolveStop;
|
|
76
94
|
timer;
|
|
@@ -81,9 +99,10 @@ export class UserScheduleRuntime {
|
|
|
81
99
|
failures = 0;
|
|
82
100
|
idleWait;
|
|
83
101
|
disposal;
|
|
84
|
-
constructor(ctx, agent) {
|
|
102
|
+
constructor(ctx, agent, getSettings) {
|
|
85
103
|
this.ctx = ctx;
|
|
86
104
|
this.agent = agent;
|
|
105
|
+
this.getSettings = getSettings;
|
|
87
106
|
this.stop = new Promise((resolve) => {
|
|
88
107
|
this.resolveStop = resolve;
|
|
89
108
|
});
|
|
@@ -200,7 +219,39 @@ export class UserScheduleRuntime {
|
|
|
200
219
|
if (this.stopping || this.disposal !== undefined) {
|
|
201
220
|
return { ok: false, code: 'stopping', message: '调度器已停止。' };
|
|
202
221
|
}
|
|
203
|
-
|
|
222
|
+
/**
|
|
223
|
+
* 暂停项的插话:直接从 sidecar 留档取材,不经过日志。
|
|
224
|
+
*
|
|
225
|
+
* 暂停 = 日志 `delete` + sidecar 留档,所以暂停项**不在日志里**,下面的
|
|
226
|
+
* `folded.active` 必然找不到它。但"插话"的语义是「不等倒计时、立刻推送」,
|
|
227
|
+
* 对暂停项恰恰是最需要的(暂停了但想马上发)。留档里已有 prompt 与 delivery,
|
|
228
|
+
* 足够构造消息。
|
|
229
|
+
*
|
|
230
|
+
* 投递成功后**清掉留档**:该提醒已经送达,不应再留在"待发送"列表里
|
|
231
|
+
* (与 dispatch 会把记录移出 active 的语义一致)。
|
|
232
|
+
*/
|
|
233
|
+
const pausedHit = await this.steerPausedById(id);
|
|
234
|
+
if (pausedHit !== undefined)
|
|
235
|
+
return pausedHit;
|
|
236
|
+
/**
|
|
237
|
+
* wire id → 日志 schedule id。
|
|
238
|
+
*
|
|
239
|
+
* ⚠️ GUI 传过来的是**投影里的 `id`**,而那是「稳定 uid 优先」的
|
|
240
|
+
* (见 projection 的 `wireItemOf`:`id: uid ?? record.id`)。而日志的
|
|
241
|
+
* `schedule/change` 记录用 `schedule-N` 作 id。两者不同,必须换算。
|
|
242
|
+
*
|
|
243
|
+
* 此前直接 `ScheduleId(id)` 并 `r.id === scheduleId` 比对,于是**所有**由
|
|
244
|
+
* 用户工具创建的活动任务插话都报 `schedule_not_found`(实测复现:
|
|
245
|
+
* uid `f0fc0c90…` vs 日志 id `schedule-1`)。删除/暂停路径早已用
|
|
246
|
+
* `resolveActiveScheduleId` 做了这层换算,插话是漏网的。
|
|
247
|
+
*/
|
|
248
|
+
const sessionId = this.agent.session.header?.id;
|
|
249
|
+
const scheduleId = (() => {
|
|
250
|
+
// 先按 uid 找当前活动的日志 id;找不到再当作日志 id 原样使用
|
|
251
|
+
//(无 uid 的旧数据 / 调用方直接传 schedule-N)。
|
|
252
|
+
const byUid = sessionId === undefined ? undefined : findScheduleIdByUid(sessionId, id);
|
|
253
|
+
return ScheduleId(byUid ?? id);
|
|
254
|
+
})();
|
|
204
255
|
let claimed = null;
|
|
205
256
|
try {
|
|
206
257
|
const claimedState = foldUserState(this.agent.session);
|
|
@@ -209,7 +260,7 @@ export class UserScheduleRuntime {
|
|
|
209
260
|
if (record === undefined) {
|
|
210
261
|
return { ok: false, code: 'schedule_not_found', message: '指定的提醒不存在或已派发。' };
|
|
211
262
|
}
|
|
212
|
-
if (record.kind
|
|
263
|
+
if (record.kind !== 'after' && record.kind !== 'at') {
|
|
213
264
|
return { ok: false, code: 'unsupported_kind', message: 'every 类提醒的插话暂未支持。' };
|
|
214
265
|
}
|
|
215
266
|
const delivery = claimedState.delivery.get(scheduleId) ?? 'context';
|
|
@@ -219,32 +270,68 @@ export class UserScheduleRuntime {
|
|
|
219
270
|
this.ctx.logger.warn(`later: steer fold 失败: ${renderThrown(error)}`);
|
|
220
271
|
return { ok: false, code: 'internal_error', message: '会话定时日志读取失败。' };
|
|
221
272
|
}
|
|
273
|
+
let message;
|
|
222
274
|
try {
|
|
223
|
-
// 同一同步块内:重 fold(防 auto-driver 刚把 dispatch 写掉的竞态)→
|
|
224
|
-
// append dispatch
|
|
275
|
+
// 同一同步块内:重 fold(防 auto-driver 刚把 dispatch 写掉的竞态)→ 投递 →
|
|
276
|
+
// append dispatch(仅当确认送达 / 或未启用确认 / 或配置为丢弃)。
|
|
225
277
|
const reState = foldUserState(this.agent.session);
|
|
226
278
|
const reRecord = reState.folded.active.find((r) => r.id === scheduleId);
|
|
227
279
|
if (reRecord === undefined || claimed === null) {
|
|
228
280
|
// 已被并发 fold 派发(auto-driver 抢先),端态与插话成功一致,不重复投。
|
|
229
281
|
return { ok: true, id, steered: true };
|
|
230
282
|
}
|
|
231
|
-
|
|
232
|
-
this.
|
|
233
|
-
this.agent.session.append('schedule/change', { version: 1, operation: 'dispatch', id: scheduleId });
|
|
283
|
+
message = this.buildMessage(claimed, reState.delivery);
|
|
284
|
+
this.deliver(message);
|
|
234
285
|
}
|
|
235
286
|
catch (error) {
|
|
236
287
|
this.ctx.logger.warn(`later: steer 失败 agent "${this.agent.id}": ${renderThrown(error)}`);
|
|
237
288
|
return { ok: false, code: 'internal_error', message: '插话执行失败。' };
|
|
238
289
|
}
|
|
290
|
+
// 送达确认:默认开启。未确认时按 onUnconfirmed 决定是否仍写出列标记。
|
|
291
|
+
const confirmed = await this.verifyDelivery(message);
|
|
292
|
+
if (!confirmed && this.getSettings().onUnconfirmed === 'keep') {
|
|
293
|
+
// 不写 dispatch:条目留在 active,用户可重试。这正是「提醒消失但消息没到」
|
|
294
|
+
// 的根治点 —— 旧实现在 steer() 后**无条件**写 dispatch,投递失败即永久丢失。
|
|
295
|
+
return { ok: false, code: 'delivery_unconfirmed', message: '投递未确认,提醒已保留,可重试。' };
|
|
296
|
+
}
|
|
297
|
+
this.agent.session.append('schedule/change', { version: 1, operation: 'dispatch', id: scheduleId });
|
|
239
298
|
// 同步落盘,确保 fold 路径下次扫描看到 dispatch 标记。
|
|
240
299
|
try {
|
|
241
300
|
await this.ctx.sessions.flush(this.agent.session);
|
|
242
301
|
}
|
|
243
302
|
catch {
|
|
244
|
-
/*
|
|
303
|
+
/* 落盘失败不影响投递已确认的语义;下次 fold 仍可能重触发,作为最坏情况接受 */
|
|
245
304
|
}
|
|
246
305
|
return { ok: true, id, steered: true };
|
|
247
306
|
}
|
|
307
|
+
/**
|
|
308
|
+
* 按配置把消息投进队列。
|
|
309
|
+
*
|
|
310
|
+
* `followup`(默认)入 `next-turn`:与 `next-step` 是两条独立队列,
|
|
311
|
+
* `claim('next-step', …)` 不触碰它,**结构上不存在**与 `turn/start` 竞争的窗口。
|
|
312
|
+
* `steer` 入 `next-step`,更快但会在撞上回合启动时被吞(见 delivery.ts 注释)。
|
|
313
|
+
*/
|
|
314
|
+
deliver(message) {
|
|
315
|
+
if (this.getSettings().steerDelivery === 'steer') {
|
|
316
|
+
this.agent.steer(message);
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
this.agent.followup(message);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* 核对投递是否真正进入会话日志。
|
|
324
|
+
*
|
|
325
|
+
* 关闭确认时直接返回 true(fire-and-forget,保持旧语义)。
|
|
326
|
+
*/
|
|
327
|
+
async verifyDelivery(message) {
|
|
328
|
+
if (!this.getSettings().confirmDelivery)
|
|
329
|
+
return true;
|
|
330
|
+
const messageId = messageIdOf(message);
|
|
331
|
+
if (messageId.length === 0)
|
|
332
|
+
return false;
|
|
333
|
+
return confirmDelivery(this.agent.session, messageId);
|
|
334
|
+
}
|
|
248
335
|
/** 执行一次用户提醒决策与派发。 */
|
|
249
336
|
async driveOnce() {
|
|
250
337
|
this.clearTimer();
|
|
@@ -344,6 +431,65 @@ export class UserScheduleRuntime {
|
|
|
344
431
|
if (this.isLive())
|
|
345
432
|
this.requestDrive();
|
|
346
433
|
}
|
|
434
|
+
/**
|
|
435
|
+
* 暂停项的插话投递。
|
|
436
|
+
*
|
|
437
|
+
* 暂停项不在日志里(其日志记录在 pause 时被删),所以从 sidecar 留档取材:
|
|
438
|
+
* `PausedEntry` 已含 `prompt` 与 `delivery`,足以构造消息。呈现方式与
|
|
439
|
+
* {@link buildMessage} 的 one-shot 分支保持一致(`user` 代发 vs `context` 注入),
|
|
440
|
+
* 避免两条路径的语义分叉。
|
|
441
|
+
*
|
|
442
|
+
* 投递成功后清掉留档:提醒已送达,不应再留在待发送列表。这与 dispatch 会把记录
|
|
443
|
+
* 移出 `active` 语义一致。(清留档不写会话事件,故投影不会自动刷新——客户端已用
|
|
444
|
+
* 本地摘除 + 投影对账处理同类情况,与"删除暂停项"同源。)
|
|
445
|
+
*
|
|
446
|
+
* @returns `undefined` 表示该 id 不是暂停项,交由调用方走常规日志路径。
|
|
447
|
+
*/
|
|
448
|
+
async steerPausedById(id) {
|
|
449
|
+
const sessionId = this.agent.session.header?.id;
|
|
450
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0)
|
|
451
|
+
return undefined;
|
|
452
|
+
const paused = getPaused(sessionId, id);
|
|
453
|
+
if (paused === undefined)
|
|
454
|
+
return undefined;
|
|
455
|
+
const asUser = paused.delivery === 'user';
|
|
456
|
+
const message = asUser
|
|
457
|
+
? createUserMessage({
|
|
458
|
+
content: [{ type: 'text', text: paused.prompt }],
|
|
459
|
+
source: { kind: 'user' },
|
|
460
|
+
})
|
|
461
|
+
: createUserMessage({
|
|
462
|
+
content: [{ type: 'text', text: renderReminderFraming({
|
|
463
|
+
id: paused.lastScheduleId,
|
|
464
|
+
kind: 'after',
|
|
465
|
+
prompt: paused.prompt,
|
|
466
|
+
afterSeconds: paused.originalAfterSeconds ?? paused.remainingSeconds,
|
|
467
|
+
scheduledAt: paused.originalScheduledAt,
|
|
468
|
+
}) }],
|
|
469
|
+
source: scheduleMessageSource(boundContextSummary(paused.prompt)),
|
|
470
|
+
});
|
|
471
|
+
try {
|
|
472
|
+
this.deliver(message);
|
|
473
|
+
}
|
|
474
|
+
catch (error) {
|
|
475
|
+
this.ctx.logger.warn(`later: 暂停项插话失败 agent "${this.agent.id}": ${renderThrown(error)}`);
|
|
476
|
+
return { ok: false, code: 'internal_error', message: '插话执行失败。' };
|
|
477
|
+
}
|
|
478
|
+
// 投递成功即出列:清掉留档,避免已送达的提醒继续显示为"待发送"。
|
|
479
|
+
// 未确认时(onUnconfirmed='keep')**不清留档** —— 留档就是暂停项的「active」,
|
|
480
|
+
// 清掉等于让提醒消失而消息没到,与日志路径同样的丢失。
|
|
481
|
+
const confirmed = await this.verifyDelivery(message);
|
|
482
|
+
if (!confirmed && this.getSettings().onUnconfirmed === 'keep') {
|
|
483
|
+
return { ok: false, code: 'delivery_unconfirmed', message: '投递未确认,提醒已保留,可重试。' };
|
|
484
|
+
}
|
|
485
|
+
try {
|
|
486
|
+
removePaused(sessionId, id);
|
|
487
|
+
}
|
|
488
|
+
catch (error) {
|
|
489
|
+
this.ctx.logger.warn(`later: 清理暂停留档失败 uid "${id}": ${renderThrown(error)}`);
|
|
490
|
+
}
|
|
491
|
+
return { ok: true, id, steered: true };
|
|
492
|
+
}
|
|
347
493
|
/**
|
|
348
494
|
* 构造注入的消息,按投递形态分流:
|
|
349
495
|
* - `user`(`/later` 显式请求的延迟发送):原样内容 + `source.kind='user'`,
|
|
@@ -370,12 +516,7 @@ export class UserScheduleRuntime {
|
|
|
370
516
|
: renderOneShotBatchFraming(records);
|
|
371
517
|
return createUserMessage({
|
|
372
518
|
content: [{ type: 'text', text }],
|
|
373
|
-
source:
|
|
374
|
-
kind: 'plugin',
|
|
375
|
-
plugin: '定时提醒',
|
|
376
|
-
form: 'notice',
|
|
377
|
-
summary: boundContextSummary(noticeSummary(records)),
|
|
378
|
-
},
|
|
519
|
+
source: scheduleMessageSource(boundContextSummary(noticeSummary(records))),
|
|
379
520
|
});
|
|
380
521
|
}
|
|
381
522
|
const records = decision.reminders.map((reminder) => reminder.record);
|
|
@@ -388,12 +529,7 @@ export class UserScheduleRuntime {
|
|
|
388
529
|
}
|
|
389
530
|
return createUserMessage({
|
|
390
531
|
content: [{ type: 'text', text: renderEveryReminderBatchFraming(decision.reminders) }],
|
|
391
|
-
source:
|
|
392
|
-
kind: 'plugin',
|
|
393
|
-
plugin: '定时提醒',
|
|
394
|
-
form: 'notice',
|
|
395
|
-
summary: boundContextSummary(noticeSummary(records)),
|
|
396
|
-
},
|
|
532
|
+
source: scheduleMessageSource(boundContextSummary(noticeSummary(records))),
|
|
397
533
|
});
|
|
398
534
|
}
|
|
399
535
|
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-schedule 0.1.5 → 0.1.7 的版本桥接层。
|
|
3
|
+
*
|
|
4
|
+
* 0.1.7 对 `dsh-schedule` 做了三处破坏性变更,本模块把差异收敛为三个函数,
|
|
5
|
+
* 使 `user-tools.ts` / `runtime.ts` 不必写版本分支:
|
|
6
|
+
*
|
|
7
|
+
* 1. `allocateScheduleId(folded)` → 删除。0.1.5 用确定性递增
|
|
8
|
+
* (`schedule-1`、`schedule-2`…,靠 `seenIds` 避让),0.1.7 内联改为
|
|
9
|
+
* `ScheduleId(\`schedule-${randomUUID()}\`)`(见 0.1.7 `ScheduleService.create`)。
|
|
10
|
+
* 本插件的 `ownership-store` 把 id 当**不透明字符串**处理(无排序、无数字
|
|
11
|
+
* 解析,见 docs/verification.md),故行为差异对功能无影响。
|
|
12
|
+
*
|
|
13
|
+
* 2. `createAfterScheduleRecord` 新增第 5 个必填参数 `title`。0.1.7 起
|
|
14
|
+
* `decodeStoredTitle` 会在读取时校验:缺失 / 未 trim / 超长(>120)的
|
|
15
|
+
* 存量记录抛 `ScheduleLogError`——**不是跳过,是整份日志拒读**。
|
|
16
|
+
*
|
|
17
|
+
* 3. `renderEveryReminderBatchFraming` → 改名
|
|
18
|
+
* `renderRecurringReminderBatchFraming`(语义与参数不变)。
|
|
19
|
+
*
|
|
20
|
+
* @module dsh-later/schedule-compat
|
|
21
|
+
*/
|
|
22
|
+
import * as sched from '@deepseek-ai/dsh-schedule';
|
|
23
|
+
import { randomUUID } from 'node:crypto';
|
|
24
|
+
const { ScheduleId } = sched;
|
|
25
|
+
/** 0.1.7 `MAX_TITLE_LENGTH`;0.1.5 无此概念,该常量在此仅作截断阈值。 */
|
|
26
|
+
export const SCHEDULE_TITLE_MAX = 120;
|
|
27
|
+
/**
|
|
28
|
+
* 读取一个**可能不存在**的导出。
|
|
29
|
+
*
|
|
30
|
+
* 必须走 namespace + 属性访问,不能用具名 import:ESM 具名 import 在目标导出
|
|
31
|
+
* 缺失时是**链接期硬失败**(`SyntaxError: does not provide an export named …`),
|
|
32
|
+
* 无法用 `as unknown as` 兜底。0.1.7 删掉 `renderEveryReminderBatchFraming` 后,
|
|
33
|
+
* 具名 import 会让整个插件在 0.1.5 上也无法加载——正好与支持区间相反。
|
|
34
|
+
*/
|
|
35
|
+
function pick(name) {
|
|
36
|
+
return sched[name];
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* 0.1.7 的 `allocateScheduleId` 等价物。
|
|
40
|
+
*
|
|
41
|
+
* 随机 UUID 而非递增序号:与官方 `ScheduleService.create` 行为一致,且并发
|
|
42
|
+
* 创建时天然无碰撞。0.1.5 下也可用(`ScheduleId` 是纯 brand 校验,任意
|
|
43
|
+
* 非空字符串通过),因此本函数在两个代次上语义相同,无需双轨。
|
|
44
|
+
*
|
|
45
|
+
* @param _folded 仅为**保持调用点不变**而接受 0.1.5 的 `folded` 参数;0.1.7
|
|
46
|
+
* 不再需要它来避让碰撞(UUID 本身唯一),故不使用。
|
|
47
|
+
*/
|
|
48
|
+
export function allocateScheduleIdCompat(
|
|
49
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
50
|
+
_folded) {
|
|
51
|
+
return ScheduleId(`schedule-${randomUUID()}`);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* 从提醒正文派生 `title`。
|
|
55
|
+
*
|
|
56
|
+
* 0.1.7 要求 title 非空、已 trim、≤120 字符。实测本插件历史 496 条提醒中
|
|
57
|
+
* 最长 575 字符(27 条超限,占 5.4%),故**必须截断**而非直接透传 prompt。
|
|
58
|
+
* 截断在词边界优先,找不到合适边界时按字符硬切并补省略号,保证结果始终
|
|
59
|
+
* 是合法 title(`scheduleTitle` 不会抛)。
|
|
60
|
+
*/
|
|
61
|
+
export function deriveScheduleTitle(prompt) {
|
|
62
|
+
const normalized = prompt.trim();
|
|
63
|
+
if (normalized.length === 0)
|
|
64
|
+
return '定时提醒';
|
|
65
|
+
if (normalized.length <= SCHEDULE_TITLE_MAX)
|
|
66
|
+
return normalized;
|
|
67
|
+
const clipped = normalized.slice(0, SCHEDULE_TITLE_MAX - 1);
|
|
68
|
+
const lastBreak = Math.max(clipped.lastIndexOf(' '), clipped.lastIndexOf(' '));
|
|
69
|
+
const body = lastBreak >= SCHEDULE_TITLE_MAX * 0.6 ? clipped.slice(0, lastBreak) : clipped;
|
|
70
|
+
return `${body.trimEnd()}…`;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* 调用一个 `create*ScheduleRecord`,按目标代次决定是否追加 `title`。
|
|
74
|
+
*
|
|
75
|
+
* 0.1.5 签名为 `(id, prompt, selector, now)`;0.1.7 追加第 5 位必填 `title`。
|
|
76
|
+
* 0.1.7 的 `scheduleTitle` 对空 / 未 trim / >120 字符一律抛错,故 title 必须
|
|
77
|
+
* 由 `deriveScheduleTitle` 保证合法。
|
|
78
|
+
*/
|
|
79
|
+
function createRecord(name, id, prompt, selector, now) {
|
|
80
|
+
const fn = pick(name);
|
|
81
|
+
if (typeof fn !== 'function') {
|
|
82
|
+
throw new Error(`dsh-later: dsh-schedule does not export ${name} — unsupported kernel generation.`);
|
|
83
|
+
}
|
|
84
|
+
// 0.1.7 的 arity 为 5;0.1.5 为 4。按 arity 择一,无需版本号判断。
|
|
85
|
+
return fn.length >= 5
|
|
86
|
+
? fn(id, prompt, selector, now, deriveScheduleTitle(prompt))
|
|
87
|
+
: fn(id, prompt, selector, now);
|
|
88
|
+
}
|
|
89
|
+
/** `createAfterScheduleRecord` 的跨版本包装。 */
|
|
90
|
+
export function createAfterRecord(id, prompt, afterSeconds, now) {
|
|
91
|
+
return createRecord('createAfterScheduleRecord', id, prompt, afterSeconds, now);
|
|
92
|
+
}
|
|
93
|
+
/** `createAtScheduleRecord` 的跨版本包装。 */
|
|
94
|
+
export function createAtRecord(id, prompt, scheduledAt, now) {
|
|
95
|
+
return createRecord('createAtScheduleRecord', id, prompt, scheduledAt, now);
|
|
96
|
+
}
|
|
97
|
+
/** `createEveryScheduleRecord` 的跨版本包装。 */
|
|
98
|
+
export function createEveryRecord(id, prompt, everySeconds, now) {
|
|
99
|
+
return createRecord('createEveryScheduleRecord', id, prompt, everySeconds, now);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* 周期提醒批次的 framing 渲染。
|
|
103
|
+
*
|
|
104
|
+
* 0.1.5 导出 `renderEveryReminderBatchFraming`,0.1.7 改名为
|
|
105
|
+
* `renderRecurringReminderBatchFraming`(并扩展到 daily/weekly/cron,本插件
|
|
106
|
+
* 只产生 `every` 记录)。两代参数与返回结构一致,按存在性择一。
|
|
107
|
+
*/
|
|
108
|
+
export function renderRecurringBatchFraming(reminders) {
|
|
109
|
+
const render = pick('renderEveryReminderBatchFraming') ??
|
|
110
|
+
pick('renderRecurringReminderBatchFraming');
|
|
111
|
+
if (typeof render !== 'function') {
|
|
112
|
+
throw new Error('dsh-later: dsh-schedule exposes neither renderEveryReminderBatchFraming ' +
|
|
113
|
+
'nor renderRecurringReminderBatchFraming — unsupported kernel generation.');
|
|
114
|
+
}
|
|
115
|
+
return render(reminders);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* 提醒注入消息的 `source`。
|
|
119
|
+
*
|
|
120
|
+
* 0.1.5 的 `MessageSourceMap` 含 `plugin: { kind:'plugin'; plugin:string }`;
|
|
121
|
+
* 0.1.7 **删除了 `plugin` 与 `host` 两个 kind**,改由内核为每个生产者登记
|
|
122
|
+
* 具名 kind。官方 `dsh-schedule` 在 0.1.7 里注入提醒用的正是
|
|
123
|
+
* `source: { kind: 'schedule' }`(见其 `lib/index.js` 的 `agent.followup` 调用点)
|
|
124
|
+
* —— 与本插件投递的语义一致,故 0.1.7 走 `schedule`。
|
|
125
|
+
*
|
|
126
|
+
* `form:'notice'` + `summary` 两代都在(`ContextFormed` 未变),保留。
|
|
127
|
+
*
|
|
128
|
+
* 不能用具名类型断言跨代:source 的字面量 union 在两代不同,断言只会把
|
|
129
|
+
* 编译错误推迟到运行期。
|
|
130
|
+
*/
|
|
131
|
+
export function scheduleMessageSource(boundedSummary) {
|
|
132
|
+
const base = { form: 'notice', summary: boundedSummary };
|
|
133
|
+
// 0.1.7 已删除 'plugin';`schedule` 与 renderRecurringReminderBatchFraming
|
|
134
|
+
// 同属 0.1.7 引入,二者同时存在即为 0.1.7+。
|
|
135
|
+
const isV17 = pick('renderRecurringReminderBatchFraming') !== undefined;
|
|
136
|
+
return isV17
|
|
137
|
+
? { kind: 'schedule', ...base }
|
|
138
|
+
: { kind: 'plugin', plugin: PLUGIN_LABEL, ...base };
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* `foldScheduleEvents` 结果的读视图收窄。
|
|
142
|
+
*
|
|
143
|
+
* 0.1.5 的 `FoldedSchedules.active` 是 `ScheduleRecord[]`;0.1.7 改为
|
|
144
|
+
* `LegacyScheduleRecord[]`(title 可选)。本插件只消费 `id` / `kind` /
|
|
145
|
+
* `prompt` / `afterSeconds` / `everySeconds` / `scheduledAt`,这些字段在
|
|
146
|
+
* `Legacy*` 视图中全部存在,故收窄无损。
|
|
147
|
+
*/
|
|
148
|
+
export function asLegacySchedules(folded) {
|
|
149
|
+
return folded;
|
|
150
|
+
}
|
|
151
|
+
/** 本插件在 0.1.5 时代的 source 标签(0.1.7 已无 plugin kind,仅作回退标识)。 */
|
|
152
|
+
const PLUGIN_LABEL = '定时提醒';
|
|
153
|
+
/**
|
|
154
|
+
* `schedule/change` 事件里 `create` 操作的载荷类型。
|
|
155
|
+
*
|
|
156
|
+
* 0.1.7 起内核把「事件载荷」与「内部记录」拆成两个类型:
|
|
157
|
+
* - `ScheduleCreateChange.schedule` 是 **`LegacyScheduleRecord`**(title 可选)
|
|
158
|
+
* - `ScheduleRecord` 是 Host 内部的新格式(title 必填,且含 daily/weekly/cron)
|
|
159
|
+
*
|
|
160
|
+
* 本插件只产生 `after` / `at` / `every` 三种(见 buildScheduleRecord),三者都是
|
|
161
|
+
* `Legacy*` 的对应分支去掉「title 必填」后的结果,故结构兼容。
|
|
162
|
+
*
|
|
163
|
+
* 但 TS 层面 `ScheduleRecord`(6 变体联合)对 `LegacyScheduleRecord`(3 变体)
|
|
164
|
+
* 不可赋值——**这不是运行时问题,是联合宽度问题**。故在此显式收窄:本插件
|
|
165
|
+
* 的产出已经过 `createAfter/At/EveryRecord` 限定,不含 daily/weekly/cron。
|
|
166
|
+
*/
|
|
167
|
+
export function toChangePayload(record) {
|
|
168
|
+
return record;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* `foldScheduleEvents().active` 元素的读取侧类型。
|
|
172
|
+
*
|
|
173
|
+
* 0.1.5 直接返回 `ScheduleRecord`;0.1.7 返回 `LegacyScheduleRecord`
|
|
174
|
+
* (title 可选,因为历史事件没有)。本插件读路径只在 title 存在时展示,
|
|
175
|
+
* 缺失时回退到 prompt——故按 Legacy 收窄是**语义正确**的,而非绕过检查。
|
|
176
|
+
*/
|
|
177
|
+
export function asLegacyRecords(records) {
|
|
178
|
+
return records;
|
|
179
|
+
}
|
|
@@ -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
|
+
}
|