dsh-mishu 0.7.3 → 0.7.4

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/core/index.js CHANGED
@@ -32,6 +32,7 @@ export { deleteMailAccount, effectiveSettings, mailAccounts, saveMailAccount, sa
32
32
  export { deleteWatch, dueWatches, entryKey, parseKeywords, resolveWatch, saveWatch, watchById, watchReportKey, watchSources, WATCH_SOURCE, } from "./watch/source.js";
33
33
  export { describeWatchError, httpFetch, parseFeed, parseJson, parsePage, plainText, readWatchBody } from "./watch/fetch.js";
34
34
  export { describeKeywords, describeWatch, diffItems, judgeWatch, syncWatch } from "./watch/sync.js";
35
+ export { BRIEF_MARK, BRIEF_TASK_KEY, briefPrompt, briefTiming, briefTitle, clockText, describeTiming, instantText, isBriefTask, sortTasks, taskView, } from "./schedule/tasks.js";
35
36
  export const DEFAULT_HOME = '~/.mishu';
36
37
  /** `~` and `~/…` expand to the home directory on every platform. */
37
38
  export function expandHome(path) {
@@ -0,0 +1,149 @@
1
+ /**
2
+ * DSH's own scheduled tasks, seen from the secretary's side.
3
+ *
4
+ * DSH keeps a Schedule service (`@deepseek-ai/dsh-schedule`) that delivers a
5
+ * prompt to a Session at a wall-clock time: the "自动化任务" page. This module
6
+ * only describes what that service returns — it never imports DSH, so the
7
+ * wording, the time formatting and the check for "is this the brief task"
8
+ * stay testable without a host.
9
+ *
10
+ * The one task the secretary creates itself is the daily brief: same content
11
+ * as the desktop notification, delivered into a conversation instead, so it
12
+ * reaches you when DSH is open but you are not looking at the board.
13
+ */
14
+ import { bi, pick } from "../i18n.js";
15
+ import { localClock } from "../notify.js";
16
+ /** Where the notebook remembers the brief task it created, so it can be updated instead of duplicated. */
17
+ export const BRIEF_TASK_KEY = 'schedule.briefTask';
18
+ /** Marks the prompt the secretary writes, so a task edited by hand is still recognisable. */
19
+ export const BRIEF_MARK = '[dsh-mishu brief]';
20
+ const pad = (n) => String(n).padStart(2, '0');
21
+ /** `HH:mm:ss.SSS` → `HH:mm`, or the raw value when it is not a clock time. */
22
+ export function clockText(time) {
23
+ if (time === undefined)
24
+ return '';
25
+ const match = /^(\d{2}):(\d{2})/.exec(time);
26
+ return match === null ? time : `${match[1]}:${match[2]}`;
27
+ }
28
+ /** An RFC 3339 instant as a local day and clock time, `11-12 08:30`. */
29
+ export function instantText(iso, timeZone, lang) {
30
+ if (iso === undefined)
31
+ return '';
32
+ const at = Date.parse(iso);
33
+ if (!Number.isFinite(at))
34
+ return iso;
35
+ let clock;
36
+ try {
37
+ clock = localClock(at, timeZone);
38
+ }
39
+ catch {
40
+ clock = localClock(at, '');
41
+ }
42
+ const [year, month, day] = clock.day.split('-');
43
+ const now = new Date();
44
+ const sameYear = Number(year) === now.getFullYear();
45
+ return `${sameYear ? '' : `${year}-`}${month}-${day} ${pad(Math.floor(clock.minutes / 60))}:${pad(clock.minutes % 60)}`;
46
+ }
47
+ const WEEKDAY_ZH = ['一', '二', '三', '四', '五', '六', '日'];
48
+ const WEEKDAY_EN = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
49
+ /** The rule in words. Every branch names the unit a person set, never a cron string alone. */
50
+ export function describeTiming(entry, lang) {
51
+ const zh = lang === 'zh';
52
+ const zone = entry.timeZone === undefined || entry.timeZone === '' ? '' : entry.timeZone;
53
+ switch (entry.kind) {
54
+ case 'after': {
55
+ const seconds = entry.afterSeconds ?? 0;
56
+ if (seconds % 3600 === 0 && seconds >= 3600)
57
+ return zh ? `${seconds / 3600} 小时后一次` : `once in ${seconds / 3600} h`;
58
+ if (seconds % 60 === 0 && seconds >= 60)
59
+ return zh ? `${seconds / 60} 分钟后一次` : `once in ${seconds / 60} min`;
60
+ return zh ? `${seconds} 秒后一次` : `once in ${seconds} s`;
61
+ }
62
+ case 'at':
63
+ return zh ? `只跑一次,${instantText(entry.scheduledAt, zone, lang)}` : `once, ${instantText(entry.scheduledAt, zone, lang)}`;
64
+ case 'every': {
65
+ const seconds = entry.everySeconds ?? 0;
66
+ if (seconds % 3600 === 0)
67
+ return zh ? `每 ${seconds / 3600} 小时` : `every ${seconds / 3600} h`;
68
+ return zh ? `每 ${Math.max(1, Math.round(seconds / 60))} 分钟` : `every ${Math.max(1, Math.round(seconds / 60))} min`;
69
+ }
70
+ case 'daily':
71
+ return zh ? `每天 ${clockText(entry.time)}` : `daily at ${clockText(entry.time)}`;
72
+ case 'weekly': {
73
+ const days = (entry.weekdays ?? []).slice().sort((a, b) => a - b);
74
+ const names = zh ? days.map(d => WEEKDAY_ZH[d - 1] ?? String(d)) : days.map(d => WEEKDAY_EN[d - 1] ?? String(d));
75
+ const list = zh ? names.join('、') : names.join(', ');
76
+ return zh ? `每${list} ${clockText(entry.time)}` : `${list} at ${clockText(entry.time)}`;
77
+ }
78
+ case 'cron':
79
+ return zh ? `按规则 ${entry.expression ?? ''}` : `on the rule ${entry.expression ?? ''}`;
80
+ case 'rrule': {
81
+ const first = (entry.rrule ?? '').split('\n').find(line => line.toUpperCase().startsWith('RRULE:')) ?? entry.rrule ?? '';
82
+ return first.replace(/^RRULE:/i, '');
83
+ }
84
+ }
85
+ }
86
+ /** One entry, as the settings page shows it. */
87
+ export function taskView(entry, lang, briefTaskId) {
88
+ const status = entry.status === undefined || entry.status === 'inactive' ? 'ended' : entry.status;
89
+ const zone = entry.timeZone ?? '';
90
+ return {
91
+ id: entry.id,
92
+ title: entry.title,
93
+ when: describeTiming(entry, lang),
94
+ next: status === 'ended' ? null : instantText(entry.scheduledAt, zone, lang) || null,
95
+ status,
96
+ prompt: entry.prompt.replace(BRIEF_MARK, '').trim().slice(0, 400),
97
+ mine: entry.id === briefTaskId || entry.prompt.includes(BRIEF_MARK),
98
+ running: entry.executionActive === true,
99
+ lastDelivery: entry.lastDelivery?.at === undefined ? null : instantText(entry.lastDelivery.at, zone, lang) || null,
100
+ };
101
+ }
102
+ /** Sorted for reading: what still runs first, then what has ended. */
103
+ export function sortTasks(tasks) {
104
+ const rank = (t) => (t.status === 'active' ? 0 : t.status === 'paused' ? 1 : 2);
105
+ return [...tasks].sort((a, b) => rank(a) - rank(b) || a.title.localeCompare(b.title));
106
+ }
107
+ /** The prompt the brief task carries: what the agent should do at that hour. */
108
+ export function briefPrompt(lang) {
109
+ return lang === 'zh'
110
+ ? [
111
+ BRIEF_MARK,
112
+ '现在是小秘书的每日简报时间。',
113
+ '用 mishu_list 读出看板,然后用三句话说清楚:今天要处理什么(最多五件,按紧急程度),哪些快到期限,有没有哪个来源同步失败了。',
114
+ '不要重复卡片原文,也不要替我做事,只把需要我决定的说出来。没有要处理的事就直接说"今天没有要处理的"。',
115
+ ].join('\n')
116
+ : [
117
+ BRIEF_MARK,
118
+ 'It is time for the secretary\'s daily brief.',
119
+ 'Read the board with mishu_list, then say in three sentences: what needs me today (at most five, most urgent first), what is coming up, and whether any source failed to sync.',
120
+ 'Do not repeat the cards verbatim and do not act for me — only surface what needs my decision. If nothing is waiting, just say "nothing needs you today".',
121
+ ].join('\n');
122
+ }
123
+ /** Title of the brief task, in the language it was created in. */
124
+ export function briefTitle(lang) {
125
+ return pick(bi('小秘书 · 每日简报', 'Secretary · daily brief'), lang);
126
+ }
127
+ /**
128
+ * The daily rule for the brief, from a `HH:mm` setting and a zone. Seconds are
129
+ * zero so the task fires on the minute; the zone falls back to this computer's
130
+ * when the settings page has not picked one yet.
131
+ */
132
+ export function briefTiming(time, timeZone) {
133
+ const match = /^(\d{2}):(\d{2})$/.exec(time.trim());
134
+ const clock = match === null ? '08:30' : `${match[1]}:${match[2]}`;
135
+ let zone = timeZone.trim();
136
+ if (zone === '') {
137
+ try {
138
+ zone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? '';
139
+ }
140
+ catch {
141
+ zone = '';
142
+ }
143
+ }
144
+ return { time: `${clock}:00.000`, time_zone: zone === '' ? 'UTC' : zone };
145
+ }
146
+ /** Whether a task already is the brief for this notebook. */
147
+ export function isBriefTask(entry, briefTaskId) {
148
+ return entry.id === briefTaskId || entry.prompt.includes(BRIEF_MARK);
149
+ }
package/lib/dsh/api.js CHANGED
@@ -151,6 +151,13 @@ export function apiHandlers(runtime, clock = Date.now) {
151
151
  feishu: { configured: (await runtime.secrets.get(FEISHU_WEBHOOK).catch(() => undefined)) !== undefined },
152
152
  last: store.get(NOTIFY_REPORT_KEY) ?? null,
153
153
  },
154
+ // DSH's own scheduled tasks; null when this Host has no Schedule service.
155
+ // A failing service must not take the rest of the settings page down.
156
+ schedule: runtime.schedule === undefined ? null : await runtime.schedule.view(lang).catch(() => ({
157
+ available: true,
158
+ tasks: [],
159
+ brief: { id: null, time: notifySettings(store).brief.time, timeZone: notifySettings(store).timeZone, armed: false, error: lang === 'zh' ? '读不到 DSH 的自动化任务' : 'could not read DSH\'s scheduled tasks' },
160
+ })),
154
161
  presets: MAIL_PRESETS.map(p => ({
155
162
  id: p.id, label: p.label[lang], host: p.host, port: p.port, secure: p.secure, supported: p.supported, howTo: p.howTo[lang],
156
163
  domains: p.domains, passwordLength: p.passwordLength ?? null,
@@ -282,6 +289,8 @@ export function apiHandlers(runtime, clock = Date.now) {
282
289
  '/notify/save': async (body) => {
283
290
  const lang = langOf(body);
284
291
  saveNotifySettings(need(lang), (body.notify ?? {}), clock());
292
+ // A brief that is also a scheduled task follows the time you just set.
293
+ await runtime.schedule?.followBrief(lang);
285
294
  return { view: await settingsView(lang) };
286
295
  },
287
296
  /** One test message through one channel, turned on or not. */
@@ -400,8 +409,41 @@ export function apiHandlers(runtime, clock = Date.now) {
400
409
  throw new ApiError(report.error[lang]);
401
410
  return { report: report ?? null, board: board(store, lang), view: await settingsView(lang) };
402
411
  },
403
- /** Whether the page's "restart DSH" button can work here, and if not, what to do instead. */
404
- '/restart/info': async (body) => {
412
+ // ── DSH's own scheduled tasks ─────────────────────────
413
+ /** Hand the daily brief to DSH's Schedule: it then arrives as a conversation, not a notification. */
414
+ '/schedule/brief/arm': async (body) => {
415
+ const lang = langOf(body);
416
+ const bridge = runtime.schedule;
417
+ if (bridge === undefined)
418
+ throw new ApiError(lang === 'zh' ? '这个 DSH 没有自动化任务服务,简报只会用桌面通知发' : 'This DSH has no scheduled tasks service; the brief stays a desktop notification');
419
+ // Arm first, then read the page back: the task list and the armed flag are only right afterwards.
420
+ const schedule = await bridge.armBrief(lang);
421
+ return { view: await settingsView(lang), schedule };
422
+ },
423
+ '/schedule/brief/disarm': async (body) => {
424
+ const lang = langOf(body);
425
+ const bridge = runtime.schedule;
426
+ if (bridge === undefined)
427
+ throw new ApiError(lang === 'zh' ? '这个 DSH 没有自动化任务服务' : 'This DSH has no scheduled tasks service');
428
+ const schedule = await bridge.disarmBrief(lang);
429
+ return { view: await settingsView(lang), schedule };
430
+ },
431
+ /** Run any task now. */
432
+ '/schedule/run': async (body) => {
433
+ const lang = langOf(body);
434
+ const bridge = runtime.schedule;
435
+ if (bridge === undefined)
436
+ throw new ApiError(lang === 'zh' ? '这个 DSH 没有自动化任务服务' : 'This DSH has no scheduled tasks service');
437
+ return { schedule: await bridge.run(String(body.id ?? ''), lang) };
438
+ },
439
+ '/schedule/delete': async (body) => {
440
+ const lang = langOf(body);
441
+ const bridge = runtime.schedule;
442
+ if (bridge === undefined)
443
+ throw new ApiError(lang === 'zh' ? '这个 DSH 没有自动化任务服务' : 'This DSH has no scheduled tasks service');
444
+ return { schedule: await bridge.delete(String(body.id ?? ''), lang) };
445
+ },
446
+ /** Whether the page's "restart DSH" button can work here, and if not, what to do instead. */ '/restart/info': async (body) => {
405
447
  const plan = runtime.restarter.plan();
406
448
  return plan.kind === 'none' ? { supported: false, why: plan.why[langOf(body)] } : { supported: true };
407
449
  },
package/lib/dsh/plugin.js CHANGED
@@ -111,6 +111,15 @@ export function apply(ctx, config, runtime = new Runtime(config, ctx.logger)) {
111
111
  }));
112
112
  }
113
113
  });
114
+ // DSH's own scheduled tasks ("自动化任务"). Optional: a Host without the
115
+ // service simply keeps the brief as a desktop notification.
116
+ ctx.inject(['schedule'], (scheduleCtx) => {
117
+ const service = scheduleCtx.schedule;
118
+ if (service === undefined || service === null)
119
+ return;
120
+ runtime.attachSchedule(service);
121
+ scheduleCtx.effect(() => () => { runtime.schedule = undefined; });
122
+ });
114
123
  ctx.inject(['commands'], (commandCtx) => {
115
124
  const commands = commandCtx.commands;
116
125
  commandCtx.effect(() => commands.register({
@@ -4,7 +4,8 @@
4
4
  * what you set on the settings page on top), the keychain, and one sync of
5
5
  * every source at a time.
6
6
  */
7
- import { dayOf, detectLang, effectiveSettings, GitHubClient, ImapFetcher, mailAccounts, mailReportKey, renderBrief, buildBoard, DesktopChannel, FeishuChannel, FileSecrets, GithubDeviceLogin, KeychainOrFile, notifySettings, notifyTick, mishuPaths, resolveGithubToken, secretAccount, secretFromEnv, syncGithub, syncMail, systemSecrets, tickManual, bi, restartPlan, scheduleRestart, dueWatches, httpFetch, syncWatch, watchReportKey, watchSources, } from "../core/index.js";
7
+ import { dayOf, detectLang, effectiveSettings, GitHubClient, ImapFetcher, mailAccounts, mailReportKey, renderBrief, buildBoard, DesktopChannel, FeishuChannel, FileSecrets, GithubDeviceLogin, KeychainOrFile, notifySettings, notifyTick, mishuPaths, resolveGithubToken, secretAccount, secretFromEnv, syncGithub, syncMail, systemSecrets, tickManual, bi, restartPlan, scheduleRestart, dueWatches, httpFetch, syncWatch, watchReportKey, watchSources, DEFAULT_NOTIFY, } from "../core/index.js";
8
+ import { ScheduleBridge } from "./schedule.js";
8
9
  export class Runtime {
9
10
  store;
10
11
  config;
@@ -21,6 +22,11 @@ export class Runtime {
21
22
  githubLogin;
22
23
  /** How DSH restarts; tests swap it so they never restart anything. */
23
24
  restarter = { plan: () => restartPlan(), schedule: plan => scheduleRestart(plan) };
25
+ /**
26
+ * DSH's own scheduled tasks, when this Host has them. Attached by the shell
27
+ * once the `schedule` service shows up; absent on a Host without it.
28
+ */
29
+ schedule;
24
30
  constructor(config, logger, secrets = new KeychainOrFile(systemSecrets(), new FileSecrets(mishuPaths(config.home).secrets)), fetcher = new ImapFetcher()) {
25
31
  this.config = config;
26
32
  this.logger = logger;
@@ -49,6 +55,23 @@ export class Runtime {
49
55
  watches() {
50
56
  return this.store === undefined ? [] : watchSources(this.store);
51
57
  }
58
+ /**
59
+ * Wire in DSH's Schedule service, once. The bridge reads the brief's time
60
+ * from the reminder settings, so a brief moved on this page moves the task
61
+ * too. Called again with the same service (a service restart) keeps the
62
+ * bridge and hands it the new handle.
63
+ */
64
+ attachSchedule(service) {
65
+ if (this.schedule !== undefined) {
66
+ this.schedule.replace(service);
67
+ return this.schedule;
68
+ }
69
+ this.schedule = new ScheduleBridge(service, () => this.store, () => this.lang(), () => {
70
+ const settings = this.store === undefined ? DEFAULT_NOTIFY : notifySettings(this.store);
71
+ return { time: settings.brief.time, timeZone: settings.timeZone };
72
+ });
73
+ return this.schedule;
74
+ }
52
75
  brief(lang = this.lang()) {
53
76
  const store = this.store;
54
77
  if (store === undefined)
@@ -0,0 +1,221 @@
1
+ /**
2
+ * The bridge to DSH's own Schedule service (`@deepseek-ai/dsh-schedule`, the
3
+ * "自动化任务" page).
4
+ *
5
+ * Two directions, both kept small on purpose:
6
+ *
7
+ * - The daily brief can be handed to Schedule: the same text the desktop
8
+ * notification carries, delivered as a turn in a conversation instead. That
9
+ * is the one task the secretary creates on its own, and it is marked in its
10
+ * prompt so a later visit recognises it even after you edited the title.
11
+ * - Every task on the Host is listed for the settings page, so what the
12
+ * secretary scheduled and what you scheduled yourself sit in one place.
13
+ *
14
+ * Schedule is optional: a Host without the service simply has no bridge, and
15
+ * the page says so instead of offering a button that cannot work.
16
+ */
17
+ import { bi, BRIEF_MARK, briefPrompt, briefTiming, briefTitle, BRIEF_TASK_KEY, isBriefTask, sortTasks, taskView } from "../core/index.js";
18
+ import { MishuError } from "../core/i18n.js";
19
+ /** A code the service returns instead of throwing (`schedule_not_found`, `schedule_conflict`, …). */
20
+ const isMiss = (value) => typeof value === 'object' && value !== null && 'code' in value && typeof value.code === 'string';
21
+ /** The service's own words, wrapped so the page shows one sentence in one language. */
22
+ function wrap(error, _lang) {
23
+ const message = error instanceof Error ? error.message : String(error);
24
+ const code = error.code;
25
+ const hint = typeof code === 'string' ? `(${code})` : '';
26
+ return new MishuError(bi(`DSH 的自动化任务没接受这个请求:${message}${hint}`, `DSH's scheduled tasks refused this: ${message}${hint}`));
27
+ }
28
+ const BRIEF_ERROR_KEY = 'schedule.briefError';
29
+ export class ScheduleBridge {
30
+ service;
31
+ store;
32
+ lang;
33
+ /** The brief's time and zone, as the reminder settings hold them. */
34
+ briefClock;
35
+ constructor(service, store, lang, briefClock) {
36
+ this.service = service;
37
+ this.store = store;
38
+ this.lang = lang;
39
+ this.briefClock = briefClock;
40
+ }
41
+ /** A restarted service: same bridge, new handle. */
42
+ replace(service) {
43
+ this.service = service;
44
+ }
45
+ /** The page's language when it asked, else the notebook's. A task's wording is read by a person, not logged. */
46
+ useLang(lang) {
47
+ return lang ?? this.lang();
48
+ }
49
+ savedId() {
50
+ return this.store()?.get(BRIEF_TASK_KEY);
51
+ }
52
+ /** Every task on the Host, newest rules first, plus whether the brief is armed. */
53
+ async view(asked) {
54
+ const lang = this.useLang(asked);
55
+ const briefId = this.savedId();
56
+ let entries = [];
57
+ let error = null;
58
+ try {
59
+ const page = await this.service.list({ limit: 100 });
60
+ entries = page.items;
61
+ }
62
+ catch (problem) {
63
+ error = wrap(problem, lang).text[lang];
64
+ }
65
+ const store = this.store();
66
+ const clock = this.briefClock();
67
+ const timing = briefTiming(clock.time, clock.timeZone);
68
+ const armed = entries.some(entry => isBriefTask(entry, briefId) && (entry.status ?? 'active') === 'active');
69
+ return {
70
+ available: true,
71
+ tasks: sortTasks(entries.map(entry => taskView(entry, lang, briefId))),
72
+ brief: {
73
+ id: briefId ?? null,
74
+ time: clock.time,
75
+ timeZone: timing.time_zone,
76
+ armed,
77
+ error: error ?? store?.get(BRIEF_ERROR_KEY) ?? null,
78
+ },
79
+ };
80
+ }
81
+ /**
82
+ * Arm the brief as a daily task, or move it when the time changed. Reuses
83
+ * the task it made before — editing it in the automation page keeps it.
84
+ */
85
+ async armBrief(asked) {
86
+ const store = this.store();
87
+ if (store === undefined)
88
+ throw new MishuError(bi('小秘书的记事本没有打开', 'the notebook is not open'));
89
+ const lang = this.useLang(asked);
90
+ const clock = this.briefClock();
91
+ const timing = { kind: 'daily', daily: briefTiming(clock.time, clock.timeZone) };
92
+ const request = { title: briefTitle(lang), prompt: briefPrompt(lang), timing };
93
+ const existing = this.savedId();
94
+ try {
95
+ let id = existing;
96
+ if (id !== undefined) {
97
+ const current = await this.service.view({ id });
98
+ if (isMiss(current))
99
+ id = undefined;
100
+ else {
101
+ const updated = await this.service.edit({
102
+ id,
103
+ action: 'update',
104
+ patch: { ...request, active: true },
105
+ ...current.updatedAt === undefined ? {} : { expectedUpdatedAt: current.updatedAt },
106
+ });
107
+ if (isMiss(updated))
108
+ id = undefined;
109
+ }
110
+ }
111
+ if (id === undefined) {
112
+ const created = await this.service.create({ ...request, target: { kind: 'new_once', create: {} } });
113
+ if (isMiss(created))
114
+ throw new MishuError(bi(`自动化任务没有建成:${created.code}`, `the task was not created: ${created.code}`));
115
+ id = created.id;
116
+ store.set(BRIEF_TASK_KEY, id);
117
+ }
118
+ store.set(BRIEF_ERROR_KEY, null);
119
+ store.log(Date.now(), 'schedule-brief', { id, time: clock.time, zone: briefTiming(clock.time, clock.timeZone).time_zone });
120
+ }
121
+ catch (problem) {
122
+ const wrapped = problem instanceof MishuError ? problem : wrap(problem, lang);
123
+ store.set(BRIEF_ERROR_KEY, wrapped.text[lang]);
124
+ throw wrapped;
125
+ }
126
+ return this.view(lang);
127
+ }
128
+ /**
129
+ * Move the brief when its time changed on the settings page, and say
130
+ * nothing when it was never armed. Errors are kept, not thrown: this runs
131
+ * behind a save that has already succeeded.
132
+ */
133
+ async followBrief(asked) {
134
+ const store = this.store();
135
+ const id = this.savedId();
136
+ if (store === undefined || id === undefined)
137
+ return;
138
+ try {
139
+ const current = await this.service.view({ id });
140
+ if (isMiss(current)) {
141
+ store.set(BRIEF_TASK_KEY, null);
142
+ return;
143
+ }
144
+ if ((current.status ?? 'active') !== 'active')
145
+ return;
146
+ const clock = this.briefClock();
147
+ const timing = briefTiming(clock.time, clock.timeZone);
148
+ if (current.kind === 'daily' && current.time === timing.time && current.timeZone === timing.time_zone)
149
+ return;
150
+ const updated = await this.service.edit({
151
+ id,
152
+ action: 'update',
153
+ patch: { timing: { kind: 'daily', daily: timing } },
154
+ ...current.updatedAt === undefined ? {} : { expectedUpdatedAt: current.updatedAt },
155
+ });
156
+ const lang = this.useLang(asked);
157
+ if (isMiss(updated))
158
+ store.set(BRIEF_ERROR_KEY, bi(`改不动:${updated.code}`, `could not move it: ${updated.code}`)[lang]);
159
+ else
160
+ store.log(Date.now(), 'schedule-brief-moved', { id, time: clock.time });
161
+ }
162
+ catch (problem) {
163
+ const lang = this.useLang(asked);
164
+ store.set(BRIEF_ERROR_KEY, wrap(problem, lang).text[lang]);
165
+ }
166
+ }
167
+ /** Take the brief task off the Host; the desktop notification is unaffected. */
168
+ async disarmBrief(asked) {
169
+ const store = this.store();
170
+ if (store === undefined)
171
+ throw new MishuError(bi('小秘书的记事本没有打开', 'the notebook is not open'));
172
+ const lang = this.useLang(asked);
173
+ const id = this.savedId();
174
+ if (id !== undefined) {
175
+ try {
176
+ const result = await this.service.edit({ id, action: 'delete' });
177
+ // Already gone is the same as gone.
178
+ if (isMiss(result) && result.code !== 'schedule_not_found')
179
+ throw new MishuError(bi(`删不掉:${result.code}`, `could not delete: ${result.code}`));
180
+ }
181
+ catch (problem) {
182
+ throw problem instanceof MishuError ? problem : wrap(problem, lang);
183
+ }
184
+ store.set(BRIEF_TASK_KEY, null);
185
+ store.log(Date.now(), 'schedule-brief-off', { id });
186
+ }
187
+ store.set(BRIEF_ERROR_KEY, null);
188
+ return this.view(lang);
189
+ }
190
+ /** Delete any task on the Host — the secretary only ever deletes its own, but the page lists all. */
191
+ async delete(id, asked) {
192
+ const lang = this.useLang(asked);
193
+ try {
194
+ const result = await this.service.edit({ id, action: 'delete' });
195
+ if (isMiss(result) && result.code === 'schedule_not_found')
196
+ throw new MishuError(bi('这个任务已经不在了', 'that task is already gone'));
197
+ }
198
+ catch (problem) {
199
+ throw problem instanceof MishuError ? problem : wrap(problem, lang);
200
+ }
201
+ if (this.savedId() === id)
202
+ this.store()?.set(BRIEF_TASK_KEY, null);
203
+ return this.view(lang);
204
+ }
205
+ /** Run a task now, without waiting for its time. */
206
+ async run(id, asked) {
207
+ const lang = this.useLang(asked);
208
+ try {
209
+ const result = await this.service.run({ id });
210
+ if (isMiss(result)) {
211
+ throw new MishuError(result.code === 'schedule_execution_pending'
212
+ ? bi('这个任务正在跑,等它跑完', 'that task is already running')
213
+ : bi('这个任务已经不在了', 'that task is already gone'));
214
+ }
215
+ }
216
+ catch (problem) {
217
+ throw problem instanceof MishuError ? problem : wrap(problem, lang);
218
+ }
219
+ return this.view(lang);
220
+ }
221
+ }
@@ -31,6 +31,7 @@ export { deleteMailAccount, effectiveSettings, mailAccounts, saveMailAccount, sa
31
31
  export { deleteWatch, dueWatches, entryKey, parseKeywords, resolveWatch, saveWatch, watchById, watchReportKey, watchSources, WATCH_SOURCE, type WatchInput, type WatchReport, type WatchSource, } from './watch/source.ts';
32
32
  export { describeWatchError, httpFetch, parseFeed, parseJson, parsePage, plainText, readWatchBody, type WatchFetch, type WatchItem } from './watch/fetch.ts';
33
33
  export { describeKeywords, describeWatch, diffItems, judgeWatch, syncWatch } from './watch/sync.ts';
34
+ export { BRIEF_MARK, BRIEF_TASK_KEY, briefPrompt, briefTiming, briefTitle, clockText, describeTiming, instantText, isBriefTask, sortTasks, taskView, type BriefTiming, type ScheduleEntry, type ScheduleKind, type ScheduleTaskView, } from './schedule/tasks.ts';
34
35
  export declare const DEFAULT_HOME = "~/.mishu";
35
36
  /** `~` and `~/…` expand to the home directory on every platform. */
36
37
  export declare function expandHome(path: string): string;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * DSH's own scheduled tasks, seen from the secretary's side.
3
+ *
4
+ * DSH keeps a Schedule service (`@deepseek-ai/dsh-schedule`) that delivers a
5
+ * prompt to a Session at a wall-clock time: the "自动化任务" page. This module
6
+ * only describes what that service returns — it never imports DSH, so the
7
+ * wording, the time formatting and the check for "is this the brief task"
8
+ * stay testable without a host.
9
+ *
10
+ * The one task the secretary creates itself is the daily brief: same content
11
+ * as the desktop notification, delivered into a conversation instead, so it
12
+ * reaches you when DSH is open but you are not looking at the board.
13
+ */
14
+ import { type Lang } from '../i18n.ts';
15
+ /** Where the notebook remembers the brief task it created, so it can be updated instead of duplicated. */
16
+ export declare const BRIEF_TASK_KEY = "schedule.briefTask";
17
+ /** Marks the prompt the secretary writes, so a task edited by hand is still recognisable. */
18
+ export declare const BRIEF_MARK = "[dsh-mishu brief]";
19
+ export type ScheduleKind = 'after' | 'at' | 'every' | 'daily' | 'weekly' | 'cron' | 'rrule';
20
+ /**
21
+ * One task as the Schedule service hands it out: its rule fields are spread
22
+ * onto the entry, so which ones are present says what kind it is.
23
+ */
24
+ export interface ScheduleEntry {
25
+ id: string;
26
+ kind: ScheduleKind;
27
+ title: string;
28
+ prompt: string;
29
+ /** Next occurrence, RFC 3339 in UTC. */
30
+ scheduledAt?: string;
31
+ afterSeconds?: number;
32
+ everySeconds?: number;
33
+ /** Local wall-clock time, `HH:mm:ss.SSS`. */
34
+ time?: string;
35
+ timeZone?: string;
36
+ /** ISO weekdays, 1 = Monday. */
37
+ weekdays?: number[];
38
+ expression?: string;
39
+ rrule?: string;
40
+ status?: 'active' | 'paused' | 'ended' | 'inactive';
41
+ createdAt?: string;
42
+ updatedAt?: string;
43
+ executionActive?: boolean;
44
+ lastDelivery?: {
45
+ at?: string;
46
+ };
47
+ }
48
+ /** One task, worded for the settings page. */
49
+ export interface ScheduleTaskView {
50
+ id: string;
51
+ title: string;
52
+ /** The rule, in words: "每天 08:30" / "every 15 minutes". */
53
+ when: string;
54
+ /** Next occurrence, `11-12 08:30` in the task's own zone; null once it has ended. */
55
+ next: string | null;
56
+ status: 'active' | 'paused' | 'ended';
57
+ /** What the task tells the session, shortened for a tooltip. */
58
+ prompt: string;
59
+ /** Created by the secretary itself (the daily brief). */
60
+ mine: boolean;
61
+ running: boolean;
62
+ lastDelivery: string | null;
63
+ }
64
+ /** `HH:mm:ss.SSS` → `HH:mm`, or the raw value when it is not a clock time. */
65
+ export declare function clockText(time: string | undefined): string;
66
+ /** An RFC 3339 instant as a local day and clock time, `11-12 08:30`. */
67
+ export declare function instantText(iso: string | undefined, timeZone: string, lang: Lang): string;
68
+ /** The rule in words. Every branch names the unit a person set, never a cron string alone. */
69
+ export declare function describeTiming(entry: ScheduleEntry, lang: Lang): string;
70
+ /** One entry, as the settings page shows it. */
71
+ export declare function taskView(entry: ScheduleEntry, lang: Lang, briefTaskId: string | undefined): ScheduleTaskView;
72
+ /** Sorted for reading: what still runs first, then what has ended. */
73
+ export declare function sortTasks(tasks: ScheduleTaskView[]): ScheduleTaskView[];
74
+ /** The prompt the brief task carries: what the agent should do at that hour. */
75
+ export declare function briefPrompt(lang: Lang): string;
76
+ /** Title of the brief task, in the language it was created in. */
77
+ export declare function briefTitle(lang: Lang): string;
78
+ export interface BriefTiming {
79
+ time: string;
80
+ time_zone: string;
81
+ }
82
+ /**
83
+ * The daily rule for the brief, from a `HH:mm` setting and a zone. Seconds are
84
+ * zero so the task fires on the minute; the zone falls back to this computer's
85
+ * when the settings page has not picked one yet.
86
+ */
87
+ export declare function briefTiming(time: string, timeZone: string): BriefTiming;
88
+ /** Whether a task already is the brief for this notebook. */
89
+ export declare function isBriefTask(entry: ScheduleEntry, briefTaskId: string | undefined): boolean;
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { GithubDeviceLogin, type Defaults, type Lang, type MailAccount, type MailFetcher, type MailSyncReport, type MishuStore, type Channel, type ChannelId, type NotifyReport, type RestartPlan, type RestartStarted, type SecretStore, type TokenResult, type WatchFetch, type WatchReport, type WatchSource } from '../core/index.ts';
8
8
  import type { MishuConfig } from './config.ts';
9
+ import { ScheduleBridge, type ScheduleLike } from './schedule.ts';
9
10
  interface Logger {
10
11
  info(message: string): void;
11
12
  warn(message: string): void;
@@ -31,6 +32,11 @@ export declare class Runtime {
31
32
  kind: 'none';
32
33
  }>): RestartStarted;
33
34
  };
35
+ /**
36
+ * DSH's own scheduled tasks, when this Host has them. Attached by the shell
37
+ * once the `schedule` service shows up; absent on a Host without it.
38
+ */
39
+ schedule: ScheduleBridge | undefined;
34
40
  constructor(config: MishuConfig, logger: Logger, secrets?: SecretStore, fetcher?: MailFetcher);
35
41
  defaults(): Defaults;
36
42
  settings(): Defaults;
@@ -39,6 +45,13 @@ export declare class Runtime {
39
45
  lang(): Lang;
40
46
  accounts(): MailAccount[];
41
47
  watches(): WatchSource[];
48
+ /**
49
+ * Wire in DSH's Schedule service, once. The bridge reads the brief's time
50
+ * from the reminder settings, so a brief moved on this page moves the task
51
+ * too. Called again with the same service (a service restart) keeps the
52
+ * bridge and hands it the new handle.
53
+ */
54
+ attachSchedule(service: ScheduleLike): ScheduleBridge;
42
55
  brief(lang?: Lang): string;
43
56
  /** The password for an account: keychain first, then the documented environment variable. */
44
57
  mailPassword(id: number): Promise<string | undefined>;