ds-bot-dual-surface 0.0.0-stage → 0.3.2
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/LICENSE +201 -0
- package/README.md +262 -3
- package/client.js +30079 -0
- package/cordis.patch.yml +26 -0
- package/index.js +94 -0
- package/office.js +8908 -0
- package/package.json +80 -6
- package/src/host/agents.js +129 -0
- package/src/host/approvals.js +116 -0
- package/src/host/avatar-library.js +131 -0
- package/src/host/avatar.js +107 -0
- package/src/host/browser/gate.js +3 -0
- package/src/host/browser/loop.js +95 -0
- package/src/host/browser/mount.js +177 -0
- package/src/host/browser/refs.js +151 -0
- package/src/host/browser/script-child.js +136 -0
- package/src/host/browser/script-runner.js +77 -0
- package/src/host/browser/script.js +39 -0
- package/src/host/browser/session-model.js +205 -0
- package/src/host/browser/session.js +757 -0
- package/src/host/browser/task.js +64 -0
- package/src/host/browser-plugins.js +174 -0
- package/src/host/browser.js +287 -0
- package/src/host/btw.js +372 -0
- package/src/host/capabilities.js +545 -0
- package/src/host/channel.js +773 -0
- package/src/host/chat-index.js +293 -0
- package/src/host/computer/catalog.js +91 -0
- package/src/host/computer/index.js +1272 -0
- package/src/host/computer/leases.js +71 -0
- package/src/host/computer/mac-helper.js +68 -0
- package/src/host/connectors/catalog.js +96 -0
- package/src/host/connectors/gateway.js +264 -0
- package/src/host/connectors/index.js +863 -0
- package/src/host/connectors/mail/index.js +613 -0
- package/src/host/connectors/mail/presets.js +23 -0
- package/src/host/connectors/mail/transport.js +306 -0
- package/src/host/connectors/oauth.js +191 -0
- package/src/host/constants.js +101 -0
- package/src/host/delivery.js +175 -0
- package/src/host/diagnostics.js +257 -0
- package/src/host/dsh-update.js +172 -0
- package/src/host/feed.js +122 -0
- package/src/host/files.js +27 -0
- package/src/host/folders.js +230 -0
- package/src/host/goal-skills.js +48 -0
- package/src/host/goals.js +812 -0
- package/src/host/group-log.js +72 -0
- package/src/host/groups.js +1613 -0
- package/src/host/handoffs.js +436 -0
- package/src/host/home-lock.js +98 -0
- package/src/host/im/format.js +190 -0
- package/src/host/im/index.js +943 -0
- package/src/host/im/platforms/dingtalk.js +249 -0
- package/src/host/im/platforms/discord.js +234 -0
- package/src/host/im/platforms/feishu.js +400 -0
- package/src/host/im/platforms/index.js +12 -0
- package/src/host/im/platforms/qq.js +251 -0
- package/src/host/im/platforms/retry.js +13 -0
- package/src/host/im/platforms/slack.js +210 -0
- package/src/host/im/platforms/telegram.js +256 -0
- package/src/host/im/platforms/wecom.js +240 -0
- package/src/host/im/platforms/weixin.js +364 -0
- package/src/host/images.js +227 -0
- package/src/host/learning.js +799 -0
- package/src/host/legacy.mjs +4 -0
- package/src/host/links.js +39 -0
- package/src/host/local-agents/acp.js +282 -0
- package/src/host/local-agents/adapter.js +650 -0
- package/src/host/local-agents/claude.js +251 -0
- package/src/host/local-agents/codex.js +219 -0
- package/src/host/local-agents/discover.js +166 -0
- package/src/host/local-agents/engines.js +91 -0
- package/src/host/local-agents/index.js +273 -0
- package/src/host/local-agents/jsonrpc.js +114 -0
- package/src/host/local-agents/mcp.js +141 -0
- package/src/host/memory-copy.js +179 -0
- package/src/host/memory-entries.js +298 -0
- package/src/host/memory-pending.js +95 -0
- package/src/host/memory-review.js +406 -0
- package/src/host/memory-search.js +29 -0
- package/src/host/memory-tidy.js +264 -0
- package/src/host/memory.js +1743 -0
- package/src/host/models.js +330 -0
- package/src/host/office-skills.js +78 -0
- package/src/host/parts.js +1765 -0
- package/src/host/permissions.js +117 -0
- package/src/host/privacy.js +368 -0
- package/src/host/prompt.js +73 -0
- package/src/host/providers.js +351 -0
- package/src/host/requests.js +141 -0
- package/src/host/resume.js +467 -0
- package/src/host/roster.js +949 -0
- package/src/host/schedules.js +248 -0
- package/src/host/secrets.js +586 -0
- package/src/host/side.js +406 -0
- package/src/host/skills/goal-grill/SKILL.md +37 -0
- package/src/host/steer.js +265 -0
- package/src/host/steward.js +165 -0
- package/src/host/store.js +631 -0
- package/src/host/text.js +49 -0
- package/src/host/thinking.js +55 -0
- package/src/host/time-context.js +46 -0
- package/src/host/tools.js +1235 -0
- package/src/host/turns.js +1242 -0
- package/src/host/update.js +318 -0
- package/src/host/usage.js +653 -0
- package/src/office/check.js +329 -0
- package/src/office/cli.js +193 -0
- package/src/office/docx.js +648 -0
- package/src/office/package.js +238 -0
- package/src/office/png.js +183 -0
- package/src/office/pptx.js +410 -0
- package/src/office/skills/office/SKILL.md +18 -0
- package/src/office/skills/office-tools/SKILL.md +41 -0
- package/src/office/xlsx.js +134 -0
- package/src/office/xml.js +128 -0
- package/src/office/zip.js +146 -0
- package/src/shared/group-replies.js +69 -0
- package/src/shared/im-platforms.js +86 -0
- package/src/shared/mentions.js +72 -0
- package/web-search.js +775 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// The memory tidy. Once a day at most, for each memory that changed, a model reads all
|
|
2
|
+
// its entries and merges repeats, settles what contradicts, and moves details to topics.
|
|
3
|
+
// A merge loses words, so nothing is deleted: what leaves goes to retired.jsonl beside
|
|
4
|
+
// the entries, what a pass did is one batch the user can undo, a pinned entry is never
|
|
5
|
+
// offered, and one pass changes at most a fifth of the entries.
|
|
6
|
+
import { randomUUID } from 'node:crypto'
|
|
7
|
+
import { readFileSync } from 'node:fs'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
import { HEADING, MAIN, addLink, cleanEntry, cleanTopic, entryId, formatEntry, insertEntry, isExpired, isPinned, joinLines, parseMemory, removeLink } from './memory-entries.js'
|
|
10
|
+
import { tidyRequest, tidySystem } from './memory-copy.js'
|
|
11
|
+
|
|
12
|
+
const EVERY_MS = 30 * 60_000
|
|
13
|
+
// Below this there is nothing a tidy would be worth a model call for.
|
|
14
|
+
const ENTRIES_MIN = 8
|
|
15
|
+
const SHARE_MAX = 5
|
|
16
|
+
const RETIRED = 'retired.jsonl'
|
|
17
|
+
const NOTE_MAX = 120
|
|
18
|
+
const ITEMS_SHOWN = 20
|
|
19
|
+
|
|
20
|
+
function parseTidy(text) {
|
|
21
|
+
const start = text.indexOf('{')
|
|
22
|
+
const end = text.lastIndexOf('}')
|
|
23
|
+
if (start === -1 || end <= start) return undefined
|
|
24
|
+
try {
|
|
25
|
+
const value = JSON.parse(text.slice(start, end + 1))
|
|
26
|
+
return Array.isArray(value?.changes) ? value.changes : undefined
|
|
27
|
+
} catch {
|
|
28
|
+
return undefined
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function installTidy(rt, memory) {
|
|
33
|
+
const { ctx, config, state } = rt
|
|
34
|
+
const { TEAM, ownScope, groupScope, keeperOf, scopeKey, dirOf, today, queued, recoverPending, writable, settled, loadScope, publish, writeStep, removeStep, writeText, botShot, languageNow, allEntries, entryProblem, changedSince, USER } = memory
|
|
35
|
+
// All of it sits in the memory's own folder: a private Bot's words stay in its own,
|
|
36
|
+
// and an archive of a deleted Bot or group takes them along.
|
|
37
|
+
const statePath = scope => join(dirOf(scope), 'tidied.json')
|
|
38
|
+
const batchPath = (scope, batch) => join(dirOf(scope), 'tidy', `${batch}.json`)
|
|
39
|
+
const doneOf = (scope) => {
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(readFileSync(statePath(scope), 'utf8'))
|
|
42
|
+
} catch {
|
|
43
|
+
return undefined
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Every memory with someone to read it for: a team tidy offers only its topics, since
|
|
48
|
+
// what every Bot always reads is the user's to change.
|
|
49
|
+
function jobs() {
|
|
50
|
+
const bots = Object.values(state.bots).map(bot => ({ scope: ownScope(bot.id), keeper: bot, who: `one Bot, ${bot.name}` }))
|
|
51
|
+
const rooms = Object.values(state.rooms).flatMap((room) => {
|
|
52
|
+
const keeper = keeperOf(room)
|
|
53
|
+
return keeper === undefined ? [] : [{ scope: groupScope(room.id), keeper, who: `one group chat, "${room.name}"` }]
|
|
54
|
+
})
|
|
55
|
+
const main = Object.values(state.bots).find(bot => rt.isMain(bot.id))
|
|
56
|
+
return [...bots, ...rooms, ...(main === undefined ? [] : [{ scope: TEAM, keeper: main, who: 'the notes a team of Bots shares' }])]
|
|
57
|
+
}
|
|
58
|
+
const offeredOf = (job, files) => allEntries(job.scope.kind === 'team' ? files.slice(1) : files).filter(entry => !isPinned(entry))
|
|
59
|
+
|
|
60
|
+
async function tidy(job) {
|
|
61
|
+
const key = scopeKey(job.scope)
|
|
62
|
+
const prior = doneOf(job.scope)
|
|
63
|
+
const day = today()
|
|
64
|
+
const files = await settled(() => loadScope(job.scope))
|
|
65
|
+
const offered = offeredOf(job, files)
|
|
66
|
+
const ended = offered.filter(entry => isExpired(entry, day))
|
|
67
|
+
const open = offered.filter(entry => !isExpired(entry, day))
|
|
68
|
+
let changes = []
|
|
69
|
+
if (open.length >= ENTRIES_MIN) {
|
|
70
|
+
const max = Math.max(1, Math.floor(offered.length / SHARE_MAX))
|
|
71
|
+
const reply = await botShot(job.keeper, tidySystem({ who: job.who, max }), tidyRequest({ entries: open, language: languageNow() }), { sessionId: job.scope.kind === 'bot' ? rt.chatOf(job.keeper.id) : job.scope.kind === 'group' ? job.scope.roomId : rt.chatOf(job.keeper.id), kind: 'memory' })
|
|
72
|
+
const parsed = parseTidy(reply)
|
|
73
|
+
if (parsed === undefined) rt.warn('ds-bot: the memory tidy of %s got no list of changes from the model', key)
|
|
74
|
+
else changes = parsed
|
|
75
|
+
}
|
|
76
|
+
await queued(async () => {
|
|
77
|
+
await recoverPending()
|
|
78
|
+
if (rt.botOf(job.keeper.id) === undefined) return
|
|
79
|
+
writable(job.scope, USER)
|
|
80
|
+
await applyTidy(job, key, day, changes, open, ended, prior)
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function applyTidy(job, key, day, changes, numbered, ended, prior) {
|
|
85
|
+
const files = loadScope(job.scope)
|
|
86
|
+
const offered = offeredOf(job, files)
|
|
87
|
+
const byId = new Map(offered.map(entry => [entryId(entry), entry]))
|
|
88
|
+
const draft = new Map(files.map(file => [file.file, [...file.lines]]))
|
|
89
|
+
const main = files[0]
|
|
90
|
+
const budget = { left: Math.max(1, Math.floor(offered.length / SHARE_MAX)) }
|
|
91
|
+
const retired = []
|
|
92
|
+
const items = []
|
|
93
|
+
const counts = { merged: 0, moved: 0, retired: 0 }
|
|
94
|
+
const batch = randomUUID()
|
|
95
|
+
const time = new Date().toISOString()
|
|
96
|
+
const take = (entry) => { draft.set(entry.file.file, draft.get(entry.file.file).filter(line => line !== entry.file.lines[entry.line])) }
|
|
97
|
+
const retire = (entry, why, extra = {}) => retired.push({ batch, time, file: entry.file.file, line: entry.file.lines[entry.line], why, ...extra })
|
|
98
|
+
const unchanged = (expected) => {
|
|
99
|
+
if (expected === undefined) return undefined
|
|
100
|
+
const current = byId.get(entryId(expected))
|
|
101
|
+
if (current?.file.file === expected.file.file && current.file.lines[current.line] === expected.file.lines[expected.line]) return current
|
|
102
|
+
return undefined
|
|
103
|
+
}
|
|
104
|
+
const pick = at => unchanged(numbered[Number(at) - 1])
|
|
105
|
+
const used = new Set()
|
|
106
|
+
|
|
107
|
+
for (const expected of ended) {
|
|
108
|
+
const entry = unchanged(expected)
|
|
109
|
+
if (entry === undefined) continue
|
|
110
|
+
take(entry)
|
|
111
|
+
retire(entry, 'ended')
|
|
112
|
+
used.add(entry)
|
|
113
|
+
counts.retired += 1
|
|
114
|
+
items.push({ op: 'retired', text: entry.text })
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const change of changes) {
|
|
118
|
+
const op = String(change?.op ?? '')
|
|
119
|
+
if (op === 'about') {
|
|
120
|
+
const name = cleanTopic(change.topic)
|
|
121
|
+
const lines = draft.get(`${name}.md`)
|
|
122
|
+
const text = cleanEntry(change.text).slice(0, NOTE_MAX)
|
|
123
|
+
if (name === undefined || lines === undefined || text === '' || entryProblem(text) !== undefined) continue
|
|
124
|
+
const title = lines.findIndex(line => HEADING.test(line))
|
|
125
|
+
const kept = lines.filter(line => !/^>\s*\S/.test(line))
|
|
126
|
+
kept.splice(title + 1, 0, '', `> ${text}`)
|
|
127
|
+
draft.set(`${name}.md`, kept)
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
if (op === 'merge') {
|
|
131
|
+
const entries = [...new Set((Array.isArray(change.entries) ? change.entries : []).map(pick))]
|
|
132
|
+
if (entries.some(entry => entry === undefined || used.has(entry))) continue
|
|
133
|
+
const text = cleanEntry(change.text)
|
|
134
|
+
if (entries.length < 2 || entries.length > budget.left || entryProblem(text) !== undefined) continue
|
|
135
|
+
budget.left -= entries.length
|
|
136
|
+
const [first, ...rest] = entries
|
|
137
|
+
const line = formatEntry(text, { ...first.meta, id: entryId(first), merged: entries.map(entryId).join(' ') })
|
|
138
|
+
draft.set(first.file.file, draft.get(first.file.file).map(item => (item === first.file.lines[first.line] ? line : item)))
|
|
139
|
+
rest.forEach(take)
|
|
140
|
+
entries.forEach(entry => { retire(entry, 'merged', { into: entryId(first) }); used.add(entry) })
|
|
141
|
+
counts.merged += entries.length
|
|
142
|
+
items.push({ op: 'merged', text })
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
const entry = pick(change?.entry)
|
|
146
|
+
if (entry === undefined || used.has(entry) || budget.left < 1) continue
|
|
147
|
+
if (op === 'retire') {
|
|
148
|
+
budget.left -= 1
|
|
149
|
+
take(entry)
|
|
150
|
+
retire(entry, String(change.why ?? '').slice(0, NOTE_MAX) || 'retired')
|
|
151
|
+
used.add(entry)
|
|
152
|
+
counts.retired += 1
|
|
153
|
+
items.push({ op: 'retired', text: entry.text })
|
|
154
|
+
} else if (op === 'move' && entry.file.topic === undefined) {
|
|
155
|
+
const name = cleanTopic(change.topic)
|
|
156
|
+
if (name === undefined) continue
|
|
157
|
+
budget.left -= 1
|
|
158
|
+
const line = entry.file.lines[entry.line]
|
|
159
|
+
take(entry)
|
|
160
|
+
const base = draft.get(`${name}.md`) ?? parseMemory(`# ${name}\n`).lines
|
|
161
|
+
draft.set(`${name}.md`, insertEntry(base, line))
|
|
162
|
+
if (!parseMemory(joinLines(draft.get(MAIN))).links.includes(name)) draft.set(MAIN, addLink(draft.get(MAIN), name))
|
|
163
|
+
used.add(entry)
|
|
164
|
+
counts.moved += 1
|
|
165
|
+
items.push({ op: 'moved', text: entry.text, topic: name })
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const dir = dirOf(job.scope)
|
|
170
|
+
const touched = []
|
|
171
|
+
const steps = []
|
|
172
|
+
const before = new Map(files.map(file => [file.file, file.content]))
|
|
173
|
+
for (const [file, lines] of draft) {
|
|
174
|
+
const emptied = file !== MAIN && lines.every(line => line.trim() === '' || HEADING.test(line) || /^>\s/.test(line))
|
|
175
|
+
const was = before.get(file) ?? null
|
|
176
|
+
const now = emptied ? null : lines.length === 0 ? '' : joinLines(lines)
|
|
177
|
+
if (was === now || (was === '' && lines.length === 0)) continue
|
|
178
|
+
touched.push({ file, before: was, after: now })
|
|
179
|
+
}
|
|
180
|
+
for (const item of touched.filter(entry => entry.after === null)) {
|
|
181
|
+
const name = item.file.slice(0, -3)
|
|
182
|
+
const lines = removeLink(draft.get(MAIN), name)
|
|
183
|
+
draft.set(MAIN, lines)
|
|
184
|
+
}
|
|
185
|
+
const mainNow = joinLines(draft.get(MAIN))
|
|
186
|
+
const mainItem = touched.find(item => item.file === MAIN)
|
|
187
|
+
if (mainItem !== undefined) mainItem.after = mainNow
|
|
188
|
+
else if (main.content !== mainNow && main.lines.length > 0) touched.push({ file: MAIN, before: main.content, after: mainNow })
|
|
189
|
+
const record = { time, day, ...(prior?.batch === undefined ? {} : { previous: prior.batch }) }
|
|
190
|
+
const stamp = { ...record, ...counts, ...(touched.length === 0 ? {} : { batch, items: items.slice(0, ITEMS_SHOWN) }) }
|
|
191
|
+
if (touched.length === 0) {
|
|
192
|
+
if (rt.readOnly() === undefined) await writeText(statePath(job.scope), JSON.stringify(stamp))
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
for (const item of touched) steps.push(item.after === null ? removeStep(join(dir, item.file)) : writeStep(join(dir, item.file), item.after))
|
|
196
|
+
const retiredPath = join(dir, RETIRED)
|
|
197
|
+
let log = ''
|
|
198
|
+
try { log = readFileSync(retiredPath, 'utf8') } catch { /* none yet */ }
|
|
199
|
+
if (retired.length > 0) steps.push(writeStep(retiredPath, `${log}${retired.map(row => JSON.stringify(row)).join('\n')}\n`))
|
|
200
|
+
steps.push(writeStep(batchPath(job.scope, batch), JSON.stringify({ batch, scope: key, files: touched, retiredBefore: log })))
|
|
201
|
+
steps.push(writeStep(statePath(job.scope), JSON.stringify(stamp)))
|
|
202
|
+
await publish({ op: 'tidy', scope: key, steps, row: { op: 'tidy', scope: key, batch, ...counts, actor: job.keeper.id, actorName: job.keeper.name, auto: true } })
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// A batch comes back whole, or not at all: a file changed since is left as it reads.
|
|
206
|
+
async function undoTidy(scope, batch) {
|
|
207
|
+
const key = scopeKey(scope)
|
|
208
|
+
await queued(async () => {
|
|
209
|
+
await recoverPending()
|
|
210
|
+
writable(scope, USER)
|
|
211
|
+
const last = doneOf(scope)
|
|
212
|
+
if (batch === '' || last?.batch !== batch) throw new Error('That tidy is not the latest one of this memory.')
|
|
213
|
+
const saved = JSON.parse(readFileSync(batchPath(scope, batch), 'utf8'))
|
|
214
|
+
const dir = dirOf(scope)
|
|
215
|
+
const read = (file) => { try { return readFileSync(join(dir, file), 'utf8') } catch { return null } }
|
|
216
|
+
if (saved.files.some(item => read(item.file) !== item.after)) throw new Error('This memory has changed since that tidy, so it cannot be undone as a whole.')
|
|
217
|
+
const steps = saved.files.map(item => (item.before === null ? removeStep(join(dir, item.file)) : writeStep(join(dir, item.file), item.before)))
|
|
218
|
+
steps.push(saved.retiredBefore === '' ? removeStep(join(dir, RETIRED)) : writeStep(join(dir, RETIRED), saved.retiredBefore))
|
|
219
|
+
const { batch: _gone, items: _items, ...rest } = last
|
|
220
|
+
steps.push(writeStep(statePath(scope), JSON.stringify({ ...rest, undone: batch })))
|
|
221
|
+
await publish({ op: 'tidy-undo', scope: key, steps, row: { op: 'tidy-undo', scope: key, batch, actor: 'user', actorName: 'user' } })
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const tidyView = (scopes) => {
|
|
226
|
+
const latest = Object.entries(scopes).map(([tag, scope]) => ({ tag, ...doneOf(scope) })).filter(item => item.batch !== undefined).sort((a, b) => String(b.time).localeCompare(String(a.time)))[0]
|
|
227
|
+
return latest === undefined ? null : { scope: latest.tag, batch: latest.batch, at: latest.time, merged: latest.merged, moved: latest.moved, retired: latest.retired, items: latest.items ?? [] }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// One memory at a time, and only one that changed since its last tidy, on a new day.
|
|
231
|
+
const due = (job) => {
|
|
232
|
+
const last = doneOf(job.scope)
|
|
233
|
+
return last?.day !== today() && changedSince(scopeKey(job.scope), last?.time)
|
|
234
|
+
}
|
|
235
|
+
let running = false
|
|
236
|
+
async function tick() {
|
|
237
|
+
if (running || rt.readOnly() !== undefined) return
|
|
238
|
+
running = true
|
|
239
|
+
try {
|
|
240
|
+
await rt.load()
|
|
241
|
+
const job = jobs().find(due)
|
|
242
|
+
if (job !== undefined) await tidy(job)
|
|
243
|
+
} catch (error) {
|
|
244
|
+
rt.warn('ds-bot: the memory tidy failed: %s', error?.message ?? error)
|
|
245
|
+
} finally {
|
|
246
|
+
running = false
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (config.memoryTidy !== false) {
|
|
251
|
+
ctx.effect(() => {
|
|
252
|
+
const timer = setInterval(() => void tick(), config.memoryTidyEveryMs ?? EVERY_MS)
|
|
253
|
+
timer.unref?.()
|
|
254
|
+
return () => clearInterval(timer)
|
|
255
|
+
}, 'ds-bot: memory tidy')
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const tidyNow = async (key) => {
|
|
259
|
+
await rt.load()
|
|
260
|
+
const job = jobs().find(item => scopeKey(item.scope) === key)
|
|
261
|
+
if (job !== undefined && doneOf(job.scope)?.day !== today()) await tidy(job)
|
|
262
|
+
}
|
|
263
|
+
return { tidyView, undoTidy, tidyNow }
|
|
264
|
+
}
|