From 2e690c30c81d956b55a11ed502fd2475a2513fb1 Mon Sep 17 00:00:00 2001 From: oliver Date: Sun, 6 Sep 2026 02:31:43 +0800 Subject: [PATCH] Inherit agent presets and auto-bind IM delivery targets (0.1.7). Cron jobs created from WhatsApp can omit bot/target ids and reuse chat overrides; sidebar/agent preset pins persist on fire. Co-authored-by: Cursor --- FORK.md | 2 +- README.md | 18 ++- lib/client.js | 216 ++++++++++++++++++++++++++++++----- lib/delivery.js | 256 ++++++++++++++++++++++++++++++++++++++---- lib/fire.js | 11 +- lib/host.js | 85 +++++++++++++- lib/index.js | 5 +- lib/isolation.js | 2 +- lib/preset.js | 79 +++++++++++++ lib/store.js | 3 + lib/tools.js | 42 ++++--- package.json | 4 +- test/delivery.test.js | 132 +++++++++++++++++++++- test/package.test.js | 11 +- test/preset.test.js | 46 ++++++++ 15 files changed, 830 insertions(+), 82 deletions(-) create mode 100644 lib/preset.js create mode 100644 test/preset.test.js diff --git a/FORK.md b/FORK.md index fac6db4..d23094f 100644 --- a/FORK.md +++ b/FORK.md @@ -25,7 +25,7 @@ dsh plugin --profile web add -w "D:\project\chatgpt\dsh-ops-cron" ## 相对上游的改动 -1. 包名 / cordis id / HTTP 前缀 / 侧栏文案 → `dsh-ops-cron` /「运维定时」 +1. 包名 / cordis id / HTTP 前缀仍为 `dsh-ops-cron`;侧栏产品文案为「定时任务」 2. Job 增加 `delivery: { kind: 'dsh'|'im', botId?, targetId? }` 3. `cron_create` 支持 `delivery` / `im_bot_id` / `im_target_id`;未显式指定时,若当前会话能 `resolveSessionPeer` 且已有匹配投递目标 → 默认 IM 4. 开火后若 `delivery.kind === 'im'`,把 assistant 摘要经 `ctx.dshIm.send` 投回 diff --git a/README.md b/README.md index f3797e4..cad842e 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # dsh-ops-cron -Ops fork of [dsh-cron-tasks](https://github.com/Whale-Zhang/dsh-cron-tasks) for DeepSeek Harness. +Scheduled-task plugin for DeepSeek Harness (fork of [dsh-cron-tasks](https://github.com/Whale-Zhang/dsh-cron-tasks)). -- Sidebar **运维定时** under New Session +- Sidebar **定时任务** under New Session - Agent tools: `cron_create` / `cron_list` / `cron_pause` / `cron_resume` / `cron_delete` - Per-job delivery: **DSH** (new root session) or **IM** (`ctx.dshIm.send`) @@ -20,11 +20,21 @@ dsh plugin --profile web add -w "github:hansjone/dsh-ops-cron" | Created from | Default delivery | |--------------|------------------| -| WhatsApp / IM session (peer resolved + matching 投递目标) | `im` → that `botId`+`targetId` | +| WhatsApp / IM session | `im` → reuse or **auto-create** a 投递目标 for that chat (group→group, DM→DM) | | Web / plain DSH session | `dsh` → sidebar history session | | Explicit `delivery` / `im_bot_id`+`im_target_id` | as specified | -Create IM targets first in IM → 投递设置 → 复制调用参数 when you need a stable target for WhatsApp. +You usually do **not** need to paste botId/targetId when creating from WhatsApp — omit `delivery` and the job binds to the current chat. + +## Agent preset + +| Source | Behavior | +|--------|----------| +| Explicit `agent_preset` / sidebar field | pinned on the job | +| WhatsApp / IM create (omit) | inherit chat/group override → bot preset → Host default | +| Sidebar leave empty | Host default at each fire | + +Scheduled runs mount `job.agentPreset` (or Host default when empty). ## License diff --git a/lib/client.js b/lib/client.js index 9133612..eeebe55 100644 --- a/lib/client.js +++ b/lib/client.js @@ -161,8 +161,13 @@ window.__ModuleLoader__.load({ const inject = ['slots', 'locale', 'settingsScope'] const API = '/dsh-ops-cron' const LOCALE_NS = 'settings.dshCronTasks' - const TITLE_PREFIX = '运维定时 · ' - const listSnapshot = { jobs: [], runs: [], workspaces: [], catalog: { groups: [], current: null } } + const TITLE_PREFIX = '定时任务 · ' + const listSnapshot = { + jobs: [], runs: [], workspaces: [], + catalog: { groups: [], current: null }, + presets: { items: [], current: null }, + imCatalog: { available: true, options: [], loading: false }, + } function jobStamp(rows) { return (rows || []).map((row) => `${row.id}:${row.updatedAt}:${row.nextRunAt}:${row.enabled}:${row.lastStatus}:${row.model}:${row.provider}:${row.delivery?.kind}:${row.delivery?.targetId}`).join('|') @@ -291,19 +296,19 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba ` const zh = { - title: '运维定时', + title: '定时任务', description: '调度策略:启用、默认时区、历史保留、重叠与漏跑。', expand: '展开设置', collapse: '收起设置', save: '保存', saving: '保存中…', discard: '放弃修改', saveFailed: '本部署没有接受这些值,已保留供你修改。', enabled: '启用调度器', timezone: '默认时区', historyLimit: '历史保留条数', overlapPolicy: '重叠策略', misfirePolicy: '漏跑策略', policySkip: '跳过', hint: '任务在左侧「新会话」下方进入。运行结果会出现在历史行里,点击记录会打开和普通会话一样的对话页,可以继续聊。', - entry: '运维定时', entryLabel: '打开运维定时', + entry: '定时任务', entryLabel: '打开定时任务', backToWorkspace: '返回工作区', backLabel: '返回工作区', newJob: '新建任务', name: '名称', prompt: '提示词', kind: '日程', cron: 'Cron(循环)', at: '一次性时间', expr: 'Cron 表达式', atTime: '时间', create: '创建', pause: '暂停', resume: '恢复', runNow: '立即运行', remove: '删除', - emptyJobs: '还没有运维定时。点右上角 + 新建。', next: '下次', last: '上次', + emptyJobs: '还没有定时任务。点右上角 + 新建。', next: '下次', last: '上次', expandRuns: '展开记录', collapseRuns: '收起记录', search: '搜索', searchPlaceholder: '搜索任务', searchClear: '清除搜索', searchEmpty: '没有匹配的任务。', paused: '已暂停', @@ -313,7 +318,10 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba cwdHint: '运行会在这个目录对应的工作区里开新会话。留空则用最近工作区。', model: '模型', modelDefault: '每次运行用当时的新会话默认', - modelHint: '运维定时会消耗这个模型的额度。指定后不会跟着聊天模型变。', + modelHint: '定时任务会消耗这个模型的额度。指定后不会跟着聊天模型变。', + agentPreset: 'Agent Preset', + agentPresetDefault: '每次运行用当时的 Host 默认', + agentPresetHint: '指定后每次触发都挂载该 Preset;留空则跟随 Host 默认(创建时若从 WhatsApp 继承会自动写入)。', editorLead: '保存后按日程在新会话里执行;若投递为 IM,结束后还会发摘要到 WhatsApp/IM。点击下方某次运行,右侧会打开那次对话,可以继续。', lastOutput: '上次输出', noOutput: '还没有输出。先立即运行一次。', delivery: '投递', @@ -321,17 +329,22 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba deliveryIm: 'WhatsApp / IM', imBotId: 'IM Bot ID', imTargetId: 'IM Target ID', - deliveryHint: 'Web 默认 DSH;WhatsApp 会话里由 Agent 创建时可自动匹配投递目标。也可粘贴 IM「投递设置」里的 botId/targetId。', + imTarget: '投递目标', + imTargetNone: '(请先在 IM「投递设置」新建目标)', + imTargetManual: '手动填写…', + imCatalogLoading: '正在加载投递目标…', + imCatalogUnavailable: '无法加载投递目标(需 dsh-im-ops ≥ops.24,且已配置投递目标)', + deliveryHint: '选 WhatsApp/IM 后从下拉选择已保存的投递目标;也可手动填写。目标在 IM 机器人 → 投递设置里创建。', } const en = { - title: 'Ops schedule', + title: 'Scheduled tasks', description: 'Scheduler policy: enable, default time zone, history retention, overlap and misfire.', expand: 'Show settings', collapse: 'Hide settings', save: 'Save', saving: 'Saving…', discard: 'Discard', saveFailed: 'The deployment did not accept these values; they were left for you to correct.', enabled: 'Enable scheduler', timezone: 'Default time zone', historyLimit: 'History retention', overlapPolicy: 'Overlap policy', misfirePolicy: 'Misfire policy', policySkip: 'Skip', hint: 'Open from under New Session. Run output appears in history; click a run to continue in the normal chat view.', - entry: 'Ops schedule', entryLabel: 'Open ops schedule', + entry: 'Scheduled tasks', entryLabel: 'Open scheduled tasks', backToWorkspace: 'Back to workspace', backLabel: 'Back to workspace', newJob: 'New job', name: 'Name', prompt: 'Prompt', kind: 'Schedule', cron: 'Cron (recurring)', at: 'One-shot time', expr: 'Cron expression', atTime: 'Time', @@ -347,6 +360,9 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba model: 'Model', modelDefault: 'Use the New Session default at fire time', modelHint: 'Scheduled runs consume this model\'s quota. A pinned model will not follow the chat selector.', + agentPreset: 'Agent preset', + agentPresetDefault: 'Use the Host default at fire time', + agentPresetHint: 'Pin a preset for every fire. Leave empty to follow the Host default (WhatsApp-created jobs inherit the chat preset automatically).', editorLead: 'Runs start a new session. IM delivery also sends a summary via dshIm after the turn. Click a run below to open that conversation.', lastOutput: 'Last output', noOutput: 'No output yet. Run it once.', delivery: 'Delivery', @@ -354,7 +370,12 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba deliveryIm: 'WhatsApp / IM', imBotId: 'IM Bot ID', imTargetId: 'IM Target ID', - deliveryHint: 'Web defaults to DSH. WhatsApp sessions can auto-match a delivery target. Or paste botId/targetId from IM delivery settings.', + imTarget: 'Delivery target', + imTargetNone: '(Create a target in IM delivery settings first)', + imTargetManual: 'Enter manually…', + imCatalogLoading: 'Loading delivery targets…', + imCatalogUnavailable: 'Cannot load targets (need dsh-im-ops ≥ops.24 with saved targets)', + deliveryHint: 'Pick a saved IM delivery target from the list, or enter botId/targetId manually. Create targets under IM bot → Delivery settings.', } function normalizePrefs(value) { @@ -597,6 +618,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba timezone: timezone || DEFAULTS.timezone, cwd: '', timeoutMinutes: 10, enabled: true, provider: current?.provider || '', model: current?.model || '', + agentPreset: '', deliveryKind: 'dsh', imBotId: '', imTargetId: '', @@ -616,6 +638,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba enabled: job.enabled !== false, provider: job.provider || '', model: job.model || '', + agentPreset: job.agentPreset || '', deliveryKind: job.delivery?.kind === 'im' ? 'im' : 'dsh', imBotId: job.delivery?.botId || '', imTargetId: job.delivery?.targetId || '', @@ -858,7 +881,110 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba ) } - function JobEditor({ t, form, setForm, isNew, error, lastOutput, nextRunAt, lastRunAt, workspaces, catalog, onSave, onRun, onPause, onRemove }) { + function PresetField({ t, form, setForm, presets }) { + const items = Array.isArray(presets?.items) ? presets.items : [] + const current = presets?.current + const selected = form.agentPreset || '' + const known = items.some((row) => row.id === selected) + const defaultLabel = current?.id + ? `${t('agentPresetDefault')}(${current.name || current.id})` + : t('agentPresetDefault') + return h('label', null, t('agentPreset'), + h('select', { + value: selected, + onChange: (e) => setForm({ ...form, agentPreset: e.target.value }), + }, + h('option', { value: '' }, defaultLabel), + !known && selected + ? h('option', { value: selected }, selected) + : null, + ...items.map((row) => h('option', { + value: row.id, + key: row.id, + }, row.name || row.id)), + ), + h('span', { className: 'dsh-ct-cwdHint' }, t('agentPresetHint')), + ) + } + + function imOptionKey(botId, targetId) { + return `${botId || ''}::${targetId || ''}` + } + + function parseImOptionKey(value) { + const raw = String(value || '') + const at = raw.indexOf('::') + if (at <= 0) return { botId: '', targetId: '' } + return { botId: raw.slice(0, at), targetId: raw.slice(at + 2) } + } + + function ImDeliveryFields({ t, form, setForm, imCatalog }) { + const options = Array.isArray(imCatalog?.options) ? imCatalog.options : [] + const loading = !!imCatalog?.loading + const available = imCatalog?.available !== false + const selectedKey = form.imBotId && form.imTargetId + ? imOptionKey(form.imBotId, form.imTargetId) + : '' + const matched = options.some((row) => imOptionKey(row.botId, row.targetId) === selectedKey) + const [manual, setManual] = useState(() => !!(selectedKey && !matched)) + useEffect(() => { + if (selectedKey && !matched && !loading) setManual(true) + if (selectedKey && matched) setManual(false) + }, [selectedKey, matched, loading]) + const selectValue = manual + ? '__manual__' + : (matched ? selectedKey : '') + const showManual = manual || (selectedKey && !matched && !loading) + + return h(React.Fragment, null, + h('label', null, t('imTarget'), + h('select', { + value: selectValue, + disabled: loading, + onChange: (e) => { + const value = e.target.value + if (value === '__manual__') { + setManual(true) + return + } + setManual(false) + if (!value) { + setForm({ ...form, imBotId: '', imTargetId: '' }) + return + } + const next = parseImOptionKey(value) + setForm({ ...form, imBotId: next.botId, imTargetId: next.targetId }) + }, + }, + h('option', { value: '' }, loading ? t('imCatalogLoading') : ( + options.length === 0 ? t('imTargetNone') : `— ${t('imTarget')} —` + )), + ...options.map((row) => h('option', { + key: imOptionKey(row.botId, row.targetId), + value: imOptionKey(row.botId, row.targetId), + }, `${row.name || row.targetId} · ${row.channel || 'im'} · ${row.targetId}`)), + h('option', { value: '__manual__' }, t('imTargetManual')), + ), + ), + !available || (options.length === 0 && !loading) + ? h('span', { className: 'dsh-ct-cwdHint' }, imCatalog?.hint || t('imCatalogUnavailable')) + : null, + showManual + ? h('div', { className: 'dsh-ct-editorRow' }, + h('label', null, t('imBotId'), h('input', { + value: form.imBotId || '', + onChange: (e) => setForm({ ...form, imBotId: e.target.value }), + })), + h('label', null, t('imTargetId'), h('input', { + value: form.imTargetId || '', + onChange: (e) => setForm({ ...form, imTargetId: e.target.value }), + })), + ) + : null, + ) + } + + function JobEditor({ t, form, setForm, isNew, error, lastOutput, nextRunAt, lastRunAt, workspaces, catalog, presets, imCatalog, onSave, onRun, onPause, onRemove }) { return h('div', { className: 'dsh-ct-editor' }, h('h1', null, isNew ? t('newJob') : (form.name || t('title'))), h('p', { className: 'dsh-ct-editorLead' }, t('editorLead')), @@ -890,6 +1016,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba ), h(CwdField, { t, form, setForm, workspaces }), h(ModelField, { t, form, setForm, catalog }), + h(PresetField, { t, form, setForm, presets }), h('label', null, t('delivery'), h('select', { value: form.deliveryKind || 'dsh', @@ -900,16 +1027,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba ), ), form.deliveryKind === 'im' - ? h('div', { className: 'dsh-ct-editorRow' }, - h('label', null, t('imBotId'), h('input', { - value: form.imBotId || '', - onChange: (e) => setForm({ ...form, imBotId: e.target.value }), - })), - h('label', null, t('imTargetId'), h('input', { - value: form.imTargetId || '', - onChange: (e) => setForm({ ...form, imTargetId: e.target.value }), - })), - ) + ? h(ImDeliveryFields, { t, form, setForm, imCatalog }) : null, h('span', { className: 'dsh-ct-cwdHint' }, t('deliveryHint')), h('div', { className: 'dsh-ct-editorActs' }, @@ -935,6 +1053,8 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba const [cronMode, setCronMode] = useState(false) const [workspaces, setWorkspaces] = useState(() => listSnapshot.workspaces) const [catalog, setCatalog] = useState(() => listSnapshot.catalog) + const [presets, setPresets] = useState(() => listSnapshot.presets || { items: [], current: null }) + const [imCatalog, setImCatalog] = useState(() => listSnapshot.imCatalog || { available: true, options: [], loading: false }) const [paneHost, setPaneHost] = useState(() => (typeof document === 'undefined' ? null : document.body)) const skipAutoSelect = useRef(false) @@ -957,18 +1077,44 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba } }, []) + const loadImCatalog = useCallback(async () => { + setImCatalog((prev) => ({ ...prev, loading: true })) + try { + const body = await api('/im-catalog') + const next = { + available: body.available !== false, + options: Array.isArray(body.options) ? body.options : [], + hint: body.hint || '', + loading: false, + } + listSnapshot.imCatalog = next + setImCatalog(next) + } catch (err) { + const next = { + available: false, + options: [], + hint: err instanceof Error ? err.message : String(err), + loading: false, + } + listSnapshot.imCatalog = next + setImCatalog(next) + } + }, []) + const load = useCallback(async () => { try { - const [jobBody, histBody, wsBody, modelBody] = await Promise.all([ + const [jobBody, histBody, wsBody, modelBody, presetBody] = await Promise.all([ api('/jobs'), api('/history'), api('/workspaces').catch(() => ({ workspaces: [] })), api('/models').catch(() => ({ groups: [], current: null })), + api('/presets').catch(() => ({ items: [], current: null })), ]) const nextJobs = jobBody.jobs || [] const nextRuns = histBody.runs || [] const nextWorkspaces = wsBody.workspaces || [] const nextCatalog = { groups: modelBody.groups || [], current: modelBody.current || null } + const nextPresets = { items: presetBody.items || [], current: presetBody.current || null } if (jobStamp(listSnapshot.jobs) !== jobStamp(nextJobs)) { listSnapshot.jobs = nextJobs setJobs(nextJobs) @@ -979,8 +1125,10 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba } listSnapshot.workspaces = nextWorkspaces listSnapshot.catalog = nextCatalog + listSnapshot.presets = nextPresets setWorkspaces(nextWorkspaces) setCatalog(nextCatalog) + setPresets(nextPresets) faces.cronSessionIds = new Set(nextRuns.map((run) => run.sessionId).filter(Boolean)) setError('') } catch (err) { @@ -1002,6 +1150,11 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba } }, [load, cronMode]) + useEffect(() => { + if (!cronMode || form.deliveryKind !== 'im') return + loadImCatalog() + }, [cronMode, form.deliveryKind, loadImCatalog]) + useEffect(() => { if (!cronMode) { skipAutoSelect.current = false @@ -1038,9 +1191,12 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba if ((!next.provider || !next.model) && catalog.current?.provider && catalog.current?.model) { next = { ...next, provider: catalog.current.provider, model: catalog.current.model } } + if (!next.agentPreset && presets.current?.id) { + next = { ...next, agentPreset: presets.current.id } + } return next }) - }, [cronMode, selection.type, catalog.current]) + }, [cronMode, selection.type, catalog.current, presets.current]) function selectJob(jobId) { const job = jobs.find((row) => row.id === jobId) @@ -1084,6 +1240,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba name: form.name, prompt: form.prompt, schedule, cwd: form.cwd, timeoutMinutes: form.timeoutMinutes, enabled: form.enabled, provider: form.provider, model: form.model, + agentPreset: form.agentPreset || '', delivery: form.deliveryKind === 'im' ? { kind: 'im', botId: form.imBotId, targetId: form.imTargetId } : { kind: 'dsh' }, @@ -1132,6 +1289,8 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba lastRunAt: selectedJob?.lastRunAt, workspaces, catalog, + presets, + imCatalog, onSave: save, onRun: () => selection.jobId && run(selection.jobId), onPause: () => { @@ -1197,7 +1356,10 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba function isCronTitle(text) { const value = String(text || '').trim() - return value.startsWith('运维定时') || value.startsWith(TITLE_PREFIX.trim()) || value.includes(TITLE_PREFIX) + return value.startsWith('定时任务') + || value.startsWith('运维定时') + || value.startsWith(TITLE_PREFIX.trim()) + || value.includes(TITLE_PREFIX) } function workspaceTitleFromCronFork(title) { @@ -1207,6 +1369,10 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba const stripped = value.replace(/^运维定时(?:\s*·\s*|\s+)/, '').trim() return stripped || value } + if (value.startsWith('定时任务')) { + const stripped = value.replace(/^定时任务(?:\s*·\s*|\s+)/, '').trim() + return stripped || value + } return value } @@ -1219,7 +1385,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba const parent = String(row.parentId || row.parentSessionId || '').trim() if (parent && ids.has(parent)) return true const title = String(row.title || row.displayTitle || '') - const looksCron = title.startsWith(TITLE_PREFIX) || title.startsWith('运维定时') + const looksCron = title.startsWith(TITLE_PREFIX) || title.startsWith('定时任务') || title.startsWith('运维定时') if (previousId && ids.has(String(previousId)) && looksCron) return true return false } diff --git a/lib/delivery.js b/lib/delivery.js index 25cbf28..ad077f5 100644 --- a/lib/delivery.js +++ b/lib/delivery.js @@ -1,5 +1,6 @@ /** * Job delivery: DSH sidebar session vs proactive IM (botId + targetId). + * Group jobs may also pin mentionJid so delivery @s the creator. */ export function normalizeDelivery(input = {}) { @@ -13,14 +14,121 @@ export function normalizeDelivery(input = {}) { error.code = 'INVALID_DELIVERY' throw error } - return { kind: 'im', botId, targetId } + const mentionJid = typeof src.mentionJid === 'string' ? src.mentionJid.trim() : '' + const mentionName = typeof src.mentionName === 'string' ? src.mentionName.trim().slice(0, 80) : '' + const delivery = { kind: 'im', botId, targetId } + if (mentionJid && /@(s\.whatsapp\.net|lid)$/i.test(mentionJid)) { + delivery.mentionJid = mentionJid + if (mentionName) delivery.mentionName = mentionName + } + return delivery } return { kind: 'dsh' } } +/** + * Keep creator @mention when the UI re-saves the same IM target without mention fields. + * @param {object|null|undefined} previous + * @param {object} next + */ +export function mergeDeliveryMention(previous, next) { + const normalized = normalizeDelivery(next) + if (normalized.kind !== 'im' || normalized.mentionJid) return normalized + if (previous?.kind !== 'im') return normalized + if (previous.botId !== normalized.botId || previous.targetId !== normalized.targetId) { + return normalized + } + if (!previous.mentionJid) return normalized + return normalizeDelivery({ + ...normalized, + mentionJid: previous.mentionJid, + mentionName: previous.mentionName, + }) +} + export function deliveryLine(delivery) { if (!delivery || delivery.kind !== 'im') return 'delivery=dsh' - return `delivery=im botId=${delivery.botId} targetId=${delivery.targetId}` + const mention = delivery.mentionJid ? ` mention=@${delivery.mentionName || delivery.mentionJid}` : '' + return `delivery=im botId=${delivery.botId} targetId=${delivery.targetId}${mention}` +} + +function peerIsGroup(peer) { + if (!peer || typeof peer !== 'object') return false + if (peer.kind === 'group') return true + const conversationId = String(peer.conversationId || '').trim() + if (conversationId.endsWith('@g.us')) return true + const key = String(peer.conversationKey || '').trim() + return key.startsWith('group:') +} + +/** + * Group creator identity for WhatsApp @mention on delivery. + * Prefer phone JID so WhatsApp shows the contact nickname and notifies; + * LID-only identities are a last resort (often render as opaque digits). + * @param {object|null} peer + * @returns {{ mentionJid: string, mentionName?: string } | null} + */ +export function mentionFromPeer(peer) { + if (!peerIsGroup(peer)) return null + const phone = typeof peer.phone === 'string' ? peer.phone.trim() : '' + const senderId = typeof peer.senderId === 'string' ? peer.senderId.trim() : '' + const phoneJid = phone ? `${phone}@s.whatsapp.net` : '' + const senderIsPhone = /@s\.whatsapp\.net$/i.test(senderId) + const senderIsLid = /@lid$/i.test(senderId) + const mentionJid = phoneJid + || (senderIsPhone ? senderId : '') + || (senderIsLid ? senderId : '') + if (!mentionJid) return null + const pushName = typeof peer.pushName === 'string' ? peer.pushName.trim() : '' + const mentionName = (pushName || phone || mentionJid.split('@')[0] || '').slice(0, 80) + return mentionName + ? { mentionJid, mentionName } + : { mentionJid } +} + +function withPeerMention(delivery, peer) { + if (!delivery || delivery.kind !== 'im') return delivery + const mention = mentionFromPeer(peer) + if (!mention) return delivery + return normalizeDelivery({ ...delivery, ...mention }) +} + +/** + * JID candidates for matching saved 投递目标. + * Groups must NEVER include the sender phone — that would bind cron delivery to a DM. + * @param {object|null} peer + * @returns {string[]} + */ +function peerJidCandidates(peer) { + if (!peer || typeof peer !== 'object') return [] + const conversationId = String(peer.conversationId || '').trim() + const key = String(peer.conversationKey || '').trim() + if (peerIsGroup(peer)) { + const groupFromKey = key.startsWith('group:') + ? key.slice('group:'.length).split(':user:')[0].trim() + : '' + return [ + peer.jid, + peer.route?.jid, + conversationId, + groupFromKey, + ] + .map((value) => String(value || '').trim().toLowerCase()) + .filter((value) => value.endsWith('@g.us')) + } + const jidFromKey = key.startsWith('direct:') + ? key.slice('direct:'.length).trim() + : (key.includes(':') ? key.slice(key.indexOf(':') + 1).split(':user:')[0] : key) + const phoneJid = peer.phone ? `${String(peer.phone).trim()}@s.whatsapp.net` : '' + return [ + peer.jid, + peer.route?.jid, + conversationId, + phoneJid, + jidFromKey, + ] + .map((value) => String(value || '').trim().toLowerCase()) + .filter(Boolean) } /** @@ -30,20 +138,123 @@ export function deliveryLine(delivery) { */ export function matchTargetForPeer(targets, peer) { if (!peer || !Array.isArray(targets) || targets.length === 0) return null - const key = String(peer.conversationKey || '').trim() - const jidFromKey = key.includes(':') ? key.slice(key.indexOf(':') + 1) : key - const peerJid = String(peer.jid || peer.route?.jid || jidFromKey || '').trim().toLowerCase() - if (!peerJid) return null + const candidates = peerJidCandidates(peer) + if (candidates.length === 0) return null + const wantGroup = peerIsGroup(peer) for (const target of targets) { + const targetKind = typeof target?.kind === 'string' ? target.kind.trim() : '' + if (wantGroup && targetKind === 'user') continue + if (!wantGroup && targetKind === 'group') continue const route = target?.route && typeof target.route === 'object' ? target.route : {} const jid = String(route.jid || route.chatId || route.openId || route.userId || '').trim().toLowerCase() - if (jid && (jid === peerJid || peerJid.endsWith(jid) || jid.endsWith(peerJid))) { + if (!jid) continue + if (wantGroup && !jid.endsWith('@g.us')) continue + if (candidates.some((peerJid) => ( + jid === peerJid || peerJid.endsWith(jid) || jid.endsWith(peerJid) + ))) { return target } } return null } +function slugId(value, fallback) { + const raw = String(value || '').trim().toLowerCase() + const slug = raw.replace(/@.*/, '').replace(/[^a-z0-9_-]+/g, '').slice(0, 40) + return slug || fallback +} + +/** + * Build a WhatsApp (or JID-shaped) delivery target draft for the current peer. + * Group chats always target the group JID; DMs prefer phone JID when known. + * @param {object|null} peer + * @returns {{ botId: string, target: object } | null} + */ +export function deliveryDraftFromPeer(peer) { + if (!peer?.botId || !peer.conversationId) return null + const conversationId = String(peer.conversationId).trim() + const isGroup = peerIsGroup(peer) + const routeJid = (!isGroup && peer.phone) + ? `${String(peer.phone).trim()}@s.whatsapp.net` + : conversationId + if (!routeJid) return null + if (isGroup && !routeJid.endsWith('@g.us')) return null + if (!isGroup && !/@(s\.whatsapp\.net|lid)$/i.test(routeJid)) { + // Non-WhatsApp peers are not auto-materialized yet. + return null + } + const name = String(peer.label || (isGroup ? 'WhatsApp 群聊' : 'WhatsApp 私聊')).trim().slice(0, 80) + const targetId = isGroup + ? `auto-group-${slugId(routeJid, 'chat')}` + : `auto-dm-${slugId(peer.phone || routeJid, 'user')}` + return { + botId: String(peer.botId).trim(), + target: { + targetId, + name: name || targetId, + kind: isGroup ? 'group' : 'user', + route: { jid: routeJid }, + }, + } +} + +/** + * Reuse or create a delivery target for the WhatsApp/IM peer that owns this session. + * @param {object|null} peer + * @param {object} dshIm + * @returns {Promise<{ kind: 'im', botId: string, targetId: string } | null>} + */ +export async function ensureImDeliveryForPeer(peer, dshIm) { + if (!peer?.botId || !dshIm || typeof dshIm.listTargets !== 'function') return null + let targets = [] + try { + const listed = await dshIm.listTargets(peer.botId) + targets = Array.isArray(listed) ? listed : [] + } catch { + return null + } + const match = matchTargetForPeer(targets, peer) + if (match?.targetId) { + return withPeerMention( + normalizeDelivery({ kind: 'im', botId: peer.botId, targetId: String(match.targetId) }), + peer, + ) + } + const draft = deliveryDraftFromPeer(peer) + if (!draft || typeof dshIm.createTarget !== 'function') return null + try { + const created = await dshIm.createTarget(draft.botId, draft.target) + const targetId = created?.targetId || draft.target.targetId + return withPeerMention( + normalizeDelivery({ kind: 'im', botId: draft.botId, targetId: String(targetId) }), + peer, + ) + } catch (error) { + if (error?.code !== 'target-conflict') return null + try { + const again = await dshIm.listTargets(peer.botId) + const rows = Array.isArray(again) ? again : [] + const byId = rows.find((row) => row?.targetId === draft.target.targetId) + if (byId?.targetId) { + return withPeerMention( + normalizeDelivery({ kind: 'im', botId: peer.botId, targetId: String(byId.targetId) }), + peer, + ) + } + const rematch = matchTargetForPeer(rows, peer) + if (rematch?.targetId) { + return withPeerMention( + normalizeDelivery({ kind: 'im', botId: peer.botId, targetId: String(rematch.targetId) }), + peer, + ) + } + } catch { + return null + } + return null + } +} + export function callerSessionId(exec) { const session = exec?.agent?.session return String( @@ -56,7 +267,8 @@ export function callerSessionId(exec) { } /** - * Resolve delivery for cron_create: explicit args win; else IM peer → matching target; else dsh. + * Resolve delivery for cron_create: explicit args win; else IM peer → matching + * or auto-created 投递目标; else dsh. * @param {object} args * @param {object} exec * @param {{ dshIm?: object }} deps @@ -78,17 +290,8 @@ export async function resolveCreateDelivery(args = {}, exec = {}, deps = {}) { if (sessionId && dshIm && typeof dshIm.resolveSessionPeer === 'function') { try { const peer = await dshIm.resolveSessionPeer(sessionId) - if (peer?.botId && typeof dshIm.listTargets === 'function') { - const targets = await dshIm.listTargets(peer.botId) - const match = matchTargetForPeer(targets, peer) - if (match?.targetId) { - return normalizeDelivery({ - kind: 'im', - botId: peer.botId, - targetId: String(match.targetId), - }) - } - } + const ensured = await ensureImDeliveryForPeer(peer, dshIm) + if (ensured) return ensured } catch { // fall through to dsh } @@ -100,6 +303,7 @@ const IM_MAX_CHARS = 3500 /** * Send run summary to IM when job.delivery.kind === 'im'. + * Group deliveries @mention the creator when mentionJid was captured at create time. */ export async function deliverRunToIm(job, summary, deps = {}) { const delivery = job?.delivery @@ -112,7 +316,15 @@ export async function deliverRunToIm(job, summary, deps = {}) { } const text = String(summary || '').trim() || `(定时任务「${job.name}」已完成,无文本摘要)` const clipped = text.length > IM_MAX_CHARS ? `${text.slice(0, IM_MAX_CHARS)}…` : text - const header = `【运维定时 · ${job.name}】\n` - await dshIm.send(delivery.botId, delivery.targetId, `${header}${clipped}`) - return { sent: true } + const header = `【定时任务 · ${job.name}】\n` + const mentionJid = typeof delivery.mentionJid === 'string' ? delivery.mentionJid.trim() : '' + let body = `${header}${clipped}` + const options = {} + if (mentionJid && /@(s\.whatsapp\.net|lid)$/i.test(mentionJid)) { + const token = mentionJid.slice(0, mentionJid.indexOf('@')) + body = `@${token}\n${header}${clipped}` + options.mentions = [mentionJid] + } + await dshIm.send(delivery.botId, delivery.targetId, body, options) + return { sent: true, mentioned: Boolean(options.mentions) } } diff --git a/lib/fire.js b/lib/fire.js index 1c32113..6bb7431 100644 --- a/lib/fire.js +++ b/lib/fire.js @@ -23,7 +23,7 @@ export function createRunRecord(job, decision, now, trigger) { } } -export const TITLE_PREFIX = '运维定时 · ' +export const TITLE_PREFIX = '定时任务 · ' export function extractAssistantText(messages, maxChars = 4000) { if (!Array.isArray(messages)) return '' @@ -55,8 +55,15 @@ export function publicJob(job) { provider: job.provider || '', model: job.model || '', reasoningEffort: job.reasoningEffort || '', + agentPreset: job.agentPreset || '', delivery: job.delivery && job.delivery.kind === 'im' - ? { kind: 'im', botId: job.delivery.botId, targetId: job.delivery.targetId } + ? { + kind: 'im', + botId: job.delivery.botId, + targetId: job.delivery.targetId, + ...job.delivery.mentionJid ? { mentionJid: job.delivery.mentionJid } : {}, + ...job.delivery.mentionName ? { mentionName: job.delivery.mentionName } : {}, + } : { kind: 'dsh' }, schedule: job.schedule, createdAt: job.createdAt, diff --git a/lib/host.js b/lib/host.js index c7c34a7..ed4d235 100644 --- a/lib/host.js +++ b/lib/host.js @@ -8,7 +8,7 @@ import { mkdir } from 'node:fs/promises' import { homedir } from 'node:os' import { basename, join } from 'node:path' import { claimOccurrence, executeClaimedRun, extractAssistantText, interruptActiveRuns, publicJob, settleRun, TITLE_PREFIX } from './fire.js' -import { deliverRunToIm } from './delivery.js' +import { deliverRunToIm, mergeDeliveryMention } from './delivery.js' import { workspaceVisibleIds } from './isolation.js' import { decideDispatch, nextFire, validateSchedule } from './scheduler.js' import { @@ -174,7 +174,10 @@ export function createHostService(options = {}) { provider: patch.provider !== undefined ? patch.provider : job.provider, model: patch.model !== undefined ? patch.model : job.model, reasoningEffort: patch.reasoningEffort !== undefined ? patch.reasoningEffort : job.reasoningEffort, - delivery: patch.delivery !== undefined ? patch.delivery : job.delivery, + agentPreset: patch.agentPreset !== undefined ? patch.agentPreset : job.agentPreset, + delivery: patch.delivery !== undefined + ? mergeDeliveryMention(job.delivery, patch.delivery) + : job.delivery, } const record = createJobRecord(nextInput, current, t) record.createdAt = job.createdAt @@ -375,6 +378,14 @@ export function createHostService(options = {}) { return } + if (path === `${API_PREFIX}/presets` && method === 'GET') { + const catalog = typeof sessionPort?.listPresets === 'function' + ? await sessionPort.listPresets() + : { items: [], current: null } + write(200, { ok: true, ...catalog }) + return + } + if (path === `${API_PREFIX}/workspaces` && method === 'GET') { const workspaces = typeof sessionPort?.listWorkspaces === 'function' ? await sessionPort.listWorkspaces() @@ -383,6 +394,35 @@ export function createHostService(options = {}) { return } + if (path === `${API_PREFIX}/im-catalog` && method === 'GET') { + const dshIm = getDshIm() + if (!dshIm || typeof dshIm.listDeliveryCatalog !== 'function') { + write(200, { + ok: true, + available: false, + options: [], + hint: 'dsh-im-ops missing or outdated — install ≥ops.24 for delivery picker', + }) + return + } + try { + const options = await dshIm.listDeliveryCatalog() + write(200, { + ok: true, + available: true, + options: Array.isArray(options) ? options : [], + }) + } catch (error) { + write(200, { + ok: true, + available: false, + options: [], + hint: error instanceof Error ? error.message : String(error), + }) + } + return + } + if (path === `${API_PREFIX}/jobs` && method === 'GET') { const state = await snapshot() write(200, { ok: true, jobs: listJobs(state).map(jobView) }) @@ -766,6 +806,35 @@ export async function listModelChoices(ctx) { return { groups, current: currentDefaultModel(ctx) } } +export async function listPresetChoices(ctx) { + const presets = tryGet(ctx, 'agentPresets') + if (!presets || typeof presets.list !== 'function') { + return { items: [], current: null } + } + let items = [] + try { + const listed = await presets.list() + const rows = Array.isArray(listed) ? listed : (Array.isArray(listed?.items) ? listed.items : []) + items = rows.map((row) => ({ + id: String(row?.id || '').trim(), + name: String(row?.name || row?.displayName || row?.id || '').trim(), + })).filter((row) => row.id) + } catch { + items = [] + } + let current = null + if (typeof presets.resolve === 'function') { + try { + const resolved = await presets.resolve() + const id = typeof resolved?.id === 'string' ? resolved.id.trim() : '' + if (id) current = { id, name: String(resolved?.name || id) } + } catch { + current = null + } + } + return { items, current } +} + export async function resolveJobModel(ctx, job) { const provider = typeof job?.provider === 'string' ? job.provider.trim() : '' const model = typeof job?.model === 'string' ? job.model.trim() : '' @@ -838,7 +907,7 @@ export function bindModelSelection(agentCtx, selection) { }) } -async function composeCronAgent(ctx, selection) { +async function composeCronAgent(ctx, selection, job) { const presets = tryGet(ctx, 'agentPresets') if (!presets || typeof presets.resolve !== 'function' || typeof presets.mount !== 'function') { return { @@ -847,7 +916,10 @@ async function composeCronAgent(ctx, selection) { }, } } - const resolved = await presets.resolve() + const wanted = typeof job?.agentPreset === 'string' ? job.agentPreset.trim() : '' + const resolved = wanted + ? await presets.resolve(wanted) + : await presets.resolve() const presetId = resolved?.id return { agentPreset: presetId, @@ -871,7 +943,7 @@ export function makeLiveSessionPort(ctx) { const cwd = placement.cwd || defaultCwd() await mkdir(cwd, { recursive: true }) const selection = await resolveJobModel(ctx, job) - const composition = await composeCronAgent(ctx, selection) + const composition = await composeCronAgent(ctx, selection, job) const handle = await agents.create({ sessionId, agentOptions: { @@ -935,6 +1007,9 @@ export function makeLiveSessionPort(ctx) { async listModels() { return listModelChoices(ctx) }, + async listPresets() { + return listPresetChoices(ctx) + }, async waitForTurn(sessionId) { const entry = handles.get(sessionId) const agents = tryGet(ctx, 'agents') diff --git a/lib/index.js b/lib/index.js index ac61847..74ee89e 100644 --- a/lib/index.js +++ b/lib/index.js @@ -2,7 +2,7 @@ * dsh-ops-cron, node half. * * Durable scheduled jobs: create a fresh root Session on fire, hide that - * session from the workspace list, and keep history under 运维定时. + * session from the workspace list, and keep history under 定时任务. * Jobs may deliver summaries to IM via soft-injected ctx.dshIm. * * Do not list @deepseek-ai/* in package.json — resolve schemastery from the @@ -131,6 +131,7 @@ function registerWebRoute(ctx, handler) { export function apply(ctx, config = {}) { const entry = resolveConfig(config) const getDshIm = () => tryGet(ctx, 'dshIm') + const getAgentPresets = () => tryGet(ctx, 'agentPresets') const service = createHostService({ sessionPort: makeLiveSessionPort(ctx), getDshIm, @@ -143,7 +144,7 @@ export function apply(ctx, config = {}) { }) ctx.inject(['tools'], (tctx) => { - registerCronTools(tctx, service, { getDshIm }) + registerCronTools(tctx, service, { getDshIm, getAgentPresets }) tctx.logger?.info?.('[dsh-ops-cron] tools cron_create/list/pause/resume/delete registered') }) diff --git a/lib/isolation.js b/lib/isolation.js index 05295c5..80500f5 100644 --- a/lib/isolation.js +++ b/lib/isolation.js @@ -5,7 +5,7 @@ */ export const HIDDEN_REASON = 'cron-run' -export const TITLE_PREFIX = '运维定时 · ' +export const TITLE_PREFIX = '定时任务 · ' /** * Strip the scheduler title prefix so a forked child can sit in the diff --git a/lib/preset.js b/lib/preset.js new file mode 100644 index 0000000..4be1822 --- /dev/null +++ b/lib/preset.js @@ -0,0 +1,79 @@ +/** + * Resolve which Agent Preset a cron job should mount. + * Priority: explicit args → IM session peer → creating session meta → Host default. + */ + +import { callerSessionId } from './delivery.js' + +const PRESET_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/ + +/** + * @param {unknown} value + * @returns {string} + */ +export function normalizeAgentPresetId(value) { + if (typeof value !== 'string') return '' + const id = value.trim() + if (!id) return '' + if (!PRESET_ID.test(id)) { + const error = new Error('agentPreset must be a short alphanumeric id') + error.code = 'INVALID_JOB' + throw error + } + return id +} + +function sessionMetaPreset(exec) { + const meta = exec?.agent?.session?.meta + || exec?.agent?.meta + || exec?.session?.meta + || null + if (!meta || typeof meta !== 'object') return '' + const raw = meta.agentPreset ?? meta.agent_preset + return typeof raw === 'string' ? raw.trim() : '' +} + +/** + * @param {object} args + * @param {object} exec + * @param {{ dshIm?: object, agentPresets?: object }} deps + * @returns {Promise} empty string means "Host default at fire time" + */ +export async function resolveCreateAgentPreset(args = {}, exec = {}, deps = {}) { + const explicit = typeof args.agent_preset === 'string' ? args.agent_preset.trim() + : (typeof args.agentPreset === 'string' ? args.agentPreset.trim() : '') + if (explicit) return normalizeAgentPresetId(explicit) + + const dshIm = deps.dshIm + const sessionId = callerSessionId(exec) + if (sessionId && dshIm && typeof dshIm.resolveSessionPeer === 'function') { + try { + const peer = await dshIm.resolveSessionPeer(sessionId) + const fromPeer = typeof peer?.agentPreset === 'string' ? peer.agentPreset.trim() : '' + if (fromPeer) return normalizeAgentPresetId(fromPeer) + } catch { + // fall through + } + } + + const fromSession = sessionMetaPreset(exec) + if (fromSession) { + try { + return normalizeAgentPresetId(fromSession) + } catch { + // ignore invalid session meta + } + } + + const presets = deps.agentPresets + if (presets && typeof presets.resolve === 'function') { + try { + const resolved = await presets.resolve() + const id = typeof resolved?.id === 'string' ? resolved.id.trim() : '' + return id ? normalizeAgentPresetId(id) : '' + } catch { + return '' + } + } + return '' +} diff --git a/lib/store.js b/lib/store.js index 204c7c7..5cdbd71 100644 --- a/lib/store.js +++ b/lib/store.js @@ -9,6 +9,7 @@ import { randomUUID } from 'node:crypto' import { applyRunIsolation, recordHiddenSession } from './isolation.js' import { nextFire, validateSchedule } from './scheduler.js' import { normalizeDelivery } from './delivery.js' +import { normalizeAgentPresetId } from './preset.js' export const STORE_VERSION = 1 @@ -94,6 +95,7 @@ export function createJobRecord(input, state, now) { const timeoutMinutes = Number(input.timeoutMinutes) const model = normalizeJobModel(input) const delivery = normalizeDelivery(input.delivery) + const agentPreset = normalizeAgentPresetId(input.agentPreset ?? input.agent_preset) const job = { id: String(input.id || newId()), name, @@ -106,6 +108,7 @@ export function createJobRecord(input, state, now) { provider: model.provider, model: model.model, reasoningEffort: model.reasoningEffort, + agentPreset, delivery, schedule: schedule.kind === 'cron' ? { kind: 'cron', expr: schedule.expr, timezone: schedule.timezone } diff --git a/lib/tools.js b/lib/tools.js index abb7a68..e9d30b9 100644 --- a/lib/tools.js +++ b/lib/tools.js @@ -5,6 +5,7 @@ import { formatInZone, resolveTodayAt } from './scheduler.js' import { deliveryLine, resolveCreateDelivery } from './delivery.js' +import { resolveCreateAgentPreset } from './preset.js' const JOB_SCHEMA = { type: 'object', @@ -19,6 +20,7 @@ const JOB_SCHEMA = { provider: { type: 'string' }, model: { type: 'string' }, reasoningEffort: { type: 'string' }, + agentPreset: { type: 'string' }, delivery: { type: 'object', additionalProperties: true }, schedule: { type: 'object', additionalProperties: true }, createdAt: { type: 'number' }, @@ -142,15 +144,16 @@ function requireId(args) { export function cronToolDefinitions(service, deps = {}) { const getDshIm = typeof deps.getDshIm === 'function' ? deps.getDshIm : () => undefined + const getAgentPresets = typeof deps.getAgentPresets === 'function' ? deps.getAgentPresets : () => undefined return [ { name: 'cron_create', - description: 'Create a DSH 运维定时 job (sidebar scheduled job). NEVER use crontab. NOT schedule_create. Default timezone Asia/Shanghai. For "in N minutes / 一分钟后" pass only after_minutes. For "today/tonight at HH:MM" pass only hour+minute (24h; hour=0 is midnight). Do not also send at or expr. Extra schedule fields are ignored (after_minutes > hour > at > expr). cwd MUST be the current workspace path when chatting in a workspace; if omitted, uses the current session working directory. Pass provider+model for the job; if omitted, snapshots the current session model. Delivery: omit for auto (WhatsApp/IM session → im if a matching 投递目标 exists; else dsh). Or pass delivery=dsh|im with im_bot_id+im_target_id from IM 投递设置.', + description: 'Create a DSH 定时任务 job (sidebar scheduled job). NEVER use crontab. NOT schedule_create. Default timezone Asia/Shanghai. For "in N minutes / 一分钟后" pass only after_minutes. For "today/tonight at HH:MM" pass only hour+minute (24h; hour=0 is midnight). Do not also send at or expr. Extra schedule fields are ignored (after_minutes > hour > at > expr). cwd MUST be the current workspace path when chatting in a workspace; if omitted, uses the current session working directory. Pass provider+model for the job; if omitted, snapshots the current session model. Agent preset: omit to inherit (WhatsApp chat preset → session → Host default); or pass agent_preset. Delivery: omit for auto — on WhatsApp/IM creates/reuses a 投递目标 for the current chat (group→group, DM→DM) and sets delivery=im; on Web/DSH defaults to dsh. Or pass delivery=dsh|im with im_bot_id+im_target_id.', parameters: { type: 'object', additionalProperties: false, properties: { - name: { type: 'string', description: 'Short display name in the 运维定时 list.' }, + name: { type: 'string', description: 'Short display name in the 定时任务 list.' }, prompt: { type: 'string', description: 'Self-contained instructions for the new session when the job fires.' }, expr: { type: 'string', description: '5-field cron in the given timezone: "minute hour day-of-month month day-of-week". Recurring jobs only. 22:55 every day = "55 22 * * *". Do not send expr together with after_minutes/hour/at — extras are ignored.' }, cron: { type: 'string', description: 'Alias of expr.' }, @@ -170,6 +173,7 @@ export function cronToolDefinitions(service, deps = {}) { delivery: { type: 'string', description: 'dsh (sidebar session) or im (proactive WhatsApp/IM via botId+targetId). Omit to auto-detect from the current session.' }, im_bot_id: { type: 'string', description: 'Opaque botId from IM 投递设置 when delivery=im.' }, im_target_id: { type: 'string', description: 'Opaque targetId from IM 投递设置 when delivery=im.' }, + agent_preset: { type: 'string', description: 'Agent preset id for scheduled runs. Omit to inherit from the current WhatsApp/IM chat or Host default.' }, }, required: ['name', 'prompt'], }, @@ -194,12 +198,16 @@ export function cronToolDefinitions(service, deps = {}) { return text(`Created "${job?.name}" (${job?.id}) ${sched} in ${tz}.${cwd}.${model}.${delivery}. Next run: ${when}. If that local time is wrong, delete this job and recreate with 24h hours (22=10pm).`) }, }, - presentCall: (args) => ({ card: 'generic', title: '创建运维定时', content: String(args?.name || '') }), + presentCall: (args) => ({ card: 'generic', title: '创建定时任务', content: String(args?.name || '') }), async execute(args, exec) { aborted(exec) const timeout = Number(args.timeout_minutes) try { const delivery = await resolveCreateDelivery(args, exec, { dshIm: getDshIm() }) + const agentPreset = await resolveCreateAgentPreset(args, exec, { + dshIm: getDshIm(), + agentPresets: getAgentPresets(), + }) const job = await service.createJob({ name: args.name, prompt: args.prompt, @@ -209,6 +217,7 @@ export function cronToolDefinitions(service, deps = {}) { timeoutMinutes: Number.isFinite(timeout) && timeout > 0 ? timeout : undefined, enabled: args.enabled !== false, delivery, + agentPreset, }) return { job } } catch (error) { @@ -225,7 +234,7 @@ export function cronToolDefinitions(service, deps = {}) { }, { name: 'cron_list', - description: 'List DSH 运维定时 jobs (sidebar scheduled jobs). Use this whenever the user asks what scheduled tasks exist. NEVER use crontab -l or /etc/cron* — those are OS crontabs, not this plugin.', + description: 'List DSH 定时任务 jobs (sidebar scheduled jobs). Use this whenever the user asks what scheduled tasks exist. NEVER use crontab -l or /etc/cron* — those are OS crontabs, not this plugin.', parameters: { type: 'object', additionalProperties: false, @@ -248,7 +257,7 @@ export function cronToolDefinitions(service, deps = {}) { return text(rows.length ? `Scheduled tasks (${value.count}):\n${rows.join('\n')}` : 'No scheduled tasks yet.') }, }, - presentCall: () => ({ card: 'generic', title: '列出运维定时' }), + presentCall: () => ({ card: 'generic', title: '列出定时任务' }), async execute(args, exec) { aborted(exec) let jobs = await service.listJobs() @@ -258,7 +267,7 @@ export function cronToolDefinitions(service, deps = {}) { }, { name: 'cron_pause', - description: 'Pause a scheduled task by id from cron_list / cron_create. It stays in 运维定时 but will not fire until resumed.', + description: 'Pause a scheduled task by id from cron_list / cron_create. It stays in 定时任务 but will not fire until resumed.', parameters: { type: 'object', additionalProperties: false, @@ -269,7 +278,7 @@ export function cronToolDefinitions(service, deps = {}) { schema: { type: 'object', additionalProperties: true, properties: { job: JOB_SCHEMA } }, render: (_args, value) => text(`Paused "${value.job?.name}" (${value.job?.id}).`), }, - presentCall: (args) => ({ card: 'generic', title: '暂停运维定时', content: String(args?.id || '') }), + presentCall: (args) => ({ card: 'generic', title: '暂停定时任务', content: String(args?.id || '') }), async execute(args, exec) { aborted(exec) const job = await service.pauseJob(requireId(args), false) @@ -289,7 +298,7 @@ export function cronToolDefinitions(service, deps = {}) { schema: { type: 'object', additionalProperties: true, properties: { job: JOB_SCHEMA } }, render: (_args, value) => text(`Resumed "${value.job?.name}" (${value.job?.id}).`), }, - presentCall: (args) => ({ card: 'generic', title: '恢复运维定时', content: String(args?.id || '') }), + presentCall: (args) => ({ card: 'generic', title: '恢复定时任务', content: String(args?.id || '') }), async execute(args, exec) { aborted(exec) const job = await service.pauseJob(requireId(args), true) @@ -317,7 +326,7 @@ export function cronToolDefinitions(service, deps = {}) { }, render: (_args, value) => text(value.deleted ? `Deleted scheduled task ${value.id}.` : `Scheduled task ${value.id} was not found.`), }, - presentCall: (args) => ({ card: 'generic', title: '删除运维定时', content: String(args?.id || '') }), + presentCall: (args) => ({ card: 'generic', title: '删除定时任务', content: String(args?.id || '') }), async execute(args, exec) { aborted(exec) const id = requireId(args) @@ -336,17 +345,18 @@ export function cronToolDefinitions(service, deps = {}) { export function cronGuidanceText(nowMs = Date.now(), timeZone = 'Asia/Shanghai') { const now = formatInZone(nowMs, timeZone) return [ - 'This deployment has a DSH sidebar feature named 运维定时 (ops scheduled tasks / dsh-ops-cron).', + 'This deployment has a DSH sidebar feature named 定时任务 (scheduled tasks / dsh-ops-cron).', 'Jobs live in that list and fire a fresh session (and optionally deliver a summary to WhatsApp/IM). They are NOT OS crontab and NOT in-chat schedule_create reminders.', `Current local time for scheduling: ${now}. Use this calendar date for "today"/"tonight". Never invent another month or day.`, 'For "in N minutes / 一分钟后", call cron_create with after_minutes=N only (do not also pass at, hour, or expr).', 'For a clock time tonight, pass only hour and minute in 24h (晚上11点34 → hour=23, minute=34; 零点33 → hour=0, minute=33). Extra at/expr fields are ignored.', 'Working directory: if the user is chatting in a workspace, pass cwd as that workspace filesystem path (the current session working directory). If they name another workspace, use that path. If cwd is omitted, cron_create uses the current session cwd.', 'Model: pass provider+model for the job. If omitted, cron_create stores the current session model. Scheduled runs consume that model\'s quota.', - 'Delivery: when chatting on WhatsApp/IM, omit delivery so the job defaults to im (requires a matching 投递目标 for this peer). On Web/DSH, default is dsh. Or pass delivery=im with im_bot_id+im_target_id from IM 投递设置 → 复制调用参数.', - 'When the user asks to look at, create, pause, resume, or delete 运维定时 / 定时任务 / scheduled tasks / cron jobs:', + 'Delivery: when chatting on WhatsApp/IM, omit delivery so the job defaults to im for the current chat (group→same group, DM→same DM). A 投递目标 is reused or auto-created. On Web/DSH, default is dsh. Or pass delivery=im with im_bot_id+im_target_id.', + 'Agent preset: omit agent_preset to inherit (WhatsApp chat/group preset → creating session → Host default). Pass agent_preset to pin a preset for every scheduled run.', + 'When the user asks to look at, create, pause, resume, or delete 定时任务 / scheduled tasks / cron jobs:', '1. If cron_list / cron_create / cron_pause / cron_resume / cron_delete are in your tool list, call them.', - '2. If they are not listed, search/unlock tools or skills with query "运维定时" or "cron" (skill_search, skill_load, or dev_tool_search) and then call them.', + '2. If they are not listed, search/unlock tools or skills with query "定时任务" or "cron" (skill_search, skill_load, or dev_tool_search) and then call them.', '3. Never run crontab, never read /etc/cron*, and never say there are no tasks until cron_list has returned.', ].join('\n') } @@ -356,15 +366,15 @@ export const CRON_GUIDANCE = cronGuidanceText() export function makeCronSkill() { return { name: 'scheduled-tasks', - description: '运维定时: 查看、创建、暂停、恢复、删除 DSH 侧栏定时任务(今天晚上几点、一次性执行、scheduled job、cron)。WhatsApp 里创建默认回投 IM;Web 里创建进侧栏会话。不要用系统 crontab,也不是会话内 reminder。', - whenToUse: 'User asks to list or create 运维定时 / 定时任务, schedule something for tonight/today, pause a job, or mentions cron / scheduled tasks in DeepSeek Harness.', + description: '定时任务: 查看、创建、暂停、恢复、删除 DSH 侧栏定时任务(今天晚上几点、一次性执行、scheduled job、cron)。WhatsApp 里创建默认回投 IM;Web 里创建进侧栏会话。不要用系统 crontab,也不是会话内 reminder。', + whenToUse: 'User asks to list or create 定时任务 / scheduled tasks, schedule something for tonight/today, pause a job, or mentions cron in DeepSeek Harness.', source: 'runtime', provider: 'runtime', content: `${cronGuidanceText()} Tools: - cron_list — list jobs -- cron_create — "一分钟后" → after_minutes=1. Clock time → hour+minute only. Do not send at/expr at the same time. Pass cwd as the current workspace path when creating from a workspace chat. Pass provider+model or inherit the current session model. Delivery auto from session, or delivery=im + im_bot_id + im_target_id. +- cron_create — "一分钟后" → after_minutes=1. Clock time → hour+minute only. Do not send at/expr at the same time. Pass cwd as the current workspace path when creating from a workspace chat. Pass provider+model or inherit the current session model. Delivery and agent preset auto from session, or pass delivery=im / agent_preset explicitly. - cron_pause / cron_resume / cron_delete — by id from cron_list `, } diff --git a/package.json b/package.json index 533a13d..d4dd5c0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dsh-ops-cron", - "description": "Ops fork of dsh-cron-tasks: Agent cron_* tools + sidebar 运维定时, with DSH or WhatsApp/IM delivery per job.", - "version": "0.1.0", + "description": "Scheduled tasks for DeepSeek Harness: Agent cron_* tools + sidebar 定时任务, with DSH or WhatsApp/IM delivery per job.", + "version": "0.1.7", "private": false, "type": "module", "repository": { diff --git a/test/delivery.test.js b/test/delivery.test.js index 07a9cf2..1075d77 100644 --- a/test/delivery.test.js +++ b/test/delivery.test.js @@ -42,6 +42,8 @@ test('resolveCreateDelivery auto-matches peer target', async () => { resolveSessionPeer: async () => ({ botId: 'bot_1', conversationKey: 'direct:86138@s.whatsapp.net', + conversationId: '86138@s.whatsapp.net', + kind: 'direct', }), listTargets: async () => ([ { targetId: 'wa-dm', route: { jid: '86138@s.whatsapp.net' } }, @@ -53,6 +55,134 @@ test('resolveCreateDelivery auto-matches peer target', async () => { assert.deepEqual(delivery, { kind: 'im', botId: 'bot_1', targetId: 'wa-dm' }) }) +test('resolveCreateDelivery auto-creates group target when none exists', async () => { + const created = [] + const dshIm = { + resolveSessionPeer: async () => ({ + botId: 'bot_wa', + kind: 'group', + conversationKey: 'group:120363@g.us:user:86138@s.whatsapp.net', + conversationId: '120363@g.us', + label: '运维群', + }), + listTargets: async () => ([]), + createTarget: async (botId, target) => { + created.push({ botId, target }) + return target + }, + } + const delivery = await resolveCreateDelivery({}, { + agent: { session: { id: 'sess-group' } }, + }, { dshIm }) + assert.equal(created.length, 1) + assert.equal(created[0].botId, 'bot_wa') + assert.equal(created[0].target.kind, 'group') + assert.equal(created[0].target.route.jid, '120363@g.us') + assert.deepEqual(delivery, { + kind: 'im', + botId: 'bot_wa', + targetId: created[0].target.targetId, + }) +}) + +test('matchTargetForPeer matches phone-mapped DM against phone JID target', () => { + const peer = { + botId: 'bot_1', + conversationKey: 'direct:910xxx@lid', + conversationId: '910xxx@lid', + phone: '8613800000000', + } + const targets = [ + { targetId: 'alerts', kind: 'user', route: { jid: '8613800000000@s.whatsapp.net' } }, + ] + assert.equal(matchTargetForPeer(targets, peer).targetId, 'alerts') +}) + +test('matchTargetForPeer does not bind group peer to a DM via sender phone', () => { + const peer = { + botId: 'bot_1', + kind: 'group', + conversationKey: 'group:120363999@g.us:user:8613800000000@s.whatsapp.net', + conversationId: '120363999@g.us', + phone: '8613800000000', + label: 'Ops Room · Alice · 8613800000000', + } + const targets = [ + { targetId: 'my-dm', kind: 'user', route: { jid: '8613800000000@s.whatsapp.net' } }, + { targetId: 'ops-group', kind: 'group', route: { jid: '120363999@g.us' } }, + ] + assert.equal(matchTargetForPeer(targets, peer).targetId, 'ops-group') +}) + +test('matchTargetForPeer ignores DM-only catalog when peer is a group', () => { + const peer = { + botId: 'bot_1', + kind: 'group', + conversationKey: 'group:120363999@g.us:user:8613800000000@s.whatsapp.net', + conversationId: '120363999@g.us', + phone: '8613800000000', + } + const targets = [ + { targetId: 'my-dm', kind: 'user', route: { jid: '8613800000000@s.whatsapp.net' } }, + ] + assert.equal(matchTargetForPeer(targets, peer), null) +}) + +test('resolveCreateDelivery pins group creator mention from peer', async () => { + const dshIm = { + resolveSessionPeer: async () => ({ + botId: 'bot_wa', + kind: 'group', + conversationKey: 'group:120363@g.us:user:910xxx@lid', + conversationId: '120363@g.us', + senderId: '910xxx@lid', + phone: '8613800000000', + pushName: 'Alice', + label: 'Ops · Alice · 8613800000000', + }), + listTargets: async () => ([ + { targetId: 'ops-group', kind: 'group', route: { jid: '120363@g.us' } }, + ]), + } + const delivery = await resolveCreateDelivery({}, { + agent: { session: { id: 'sess-group' } }, + }, { dshIm }) + assert.deepEqual(delivery, { + kind: 'im', + botId: 'bot_wa', + targetId: 'ops-group', + // Prefer phone JID over LID so WhatsApp can show nickname + notify. + mentionJid: '8613800000000@s.whatsapp.net', + mentionName: 'Alice', + }) +}) + +test('deliverRunToIm @mentions the creator in group deliveries', async () => { + const sent = [] + await deliverRunToIm( + { + name: 'ping', + delivery: { + kind: 'im', + botId: 'b', + targetId: 't', + mentionJid: '8613800000000@s.whatsapp.net', + mentionName: 'Alice', + }, + }, + 'hello world', + { + dshIm: { + send: async (...args) => { sent.push(args) }, + }, + }, + ) + assert.equal(sent.length, 1) + assert.match(sent[0][2], /^@8613800000000\n/) + assert.match(sent[0][2], /定时任务/) + assert.deepEqual(sent[0][3], { mentions: ['8613800000000@s.whatsapp.net'] }) +}) + test('deliverRunToIm sends via dshIm', async () => { const sent = [] await deliverRunToIm( @@ -63,6 +193,6 @@ test('deliverRunToIm sends via dshIm', async () => { assert.equal(sent.length, 1) assert.equal(sent[0][0], 'b') assert.equal(sent[0][1], 't') - assert.match(sent[0][2], /运维定时/) + assert.match(sent[0][2], /定时任务/) assert.match(sent[0][2], /hello world/) }) diff --git a/test/package.test.js b/test/package.test.js index f9bdc0d..7e700e9 100644 --- a/test/package.test.js +++ b/test/package.test.js @@ -34,6 +34,8 @@ test('installable bundle declares host apply, client half, unique id, and no @de assert.match(tools, /resolveCreateCwd/) assert.match(tools, /after_minutes/) assert.match(tools, /Extra schedule fields are ignored/) + assert.match(tools, /agent_preset/) + assert.match(tools, /resolveCreateAgentPreset/) assert.doesNotMatch(tools, /pass only one of expr, at, or hour/) assert.match(index, /makeCronSkill/) assert.doesNotMatch(index, /agent\/session-start/) @@ -46,9 +48,12 @@ test('installable bundle declares host apply, client half, unique id, and no @de assert.match(host, /API_PREFIX = '\/dsh-ops-cron'/) assert.match(host, /resolveSessionPlacement/) assert.match(host, /\/models/) + assert.match(host, /\/presets/) + assert.match(host, /composeCronAgent/) + assert.match(host, /listPresetChoices/) assert.doesNotMatch(host, /from ['"]@deepseek-ai\//) const client = await readFile(join(root, 'lib/client.js'), 'utf8') - assert.match(client, /运维定时/) + assert.match(client, /定时任务/) assert.match(client, /dsh-ops-cron/) assert.match(client, /settings\.plugin\.item/) assert.match(client, /deliveryKind/) @@ -56,7 +61,11 @@ test('installable bundle declares host apply, client half, unique id, and no @de assert.match(client, /imTargetId/) assert.match(client, /CwdField/) assert.match(client, /ModelField/) + assert.match(client, /PresetField/) + assert.match(client, /agentPreset/) assert.match(client, /inject = \['slots', 'locale', 'settingsScope'\]/) + const preset = await readFile(join(root, 'lib/preset.js'), 'utf8') + assert.match(preset, /resolveCreateAgentPreset/) const delivery = await readFile(join(root, 'lib/delivery.js'), 'utf8') assert.match(delivery, /resolveCreateDelivery/) assert.match(delivery, /deliverRunToIm/) diff --git a/test/preset.test.js b/test/preset.test.js new file mode 100644 index 0000000..98f59d1 --- /dev/null +++ b/test/preset.test.js @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { normalizeAgentPresetId, resolveCreateAgentPreset } from '../lib/preset.js' +import { createJobRecord, emptyState } from '../lib/store.js' + +test('normalizeAgentPresetId accepts empty and valid ids', () => { + assert.equal(normalizeAgentPresetId(''), '') + assert.equal(normalizeAgentPresetId(' ops-desk '), 'ops-desk') + assert.throws(() => normalizeAgentPresetId('bad preset'), /agentPreset/) +}) + +test('createJobRecord persists agentPreset', () => { + const job = createJobRecord({ + name: 'n', + prompt: 'p', + schedule: { kind: 'cron', expr: '0 9 * * *', timezone: 'Asia/Shanghai' }, + agentPreset: 'ops-desk', + }, emptyState(), Date.now()) + assert.equal(job.agentPreset, 'ops-desk') +}) + +test('resolveCreateAgentPreset prefers explicit then peer then session then host default', async () => { + assert.equal( + await resolveCreateAgentPreset({ agent_preset: 'explicit' }, {}, {}), + 'explicit', + ) + + const fromPeer = await resolveCreateAgentPreset({}, { agent: { session: { id: 's1' } } }, { + dshIm: { + resolveSessionPeer: async () => ({ agentPreset: 'from-peer' }), + }, + }) + assert.equal(fromPeer, 'from-peer') + + const fromMeta = await resolveCreateAgentPreset({}, { + agent: { session: { meta: { agentPreset: 'from-meta' } } }, + }, {}) + assert.equal(fromMeta, 'from-meta') + + const fromHost = await resolveCreateAgentPreset({}, {}, { + agentPresets: { + resolve: async () => ({ id: 'host-default' }), + }, + }) + assert.equal(fromHost, 'host-default') +})