@pointylab/dsh 0.0.0-stage → 0.4.3

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 (88) hide show
  1. package/ASSETS.md +20 -0
  2. package/LICENSE +21 -0
  3. package/README.md +85 -2
  4. package/cordis.patch.yml +13 -0
  5. package/lib/client.js +33 -0
  6. package/package.json +38 -4
  7. package/pet/app/card/card.css +365 -0
  8. package/pet/app/card/card.html +68 -0
  9. package/pet/app/card/card.js +896 -0
  10. package/pet/app/card/markdown.js +64 -0
  11. package/pet/app/card/mascot/blink.png +0 -0
  12. package/pet/app/card/mascot/done.png +0 -0
  13. package/pet/app/card/mascot/error.png +0 -0
  14. package/pet/app/card/mascot/idle.png +0 -0
  15. package/pet/app/card/mascot/peek.png +0 -0
  16. package/pet/app/card/mascot/think.png +0 -0
  17. package/pet/app/card/preload.js +21 -0
  18. package/pet/app/fullscreen.js +131 -0
  19. package/pet/app/main.js +1252 -0
  20. package/pet/app/native/uia-selection.ps1 +158 -0
  21. package/pet/app/notice/notice.html +37 -0
  22. package/pet/app/notice/notice.js +31 -0
  23. package/pet/app/notice/preload.js +7 -0
  24. package/pet/app/overlay/overlay.html +30 -0
  25. package/pet/app/overlay/overlay.js +42 -0
  26. package/pet/app/overlay/preload.js +7 -0
  27. package/pet/app/pill/pill.css +73 -0
  28. package/pet/app/pill/pill.html +11 -0
  29. package/pet/app/pill/pill.js +35 -0
  30. package/pet/app/pill/preload.js +8 -0
  31. package/pet/app/preload.js +16 -0
  32. package/pet/app/renderer/index.html +24 -0
  33. package/pet/app/renderer/lines.js +25 -0
  34. package/pet/app/renderer/pet.js +292 -0
  35. package/pet/app/renderer/renderer2d.js +71 -0
  36. package/pet/app/renderer/style.css +157 -0
  37. package/pet/app/selection.js +246 -0
  38. package/pet/app/shake.js +76 -0
  39. package/pet/app/uipick.js +314 -0
  40. package/pet/assets/cursor/cursor-32.cur +0 -0
  41. package/pet/assets/cursor/cursor-32.png +0 -0
  42. package/pet/assets/cursor/cursor-48.cur +0 -0
  43. package/pet/assets/cursor/cursor-48.png +0 -0
  44. package/pet/assets/cursor/cursor-64.cur +0 -0
  45. package/pet/assets/cursor/cursor-64.png +0 -0
  46. package/pet/assets/cursor/cursor-clean.png +0 -0
  47. package/pet/assets/cursor/make_cursor.py +85 -0
  48. package/pet/assets/cursor/source.png +0 -0
  49. package/pet/assets/pointy.ico +0 -0
  50. package/pet/assets/tray.png +0 -0
  51. package/pet/assets/tray@2x.png +0 -0
  52. package/pet/boot.js +6 -0
  53. package/pet/extension/background.js +252 -0
  54. package/pet/extension/cursor-32.png +0 -0
  55. package/pet/extension/cursor-48.png +0 -0
  56. package/pet/extension/cursor-64.png +0 -0
  57. package/pet/extension/icon.png +0 -0
  58. package/pet/extension/manifest.json +12 -0
  59. package/pet/extension/pick.js +391 -0
  60. package/pet/extension/popup.html +29 -0
  61. package/pet/extension/popup.js +30 -0
  62. package/pet/package.json +11 -0
  63. package/pet/vendor/koffi/LICENSE.txt +22 -0
  64. package/pet/vendor/koffi/build/koffi/win32_x64/koffi.exp +0 -0
  65. package/pet/vendor/koffi/build/koffi/win32_x64/koffi.lib +0 -0
  66. package/pet/vendor/koffi/build/koffi/win32_x64/koffi.node +0 -0
  67. package/pet/vendor/koffi/index.js +634 -0
  68. package/pet/vendor/koffi/indirect.js +533 -0
  69. package/pet/vendor/koffi/package.json +35 -0
  70. package/pet/vendor/ws/LICENSE +20 -0
  71. package/pet/vendor/ws/index.js +22 -0
  72. package/pet/vendor/ws/lib/buffer-util.js +142 -0
  73. package/pet/vendor/ws/lib/constants.js +19 -0
  74. package/pet/vendor/ws/lib/event-target.js +292 -0
  75. package/pet/vendor/ws/lib/extension.js +203 -0
  76. package/pet/vendor/ws/lib/limiter.js +55 -0
  77. package/pet/vendor/ws/lib/permessage-deflate.js +530 -0
  78. package/pet/vendor/ws/lib/receiver.js +743 -0
  79. package/pet/vendor/ws/lib/sender.js +610 -0
  80. package/pet/vendor/ws/lib/stream.js +161 -0
  81. package/pet/vendor/ws/lib/subprotocol.js +62 -0
  82. package/pet/vendor/ws/lib/validation.js +152 -0
  83. package/pet/vendor/ws/lib/websocket-server.js +562 -0
  84. package/pet/vendor/ws/lib/websocket.js +1426 -0
  85. package/pet/vendor/ws/package.json +74 -0
  86. package/pet/vendor/ws/wrapper.mjs +21 -0
  87. package/src/node.js +732 -0
  88. package/src/runtime.js +178 -0
package/src/node.js ADDED
@@ -0,0 +1,732 @@
1
+ /**
2
+ * @pointylab/dsh host half (Pointy).
3
+ *
4
+ * - Carries Pointy itself in ../pet and starts it on the first DSH run where it
5
+ * is not already running, after downloading its Electron runtime once
6
+ * (see runtime.js). Pointy quits on its own when DSH goes away.
7
+ *
8
+ * - Listens to `session/event`, `agent/status` and `agent/error` and pushes a
9
+ * compact pet state (thinking / running / editing / ... / done / error) to
10
+ * the pet over ws://127.0.0.1:<port>.
11
+ * - Registers a prepended `approval/request` waterfall listener: the request
12
+ * still goes to the DSH UI through `next()`, and the pet may answer first.
13
+ * When the pet wins, an AbortController merged into `request.signal` makes
14
+ * the gateway withdraw the UI prompt.
15
+ * - Delivers files dropped on the pet to the most recently active root session
16
+ * with `agent.followup()`.
17
+ * - Generates short pet lines through `ctx.llm`, reusing DSH's own model route
18
+ * and credentials; no key ever passes through this plugin.
19
+ *
20
+ * Both ends prove knowledge of the token in %USERPROFILE%\.pointy\token with
21
+ * an HMAC challenge before the plugin accepts approvals or files.
22
+ */
23
+ import { spawn } from 'node:child_process'
24
+ import crypto from 'node:crypto'
25
+ import fs from 'node:fs'
26
+ import os from 'node:os'
27
+ import path from 'node:path'
28
+ import { BlockAssembler, createUserMessage } from '@deepseek-ai/dsh-llm'
29
+ import { PET_APP_DIR, ensureRuntime } from './runtime.js'
30
+
31
+ export const name = 'pointy'
32
+
33
+ export const inject = ['llm', 'agents']
34
+
35
+ const PORT = Number(process.env.POINTY_PORT) || 47321
36
+ const PET_DIR = path.join(os.homedir(), '.pointy')
37
+ const TOKEN_FILE = path.join(PET_DIR, 'token')
38
+ const PET_LOG = path.join(PET_DIR, 'pet.log')
39
+ const PET_LOG_MAX = 256 * 1024
40
+ const RECONNECT_MS = 3000
41
+ /** Events older than this are replays (session load), not live activity. */
42
+ const STALE_EVENT_MS = 30_000
43
+ const LINE_MIN_INTERVAL_MS = 15_000
44
+ const LINE_TIMEOUT_MS = 15_000
45
+ const DEFAULT_ROUTE = { provider: 'deepseek-account', model: 'deepseek-flash' }
46
+ /** Private RPC channel the browser half (lib/client.js) polls for sessions to open. */
47
+ const RPC_CHANNEL = '/pointy'
48
+ const FOCUS_POLL_MS = 20_000
49
+ const FOCUS_PENDING_MS = 8_000
50
+ const MAX_DROPPED_FILES = 20
51
+ /** Desktop DSH runs plugins through dsh-desktop-host/lib/index.js; the web UI (`dsh <profile>`) goes through lib/cli.js. */
52
+ const DESKTOP = process.argv.some((a) => /dsh-desktop-host[\\/]lib[\\/]index\.js$/.test(a))
53
+ const PLUGIN_VERSION = (() => {
54
+ try { return JSON.parse(fs.readFileSync(path.join(PET_APP_DIR, '..', 'package.json'), 'utf8')).version } catch { return '?' }
55
+ })()
56
+
57
+ const PERSONA = [
58
+ '你是桌面助手 Pointy,一个穿女仆装的 Q 版鲸鱼娘。',
59
+ '她陪主人用 DeepSeek Harness 写代码。性格:认真、有点小傲娇、会吐槽但很贴心。',
60
+ '只输出一句中文台词,15 字以内,口语,不要引号、不要表情符号、不要解释。',
61
+ ].join('\n')
62
+
63
+ const STATE_HINT = {
64
+ idle: '主人没在让你干活,你在发呆',
65
+ thinking: '你正在想下一步怎么做',
66
+ running: '你在终端里执行命令',
67
+ editing: '你在改文件',
68
+ reading: '你在翻文件、搜代码',
69
+ web: '你在上网查资料',
70
+ waiting: '你在等主人批准一个操作',
71
+ error: '刚刚出错了',
72
+ done: '这一轮任务刚做完',
73
+ sleep: '太久没事干,你困了',
74
+ poke: '主人戳了你一下',
75
+ files: '主人把文件拖给了你',
76
+ }
77
+
78
+ /** Map a DSH tool name to the pet's activity state. */
79
+ function classifyTool(toolName) {
80
+ const n = toolName.toLowerCase()
81
+ if (/^(pwsh|bash|terminal|shell|run_code|job_|ptc)/.test(n)) return 'running'
82
+ if (/^(write|edit|str_replace|apply_patch|multi_edit)/.test(n)) return 'editing'
83
+ if (/^(web|fetch)/.test(n)) return 'web'
84
+ if (/^(read|glob|grep|list|search|find|ls)/.test(n)) return 'reading'
85
+ return 'thinking'
86
+ }
87
+
88
+ /** A one-line, display-only summary of a tool call; never the full arguments. */
89
+ function summarizeCall(toolName, rawArgs) {
90
+ let args
91
+ try { args = JSON.parse(rawArgs) } catch { return '' }
92
+ if (args === null || typeof args !== 'object') return ''
93
+ const pick = args.description ?? args.command ?? args.path ?? args.file_path ?? args.url ?? args.query ?? args.pattern
94
+ return typeof pick === 'string' ? pick.replace(/\s+/g, ' ').slice(0, 160) : ''
95
+ }
96
+
97
+ function textOf(content) {
98
+ if (!Array.isArray(content)) return ''
99
+ return content.filter(c => c?.type === 'text').map(c => c.text).join('\n')
100
+ }
101
+
102
+ function hmac(token, data) {
103
+ return crypto.createHmac('sha256', token).update(data).digest('hex')
104
+ }
105
+
106
+ function safeEqual(a, b) {
107
+ return typeof a === 'string' && typeof b === 'string' && a.length === b.length &&
108
+ crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b))
109
+ }
110
+
111
+ /** The pet's console output, started fresh once it grows past PET_LOG_MAX. */
112
+ function openPetLog() {
113
+ fs.mkdirSync(PET_DIR, { recursive: true })
114
+ const size = fs.statSync(PET_LOG, { throwIfNoEntry: false })?.size ?? 0
115
+ return fs.openSync(PET_LOG, size > PET_LOG_MAX ? 'w' : 'a')
116
+ }
117
+
118
+ /** Spawn the bundled pet detached on the downloaded Electron runtime. */
119
+ async function launchPet(logger) {
120
+ let exe
121
+ try {
122
+ exe = await ensureRuntime(logger)
123
+ } catch (error) {
124
+ return logger.warn(`pointy: cannot start Pointy: ${error.message}`)
125
+ }
126
+ logger.info(`pointy: starting Pointy: ${exe} ${PET_APP_DIR}`)
127
+ // The DSH host runs Electron in node mode; the pet must not inherit that.
128
+ const env = { ...process.env, POINTY_MANAGED: '1' }
129
+ delete env.ELECTRON_RUN_AS_NODE
130
+ delete env.NODE_OPTIONS
131
+ try {
132
+ // stdout/stderr must not be the NUL device: koffi flushes stderr at exit, FlushFileBuffers
133
+ // fails on NUL, and koffi's error path re-locks the stream mutex and aborts (0xC0000409).
134
+ const out = openPetLog()
135
+ const child = spawn(exe, [PET_APP_DIR], { cwd: PET_APP_DIR, env, detached: true, stdio: ['ignore', out, out] })
136
+ fs.closeSync(out)
137
+ child.on('error', (error) => logger.warn(`pointy: failed to start Pointy: ${error.message}`))
138
+ child.unref()
139
+ } catch (error) {
140
+ logger.warn(`pointy: failed to start Pointy: ${error.message}`)
141
+ }
142
+ }
143
+
144
+ /** Reconnecting, HMAC-authenticated WebSocket client to the pet. */
145
+ class PetLink {
146
+ constructor(logger, onMessage) {
147
+ this.logger = logger
148
+ this.onMessage = onMessage
149
+ this.ws = undefined
150
+ this.authed = false
151
+ this.stopped = false
152
+ this.timer = undefined
153
+ this.onReady = () => {}
154
+ this.onLost = () => {}
155
+ /** Called when nothing is listening on the port, i.e. the pet is not running. */
156
+ this.onUnreachable = () => {}
157
+ }
158
+
159
+ start() {
160
+ this.stopped = false
161
+ this.connect()
162
+ }
163
+
164
+ stop() {
165
+ this.stopped = true
166
+ clearTimeout(this.timer)
167
+ this.ws?.close()
168
+ }
169
+
170
+ connect() {
171
+ if (this.stopped) return
172
+ let token
173
+ try { token = fs.readFileSync(TOKEN_FILE, 'utf8').trim() } catch {
174
+ this.onUnreachable()
175
+ return this.retry()
176
+ }
177
+ const ws = new WebSocket(`ws://127.0.0.1:${PORT}`)
178
+ const nonce = crypto.randomBytes(16).toString('hex')
179
+ let opened = false
180
+ this.ws = ws
181
+ ws.addEventListener('open', () => { opened = true })
182
+ ws.addEventListener('message', (ev) => {
183
+ let msg
184
+ try { msg = JSON.parse(String(ev.data)) } catch { return }
185
+ if (msg.type === 'challenge') {
186
+ ws.send(JSON.stringify({ type: 'hello', v: 1, nonce, proof: hmac(token, `plugin|${msg.nonce}`) }))
187
+ } else if (msg.type === 'hello-ok') {
188
+ if (!safeEqual(msg.proof, hmac(token, `pet|${nonce}`))) {
189
+ this.logger.warn('pointy: pet failed authentication, closing')
190
+ ws.close()
191
+ return
192
+ }
193
+ this.authed = true
194
+ this.onReady()
195
+ } else if (this.authed) {
196
+ this.onMessage(msg)
197
+ }
198
+ })
199
+ ws.addEventListener('close', () => {
200
+ const wasAuthed = this.authed
201
+ this.authed = false
202
+ if (this.ws === ws) this.ws = undefined
203
+ if (wasAuthed) this.onLost()
204
+ if (!opened) this.onUnreachable()
205
+ this.retry()
206
+ })
207
+ ws.addEventListener('error', () => {})
208
+ }
209
+
210
+ retry() {
211
+ if (this.stopped) return
212
+ clearTimeout(this.timer)
213
+ this.timer = setTimeout(() => this.connect(), RECONNECT_MS)
214
+ }
215
+
216
+ send(msg) {
217
+ if (!this.authed || this.ws?.readyState !== WebSocket.OPEN) return false
218
+ this.ws.send(JSON.stringify(msg))
219
+ return true
220
+ }
221
+ }
222
+
223
+ export function apply(ctx) {
224
+ const logger = ctx.logger ?? console
225
+ /** Most recent route seen in a `request/header`, reused for pet lines. */
226
+ let route
227
+ /** Most recently active root session id: target for dropped files. */
228
+ let activeRoot
229
+ const titles = new Map()
230
+ /** callId -> summary, bounded; lets the approval card show what will run. */
231
+ const callSummaries = new Map()
232
+ /** approval id -> answer(decision) */
233
+ const pendingApprovals = new Map()
234
+ /** Last state per session, replayed to a pet that connects mid-task. */
235
+ const lastState = new Map()
236
+ /** sessionId -> askId of the card currently following that session */
237
+ const asks = new Map()
238
+ let lastLineAt = 0
239
+ let lineBusy = false
240
+
241
+ const link = new PetLink(logger, handlePetMessage)
242
+ link.onReady = () => {
243
+ link.send({ type: 'ready', plugin: PLUGIN_VERSION })
244
+ link.send(uiInfo())
245
+ for (const msg of lastState.values()) link.send(msg)
246
+ }
247
+ // Start the pet once per DSH run if it is not already running.
248
+ let launchTried = false
249
+ link.onUnreachable = () => {
250
+ if (launchTried) return
251
+ launchTried = true
252
+ launchPet(logger).catch((error) => logger.warn(`pointy: ${error.message}`))
253
+ }
254
+ link.onLost = () => {
255
+ // A pet that vanished cannot answer; leave pending approvals to the UI.
256
+ for (const answer of pendingApprovals.values()) answer('detach')
257
+ }
258
+
259
+ // Where "open in DSH" should go: the desktop window, or the browser tab serving the web UI.
260
+ let webOrigin
261
+ const uiInfo = () => ({ type: 'ui', kind: DESKTOP ? 'desktop' : 'web', origin: DESKTOP ? undefined : webOrigin })
262
+ function noteOrigin(origin) {
263
+ if (DESKTOP || typeof origin !== 'string' || !/^https?:\/\/[^/]+$/.test(origin) || origin === webOrigin) return
264
+ webOrigin = origin
265
+ link.send(uiInfo())
266
+ }
267
+
268
+ function push(sessionId, state, extra = {}) {
269
+ const msg = { type: 'state', sessionId, title: titles.get(sessionId), state, at: Date.now(), ...extra }
270
+ lastState.set(sessionId, msg)
271
+ if (lastState.size > 20) lastState.delete(lastState.keys().next().value)
272
+ link.send(msg)
273
+ }
274
+
275
+ function isRoot(session) {
276
+ const depth = session?.header?.delegationDepth ?? session?.delegationDepth
277
+ return depth === undefined || depth === 0
278
+ }
279
+
280
+ ctx.on('session/event', (session, event) => {
281
+ if (typeof event?.time === 'number' && Date.now() - event.time > STALE_EVENT_MS) return
282
+ const sid = session.id
283
+ const data = event.data ?? {}
284
+ switch (event.type) {
285
+ case 'session/title':
286
+ if (typeof data.title === 'string') {
287
+ titles.set(sid, data.title)
288
+ // DSH often names the session after the answer is done; the card and its pill want the real name too.
289
+ link.send({ type: 'title', sessionId: sid, title: data.title })
290
+ }
291
+ break
292
+ case 'request/header': {
293
+ const config = data.header?.config
294
+ if (config?.provider && config?.model) route = { provider: config.provider, model: config.model }
295
+ break
296
+ }
297
+ case 'turn/start':
298
+ if (isRoot(session)) activeRoot = sid
299
+ push(sid, 'thinking')
300
+ break
301
+ case 'step/start':
302
+ push(sid, 'thinking')
303
+ break
304
+ case 'tool/call': {
305
+ const detail = summarizeCall(data.name, data.arguments)
306
+ callSummaries.set(data.callId, detail)
307
+ if (callSummaries.size > 200) callSummaries.delete(callSummaries.keys().next().value)
308
+ push(sid, classifyTool(String(data.name)), { tool: data.name, detail })
309
+ if (asks.has(sid)) link.send({ type: 'ask-activity', askId: asks.get(sid), sessionId: sid, tool: data.name, detail })
310
+ break
311
+ }
312
+ case 'tool/result':
313
+ if (data.message?.isError) push(sid, 'error', { detail: textOf(data.message.content).slice(0, 2000) })
314
+ break
315
+ case 'turn/end': {
316
+ const kind = data.reason?.kind
317
+ if (kind === 'completed' || kind === 'max-tokens') push(sid, 'done', { detail: kind })
318
+ else if (kind === 'error') push(sid, 'error', { detail: String(data.reason?.message ?? data.reason?.error ?? 'turn ended with an error').slice(0, 2000) })
319
+ else push(sid, 'idle', { detail: kind })
320
+ if (asks.has(sid)) link.send({ type: 'ask-done', askId: asks.get(sid), sessionId: sid, title: titles.get(sid), reason: kind })
321
+ break
322
+ }
323
+ }
324
+ })
325
+
326
+ ctx.on('agent/status', ({ agent, status }) => {
327
+ link.send({ type: 'status', sessionId: agent.id, running: status === 'running' })
328
+ }, { global: true })
329
+
330
+ ctx.on('agent/error', ({ agent, error }) => {
331
+ const detail = String(error?.message ?? error).slice(0, 2000)
332
+ push(agent.id, 'error', { detail })
333
+ if (asks.has(agent.id)) link.send({ type: 'ask-error', askId: asks.get(agent.id), sessionId: agent.id, error: detail })
334
+ }, { global: true })
335
+
336
+ // Live text of sessions started or continued from the ask card.
337
+ ctx.on('agent/assistant-stream', ({ agent, frame }) => {
338
+ const askId = asks.get(agent.session.id)
339
+ if (askId === undefined) return
340
+ if (frame.type === 'start') link.send({ type: 'ask-step', askId, sessionId: agent.session.id })
341
+ else if (frame.type === 'chunk' && frame.chunk?.type === 'text-delta' && frame.chunk.text) {
342
+ link.send({ type: 'ask-delta', askId, sessionId: agent.session.id, text: frame.chunk.text })
343
+ }
344
+ }, { global: true })
345
+
346
+ // Prepended so the pet sees the request before the UI forwarder, which only
347
+ // calls next() when every UI client declines.
348
+ ctx.on('approval/request', function (request, next) {
349
+ if (!link.authed) return next()
350
+ const id = crypto.randomUUID()
351
+ const sid = request.agent?.id
352
+ const original = request.signal
353
+ const withdraw = new AbortController()
354
+ try {
355
+ request.signal = original ? AbortSignal.any([original, withdraw.signal]) : withdraw.signal
356
+ } catch {
357
+ // A frozen request still works; the UI prompt just stays until answered.
358
+ }
359
+ return new Promise((resolve) => {
360
+ let settled = false
361
+ let uiDeclined = false
362
+ const finish = (outcome, by) => {
363
+ if (settled) return
364
+ settled = true
365
+ pendingApprovals.delete(id)
366
+ original?.removeEventListener('abort', onAbort)
367
+ link.send({ type: 'approval-closed', id, sessionId: sid, outcome, by })
368
+ resolve(outcome)
369
+ }
370
+ const onAbort = () => finish('cancelled', 'dsh')
371
+ original?.addEventListener('abort', onAbort, { once: true })
372
+ pendingApprovals.set(id, (decision) => {
373
+ if (decision === 'detach') {
374
+ if (uiDeclined) finish('unavailable', 'dsh')
375
+ return
376
+ }
377
+ finish(decision === 'allow' ? 'allowed-once' : 'rejected', 'pet')
378
+ withdraw.abort(new Error('answered on Pointy'))
379
+ })
380
+ Promise.resolve().then(next).then((outcome) => {
381
+ // No UI answered: keep the question open for the pet alone.
382
+ if (outcome === 'unavailable' && link.authed && !settled) { uiDeclined = true; return }
383
+ finish(outcome, 'dsh')
384
+ }, () => {
385
+ if (!withdraw.signal.aborted) finish('unavailable', 'dsh')
386
+ })
387
+ push(sid, 'waiting')
388
+ link.send({
389
+ type: 'approval',
390
+ id,
391
+ sessionId: sid,
392
+ title: titles.get(sid),
393
+ toolName: request.toolName,
394
+ // displayReason is a locale descriptor only the DSH client can render
395
+ reason: typeof request.reason === 'string' ? request.reason : '',
396
+ detail: request.callId === undefined ? '' : (callSummaries.get(request.callId) ?? ''),
397
+ })
398
+ })
399
+ }, true)
400
+
401
+ ctx.on('user-questions/request', function (request, next) {
402
+ if (request?.agent?.id) push(request.agent.id, 'waiting', { detail: 'question' })
403
+ return next()
404
+ }, true)
405
+
406
+ function handlePetMessage(msg) {
407
+ if (msg.type === 'approve') {
408
+ const answer = pendingApprovals.get(msg.id)
409
+ if (answer && (msg.decision === 'allow' || msg.decision === 'reject')) answer(msg.decision)
410
+ } else if (msg.type === 'files') {
411
+ link.send({ type: 'files-result', ...deliverFiles(msg.paths) })
412
+ } else if (msg.type === 'line-request') {
413
+ generateLine(msg).then(
414
+ text => link.send({ type: 'line', reqId: msg.reqId, text }),
415
+ error => link.send({ type: 'line', reqId: msg.reqId, error: String(error?.message ?? error) }),
416
+ )
417
+ } else if (msg.type === 'sessions-request') {
418
+ Promise.all([listSessions().catch(() => []), modelCatalog().catch(() => ({}))]).then(([items, catalog]) => link.send({
419
+ type: 'sessions',
420
+ reqId: msg.reqId,
421
+ // 这次 DSH 运行里还没跑过会话时,最近更新的那个就算"当前会话"
422
+ activeRoot: activeRoot ?? items[0]?.sessionId,
423
+ items: items.slice(0, 15),
424
+ model: (route ?? DEFAULT_ROUTE).model,
425
+ workspaces: workspaceList(),
426
+ defaultWorkspaceId: defaultWorkspace()?.id,
427
+ ...catalog,
428
+ }))
429
+ } else if (msg.type === 'sessions-search') {
430
+ searchSessions(String(msg.query ?? '')).then(
431
+ items => link.send({ type: 'sessions', reqId: msg.reqId, items }),
432
+ error => link.send({ type: 'sessions', reqId: msg.reqId, items: [], error: String(error?.message ?? error) }),
433
+ )
434
+ } else if (msg.type === 'focus') {
435
+ if (typeof msg.sessionId === 'string') requestFocus(msg.sessionId)
436
+ } else if (msg.type === 'suggest-request') {
437
+ suggest(msg).then(
438
+ suggestions => link.send({ type: 'suggestions', reqId: msg.reqId, suggestions }),
439
+ error => link.send({ type: 'suggestions', reqId: msg.reqId, suggestions: [], error: String(error?.message ?? error) }),
440
+ )
441
+ } else if (msg.type === 'ask') {
442
+ ask(msg).then(
443
+ sessionId => link.send({ type: 'ask-accepted', askId: msg.askId, sessionId, title: titles.get(sessionId) }),
444
+ error => link.send({ type: 'ask-error', askId: msg.askId, error: String(error?.message ?? error) }),
445
+ )
446
+ }
447
+ }
448
+
449
+ /** Root sessions for the card's target picker, newest first. */
450
+ async function listSessions() {
451
+ const controller = ctx.get('sessionController')
452
+ const seen = new Map([...titles].map(([id, title]) => [id, { sessionId: id, title }]))
453
+ const workspaceOf = new Map(workspaceList().flatMap(w => w.sessionIds.map(id => [id, w.title])))
454
+ if (controller) {
455
+ const { items } = await controller.list({}, AbortSignal.timeout(5000))
456
+ for (const item of items) {
457
+ if (item.archived || item.blank) continue
458
+ const title = item.projections?.values?.title ?? item.title ?? titles.get(item.sessionId)
459
+ const selection = item.projections?.values?.modelSelection?.next
460
+ const model = selection?.provider && selection?.model ? { provider: selection.provider, model: selection.model, ...(selection.reasoningEffort ? { reasoningEffort: String(selection.reasoningEffort) } : {}) } : undefined
461
+ seen.set(item.sessionId, { sessionId: item.sessionId, title, updatedAt: item.updatedAt, running: item.running, model, workspace: workspaceOf.get(item.sessionId) })
462
+ }
463
+ }
464
+ return [...seen.values()]
465
+ .sort((a, b) => Number(b.updatedAt ?? 0) - Number(a.updatedAt ?? 0) || String(b.updatedAt ?? '').localeCompare(String(a.updatedAt ?? '')))
466
+ }
467
+
468
+ /**
469
+ * Sessions matching the card's search box: title matches first, then
470
+ * sessions whose messages contain the text (DSH's own session search).
471
+ */
472
+ async function searchSessions(query) {
473
+ const q = query.trim()
474
+ if (!q) return []
475
+ const all = await listSessions()
476
+ const byId = new Map(all.map(s => [s.sessionId, s]))
477
+ const lower = q.toLowerCase()
478
+ const hits = all.filter(s => String(s.title ?? '').toLowerCase().includes(lower))
479
+ const controller = ctx.get('sessionController')
480
+ if (controller?.search) {
481
+ try {
482
+ const { items } = await controller.search({ query: q.slice(0, 200) }, AbortSignal.timeout(5000))
483
+ for (const item of items) {
484
+ const s = byId.get(item.sessionId)
485
+ if (s && !hits.includes(s)) hits.push({ ...s, snippet: item.snippet })
486
+ }
487
+ } catch (error) {
488
+ logger.warn(`pointy: session search failed: ${error.message}`)
489
+ }
490
+ }
491
+ return hits.slice(0, 20)
492
+ }
493
+
494
+ /** Workspaces a new task can go to, as the DSH sidebar names them. */
495
+ function workspaceList() {
496
+ const registry = ctx.get('workspaceRegistry')
497
+ return (registry?.list() ?? []).map(w => ({
498
+ id: w.id,
499
+ title: w.title || String(w.path ?? '').split(/[\\/]/).filter(Boolean).pop() || w.id,
500
+ sessionIds: w.sessionIds ?? [],
501
+ }))
502
+ }
503
+
504
+ /** Models the card's picker offers: the same catalog DSH's own model picker uses. */
505
+ async function modelCatalog() {
506
+ const controller = ctx.get('sessionController')
507
+ if (!controller?.modelCatalog) return {}
508
+ const catalog = await controller.modelCatalog()
509
+ const models = (catalog?.groups ?? []).flatMap(g => g.models.map(m => ({
510
+ provider: g.id,
511
+ providerName: g.name,
512
+ model: m.id,
513
+ name: m.name || m.id,
514
+ // Thinking efforts the model offers, as DSH's own picker names them.
515
+ ...(m.reasoning?.efforts?.length ? { efforts: m.reasoning.efforts.map(e => ({ id: e.id, name: e.name || e.id })), defaultEffort: m.reasoning.defaultEffort } : {}),
516
+ })))
517
+ const d = catalog?.default
518
+ return { models, defaultModel: d?.provider && d?.model ? { provider: d.provider, model: d.model, ...(d.reasoningEffort ? { reasoningEffort: d.reasoningEffort } : {}) } : undefined }
519
+ }
520
+
521
+ /**
522
+ * Two next steps the user likely wants for what they pointed at, in their
523
+ * own voice. Clicking one sends it as the question; nothing here asks the
524
+ * agent to save or file anything. Recent session titles used to be passed
525
+ * in and pulled the model toward "is this related to my project?", so the
526
+ * prompt sees only the pointed content.
527
+ */
528
+ async function suggest({ context }) {
529
+ const system = [
530
+ '用户在屏幕上指着一块内容(网页评论、帖子、issue、代码、报错、图片等),准备交给他的 AI 助手。你替他想 2 个下一步最想让助手做的事。',
531
+ '',
532
+ '先判断这块内容是什么,再挑最有用的动作:',
533
+ '- issue、技术讨论里的提问:起草回复、查原因',
534
+ '- 帖子、评论里的说法:查证真假、补背景',
535
+ '- 报错、日志:查原因、给修法',
536
+ '- 代码、命令:讲清楚它在做什么、找问题',
537
+ '- 提到的产品、工具、项目、人、术语:这是什么、怎么用、和同类比',
538
+ '- 图片、截图:看懂图里的内容',
539
+ '',
540
+ '要求:',
541
+ '- label 里要带上这块内容里的具体名字或说法(比如"OpenShell 支持 cgroup v1 吗"),不要"起草回复问细节"这种换个内容也成立的话。',
542
+ '- 两个要是不同方向的动作。',
543
+ '- 只围绕这块内容本身,不要问它和用户的项目、工作有没有关系,不要问怎么抓取或保存它。',
544
+ '- 不要泛泛的"解释一下""总结一下""这是什么意思"。',
545
+ '- 会发到外面的(回复、评论、消息、issue),只能是"起草",不能直接发;只有用户确实可能去回的讨论才给起草回复。',
546
+ '- 用用户自己的口吻,中文。',
547
+ '',
548
+ '只输出 JSON 数组,每项 {"label": "按钮上的字,一个完整的短语,不超过 12 个字(英文名算 1 个字),宁可短也别写半句", "prompt": "实际发给助手的话,一两句"}。',
549
+ ].join('\n')
550
+ const user = `用户指着的内容:\n${String(context ?? '').slice(0, 3000)}`
551
+ const signal = AbortSignal.timeout(LINE_TIMEOUT_MS)
552
+ const call = await ctx.llm.prepareCall({ ...(route ?? DEFAULT_ROUTE), maxTokens: 600 }, signal, (controls, model) => {
553
+ const efforts = model.reasoning?.efforts?.map(e => e.id) ?? []
554
+ return efforts.includes('off') ? { ...controls, reasoningEffort: 'off' } : controls
555
+ })
556
+ const messages = [createUserMessage({ content: [{ type: 'text', text: user }], source: { kind: 'pointy-suggest' } })]
557
+ const assembler = new BlockAssembler()
558
+ for await (const chunk of call.stream({ ...call.config, messages, system, purpose: 'pointy-suggest', signal })) assembler.push(chunk)
559
+ const text = assembler.blocks().filter(b => b.type === 'text').map(b => b.text).join('')
560
+ const json = text.slice(text.indexOf('['), text.lastIndexOf(']') + 1)
561
+ const items = JSON.parse(json)
562
+ return items
563
+ .filter(i => typeof i?.label === 'string' && typeof i?.prompt === 'string')
564
+ .slice(0, 2)
565
+ // 不按字数硬切(会切成"stream uploads 的"这种半句),太长的由卡片按宽度显示省略号
566
+ .map(i => ({ label: i.label.trim().slice(0, 60), prompt: i.prompt.slice(0, 600) }))
567
+ }
568
+
569
+ /** New tasks go to the default workspace (unless the card picked another) so they show up in the DSH sidebar. */
570
+ function defaultWorkspace() {
571
+ const registry = ctx.get('workspaceRegistry')
572
+ const all = registry?.list() ?? []
573
+ return all.find(w => /default-workspace$/i.test(w.path)) ??
574
+ all.find(w => activeRoot !== undefined && w.sessionIds.includes(activeRoot)) ??
575
+ all[0]
576
+ }
577
+
578
+ /**
579
+ * 0.2.0–0.2.2 registered the pet's notebook folder as a workspace named
580
+ * "桌宠小本本". The notebook is gone; drop that one registration. Removing a
581
+ * workspace keeps its folder and sessions (they become ungrouped).
582
+ */
583
+ async function removeNotebookWorkspace() {
584
+ const registry = ctx.get('workspaceRegistry')
585
+ if (!registry) return
586
+ for (const w of registry.list()) {
587
+ if (w.title === '桌宠小本本' && /[\\/]dsh-pet-notebook$/i.test(w.path)) {
588
+ try {
589
+ await registry.delete(w.id)
590
+ logger.info(`pointy: removed the old notebook workspace registration ${w.path}`)
591
+ } catch (error) {
592
+ logger.warn(`pointy: could not remove the notebook workspace: ${error.message}`)
593
+ }
594
+ }
595
+ }
596
+ }
597
+
598
+ async function ask({ askId, target, text, model, image, images, workspaceId }) {
599
+ if (typeof text !== 'string' || !text.trim()) throw new Error('没有内容')
600
+ const controller = ctx.get('sessionController')
601
+ let sessionId = target
602
+ if (target === 'new') {
603
+ if (!controller) throw new Error('这个 DSH 版本没有会话服务,没法新建任务')
604
+ const chosen = ctx.get('workspaceRegistry')?.list().find(w => w.id === workspaceId)
605
+ const workspace = chosen ?? defaultWorkspace()
606
+ sessionId = (await controller.create(workspace ? { workspaceId: workspace.id } : {})).sessionId
607
+ }
608
+ let agent = ctx.agents.get(sessionId)
609
+ if (agent === undefined && controller?.agents?.resolveAgent) {
610
+ // Not loaded yet (an older session): activate it the way the DSH UI does.
611
+ const resolved = await controller.agents.resolveAgent(sessionId)
612
+ if ('agent' in resolved) agent = resolved.agent
613
+ }
614
+ if (agent === undefined) throw new Error('这个会话打不开,先在 DSH 里点开它')
615
+ // Switching works like DSH's own picker: the session uses it from the next request on.
616
+ if (model?.provider && model?.model) {
617
+ if (!controller?.selectModel) throw new Error('这个 DSH 版本不能从外面换模型')
618
+ await controller.selectModel({ sessionId, provider: String(model.provider), model: String(model.model), ...(model.reasoningEffort ? { reasoningEffort: String(model.reasoningEffort) } : {}) })
619
+ }
620
+ const content = await promptContent(text, Array.isArray(images) ? images : [image])
621
+ asks.set(sessionId, askId)
622
+ if (asks.size > 20) asks.delete(asks.keys().next().value)
623
+ agent.followup(createUserMessage({ content, source: { kind: 'user' } }))
624
+ return sessionId
625
+ }
626
+
627
+ /**
628
+ * Text plus optional screenshots of what the user pointed at (in QQ-like apps:
629
+ * the strip around the mouse and the whole area). The images go through DSH's
630
+ * own attachment admission, the same path as images pasted into the DSH
631
+ * composer, so they are stored with the session like those.
632
+ */
633
+ async function promptContent(text, images) {
634
+ const parts = images
635
+ .map(image => typeof image === 'string' ? image.match(/^data:(image\/(?:png|jpeg|webp));base64,([A-Za-z0-9+/=]+)$/) : null)
636
+ .filter(Boolean)
637
+ .slice(0, 4)
638
+ .map(match => ({ type: 'image', mediaType: match[1], data: match[2] }))
639
+ if (!parts.length) return [{ type: 'text', text }]
640
+ const attachments = ctx.get('attachments')
641
+ if (!attachments?.admitPromptContent) throw new Error('这个 DSH 版本收不了图片')
642
+ return attachments.admitPromptContent([{ type: 'text', text }, ...parts])
643
+ }
644
+
645
+ function deliverFiles(paths) {
646
+ if (!Array.isArray(paths) || paths.length === 0) return { ok: false, error: '没收到文件' }
647
+ const files = paths
648
+ .filter(p => typeof p === 'string' && path.isAbsolute(p) && fs.existsSync(p))
649
+ .slice(0, MAX_DROPPED_FILES)
650
+ if (files.length === 0) return { ok: false, error: '这些文件找不到' }
651
+ const agent = activeRoot === undefined ? undefined : ctx.agents.get(activeRoot)
652
+ if (agent === undefined) return { ok: false, error: '还没有正在用的会话,先在 DSH 里说句话吧' }
653
+ const text = ['用户把这些文件拖给了 Pointy,请先查看:', ...files.map(f => `- ${f}`)].join('\n')
654
+ agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }))
655
+ return { ok: true, sessionId: activeRoot, count: files.length }
656
+ }
657
+
658
+ async function generateLine(req) {
659
+ const now = Date.now()
660
+ if (lineBusy || now - lastLineAt < LINE_MIN_INTERVAL_MS) throw new Error('rate limited')
661
+ lineBusy = true
662
+ lastLineAt = now
663
+ try {
664
+ const hint = STATE_HINT[req.state] ?? STATE_HINT.idle
665
+ const detail = typeof req.detail === 'string' && req.detail ? `(${req.detail.slice(0, 80)})` : ''
666
+ const signal = AbortSignal.timeout(LINE_TIMEOUT_MS)
667
+ const call = await ctx.llm.prepareCall({ ...(route ?? DEFAULT_ROUTE), maxTokens: 200 }, signal, (controls, model) => {
668
+ const efforts = model.reasoning?.efforts?.map(e => e.id) ?? []
669
+ return efforts.includes('off') ? { ...controls, reasoningEffort: 'off' } : controls
670
+ })
671
+ const messages = [createUserMessage({
672
+ content: [{ type: 'text', text: `现在的情况:${hint}${detail}。说一句话。` }],
673
+ source: { kind: 'pointy-line' },
674
+ })]
675
+ const assembler = new BlockAssembler()
676
+ for await (const chunk of call.stream({ ...call.config, messages, system: PERSONA, purpose: 'pointy-line', signal })) {
677
+ assembler.push(chunk)
678
+ }
679
+ const finish = assembler.finish
680
+ if (finish?.kind === 'error' || finish?.kind === 'aborted') throw new Error(finish.failure?.message ?? finish.kind)
681
+ const text = assembler.blocks().filter(b => b.type === 'text').map(b => b.text).join('').trim()
682
+ return text.replace(/^["“「]|["”」]$/g, '').split('\n')[0].slice(0, 30)
683
+ } finally {
684
+ lineBusy = false
685
+ }
686
+ }
687
+
688
+ ctx.effect(() => {
689
+ link.start()
690
+ return () => link.stop()
691
+ }, 'pointy: pet link')
692
+
693
+ // "在 DSH 中打开":Pointy发来会话 id,交给 DSH 页面里的插件界面那一半去 openSession。
694
+ // 页面那边长轮询 next-focus;没人在等时先存着,几秒内来问的还能拿到。
695
+ const focusWaiters = new Set()
696
+ let pendingFocus // { sessionId, at }
697
+ function requestFocus(sessionId) {
698
+ if (focusWaiters.size) {
699
+ for (const resolve of focusWaiters) resolve(sessionId)
700
+ focusWaiters.clear()
701
+ } else {
702
+ pendingFocus = { sessionId, at: Date.now() }
703
+ }
704
+ }
705
+ function nextFocus() {
706
+ if (pendingFocus && Date.now() - pendingFocus.at < FOCUS_PENDING_MS) {
707
+ const { sessionId } = pendingFocus
708
+ pendingFocus = undefined
709
+ return Promise.resolve(sessionId)
710
+ }
711
+ return new Promise((resolve) => {
712
+ const done = (value) => { clearTimeout(timer); focusWaiters.delete(done); resolve(value) }
713
+ const timer = setTimeout(() => done(null), FOCUS_POLL_MS)
714
+ focusWaiters.add(done)
715
+ })
716
+ }
717
+ ctx.inject(['connection'], (connectionCtx) => {
718
+ const dispose = connectionCtx.connection.rpc.handle(RPC_CHANNEL, async (endpoint, params) => {
719
+ if (endpoint === 'next-focus') {
720
+ noteOrigin(params?.origin)
721
+ return { ok: true, value: await nextFocus() }
722
+ }
723
+ return { ok: false, error: { code: 'unknown-endpoint', message: `unknown endpoint ${endpoint}` } }
724
+ })
725
+ return async () => {
726
+ for (const resolve of focusWaiters) resolve(null)
727
+ await dispose()
728
+ }
729
+ })
730
+
731
+ Promise.resolve(ctx.get('loader')?.await?.()).then(removeNotebookWorkspace).catch(() => {})
732
+ }