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.
Files changed (122) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +262 -3
  3. package/client.js +30079 -0
  4. package/cordis.patch.yml +26 -0
  5. package/index.js +94 -0
  6. package/office.js +8908 -0
  7. package/package.json +80 -6
  8. package/src/host/agents.js +129 -0
  9. package/src/host/approvals.js +116 -0
  10. package/src/host/avatar-library.js +131 -0
  11. package/src/host/avatar.js +107 -0
  12. package/src/host/browser/gate.js +3 -0
  13. package/src/host/browser/loop.js +95 -0
  14. package/src/host/browser/mount.js +177 -0
  15. package/src/host/browser/refs.js +151 -0
  16. package/src/host/browser/script-child.js +136 -0
  17. package/src/host/browser/script-runner.js +77 -0
  18. package/src/host/browser/script.js +39 -0
  19. package/src/host/browser/session-model.js +205 -0
  20. package/src/host/browser/session.js +757 -0
  21. package/src/host/browser/task.js +64 -0
  22. package/src/host/browser-plugins.js +174 -0
  23. package/src/host/browser.js +287 -0
  24. package/src/host/btw.js +372 -0
  25. package/src/host/capabilities.js +545 -0
  26. package/src/host/channel.js +773 -0
  27. package/src/host/chat-index.js +293 -0
  28. package/src/host/computer/catalog.js +91 -0
  29. package/src/host/computer/index.js +1272 -0
  30. package/src/host/computer/leases.js +71 -0
  31. package/src/host/computer/mac-helper.js +68 -0
  32. package/src/host/connectors/catalog.js +96 -0
  33. package/src/host/connectors/gateway.js +264 -0
  34. package/src/host/connectors/index.js +863 -0
  35. package/src/host/connectors/mail/index.js +613 -0
  36. package/src/host/connectors/mail/presets.js +23 -0
  37. package/src/host/connectors/mail/transport.js +306 -0
  38. package/src/host/connectors/oauth.js +191 -0
  39. package/src/host/constants.js +101 -0
  40. package/src/host/delivery.js +175 -0
  41. package/src/host/diagnostics.js +257 -0
  42. package/src/host/dsh-update.js +172 -0
  43. package/src/host/feed.js +122 -0
  44. package/src/host/files.js +27 -0
  45. package/src/host/folders.js +230 -0
  46. package/src/host/goal-skills.js +48 -0
  47. package/src/host/goals.js +812 -0
  48. package/src/host/group-log.js +72 -0
  49. package/src/host/groups.js +1613 -0
  50. package/src/host/handoffs.js +436 -0
  51. package/src/host/home-lock.js +98 -0
  52. package/src/host/im/format.js +190 -0
  53. package/src/host/im/index.js +943 -0
  54. package/src/host/im/platforms/dingtalk.js +249 -0
  55. package/src/host/im/platforms/discord.js +234 -0
  56. package/src/host/im/platforms/feishu.js +400 -0
  57. package/src/host/im/platforms/index.js +12 -0
  58. package/src/host/im/platforms/qq.js +251 -0
  59. package/src/host/im/platforms/retry.js +13 -0
  60. package/src/host/im/platforms/slack.js +210 -0
  61. package/src/host/im/platforms/telegram.js +256 -0
  62. package/src/host/im/platforms/wecom.js +240 -0
  63. package/src/host/im/platforms/weixin.js +364 -0
  64. package/src/host/images.js +227 -0
  65. package/src/host/learning.js +799 -0
  66. package/src/host/legacy.mjs +4 -0
  67. package/src/host/links.js +39 -0
  68. package/src/host/local-agents/acp.js +282 -0
  69. package/src/host/local-agents/adapter.js +650 -0
  70. package/src/host/local-agents/claude.js +251 -0
  71. package/src/host/local-agents/codex.js +219 -0
  72. package/src/host/local-agents/discover.js +166 -0
  73. package/src/host/local-agents/engines.js +91 -0
  74. package/src/host/local-agents/index.js +273 -0
  75. package/src/host/local-agents/jsonrpc.js +114 -0
  76. package/src/host/local-agents/mcp.js +141 -0
  77. package/src/host/memory-copy.js +179 -0
  78. package/src/host/memory-entries.js +298 -0
  79. package/src/host/memory-pending.js +95 -0
  80. package/src/host/memory-review.js +406 -0
  81. package/src/host/memory-search.js +29 -0
  82. package/src/host/memory-tidy.js +264 -0
  83. package/src/host/memory.js +1743 -0
  84. package/src/host/models.js +330 -0
  85. package/src/host/office-skills.js +78 -0
  86. package/src/host/parts.js +1765 -0
  87. package/src/host/permissions.js +117 -0
  88. package/src/host/privacy.js +368 -0
  89. package/src/host/prompt.js +73 -0
  90. package/src/host/providers.js +351 -0
  91. package/src/host/requests.js +141 -0
  92. package/src/host/resume.js +467 -0
  93. package/src/host/roster.js +949 -0
  94. package/src/host/schedules.js +248 -0
  95. package/src/host/secrets.js +586 -0
  96. package/src/host/side.js +406 -0
  97. package/src/host/skills/goal-grill/SKILL.md +37 -0
  98. package/src/host/steer.js +265 -0
  99. package/src/host/steward.js +165 -0
  100. package/src/host/store.js +631 -0
  101. package/src/host/text.js +49 -0
  102. package/src/host/thinking.js +55 -0
  103. package/src/host/time-context.js +46 -0
  104. package/src/host/tools.js +1235 -0
  105. package/src/host/turns.js +1242 -0
  106. package/src/host/update.js +318 -0
  107. package/src/host/usage.js +653 -0
  108. package/src/office/check.js +329 -0
  109. package/src/office/cli.js +193 -0
  110. package/src/office/docx.js +648 -0
  111. package/src/office/package.js +238 -0
  112. package/src/office/png.js +183 -0
  113. package/src/office/pptx.js +410 -0
  114. package/src/office/skills/office/SKILL.md +18 -0
  115. package/src/office/skills/office-tools/SKILL.md +41 -0
  116. package/src/office/xlsx.js +134 -0
  117. package/src/office/xml.js +128 -0
  118. package/src/office/zip.js +146 -0
  119. package/src/shared/group-replies.js +69 -0
  120. package/src/shared/im-platforms.js +86 -0
  121. package/src/shared/mentions.js +72 -0
  122. 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
+ }