dsh-capyreporter 0.1.0
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 +21 -0
- package/README.md +60 -0
- package/README.zh.md +60 -0
- package/assets/capybara.png +0 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.js +567 -0
- package/lib/index.js +494 -0
- package/package.json +69 -0
- package/runtime/electron-helper/index.html +160 -0
- package/runtime/electron-helper/main.js +140 -0
- package/runtime/electron-helper/package.json +5 -0
- package/runtime/electron-helper/preload.js +17 -0
- package/runtime/electron-helper/renderer.js +249 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
// dsh-capyreporter — host half.
|
|
2
|
+
// A persistent floating desktop pet bundle. Host serves the pet image + activity
|
|
3
|
+
// over webServer routes, tracks running tasks from host events, and spawns an
|
|
4
|
+
// always-on-top transparent Electron window that shows the pet over other apps.
|
|
5
|
+
|
|
6
|
+
import { spawn } from 'node:child_process'
|
|
7
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import { readFile } from 'node:fs/promises'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
|
|
12
|
+
const name = 'capyreporter'
|
|
13
|
+
const inject = ['webServer']
|
|
14
|
+
|
|
15
|
+
const PACKAGE_ROOT = fileURLToPath(new URL('..', import.meta.url))
|
|
16
|
+
const ROUTE = '/dsh-capyreporter'
|
|
17
|
+
const HELPER_MAIN = join(PACKAGE_ROOT, 'runtime', 'electron-helper', 'main.js')
|
|
18
|
+
|
|
19
|
+
function dshHome() {
|
|
20
|
+
const u = process.env.USERPROFILE || process.env.HOME || ''
|
|
21
|
+
return process.env.DSH_HOME || (u ? u + '\\.dsh' : '.dsh')
|
|
22
|
+
}
|
|
23
|
+
const CONFIG_PATH = join(dshHome(), 'capyreporter.json')
|
|
24
|
+
|
|
25
|
+
const state = {
|
|
26
|
+
enabled: true,
|
|
27
|
+
customImage: null, // base64 payload (after the data: prefix), or null for default
|
|
28
|
+
desktopEnabled: true,
|
|
29
|
+
scale: 1, // uniform pet size multiplier shared by the page overlay and the desktop window
|
|
30
|
+
desktopRunning: false,
|
|
31
|
+
helperChild: null,
|
|
32
|
+
defaultBuffer: null,
|
|
33
|
+
completed: null,
|
|
34
|
+
running: new Map(),
|
|
35
|
+
workspaceNames: new Map(), // normalized workspace path -> workspace title (project name)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function loadConfig() {
|
|
39
|
+
// Fresh installs read the new config path; local upgrades migrate their
|
|
40
|
+
// settings (e.g. the custom pet image) from the old homura-pet.json.
|
|
41
|
+
try {
|
|
42
|
+
const p = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'))
|
|
43
|
+
if (typeof p.enabled === 'boolean') state.enabled = p.enabled
|
|
44
|
+
if (typeof p.customImage === 'string' && p.customImage) state.customImage = p.customImage
|
|
45
|
+
if (typeof p.desktopEnabled === 'boolean') state.desktopEnabled = p.desktopEnabled
|
|
46
|
+
if (typeof p.scale === 'number' && Number.isFinite(p.scale)) state.scale = Math.min(2.5, Math.max(0.5, p.scale))
|
|
47
|
+
return
|
|
48
|
+
} catch (e) {}
|
|
49
|
+
try {
|
|
50
|
+
const p = JSON.parse(readFileSync(join(dshHome(), 'homura-pet.json'), 'utf8'))
|
|
51
|
+
if (typeof p.enabled === 'boolean') state.enabled = p.enabled
|
|
52
|
+
if (typeof p.customImage === 'string' && p.customImage) state.customImage = p.customImage
|
|
53
|
+
if (typeof p.desktopEnabled === 'boolean') state.desktopEnabled = p.desktopEnabled
|
|
54
|
+
if (typeof p.scale === 'number' && Number.isFinite(p.scale)) state.scale = Math.min(2.5, Math.max(0.5, p.scale))
|
|
55
|
+
} catch (e) {}
|
|
56
|
+
}
|
|
57
|
+
function saveConfig() {
|
|
58
|
+
try {
|
|
59
|
+
writeFileSync(CONFIG_PATH, JSON.stringify({ enabled: state.enabled, customImage: state.customImage, desktopEnabled: state.desktopEnabled, scale: state.scale }, null, 2), 'utf8')
|
|
60
|
+
} catch (e) {}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function resolveElectron() {
|
|
64
|
+
const u = process.env.USERPROFILE || process.env.HOME || ''
|
|
65
|
+
const local = process.env.LOCALAPPDATA || (u ? u + '/AppData/Local' : '')
|
|
66
|
+
const appData = process.env.APPDATA || (u ? u + '/AppData/Roaming' : '')
|
|
67
|
+
const list = [
|
|
68
|
+
process.env.DSH_PET_ELECTRON_PATH,
|
|
69
|
+
process.env.ELECTRON_PATH,
|
|
70
|
+
join(dshHome(), 'electron', 'electron.exe'),
|
|
71
|
+
join(appData, 'npm', 'node_modules', 'electron', 'dist', 'electron.exe'),
|
|
72
|
+
join(local, 'Programs', 'Electron', 'electron.exe'),
|
|
73
|
+
'C:/Program Files/Electron/electron.exe',
|
|
74
|
+
'C:/Program Files (x86)/Electron/electron.exe',
|
|
75
|
+
]
|
|
76
|
+
for (const c of list) if (c && existsSync(c)) return c
|
|
77
|
+
return undefined
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function startHelper() {
|
|
81
|
+
if (state.helperChild || state.desktopEnabled !== true || !state.enabled) return
|
|
82
|
+
const exe = resolveElectron()
|
|
83
|
+
if (!exe || !existsSync(HELPER_MAIN)) return
|
|
84
|
+
const baseUrl = process.env.DSH_CAPYREP_BASE_URL || 'http://127.0.0.1:3080'
|
|
85
|
+
const child = spawn(exe, [HELPER_MAIN], {
|
|
86
|
+
cwd: join(PACKAGE_ROOT, 'runtime', 'electron-helper'),
|
|
87
|
+
env: { ...process.env, DSH_CAPYREP_BASE_URL: baseUrl },
|
|
88
|
+
stdio: 'ignore',
|
|
89
|
+
windowsHide: true,
|
|
90
|
+
})
|
|
91
|
+
state.helperChild = child
|
|
92
|
+
state.desktopRunning = true
|
|
93
|
+
child.on('exit', () => {
|
|
94
|
+
if (state.helperChild === child) {
|
|
95
|
+
state.helperChild = null
|
|
96
|
+
state.desktopRunning = false
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
child.on('error', () => {
|
|
100
|
+
if (state.helperChild === child) {
|
|
101
|
+
state.helperChild = null
|
|
102
|
+
state.desktopRunning = false
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function stopHelper() {
|
|
108
|
+
if (state.helperChild) {
|
|
109
|
+
try {
|
|
110
|
+
state.helperChild.kill()
|
|
111
|
+
} catch (e) {}
|
|
112
|
+
}
|
|
113
|
+
state.helperChild = null
|
|
114
|
+
state.desktopRunning = false
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function sendJson(res, status, obj) {
|
|
118
|
+
const body = JSON.stringify(obj)
|
|
119
|
+
res.writeHead(status, {
|
|
120
|
+
'content-type': 'application/json; charset=utf-8',
|
|
121
|
+
'cache-control': 'no-store',
|
|
122
|
+
'access-control-allow-origin': '*',
|
|
123
|
+
'content-length': Buffer.byteLength(body),
|
|
124
|
+
})
|
|
125
|
+
res.end(body)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function readBody(req) {
|
|
129
|
+
return new Promise((resolve, reject) => {
|
|
130
|
+
let data = ''
|
|
131
|
+
req.on('data', (c) => {
|
|
132
|
+
data += c
|
|
133
|
+
if (data.length > 8_000_000) req.destroy()
|
|
134
|
+
})
|
|
135
|
+
req.on('end', () => resolve(data))
|
|
136
|
+
req.on('error', reject)
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function asText(v, max) {
|
|
141
|
+
if (typeof v !== 'string') return null
|
|
142
|
+
const t = v.replace(/\s+/g, ' ').trim()
|
|
143
|
+
return t ? t.slice(0, max) : null
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function titleOf(snap) {
|
|
147
|
+
if (!snap || typeof snap !== 'object') return null
|
|
148
|
+
const keys = ['title', 'displayTitle', 'name', 'label', 'summary', 'text']
|
|
149
|
+
for (let i = 0; i < keys.length; i++) {
|
|
150
|
+
const t = asText(snap[keys[i]], 80)
|
|
151
|
+
if (t) return t
|
|
152
|
+
}
|
|
153
|
+
return null
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Extract the text-bearing payload of a session event. Streaming deltas
|
|
157
|
+
// ('delta'/'chunk') accumulate; whole-message payloads ('text'/'output'/message.text)
|
|
158
|
+
// replace. Returns null when the event carries no readable text.
|
|
159
|
+
function extractEventText(ev) {
|
|
160
|
+
if (!ev || typeof ev !== 'object') return null
|
|
161
|
+
for (const k of ['delta', 'chunk']) {
|
|
162
|
+
const t = asText(ev[k], 4000)
|
|
163
|
+
if (t) return { text: t, append: true }
|
|
164
|
+
}
|
|
165
|
+
for (const k of ['text', 'output', 'preview']) {
|
|
166
|
+
const t = asText(ev[k], 4000)
|
|
167
|
+
if (t) return { text: t, append: false }
|
|
168
|
+
}
|
|
169
|
+
const m = ev.message
|
|
170
|
+
if (m && typeof m === 'object') {
|
|
171
|
+
const t = asText(m.text, 4000)
|
|
172
|
+
if (t) return { text: t, append: false }
|
|
173
|
+
}
|
|
174
|
+
return null
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Concise tail preview: whitespace collapsed, last ~120 chars, cut at a word
|
|
178
|
+
// boundary, prefixed with an ellipsis when something was dropped.
|
|
179
|
+
function tailPreview(s) {
|
|
180
|
+
const t = (s || '').replace(/\s+/g, ' ').trim()
|
|
181
|
+
if (!t) return null
|
|
182
|
+
if (t.length <= 120) return t
|
|
183
|
+
const tail = t.slice(t.length - 120)
|
|
184
|
+
const sp = tail.indexOf(' ')
|
|
185
|
+
return '\u2026' + (sp >= 0 ? tail.slice(sp + 1) : tail)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Summarize a trajectory-style session event as a short status line, e.g.
|
|
189
|
+
// "🔧 pwsh", "💭 writing…", "🚀 workflow phase", "🧭 steering", "⚠️ retry".
|
|
190
|
+
// Returns { kind, line } or null when the event carries no recognizable step.
|
|
191
|
+
function stepLineOf(ev) {
|
|
192
|
+
try {
|
|
193
|
+
if (!ev || typeof ev !== 'object') return null
|
|
194
|
+
const kd = String(ev.kind || ev.type || '').toLowerCase()
|
|
195
|
+
const tool =
|
|
196
|
+
(ev.tool && typeof ev.tool === 'object' ? ev.tool.name : null) ||
|
|
197
|
+
(typeof ev.tool === 'string' ? ev.tool : null) ||
|
|
198
|
+
(typeof ev.toolName === 'string' ? ev.toolName : null) ||
|
|
199
|
+
(typeof ev.name === 'string' ? ev.name : null)
|
|
200
|
+
const sub = asText(ev.title || ev.summary || ev.commandName || ev.phase, 44)
|
|
201
|
+
if (kd.includes('tool') || tool) {
|
|
202
|
+
return { kind: 'tool', line: '\uD83D\uDD27 ' + (tool || 'tool') + (sub ? ' \u00B7 ' + sub : '') }
|
|
203
|
+
}
|
|
204
|
+
if (kd.includes('error')) return { kind: 'error', line: '\u26A0\uFE0F ' + (sub || 'error') }
|
|
205
|
+
if (kd.includes('retry')) return { kind: 'retry', line: '\uD83D\uDD01 retry' + (sub ? ' \u00B7 ' + sub : '') }
|
|
206
|
+
if (kd.includes('steering')) return { kind: 'steering', line: '\uD83E\uDDED steering' + (sub ? ' \u00B7 ' + sub : '') }
|
|
207
|
+
if (kd.includes('workflow')) return { kind: 'workflow', line: '\uD83D\uDE80 ' + (sub || 'workflow phase') }
|
|
208
|
+
if (kd.includes('command')) return { kind: 'command', line: '\u2328\uFE0F command' + (sub ? ' \u00B7 ' + sub : '') }
|
|
209
|
+
if (kd.includes('assistant')) return { kind: 'assistant', line: '\uD83D\uDCAD writing\u2026' }
|
|
210
|
+
if (kd.includes('compaction') || kd.includes('context')) return { kind: 'context', line: '\uD83D\uDCC4 ' + (sub || 'context') }
|
|
211
|
+
return null
|
|
212
|
+
} catch (e) {
|
|
213
|
+
return null
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function apply(ctx) {
|
|
218
|
+
loadConfig()
|
|
219
|
+
|
|
220
|
+
// ---- workspace (project) name resolution ---------------------------------
|
|
221
|
+
const norm = (p) => String(p || '').replace(/\\/g, '/').replace(/\/+$/, '')
|
|
222
|
+
const cwdOf = (s) =>
|
|
223
|
+
(s && s.header && s.header.cwd) || (s && s.cwd) || (s && s.meta && s.meta.cwd) || null
|
|
224
|
+
const refreshWorkspaces = async () => {
|
|
225
|
+
try {
|
|
226
|
+
const reg = typeof ctx.get === 'function' ? ctx.get('workspaceRegistry') : undefined
|
|
227
|
+
if (!reg || typeof reg.list !== 'function') return
|
|
228
|
+
const list = await reg.list()
|
|
229
|
+
if (!Array.isArray(list)) return
|
|
230
|
+
for (const w of list) {
|
|
231
|
+
if (!w || typeof w.path !== 'string') continue
|
|
232
|
+
const nm = asText(w.title || w.name, 80) || w.path.split(/[\\/]/).pop() || null
|
|
233
|
+
if (nm) state.workspaceNames.set(norm(w.path), nm)
|
|
234
|
+
}
|
|
235
|
+
} catch (e) {}
|
|
236
|
+
}
|
|
237
|
+
const workspaceNameFor = (cwd) => {
|
|
238
|
+
if (!cwd) return null
|
|
239
|
+
const n = state.workspaceNames.get(norm(cwd))
|
|
240
|
+
if (n) return n
|
|
241
|
+
refreshWorkspaces() // lazy refetch when a new project appears
|
|
242
|
+
return null
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const sessionTitleOf = (sessionOrId) => {
|
|
246
|
+
try {
|
|
247
|
+
const sessions = typeof ctx.get === 'function' ? ctx.get('sessions') : undefined
|
|
248
|
+
const titles = typeof ctx.get === 'function' ? ctx.get('sessionTitle') : undefined
|
|
249
|
+
if (!sessions || !titles) return null
|
|
250
|
+
const s = sessionOrId && typeof sessionOrId === 'object' ? sessionOrId : sessions.get(sessionOrId)
|
|
251
|
+
if (!s) return null
|
|
252
|
+
// Prefer the workspace (project) name — the label shown in the sidebar —
|
|
253
|
+
// so the bubble tells you WHICH project is reporting; fall back to the
|
|
254
|
+
// generated session title.
|
|
255
|
+
return workspaceNameFor(cwdOf(s)) || titleOf(titles.get(s))
|
|
256
|
+
} catch (e) {
|
|
257
|
+
return null
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
refreshWorkspaces()
|
|
262
|
+
try {
|
|
263
|
+
setTimeout(refreshWorkspaces, 8000) // second pass after the registry has settled
|
|
264
|
+
} catch (e) {}
|
|
265
|
+
|
|
266
|
+
ctx.on('agent/status', (payload) => {
|
|
267
|
+
try {
|
|
268
|
+
if (!payload) return
|
|
269
|
+
const a = payload.agent
|
|
270
|
+
const status = payload.status
|
|
271
|
+
if (!a) return
|
|
272
|
+
const key = a.id !== undefined ? a.id : a.sessionId !== undefined ? a.sessionId : a
|
|
273
|
+
if (status === 'running') {
|
|
274
|
+
let rec = state.running.get(key)
|
|
275
|
+
if (!rec) rec = { title: null, outText: '', log: [], lastKind: '', lastEventAt: Date.now(), running: false }
|
|
276
|
+
rec.running = true
|
|
277
|
+
rec.lastEventAt = Date.now()
|
|
278
|
+
if (!rec.title) rec.title = sessionTitleOf(key)
|
|
279
|
+
state.running.set(key, rec)
|
|
280
|
+
state.completed = null
|
|
281
|
+
} else if (status === 'idle' || status === 'disposed' || status === 'stopped' || status === 'error') {
|
|
282
|
+
const rec = state.running.get(key)
|
|
283
|
+
if (rec && rec.running === true) {
|
|
284
|
+
state.completed = { title: rec.title || 'Task', at: Date.now(), dismissed: false }
|
|
285
|
+
}
|
|
286
|
+
state.running.delete(key)
|
|
287
|
+
}
|
|
288
|
+
} catch (e) {}
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
ctx.on('session/event', (session, event) => {
|
|
292
|
+
try {
|
|
293
|
+
const key = session && session.id !== undefined ? session.id : event && event.sessionId !== undefined ? event.sessionId : null
|
|
294
|
+
if (key === null || key === undefined) return
|
|
295
|
+
let rec = state.running.get(key)
|
|
296
|
+
if (!rec) rec = { title: null, outText: '', log: [], lastKind: '', lastEventAt: 0, running: false }
|
|
297
|
+
rec.lastEventAt = Date.now()
|
|
298
|
+
if (!rec.title) rec.title = sessionTitleOf(session || key)
|
|
299
|
+
const ex = extractEventText(event)
|
|
300
|
+
if (ex) rec.outText = ex.append ? (rec.outText + ex.text).slice(-600) : ex.text.slice(-600)
|
|
301
|
+
const sl = stepLineOf(event)
|
|
302
|
+
if (sl) {
|
|
303
|
+
if (rec.log.length && rec.lastKind === sl.kind) {
|
|
304
|
+
rec.log[rec.log.length - 1] = sl.line
|
|
305
|
+
} else {
|
|
306
|
+
rec.log.push(sl.line)
|
|
307
|
+
rec.lastKind = sl.kind
|
|
308
|
+
if (rec.log.length > 16) rec.log.shift()
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
state.running.set(key, rec)
|
|
312
|
+
} catch (e) {}
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
ctx.on('session/disposed', (session) => {
|
|
316
|
+
try {
|
|
317
|
+
if (session && session.id !== undefined) state.running.delete(session.id)
|
|
318
|
+
} catch (e) {}
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
ctx.effect(
|
|
322
|
+
() =>
|
|
323
|
+
ctx.webServer.register({
|
|
324
|
+
kind: 'prefix',
|
|
325
|
+
path: ROUTE,
|
|
326
|
+
handler: async (req, res) => {
|
|
327
|
+
try {
|
|
328
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
329
|
+
const rest = decodeURIComponent(url.pathname.slice(ROUTE.length + 1))
|
|
330
|
+
|
|
331
|
+
if (rest === 'image') {
|
|
332
|
+
if (req.method === 'GET') {
|
|
333
|
+
const buf = state.customImage ? Buffer.from(state.customImage, 'base64') : state.defaultBuffer
|
|
334
|
+
if (!buf) {
|
|
335
|
+
sendJson(res, 404, { error: 'no image available' })
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
res.writeHead(200, { 'content-type': 'image/png', 'cache-control': 'no-store', 'access-control-allow-origin': '*', 'content-length': buf.length })
|
|
339
|
+
res.end(buf)
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
if (req.method === 'POST') {
|
|
343
|
+
const body = await readBody(req)
|
|
344
|
+
let parsed
|
|
345
|
+
try {
|
|
346
|
+
parsed = JSON.parse(body)
|
|
347
|
+
} catch {
|
|
348
|
+
sendJson(res, 400, { error: 'invalid JSON body' })
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
const d = parsed && parsed.dataUrl
|
|
352
|
+
if (typeof d !== 'string' || d.slice(0, 11) !== 'data:image/') {
|
|
353
|
+
sendJson(res, 400, { error: 'expected an image data URL' })
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
if (d.length > 8_000_000) {
|
|
357
|
+
sendJson(res, 400, { error: 'image too large (keep it under ~4 MB)' })
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
const comma = d.indexOf(',')
|
|
361
|
+
state.customImage = comma >= 0 ? d.slice(comma + 1) : d
|
|
362
|
+
saveConfig()
|
|
363
|
+
sendJson(res, 200, { ok: true })
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
sendJson(res, 405, { error: 'method not allowed' })
|
|
367
|
+
return
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (rest === 'reset') {
|
|
371
|
+
state.customImage = null
|
|
372
|
+
saveConfig()
|
|
373
|
+
sendJson(res, 200, { ok: true })
|
|
374
|
+
return
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (rest === 'enabled') {
|
|
378
|
+
const body = await readBody(req)
|
|
379
|
+
let parsed
|
|
380
|
+
try {
|
|
381
|
+
parsed = JSON.parse(body)
|
|
382
|
+
} catch {
|
|
383
|
+
sendJson(res, 400, { error: 'invalid JSON body' })
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
state.enabled = !!(parsed && parsed.enabled)
|
|
387
|
+
if (!state.enabled) state.completed = null
|
|
388
|
+
saveConfig()
|
|
389
|
+
if (!state.enabled) stopHelper()
|
|
390
|
+
else startHelper()
|
|
391
|
+
sendJson(res, 200, { enabled: state.enabled })
|
|
392
|
+
return
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (rest === 'dismiss') {
|
|
396
|
+
const body = await readBody(req)
|
|
397
|
+
let at
|
|
398
|
+
try {
|
|
399
|
+
at = JSON.parse(body).at
|
|
400
|
+
} catch {}
|
|
401
|
+
if (state.completed && (at === undefined || at === state.completed.at)) state.completed.dismissed = true
|
|
402
|
+
sendJson(res, 200, { ok: true })
|
|
403
|
+
return
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (rest === 'desktop/hide') {
|
|
407
|
+
state.desktopEnabled = false
|
|
408
|
+
saveConfig()
|
|
409
|
+
stopHelper()
|
|
410
|
+
sendJson(res, 200, { ok: true })
|
|
411
|
+
return
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (rest === 'desktop/show') {
|
|
415
|
+
state.desktopEnabled = true
|
|
416
|
+
saveConfig()
|
|
417
|
+
startHelper()
|
|
418
|
+
sendJson(res, 200, { ok: true, desktop: !!state.desktopRunning })
|
|
419
|
+
return
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (rest === 'scale') {
|
|
423
|
+
const body = await readBody(req)
|
|
424
|
+
let parsed
|
|
425
|
+
try {
|
|
426
|
+
parsed = JSON.parse(body)
|
|
427
|
+
} catch {
|
|
428
|
+
sendJson(res, 400, { error: 'invalid JSON body' })
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
const s = Number(parsed && parsed.scale)
|
|
432
|
+
if (!Number.isFinite(s)) {
|
|
433
|
+
sendJson(res, 400, { error: 'expected a numeric scale' })
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
state.scale = Math.min(2.5, Math.max(0.5, s))
|
|
437
|
+
saveConfig()
|
|
438
|
+
sendJson(res, 200, { ok: true, scale: state.scale })
|
|
439
|
+
return
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (rest === 'activity') {
|
|
443
|
+
const now = Date.now()
|
|
444
|
+
if (state.completed && now - state.completed.at > 21600000) state.completed = null
|
|
445
|
+
const running = []
|
|
446
|
+
state.running.forEach((rec) => {
|
|
447
|
+
const fresh = rec.running === true || now - rec.lastEventAt < 15000
|
|
448
|
+
if (!fresh) return
|
|
449
|
+
running.push({
|
|
450
|
+
title: rec.title || 'Session',
|
|
451
|
+
preview: tailPreview(rec.outText),
|
|
452
|
+
lines: rec.log.slice(-12),
|
|
453
|
+
ageMs: Math.max(0, now - rec.lastEventAt),
|
|
454
|
+
})
|
|
455
|
+
})
|
|
456
|
+
running.sort((a, b) => a.ageMs - b.ageMs)
|
|
457
|
+
const completed = state.completed && !state.completed.dismissed ? { title: state.completed.title, at: state.completed.at } : null
|
|
458
|
+
sendJson(res, 200, { running, runningCount: running.length, completed })
|
|
459
|
+
return
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (rest === 'state') {
|
|
463
|
+
sendJson(res, 200, { enabled: state.enabled, hasCustom: !!state.customImage, desktop: !!state.desktopRunning, desktopEnabled: state.desktopEnabled, scale: state.scale })
|
|
464
|
+
return
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
sendJson(res, 404, { error: 'not found' })
|
|
468
|
+
} catch (e) {
|
|
469
|
+
try {
|
|
470
|
+
sendJson(res, 500, { error: String((e && e.message) || e) })
|
|
471
|
+
} catch {}
|
|
472
|
+
}
|
|
473
|
+
},
|
|
474
|
+
}),
|
|
475
|
+
'dsh-capyreporter: routes',
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
ctx.effect(
|
|
479
|
+
() => () => {
|
|
480
|
+
stopHelper()
|
|
481
|
+
},
|
|
482
|
+
'dsh-capyreporter: dispose',
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
readFile(join(PACKAGE_ROOT, 'assets', 'capybara.png'))
|
|
486
|
+
.then((b) => {
|
|
487
|
+
state.defaultBuffer = b
|
|
488
|
+
})
|
|
489
|
+
.catch(() => {})
|
|
490
|
+
|
|
491
|
+
if (state.enabled && state.desktopEnabled) startHelper()
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
export { apply, inject, name }
|
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-capyreporter",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CapyReporter — an always-on-top capybara task reporter for the DeepSeek Harness Web UI. A transparent floating pet that narrates each trajectory step in a speech bubble, tells you which project is reporting, alerts you when a task completes while you are away, and lets you upload your own pet art.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dsh",
|
|
7
|
+
"dsh-plugin",
|
|
8
|
+
"deepseek-harness",
|
|
9
|
+
"capybara",
|
|
10
|
+
"desktop-pet",
|
|
11
|
+
"pet",
|
|
12
|
+
"floating-widget",
|
|
13
|
+
"task-reporter",
|
|
14
|
+
"cordis",
|
|
15
|
+
"react"
|
|
16
|
+
],
|
|
17
|
+
"homepage": "https://github.com/linkbag/dsh-capyreporter#readme",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/linkbag/dsh-capyreporter.git"
|
|
21
|
+
},
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/linkbag/dsh-capyreporter/issues"
|
|
24
|
+
},
|
|
25
|
+
"type": "module",
|
|
26
|
+
"main": "lib/index.js",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"default": "./lib/index.js"
|
|
30
|
+
},
|
|
31
|
+
"./client": {
|
|
32
|
+
"default": "./lib/client.js"
|
|
33
|
+
},
|
|
34
|
+
"./package.json": "./package.json"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"lib",
|
|
38
|
+
"assets",
|
|
39
|
+
"runtime",
|
|
40
|
+
"cordis.patch.yml",
|
|
41
|
+
"README.md",
|
|
42
|
+
"README.zh.md",
|
|
43
|
+
"LICENSE"
|
|
44
|
+
],
|
|
45
|
+
"dsh": {
|
|
46
|
+
"bundle": {
|
|
47
|
+
"patch": "./cordis.patch.yml"
|
|
48
|
+
},
|
|
49
|
+
"client": {
|
|
50
|
+
"inject": [
|
|
51
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
52
|
+
"@deepseek-ai/dsh-client-connection"
|
|
53
|
+
],
|
|
54
|
+
"platform": "web"
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
59
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
|
|
60
|
+
"@deepseek-ai/dsh-client-connection": "^0.1.1-rc.2",
|
|
61
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.2",
|
|
62
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.1-rc.2",
|
|
63
|
+
"react": "^18.2.0"
|
|
64
|
+
},
|
|
65
|
+
"engines": {
|
|
66
|
+
"node": ">=20"
|
|
67
|
+
},
|
|
68
|
+
"license": "MIT"
|
|
69
|
+
}
|