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/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
+ }