dsh-mishu 0.7.2 → 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/README.md +34 -4
- package/lib/client.js +670 -308
- package/lib/core/ask.js +16 -0
- package/lib/core/board.js +15 -3
- package/lib/core/brief.js +16 -0
- package/lib/core/index.js +4 -0
- package/lib/core/schedule/tasks.js +149 -0
- package/lib/core/watch/fetch.js +169 -0
- package/lib/core/watch/source.js +116 -0
- package/lib/core/watch/sync.js +171 -0
- package/lib/dsh/api.js +136 -4
- package/lib/dsh/plugin.js +9 -0
- package/lib/dsh/runtime.js +43 -2
- package/lib/dsh/schedule.js +221 -0
- package/lib/types/core/ask.d.ts +5 -0
- package/lib/types/core/board.d.ts +12 -1
- package/lib/types/core/brief.d.ts +5 -0
- package/lib/types/core/index.d.ts +4 -0
- package/lib/types/core/schedule/tasks.d.ts +89 -0
- package/lib/types/core/watch/fetch.d.ts +50 -0
- package/lib/types/core/watch/source.d.ts +68 -0
- package/lib/types/core/watch/sync.d.ts +51 -0
- package/lib/types/dsh/runtime.d.ts +19 -1
- package/lib/types/dsh/schedule.d.ts +113 -0
- package/package.json +1 -1
package/lib/core/ask.js
CHANGED
|
@@ -16,6 +16,7 @@ import { mailReportKey } from "./mail/sync.js";
|
|
|
16
16
|
import { isActive } from "./matters.js";
|
|
17
17
|
import { CATEGORY_LABELS, describeRepeat, occurrence, remindDay } from "./recur.js";
|
|
18
18
|
import { dayOf, formatMoment } from "./time.js";
|
|
19
|
+
import { watchById, watchReportKey } from "./watch/source.js";
|
|
19
20
|
/** The tools the model can use in that chat, so it can look further and act only with your OK. */
|
|
20
21
|
function toolsHint(lang, id) {
|
|
21
22
|
return lang === 'zh'
|
|
@@ -128,5 +129,20 @@ export function buildAsk(store, request, options) {
|
|
|
128
129
|
? `我想让小秘书只读地查看我的「${preset.label.zh}」收件箱,需要开通 IMAP 并拿到客户端授权码(或应用专用密码)。请一步步教我怎么做,包括在网页上点哪里、要注意什么。\n\n小秘书给的说明:${preset.howTo.zh}\nIMAP 服务器:${preset.host || '(自己填)'}:${preset.port}${lines('\n测试连接时报错:', request.error)}`
|
|
129
130
|
: `I want my secretary to read my ${preset.label.en} inbox (read-only), which needs IMAP turned on and an app password. Walk me through it step by step, including where to click and what to watch out for.\n\nThe secretary's note: ${preset.howTo.en}\nIMAP server: ${preset.host || '(enter your own)'}:${preset.port}${lines('\nThe connection test said: ', request.error)}`;
|
|
130
131
|
}
|
|
132
|
+
case 'watch-error': {
|
|
133
|
+
const source = watchById(store, request.watchId);
|
|
134
|
+
if (source === undefined)
|
|
135
|
+
throw notFound();
|
|
136
|
+
const report = store.get(watchReportKey(source.id));
|
|
137
|
+
const error = report?.error?.[lang] ?? (zh ? '(没有记录到错误)' : '(no error recorded)');
|
|
138
|
+
return zh
|
|
139
|
+
? `我让小秘书盯着「${source.label}」这个网址,但它读不到内容,帮我弄明白原因、怎么解决:\n\n网址:${source.url}\n多久看一次:${source.intervalMinutes} 分钟\n我关注的字:${source.keywords.length === 0 ? '(没设,有任何变化就告诉我)' : source.keywords.join('、')}\n报错:${error}\n\n小秘书是怎么读的:普通 GET,20 秒超时,能读 RSS/Atom、JSON 列表或普通网页。如果这个网站要登录才能看,或者防爬,告诉我该怎么办。`
|
|
140
|
+
: `I asked my secretary to keep an eye on ${source.url}, but it cannot read it. Help me understand why and what to do:\n\nAddress: ${source.url}\nCheck every: ${source.intervalMinutes} minutes\nWords I watch for: ${source.keywords.length === 0 ? '(none — tell me about any change)' : source.keywords.join(', ')}\nError: ${error}\n\nHow it reads: a plain GET with a 20-second timeout, working on RSS/Atom, a JSON list or a plain page. If the site needs a login or blocks robots, tell me what to do instead.`;
|
|
141
|
+
}
|
|
142
|
+
case 'watch-setup': {
|
|
143
|
+
return zh
|
|
144
|
+
? '我想让小秘书帮我盯着一些我自己关心的东西:某个公告页、某个商品的价格、某个职位的招聘页、某个订阅源。请告诉我:哪些网址它读得到、哪些读不到(要登录的、全靠 JS 渲染的、防爬的),RSS 和普通网页分别会怎么处理,关键词该怎么写才能不漏也不吵,检查间隔设多长合适。'
|
|
145
|
+
: 'I want my secretary to watch a few things I care about myself: a page of announcements, the price of something, a job posting, a feed. Tell me which addresses it can read and which it cannot (login walls, JS-only pages, bot blocking), how it treats a feed versus a plain page, how to write the words I watch for so nothing is missed and nothing is noisy, and how often to check.';
|
|
146
|
+
}
|
|
131
147
|
}
|
|
132
148
|
}
|
package/lib/core/board.js
CHANGED
|
@@ -13,6 +13,7 @@ import { decode } from "./i18n.js";
|
|
|
13
13
|
import { manualExplain, storedExplain } from "./explain.js";
|
|
14
14
|
import { isActive } from "./matters.js";
|
|
15
15
|
import { mailReportKey } from "./mail/sync.js";
|
|
16
|
+
import { watchReportKey } from "./watch/source.js";
|
|
16
17
|
import { CATEGORY_LABELS, describeRepeat, occurrence, remindDay } from "./recur.js";
|
|
17
18
|
import { addDays, dayOf, dayStart, daysBetween, formatMoment, relativeDay, weekday } from "./time.js";
|
|
18
19
|
const zhOr = (lang, zh, en) => (lang === 'zh' ? zh : en);
|
|
@@ -43,7 +44,7 @@ function matterCard(store, m, today, now, lang, column) {
|
|
|
43
44
|
}
|
|
44
45
|
else {
|
|
45
46
|
const label = CATEGORY_LABELS[m.kind];
|
|
46
|
-
const where = m.source === 'mail' ? zhOr(lang, '邮件', 'Mail') : 'GitHub';
|
|
47
|
+
const where = m.source === 'mail' ? zhOr(lang, '邮件', 'Mail') : m.source === 'watch' ? zhOr(lang, '关注', 'Watching') : 'GitHub';
|
|
47
48
|
meta = manual && label !== undefined ? label[lang] : `${where} · ${reasonLabel(m.reason, lang)}`;
|
|
48
49
|
}
|
|
49
50
|
const actions = column === 'handle'
|
|
@@ -61,7 +62,8 @@ function matterCard(store, m, today, now, lang, column) {
|
|
|
61
62
|
explain.why += zhOr(lang, `你说了"明天再说",${m.snoozeUntil} 会回到"要你处理"。`, ` You put it off; it comes back on ${m.snoozeUntil}.`);
|
|
62
63
|
}
|
|
63
64
|
// The open button says what it opens ("Turn off Actions", "Open the failing log").
|
|
64
|
-
|
|
65
|
+
const source = manual ? 'manual' : m.source === 'mail' ? 'mail' : m.source === 'watch' ? 'watch' : 'github';
|
|
66
|
+
return { id: m.id, target: 'matter', source, title, meta, note: note === '' ? null : note, url: explain?.link?.url ?? m.url, tone, actions, explain };
|
|
65
67
|
}
|
|
66
68
|
export function buildBoard(store, options) {
|
|
67
69
|
const { now, lang } = options;
|
|
@@ -121,6 +123,14 @@ export function buildBoard(store, options) {
|
|
|
121
123
|
return { id: account.id, label: account.label, state: 'error', text: zhOr(lang, `${account.label}:${report.error.zh}`, `${account.label}: ${report.error.en}`) };
|
|
122
124
|
return { id: account.id, label: account.label, state: 'ok', text: zhOr(lang, `${account.label} ${formatMoment(report.at, now)} 收信`, `${account.label} checked ${formatMoment(report.at, now)}`) };
|
|
123
125
|
});
|
|
126
|
+
const watch = (options.watches ?? []).map((source) => {
|
|
127
|
+
const report = store.get(watchReportKey(source.id));
|
|
128
|
+
if (report === undefined)
|
|
129
|
+
return { id: source.id, label: source.label, state: 'never', text: zhOr(lang, `${source.label} 还没看过`, `${source.label} not checked yet`) };
|
|
130
|
+
if (report.error !== undefined)
|
|
131
|
+
return { id: source.id, label: source.label, state: 'error', text: zhOr(lang, `${source.label}:${report.error.zh}`, `${source.label}: ${report.error.en}`) };
|
|
132
|
+
return { id: source.id, label: source.label, state: 'ok', text: zhOr(lang, `${source.label} ${formatMoment(report.at, now)} 看过`, `${source.label} checked ${formatMoment(report.at, now)}`) };
|
|
133
|
+
});
|
|
124
134
|
const github = !options.github.enabled
|
|
125
135
|
? { state: 'off', text: zhOr(lang, 'GitHub 未开启', 'GitHub off') }
|
|
126
136
|
: last === undefined
|
|
@@ -143,12 +153,14 @@ export function buildBoard(store, options) {
|
|
|
143
153
|
parts.push(zhOr(lang, 'GitHub 这次没同步成。', 'The last GitHub sync failed.'));
|
|
144
154
|
for (const box of mail.filter(b => b.state === 'error'))
|
|
145
155
|
parts.push(zhOr(lang, `邮箱「${box.label}」这次没收成。`, `Mail "${box.label}" could not be checked.`));
|
|
156
|
+
for (const box of watch.filter(b => b.state === 'error'))
|
|
157
|
+
parts.push(zhOr(lang, `关注「${box.label}」这次没读成。`, `Watch "${box.label}" could not be read.`));
|
|
146
158
|
return {
|
|
147
159
|
lang,
|
|
148
160
|
at: now,
|
|
149
161
|
summary: parts.join(lang === 'zh' ? '' : ' '),
|
|
150
162
|
metrics: { handle: handle.length, overdue, dueSoon, closedToday, shadow },
|
|
151
|
-
sources: { github, manual: { series: store.activeSeries().length }, mail },
|
|
163
|
+
sources: { github, manual: { series: store.activeSeries().length }, mail, watch },
|
|
152
164
|
markRead: options.github.markRead,
|
|
153
165
|
columns: {
|
|
154
166
|
handle: handle.map(m => matterCard(store, m, today, now, lang, 'handle')),
|
package/lib/core/brief.js
CHANGED
|
@@ -10,6 +10,7 @@ import { isActive } from "./matters.js";
|
|
|
10
10
|
import { mailReportKey } from "./mail/sync.js";
|
|
11
11
|
import { CATEGORY_LABELS } from "./recur.js";
|
|
12
12
|
import { dayOf, daysBetween, formatMoment, relativeDay, weekday } from "./time.js";
|
|
13
|
+
import { watchReportKey } from "./watch/source.js";
|
|
13
14
|
export const REASON_LABELS = {
|
|
14
15
|
merged: bi('已合并', 'merged'),
|
|
15
16
|
closed: bi('已关闭', 'closed'),
|
|
@@ -43,6 +44,10 @@ export const REASON_LABELS = {
|
|
|
43
44
|
'mail-cc': bi('只是抄送', 'Cc only'),
|
|
44
45
|
'read-in-mail': bi('你在邮箱里看过了', 'read in your mail'),
|
|
45
46
|
'account-removed': bi('邮箱账户已移除', 'mail account removed'),
|
|
47
|
+
'watch-new': bi('有新内容', 'new entries'),
|
|
48
|
+
'watch-changed': bi('内容有变化', 'changed'),
|
|
49
|
+
'watch-keyword': bi('提到你关注的字', 'mentioned your words'),
|
|
50
|
+
'watch-removed': bi('你删掉了这个关注', 'watch removed'),
|
|
46
51
|
};
|
|
47
52
|
export function reasonLabel(reason, lang) {
|
|
48
53
|
if (reason === null)
|
|
@@ -75,6 +80,8 @@ function sourceTag(m, lang) {
|
|
|
75
80
|
return 'GitHub';
|
|
76
81
|
if (m.source === 'mail')
|
|
77
82
|
return pick(bi('邮件', 'Mail'), lang);
|
|
83
|
+
if (m.source === 'watch')
|
|
84
|
+
return pick(bi('关注', 'Watching'), lang);
|
|
78
85
|
return m.source;
|
|
79
86
|
}
|
|
80
87
|
/** One matter as a few lines. */
|
|
@@ -181,6 +188,15 @@ export function renderBrief(store, options) {
|
|
|
181
188
|
else
|
|
182
189
|
out.push(zh ? `邮件「${account.label}」:${formatMoment(report.at, now)} 收信,${report.messages} 封未读。` : `Mail "${account.label}": checked ${formatMoment(report.at, now)}, ${report.messages} unread.`);
|
|
183
190
|
}
|
|
191
|
+
for (const source of options.watches ?? []) {
|
|
192
|
+
const report = store.get(watchReportKey(source.id));
|
|
193
|
+
if (report === undefined)
|
|
194
|
+
out.push(zh ? `关注「${source.label}」:还没看过。` : `Watching "${source.label}": not checked yet.`);
|
|
195
|
+
else if (report.error !== undefined)
|
|
196
|
+
out.push(zh ? `关注「${source.label}」:${formatMoment(report.at, now)} 没读成:${report.error.zh}` : `Watching "${source.label}": failed at ${formatMoment(report.at, now)}: ${report.error.en}`);
|
|
197
|
+
else
|
|
198
|
+
out.push(zh ? `关注「${source.label}」:${formatMoment(report.at, now)} 看过,${report.items} 条内容。` : `Watching "${source.label}": checked ${formatMoment(report.at, now)}, ${report.items} entries.`);
|
|
199
|
+
}
|
|
184
200
|
return out.join('\n');
|
|
185
201
|
}
|
|
186
202
|
/** Recently closed matters with reasons, so a wrong closure can be reopened. */
|
package/lib/core/index.js
CHANGED
|
@@ -29,6 +29,10 @@ export { DEFAULT_NOTIFY, deliver, inQuiet, localClock, notifySettings, notifyTic
|
|
|
29
29
|
export { restartPlan, scheduleRestart } from "./restart.js";
|
|
30
30
|
export { FileSecrets, KeychainOrFile, MemorySecrets, secretEnvName, secretFromEnv, systemSecrets } from "./secrets.js";
|
|
31
31
|
export { deleteMailAccount, effectiveSettings, mailAccounts, saveMailAccount, saveSettings } from "./settings.js";
|
|
32
|
+
export { deleteWatch, dueWatches, entryKey, parseKeywords, resolveWatch, saveWatch, watchById, watchReportKey, watchSources, WATCH_SOURCE, } from "./watch/source.js";
|
|
33
|
+
export { describeWatchError, httpFetch, parseFeed, parseJson, parsePage, plainText, readWatchBody } from "./watch/fetch.js";
|
|
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";
|
|
32
36
|
export const DEFAULT_HOME = '~/.mishu';
|
|
33
37
|
/** `~` and `~/…` expand to the home directory on every platform. */
|
|
34
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
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a watched address: a feed, a JSON list, or just a page.
|
|
3
|
+
*
|
|
4
|
+
* Whatever comes back is turned into a handful of items — a title, a bit of
|
|
5
|
+
* text and a stable key — so the rest of the code never has to care whether
|
|
6
|
+
* it was RSS, Atom, JSON or plain HTML. No parser library: the shapes that
|
|
7
|
+
* matter here are small, and a wrong guess costs nothing (the page is read
|
|
8
|
+
* as text and still works).
|
|
9
|
+
*/
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import { bi } from "../i18n.js";
|
|
12
|
+
import { entryKey } from "./source.js";
|
|
13
|
+
/** The real fetch: a plain GET with a short timeout and no cookies. */
|
|
14
|
+
export const httpFetch = async (url) => {
|
|
15
|
+
const controller = new AbortController();
|
|
16
|
+
const timer = setTimeout(() => controller.abort(), 20_000);
|
|
17
|
+
try {
|
|
18
|
+
const response = await fetch(url, {
|
|
19
|
+
method: 'GET',
|
|
20
|
+
redirect: 'follow',
|
|
21
|
+
signal: controller.signal,
|
|
22
|
+
headers: {
|
|
23
|
+
'user-agent': 'dsh-mishu/1 (+https://github.com/AIcivilization/dsh-mishu)',
|
|
24
|
+
accept: 'application/rss+xml, application/atom+xml, application/json, text/html;q=0.9, */*;q=0.8',
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
const body = await response.text();
|
|
28
|
+
return { status: response.status, body, contentType: response.headers.get('content-type') ?? '' };
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
|
|
35
|
+
function unescape(text) {
|
|
36
|
+
return text
|
|
37
|
+
.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (whole, name) => {
|
|
38
|
+
if (name.startsWith('#')) {
|
|
39
|
+
const code = name[1]?.toLowerCase() === 'x' ? Number.parseInt(name.slice(2), 16) : Number.parseInt(name.slice(1), 10);
|
|
40
|
+
return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : whole;
|
|
41
|
+
}
|
|
42
|
+
return ENTITIES[name.toLowerCase()] ?? whole;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/** Markup out, whitespace collapsed. */
|
|
46
|
+
export function plainText(markup) {
|
|
47
|
+
return unescape(markup
|
|
48
|
+
.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, ' ')
|
|
49
|
+
.replace(/<[^>]+>/g, ' '))
|
|
50
|
+
.replace(/\s+/g, ' ')
|
|
51
|
+
.trim();
|
|
52
|
+
}
|
|
53
|
+
function tag(block, name) {
|
|
54
|
+
const match = new RegExp(`<${name}\\b[^>]*>([\\s\\S]*?)</${name}>`, 'i').exec(block);
|
|
55
|
+
if (match === null)
|
|
56
|
+
return undefined;
|
|
57
|
+
const inner = match[1];
|
|
58
|
+
const cdata = /^\s*<!\[CDATA\[([\s\S]*?)\]\]>\s*$/.exec(inner);
|
|
59
|
+
return plainText(cdata === null ? inner : cdata[1]);
|
|
60
|
+
}
|
|
61
|
+
function attr(block, name) {
|
|
62
|
+
const match = new RegExp(`\\b${name}\\s*=\\s*["']([^"']*)["']`, 'i').exec(block);
|
|
63
|
+
return match === null ? undefined : unescape(match[1]);
|
|
64
|
+
}
|
|
65
|
+
/** RSS `<item>` and Atom `<entry>` blocks, whichever the document has. */
|
|
66
|
+
export function parseFeed(body, now, base) {
|
|
67
|
+
const blocks = [...body.matchAll(/<(item|entry)\b[\s\S]*?<\/\1>/gi)].map(m => m[0]);
|
|
68
|
+
if (blocks.length === 0)
|
|
69
|
+
return undefined;
|
|
70
|
+
return blocks.map((block) => {
|
|
71
|
+
const title = tag(block, 'title') ?? '';
|
|
72
|
+
const link = tag(block, 'link') ?? (() => {
|
|
73
|
+
const href = attr(block, 'href');
|
|
74
|
+
return href === undefined ? undefined : href;
|
|
75
|
+
})();
|
|
76
|
+
const bodyText = tag(block, 'description') ?? tag(block, 'summary') ?? tag(block, 'content') ?? '';
|
|
77
|
+
const guid = tag(block, 'guid') ?? tag(block, 'id');
|
|
78
|
+
const when = tag(block, 'pubDate') ?? tag(block, 'updated') ?? tag(block, 'published') ?? tag(block, 'dc:date');
|
|
79
|
+
const at = when === undefined ? now : Date.parse(when);
|
|
80
|
+
const href = link === undefined ? null : absolute(link, base);
|
|
81
|
+
return {
|
|
82
|
+
key: entryKey({ id: guid ?? href ?? undefined, link: href ?? undefined, title }),
|
|
83
|
+
title: title === '' ? (href ?? '?') : title,
|
|
84
|
+
text: bodyText,
|
|
85
|
+
url: href,
|
|
86
|
+
at: Number.isFinite(at) ? at : now,
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
function absolute(link, base) {
|
|
91
|
+
try {
|
|
92
|
+
return new URL(link, base).toString();
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return link;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* A JSON body: an array of objects, or an object with one array inside. Each
|
|
100
|
+
* element's title-like and text-like fields become an item.
|
|
101
|
+
*/
|
|
102
|
+
export function parseJson(body, now, base) {
|
|
103
|
+
let value;
|
|
104
|
+
try {
|
|
105
|
+
value = JSON.parse(body);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
const list = Array.isArray(value)
|
|
111
|
+
? value
|
|
112
|
+
: Object.values(value).find(Array.isArray);
|
|
113
|
+
if (list === undefined || list.length === 0)
|
|
114
|
+
return undefined;
|
|
115
|
+
const str = (item, names) => {
|
|
116
|
+
for (const name of names) {
|
|
117
|
+
const found = item[name];
|
|
118
|
+
if (typeof found === 'string' && found.trim() !== '')
|
|
119
|
+
return found;
|
|
120
|
+
if (typeof found === 'number')
|
|
121
|
+
return String(found);
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
};
|
|
125
|
+
return list.slice(0, 60).map((raw) => {
|
|
126
|
+
if (typeof raw !== 'object' || raw === null)
|
|
127
|
+
return { key: entryKey({ title: String(raw) }), title: String(raw), text: '', url: null, at: now };
|
|
128
|
+
const item = raw;
|
|
129
|
+
const title = str(item, ['title', 'name', 'subject', 'headline', 'version', 'label']) ?? JSON.stringify(item).slice(0, 120);
|
|
130
|
+
const text = str(item, ['description', 'body', 'content', 'summary', 'text', 'excerpt', 'details']) ?? '';
|
|
131
|
+
const link = str(item, ['url', 'link', 'html_url', 'permalink', 'href']);
|
|
132
|
+
const when = str(item, ['date', 'published', 'updated', 'pubDate', 'created_at', 'time', 'timestamp']);
|
|
133
|
+
const at = when === undefined ? now : Date.parse(when);
|
|
134
|
+
const href = link === undefined ? null : absolute(link, base);
|
|
135
|
+
return {
|
|
136
|
+
key: entryKey({ id: str(item, ['id', 'guid', 'uuid', 'key']) ?? href ?? undefined, link: href ?? undefined, title }),
|
|
137
|
+
title, text, url: href, at: Number.isFinite(at) ? at : now,
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
/** A page with no feed: its title and text, as one item keyed by a hash of the text. */
|
|
142
|
+
export function parsePage(body, now, base) {
|
|
143
|
+
const title = plainText(/<title\b[^>]*>([\s\S]*?)<\/title>/i.exec(body)?.[1] ?? '') || new URL(base).hostname;
|
|
144
|
+
const text = plainText(/<body\b[^>]*>([\s\S]*?)<\/body>/i.exec(body)?.[1] ?? body).slice(0, 20_000);
|
|
145
|
+
const key = createHash('sha256').update(text).digest('hex').slice(0, 16);
|
|
146
|
+
return [{ key, title, text, url: base, at: now }];
|
|
147
|
+
}
|
|
148
|
+
/** Whatever the address returned, as items. */
|
|
149
|
+
export function readWatchBody(options) {
|
|
150
|
+
const { body, contentType, url, now } = options;
|
|
151
|
+
const looksJson = /json/i.test(contentType) || /^\s*[[{]/.test(body);
|
|
152
|
+
const looksFeed = /xml|rss|atom/i.test(contentType) || /<(rss|feed)\b/i.test(body);
|
|
153
|
+
if (looksFeed || (!looksJson && /<(item|entry)\b/i.test(body)))
|
|
154
|
+
return parseFeed(body, now, url) ?? parsePage(body, now, url);
|
|
155
|
+
if (looksJson)
|
|
156
|
+
return parseJson(body, now, url) ?? parsePage(body, now, url);
|
|
157
|
+
return parsePage(body, now, url);
|
|
158
|
+
}
|
|
159
|
+
/** The sentence shown when a watch cannot be read. */
|
|
160
|
+
export function describeWatchError(error, source) {
|
|
161
|
+
const raw = error.message ?? String(error);
|
|
162
|
+
if (/abort/i.test(raw))
|
|
163
|
+
return bi(`${source.label} 20 秒没有回应,可能是网站太慢或打不开。`, `${source.label} did not answer within 20 seconds; the site may be slow or down.`);
|
|
164
|
+
if (/ENOTFOUND|EAI_AGAIN|getaddrinfo/i.test(raw))
|
|
165
|
+
return bi(`${source.label} 的网址找不到服务器:检查一下网址有没有写错。`, `The server for ${source.label} was not found: check the address.`);
|
|
166
|
+
if (/certificate|self.signed|SSL|TLS/i.test(raw))
|
|
167
|
+
return bi(`${source.label} 的证书有问题,读不了。`, `${source.label} has a certificate problem and could not be read.`);
|
|
168
|
+
return bi(`读 ${source.label} 失败:${raw}`, `Could not read ${source.label}: ${raw}`);
|
|
169
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Things you watch yourself: a page, a feed or an API you care about.
|
|
3
|
+
*
|
|
4
|
+
* GitHub and mail are sources the secretary knows how to read. Everything
|
|
5
|
+
* else a person cares about — a game's patch notes, a job posting, a price,
|
|
6
|
+
* a status page — is a URL. You add it here with the words that matter to
|
|
7
|
+
* you, and the secretary looks at it now and then: when it changes, or when
|
|
8
|
+
* one of your words shows up, it becomes a card like anything else.
|
|
9
|
+
*
|
|
10
|
+
* Only where and what to look for is kept here. What was seen last lives in
|
|
11
|
+
* the notebook's key-value table, so an entry never grows without bound.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { bi, MishuError } from "../i18n.js";
|
|
15
|
+
export const WATCH_SOURCE = 'watch';
|
|
16
|
+
const SOURCES_KEY = 'watch.sources';
|
|
17
|
+
const NEXT_ID_KEY = 'watch.nextId';
|
|
18
|
+
export function watchSources(store) {
|
|
19
|
+
return store.get(SOURCES_KEY) ?? [];
|
|
20
|
+
}
|
|
21
|
+
export function watchById(store, id) {
|
|
22
|
+
return watchSources(store).find(s => s.id === id);
|
|
23
|
+
}
|
|
24
|
+
export function watchReportKey(id) {
|
|
25
|
+
return `watch.lastSync.${id}`;
|
|
26
|
+
}
|
|
27
|
+
/** Where one watch keeps what it has already seen. */
|
|
28
|
+
export function watchStateKey(id) {
|
|
29
|
+
return `watch.state.${id}`;
|
|
30
|
+
}
|
|
31
|
+
export function watchSeenKey(id) {
|
|
32
|
+
return `watch.seen.${id}`;
|
|
33
|
+
}
|
|
34
|
+
/** A stable id for a feed entry: its guid, else its link, else its title. */
|
|
35
|
+
export function entryKey(entry) {
|
|
36
|
+
const basis = entry.id ?? entry.link ?? entry.title ?? '';
|
|
37
|
+
return createHash('sha256').update(basis).digest('hex').slice(0, 16);
|
|
38
|
+
}
|
|
39
|
+
/** Split what you typed into words: commas, 、, spaces or new lines all work. */
|
|
40
|
+
export function parseKeywords(value) {
|
|
41
|
+
const list = Array.isArray(value) ? value : String(value ?? '').split(/[,,、;\n]+/);
|
|
42
|
+
const words = list.map(w => w.trim().toLowerCase()).filter(w => w !== '');
|
|
43
|
+
return [...new Set(words)].slice(0, 20);
|
|
44
|
+
}
|
|
45
|
+
/** Check and complete what the settings page sent; throws a sentence a person can act on. */
|
|
46
|
+
export function resolveWatch(input, id) {
|
|
47
|
+
const raw = String(input.url ?? '').trim();
|
|
48
|
+
if (raw === '')
|
|
49
|
+
throw new MishuError(bi('还没填网址', 'the address is empty'));
|
|
50
|
+
let url;
|
|
51
|
+
try {
|
|
52
|
+
url = new URL(raw);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
throw new MishuError(bi(`网址看不懂:${raw}。要完整的一串,比如 https://example.com/news`, `that is not a web address: ${raw}. It should look like https://example.com/news`));
|
|
56
|
+
}
|
|
57
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
58
|
+
throw new MishuError(bi('只支持 http 和 https 的网址', 'only http and https addresses work'));
|
|
59
|
+
}
|
|
60
|
+
const label = String(input.label ?? '').trim() || url.hostname;
|
|
61
|
+
if (label.length > 60)
|
|
62
|
+
throw new MishuError(bi('名字太长了,60 个字以内', 'the name is too long; keep it under 60 characters'));
|
|
63
|
+
const intervalMinutes = Number(input.intervalMinutes ?? 60);
|
|
64
|
+
if (!Number.isInteger(intervalMinutes) || intervalMinutes < 15 || intervalMinutes > 1440) {
|
|
65
|
+
throw new MishuError(bi('多久看一次要是 15 到 1440 分钟', 'how often to look must be 15 to 1440 minutes'));
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
id,
|
|
69
|
+
label,
|
|
70
|
+
url: url.toString(),
|
|
71
|
+
keywords: parseKeywords(input.keywords),
|
|
72
|
+
enabled: input.enabled !== false,
|
|
73
|
+
intervalMinutes,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** Add (no id) or update one watch. */
|
|
77
|
+
export function saveWatch(store, input, now) {
|
|
78
|
+
const sources = watchSources(store);
|
|
79
|
+
const existing = input.id === undefined ? undefined : sources.find(s => s.id === input.id);
|
|
80
|
+
if (input.id !== undefined && existing === undefined)
|
|
81
|
+
throw new MishuError(bi(`没有 #${input.id} 这个关注`, `there is no watch #${input.id}`));
|
|
82
|
+
const id = existing?.id ?? (store.get(NEXT_ID_KEY) ?? 1);
|
|
83
|
+
const source = resolveWatch({ ...existing, ...input }, id);
|
|
84
|
+
store.set(SOURCES_KEY, existing === undefined ? [...sources, source] : sources.map(s => (s.id === id ? source : s)));
|
|
85
|
+
if (existing === undefined)
|
|
86
|
+
store.set(NEXT_ID_KEY, id + 1);
|
|
87
|
+
store.log(now, existing === undefined ? 'watch-add' : 'watch-update', { id, url: source.url, keywords: source.keywords.length });
|
|
88
|
+
return source;
|
|
89
|
+
}
|
|
90
|
+
/** Remove one watch and close whatever it still has open. */
|
|
91
|
+
export function deleteWatch(store, id, now) {
|
|
92
|
+
const sources = watchSources(store);
|
|
93
|
+
if (!sources.some(s => s.id === id))
|
|
94
|
+
return false;
|
|
95
|
+
store.transaction(() => {
|
|
96
|
+
store.set(SOURCES_KEY, sources.filter(s => s.id !== id));
|
|
97
|
+
store.set(watchStateKey(id), null);
|
|
98
|
+
store.set(watchSeenKey(id), null);
|
|
99
|
+
for (const matter of store.mattersOfSource(WATCH_SOURCE)) {
|
|
100
|
+
if (matter.status === 'open' && (matter.key === `watch:${id}` || matter.key.startsWith(`watch:${id}:`))) {
|
|
101
|
+
store.updateMatter(matter.id, { status: 'done', reason: 'watch-removed', closedBy: 'source', closedAt: now }, now);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
store.log(now, 'watch-delete', { id });
|
|
105
|
+
});
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
/** The watches whose next check is due. */
|
|
109
|
+
export function dueWatches(store, now) {
|
|
110
|
+
return watchSources(store).filter((source) => {
|
|
111
|
+
if (!source.enabled)
|
|
112
|
+
return false;
|
|
113
|
+
const report = store.get(watchReportKey(source.id));
|
|
114
|
+
return report === undefined || now - report.at >= source.intervalMinutes * 60_000;
|
|
115
|
+
});
|
|
116
|
+
}
|