@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.
- package/ASSETS.md +20 -0
- package/LICENSE +21 -0
- package/README.md +85 -2
- package/cordis.patch.yml +13 -0
- package/lib/client.js +33 -0
- package/package.json +38 -4
- package/pet/app/card/card.css +365 -0
- package/pet/app/card/card.html +68 -0
- package/pet/app/card/card.js +896 -0
- package/pet/app/card/markdown.js +64 -0
- package/pet/app/card/mascot/blink.png +0 -0
- package/pet/app/card/mascot/done.png +0 -0
- package/pet/app/card/mascot/error.png +0 -0
- package/pet/app/card/mascot/idle.png +0 -0
- package/pet/app/card/mascot/peek.png +0 -0
- package/pet/app/card/mascot/think.png +0 -0
- package/pet/app/card/preload.js +21 -0
- package/pet/app/fullscreen.js +131 -0
- package/pet/app/main.js +1252 -0
- package/pet/app/native/uia-selection.ps1 +158 -0
- package/pet/app/notice/notice.html +37 -0
- package/pet/app/notice/notice.js +31 -0
- package/pet/app/notice/preload.js +7 -0
- package/pet/app/overlay/overlay.html +30 -0
- package/pet/app/overlay/overlay.js +42 -0
- package/pet/app/overlay/preload.js +7 -0
- package/pet/app/pill/pill.css +73 -0
- package/pet/app/pill/pill.html +11 -0
- package/pet/app/pill/pill.js +35 -0
- package/pet/app/pill/preload.js +8 -0
- package/pet/app/preload.js +16 -0
- package/pet/app/renderer/index.html +24 -0
- package/pet/app/renderer/lines.js +25 -0
- package/pet/app/renderer/pet.js +292 -0
- package/pet/app/renderer/renderer2d.js +71 -0
- package/pet/app/renderer/style.css +157 -0
- package/pet/app/selection.js +246 -0
- package/pet/app/shake.js +76 -0
- package/pet/app/uipick.js +314 -0
- package/pet/assets/cursor/cursor-32.cur +0 -0
- package/pet/assets/cursor/cursor-32.png +0 -0
- package/pet/assets/cursor/cursor-48.cur +0 -0
- package/pet/assets/cursor/cursor-48.png +0 -0
- package/pet/assets/cursor/cursor-64.cur +0 -0
- package/pet/assets/cursor/cursor-64.png +0 -0
- package/pet/assets/cursor/cursor-clean.png +0 -0
- package/pet/assets/cursor/make_cursor.py +85 -0
- package/pet/assets/cursor/source.png +0 -0
- package/pet/assets/pointy.ico +0 -0
- package/pet/assets/tray.png +0 -0
- package/pet/assets/tray@2x.png +0 -0
- package/pet/boot.js +6 -0
- package/pet/extension/background.js +252 -0
- package/pet/extension/cursor-32.png +0 -0
- package/pet/extension/cursor-48.png +0 -0
- package/pet/extension/cursor-64.png +0 -0
- package/pet/extension/icon.png +0 -0
- package/pet/extension/manifest.json +12 -0
- package/pet/extension/pick.js +391 -0
- package/pet/extension/popup.html +29 -0
- package/pet/extension/popup.js +30 -0
- package/pet/package.json +11 -0
- package/pet/vendor/koffi/LICENSE.txt +22 -0
- package/pet/vendor/koffi/build/koffi/win32_x64/koffi.exp +0 -0
- package/pet/vendor/koffi/build/koffi/win32_x64/koffi.lib +0 -0
- package/pet/vendor/koffi/build/koffi/win32_x64/koffi.node +0 -0
- package/pet/vendor/koffi/index.js +634 -0
- package/pet/vendor/koffi/indirect.js +533 -0
- package/pet/vendor/koffi/package.json +35 -0
- package/pet/vendor/ws/LICENSE +20 -0
- package/pet/vendor/ws/index.js +22 -0
- package/pet/vendor/ws/lib/buffer-util.js +142 -0
- package/pet/vendor/ws/lib/constants.js +19 -0
- package/pet/vendor/ws/lib/event-target.js +292 -0
- package/pet/vendor/ws/lib/extension.js +203 -0
- package/pet/vendor/ws/lib/limiter.js +55 -0
- package/pet/vendor/ws/lib/permessage-deflate.js +530 -0
- package/pet/vendor/ws/lib/receiver.js +743 -0
- package/pet/vendor/ws/lib/sender.js +610 -0
- package/pet/vendor/ws/lib/stream.js +161 -0
- package/pet/vendor/ws/lib/subprotocol.js +62 -0
- package/pet/vendor/ws/lib/validation.js +152 -0
- package/pet/vendor/ws/lib/websocket-server.js +562 -0
- package/pet/vendor/ws/lib/websocket.js +1426 -0
- package/pet/vendor/ws/package.json +74 -0
- package/pet/vendor/ws/wrapper.mjs +21 -0
- package/src/node.js +732 -0
- 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
|
+
}
|