@chentyit/dsh-chatbot-panel 0.0.0-stage → 0.2.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/index.js ADDED
@@ -0,0 +1,718 @@
1
+ /**
2
+ * Host half of the ChatBot capability panel bundle.
3
+ *
4
+ * Owns four things:
5
+ * 1. Per-session capability state (`chatbotCapabilities` projection), derived
6
+ * from the `/chatbot` command lifecycle events that the Session log already
7
+ * records — no new Session event type is introduced.
8
+ * 2. Tool gating: while web capability is off, the built-in `web_search` /
9
+ * `web_fetch` tools are restricted away from that agent, and the panel's
10
+ * image-search and deep-search tools leave the model's tool catalog; the
11
+ * image tool is declared while web is on, the deep tool while both are on.
12
+ * 3. Image input: the panel's own tools can read the images attached to the
13
+ * current turn's user message (or an explicit path/URL/data URI) and pass
14
+ * them to DeepSeek's native `deepseek_web_search` server tool, which is the
15
+ * only way to run an image search through the Messages API.
16
+ * 4. Usage and cost accounting (`chatbotUsage` projection), folded from the
17
+ * `tool/call` + `tool/result` events: successful REST searches are billed
18
+ * per query, fetches only when the DeepSeek Fetch provider serves them,
19
+ * and both auxiliary tools report their billed tool counts and tokens.
20
+ *
21
+ * The browser half renders the capsule and the upward panel; it reads both
22
+ * projections through `useProjection` and writes through `/chatbot ...`.
23
+ */
24
+
25
+ import {
26
+ BUILTIN_WEB_TOOLS,
27
+ CAP_KEY,
28
+ DEEP_TOOL,
29
+ IMAGE_TOOL,
30
+ MAX_IMAGES_PER_SEARCH,
31
+ USAGE_KEY,
32
+ applyCapabilityPatch,
33
+ deepInstruction,
34
+ emptyCapabilities,
35
+ emptyUsage,
36
+ imageInstruction,
37
+ latestTurnText,
38
+ mapSearchResponse,
39
+ parseCapabilityInput,
40
+ parseDataImage,
41
+ reduceUsage,
42
+ renderSearchResult,
43
+ resolveConfig,
44
+ sniffImageMediaType,
45
+ SUPPORTED_IMAGE_TYPES,
46
+ turnImageRefs,
47
+ usageView,
48
+ } from './pure.js'
49
+
50
+ /** Anthropic-compatible API version header. */
51
+ const ANTHROPIC_VERSION = '2023-06-01'
52
+ /** User-agent attribution. */
53
+ const USER_AGENT = 'dsh-chatbot-panel/0.2.0'
54
+ /** Upper bound on one explicitly supplied image file. */
55
+ const MAX_IMAGE_FILE_BYTES = 8 * 1024 * 1024
56
+ /** Longest edge sent for an attached image, matching the DeepSeek vision budget. */
57
+ const MAX_REQUEST_IMAGE_EDGE = 2048
58
+ /** Encoded-byte target for one attached request image. */
59
+ const REQUEST_IMAGE_MAX_BYTES = 2 * 1024 * 1024
60
+ /** Values of `image` that select the images attached in the current turn. */
61
+ const AUTO_IMAGE_KEYWORDS = new Set(['auto', 'current', 'latest', 'message', 'turn', 'this-turn', 'this_turn'])
62
+ /** Duck-typed schema: the projection registry only calls `parse`. */
63
+ const passthroughSchema = { parse: value => value }
64
+ /** Message shown when no image can be found for an image search. */
65
+ const NO_IMAGE_MESSAGE = 'No image found in this turn\'s user message. Attach an image in the composer, '
66
+ + 'or pass `image` as a workspace path, an http(s) URL, or a data:image/... base64 URI.'
67
+
68
+ /** Usage shape used when an older replayed result omitted the field. */
69
+ const ZERO_USAGE = { toolSearch: 0, toolFetch: 0, imageSearch: 0, inputTokens: 0, outputTokens: 0 }
70
+
71
+ /** True for a finite non-negative count. */
72
+ function finiteCount(value) {
73
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0
74
+ }
75
+
76
+ /** Schema shared by every page source. */
77
+ const sourceSchema = {
78
+ type: 'object',
79
+ additionalProperties: false,
80
+ required: ['url'],
81
+ properties: {
82
+ url: { type: 'string' },
83
+ title: { type: 'string' },
84
+ snippet: { type: 'string' },
85
+ site: { type: 'string' },
86
+ publishedAt: { type: 'string' },
87
+ },
88
+ }
89
+
90
+ /** Schema for one matched image (the page URL plus the image URL). */
91
+ const imageSchema = {
92
+ type: 'object',
93
+ additionalProperties: false,
94
+ required: ['url', 'imageUrl'],
95
+ properties: {
96
+ ...sourceSchema.properties,
97
+ imageUrl: { type: 'string' },
98
+ },
99
+ }
100
+
101
+ /** Schema for the auxiliary request's billed counters and token usage. */
102
+ const usageSchema = {
103
+ type: 'object',
104
+ additionalProperties: false,
105
+ required: ['toolSearch', 'toolFetch', 'imageSearch', 'inputTokens', 'outputTokens'],
106
+ properties: {
107
+ toolSearch: { type: 'number' },
108
+ toolFetch: { type: 'number' },
109
+ imageSearch: { type: 'number' },
110
+ inputTokens: { type: 'number' },
111
+ outputTokens: { type: 'number' },
112
+ },
113
+ }
114
+
115
+ /** True when one `image` argument selects the current turn's attachments. */
116
+ function isAutoImage(value) {
117
+ return typeof value === 'string' && AUTO_IMAGE_KEYWORDS.has(value.trim().toLowerCase())
118
+ }
119
+
120
+ /**
121
+ * Resolve authentication for one auxiliary Messages request.
122
+ *
123
+ * A DeepSeek account token is used when the Session runs on the account route
124
+ * (or its route cannot be resolved yet, because a sign-in-only profile has no
125
+ * other credential). Otherwise the API key comes from the credential service,
126
+ * the documented environment variable, or a literal env value.
127
+ */
128
+ async function resolveDeepAuth(ctx, config, endpoint, agent) {
129
+ const route = agent?.session?.requestContext?.()?.provider
130
+ ?? (typeof ctx.agents?.currentInitiator === 'function'
131
+ ? ctx.agents.currentInitiator()?.session?.requestContext?.()?.provider
132
+ : undefined)
133
+ const account = typeof ctx.get === 'function' ? ctx.get('deepseekAccount') : undefined
134
+ if ((route === undefined || route === 'deepseek-account')
135
+ && account !== undefined && account !== null
136
+ && typeof account.resolveToken === 'function') {
137
+ try {
138
+ const token = await account.resolveToken(endpoint)
139
+ if (typeof token === 'string' && token.length > 0) {
140
+ return {
141
+ kind: 'account',
142
+ headers: { 'x-dsh-auth-token': token },
143
+ reject: async () => {
144
+ if (typeof account.rejectToken === 'function') await account.rejectToken(token)
145
+ },
146
+ }
147
+ }
148
+ } catch (_accountTokenUnavailable) {
149
+ /* Fall through to the API key. */
150
+ }
151
+ }
152
+ const apiKeyHeaders = key => ({ kind: 'api-key', headers: { 'x-api-key': key, authorization: `Bearer ${key}` } })
153
+ const credentials = typeof ctx.get === 'function' ? ctx.get('credentials') : undefined
154
+ if (credentials !== undefined && credentials !== null && typeof credentials.resolve === 'function') {
155
+ try {
156
+ const resolved = await credentials.resolve(config.deepSearch.apiKeyEnv)
157
+ const key = typeof resolved?.value === 'string' ? resolved.value.trim() : ''
158
+ if (key.length > 0) return apiKeyHeaders(key)
159
+ } catch (_credentialResolutionFailed) {
160
+ /* Fall through to the environment. */
161
+ }
162
+ }
163
+ const environment = globalThis.process !== undefined ? globalThis.process.env : undefined
164
+ const fromEnvironment = environment === undefined ? undefined : environment[config.deepSearch.apiKeyEnv]
165
+ const key = typeof fromEnvironment === 'string' && fromEnvironment.trim().length > 0
166
+ ? fromEnvironment.trim()
167
+ : undefined
168
+ if (key !== undefined) return apiKeyHeaders(key)
169
+ throw new Error(
170
+ `ChatBot image/deep search needs a credential: sign in to a DeepSeek account in DSH, `
171
+ + `or set ${config.deepSearch.apiKeyEnv} in $DSH_HOME/.env.`,
172
+ )
173
+ }
174
+
175
+ /** Build one auxiliary Messages request body. */
176
+ function auxiliaryBody(config, thinking, imageBlocks, instruction) {
177
+ return {
178
+ model: config.deepSearch.model,
179
+ max_tokens: config.deepSearch.maxTokens,
180
+ thinking: { type: thinking ? 'enabled' : 'disabled' },
181
+ tools: [{ type: 'deepseek_web_search', name: 'deepseek_web_search' }],
182
+ messages: [{
183
+ role: 'user',
184
+ content: [...imageBlocks, { type: 'text', text: instruction }],
185
+ }],
186
+ }
187
+ }
188
+
189
+ /** Dispatch one auxiliary search request and return its parsed Messages payload. */
190
+ async function requestAuxiliarySearch(ctx, config, body, agent, exec) {
191
+ const endpoint = `${config.deepSearch.baseURL}/messages`
192
+ const auth = await resolveDeepAuth(ctx, config, endpoint, agent)
193
+ let response
194
+ try {
195
+ response = await fetch(endpoint, {
196
+ method: 'POST',
197
+ redirect: 'error',
198
+ headers: {
199
+ ...auth.headers,
200
+ 'anthropic-version': ANTHROPIC_VERSION,
201
+ 'content-type': 'application/json',
202
+ accept: 'application/json',
203
+ 'user-agent': USER_AGENT,
204
+ },
205
+ body: JSON.stringify(body),
206
+ signal: exec.signal,
207
+ })
208
+ } catch (error) {
209
+ if (exec.signal.aborted) throw error
210
+ throw new Error(`ChatBot search request failed at ${endpoint}: ${String(error)}`)
211
+ }
212
+ if (!response.ok) {
213
+ let detail = `HTTP ${response.status}`
214
+ try {
215
+ const parsed = await response.json()
216
+ const message = typeof parsed?.error === 'string' ? parsed.error : parsed?.error?.message ?? parsed?.message
217
+ if (typeof message === 'string' && message.length > 0) detail += `: ${message}`
218
+ } catch (_unreadableErrorBody) {
219
+ /* The HTTP status is the actionable part. */
220
+ }
221
+ if (response.status === 401 && auth.kind === 'account' && typeof auth.reject === 'function') {
222
+ try { await auth.reject() } catch (_credentialRemovalFailed) { /* report the original failure */ }
223
+ throw new Error(
224
+ `ChatBot search was rejected (${detail}); the DeepSeek account sign-in is no longer valid, `
225
+ + 'sign in again or configure an API key in $DSH_HOME/.env.',
226
+ )
227
+ }
228
+ throw new Error(`ChatBot search was rejected (${detail}) at ${endpoint}`)
229
+ }
230
+ let payload
231
+ try {
232
+ payload = await response.json()
233
+ } catch (error) {
234
+ throw new Error(`ChatBot search returned an unreadable response: ${String(error)}`)
235
+ }
236
+ if (payload?.type === 'error' && payload?.error !== null && typeof payload?.error === 'object') {
237
+ const message = typeof payload.error.message === 'string' ? payload.error.message : 'unknown error'
238
+ throw new Error(`ChatBot search failed after the response started: ${message}`)
239
+ }
240
+ return payload
241
+ }
242
+
243
+ /** Read the images attached to the latest image-bearing user message. */
244
+ async function attachmentImageBlocks(ctx, refs, signal) {
245
+ const attachments = typeof ctx.get === 'function' ? ctx.get('attachments') : undefined
246
+ if (attachments === undefined || attachments === null) {
247
+ throw new Error('ChatBot image search needs the attachment service to read images from this message.')
248
+ }
249
+ const blocks = []
250
+ for (const ref of refs.slice(0, MAX_IMAGES_PER_SEARCH)) {
251
+ const width = typeof ref.width === 'number' && ref.width > 0 ? ref.width : 0
252
+ const height = typeof ref.height === 'number' && ref.height > 0 ? ref.height : 0
253
+ let stored
254
+ if (width > 0 && height > 0 && typeof attachments.readImageRequest === 'function') {
255
+ const scale = Math.min(1, MAX_REQUEST_IMAGE_EDGE / Math.max(width, height))
256
+ try {
257
+ stored = await attachments.readImageRequest(ref, {
258
+ width: Math.max(1, Math.round(width * scale)),
259
+ height: Math.max(1, Math.round(height * scale)),
260
+ maxBytes: REQUEST_IMAGE_MAX_BYTES,
261
+ }, signal)
262
+ } catch (_resizeFailed) {
263
+ stored = undefined
264
+ }
265
+ }
266
+ if (stored === undefined && typeof attachments.readImage === 'function') {
267
+ stored = await attachments.readImage(ref, signal)
268
+ }
269
+ const bytes = stored?.data
270
+ if (bytes === undefined || bytes === null || typeof bytes.length !== 'number' || bytes.length === 0) {
271
+ throw new Error(`ChatBot could not read the attached image ${ref.attachmentId}.`)
272
+ }
273
+ if (bytes.length > MAX_IMAGE_FILE_BYTES) {
274
+ throw new Error(`ChatBot image search is limited to ${MAX_IMAGE_FILE_BYTES / (1024 * 1024)} MB per image.`)
275
+ }
276
+ const mediaType = typeof stored.mediaType === 'string'
277
+ ? stored.mediaType
278
+ : typeof stored.ref?.mediaType === 'string' ? stored.ref.mediaType : ref.mediaType
279
+ if (!SUPPORTED_IMAGE_TYPES.includes(mediaType)) {
280
+ throw new Error(`ChatBot image search supports ${SUPPORTED_IMAGE_TYPES.join(', ')} images, not ${String(mediaType)}.`)
281
+ }
282
+ blocks.push({
283
+ type: 'image',
284
+ source: { type: 'base64', media_type: mediaType, data: Buffer.from(bytes).toString('base64') },
285
+ })
286
+ }
287
+ return blocks
288
+ }
289
+
290
+ /** Read one explicit workspace image path into an inline image block. */
291
+ async function pathImageBlock(exec, value) {
292
+ const session = exec.agent === undefined ? undefined : exec.agent.session
293
+ const cwd = typeof session?.header?.cwd === 'string' && session.header.cwd.length > 0
294
+ ? session.header.cwd
295
+ : (globalThis.process !== undefined && typeof globalThis.process.cwd === 'function' ? globalThis.process.cwd() : '.')
296
+ const [{ readFile }, path] = await Promise.all([import('node:fs/promises'), import('node:path')])
297
+ const absolute = path.isAbsolute(value) ? value : path.resolve(cwd, value)
298
+ let data
299
+ try {
300
+ data = await readFile(absolute)
301
+ } catch (error) {
302
+ throw new Error(`ChatBot could not read the image at ${absolute}: ${String(error)}`)
303
+ }
304
+ if (data.length === 0) throw new Error(`ChatBot image search found an empty file at ${absolute}.`)
305
+ if (data.length > MAX_IMAGE_FILE_BYTES) {
306
+ throw new Error(`ChatBot image search is limited to ${MAX_IMAGE_FILE_BYTES / (1024 * 1024)} MB per image.`)
307
+ }
308
+ const mediaType = sniffImageMediaType(data)
309
+ if (mediaType === undefined) {
310
+ throw new Error(
311
+ `ChatBot image search supports ${SUPPORTED_IMAGE_TYPES.join(', ')} images; ${absolute} is not one of those.`,
312
+ )
313
+ }
314
+ return { type: 'image', source: { type: 'base64', media_type: mediaType, data: data.toString('base64') } }
315
+ }
316
+
317
+ /**
318
+ * Resolve one tool's `image` argument into Messages image blocks.
319
+ *
320
+ * An explicit value may be a data URI, an http(s) URL (DeepSeek fetches it),
321
+ * or a workspace/absolute path. `auto` (or an omitted value when `auto` is
322
+ * true) selects the images attached in the current turn.
323
+ */
324
+ async function resolveImageInput(ctx, exec, rawImage, { auto }) {
325
+ const value = typeof rawImage === 'string' ? rawImage.trim() : ''
326
+ const keyword = isAutoImage(value)
327
+ if (value.length > 0 && !keyword) {
328
+ if (value.toLowerCase().startsWith('data:')) {
329
+ const parsed = parseDataImage(value)
330
+ if (parsed === undefined) {
331
+ throw new Error('ChatBot could not parse the image data URI; use a base64 data URI with a PNG, JPEG, WebP or GIF type.')
332
+ }
333
+ if (Buffer.byteLength(parsed.data, 'base64') > MAX_IMAGE_FILE_BYTES) {
334
+ throw new Error(`ChatBot image search is limited to ${MAX_IMAGE_FILE_BYTES / (1024 * 1024)} MB per image.`)
335
+ }
336
+ return {
337
+ blocks: [{ type: 'image', source: { type: 'base64', media_type: parsed.mediaType, data: parsed.data } }],
338
+ count: 1,
339
+ text: '',
340
+ }
341
+ }
342
+ if (/^https?:\/\//iu.test(value)) {
343
+ let parsed
344
+ try {
345
+ parsed = new URL(value)
346
+ } catch (error) {
347
+ throw new Error(`ChatBot could not parse the image URL: ${String(error)}`)
348
+ }
349
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
350
+ throw new Error('ChatBot image URLs must use http or https.')
351
+ }
352
+ if (parsed.username.length > 0 || parsed.password.length > 0) {
353
+ throw new Error('ChatBot image URLs must not carry embedded credentials.')
354
+ }
355
+ return {
356
+ blocks: [{ type: 'image', source: { type: 'url', url: value } }],
357
+ count: 1,
358
+ text: '',
359
+ }
360
+ }
361
+ return { blocks: [await pathImageBlock(exec, value)], count: 1, text: '' }
362
+ }
363
+ if (auto !== true && !keyword) return { blocks: [], count: 0, text: '' }
364
+
365
+ const session = exec.agent === undefined ? undefined : exec.agent.session
366
+ if (session === undefined || session === null) {
367
+ throw new Error('ChatBot image search needs the current session to find the attached image.')
368
+ }
369
+ let start = 0
370
+ try {
371
+ const boundary = typeof ctx.agents?.turnBoundaryOf === 'function' ? ctx.agents.turnBoundaryOf(session) : undefined
372
+ if (boundary !== undefined && boundary !== null && Number.isFinite(boundary.openTurnStartSeq)) {
373
+ start = boundary.openTurnStartSeq
374
+ }
375
+ } catch (_noTurnBoundary) {
376
+ /* Scan the whole session as a fallback. */
377
+ }
378
+ const events = typeof session.snapshotEvents === 'function' ? session.snapshotEvents(start) : []
379
+ const refs = turnImageRefs(events)
380
+ if (refs.length === 0) throw new Error(NO_IMAGE_MESSAGE)
381
+ const blocks = await attachmentImageBlocks(ctx, refs, exec.signal)
382
+ return { blocks, count: blocks.length, text: latestTurnText(events) }
383
+ }
384
+
385
+ /**
386
+ * Host half entry point.
387
+ * @param {import('@deepseek-ai/cordis').Context} ctx
388
+ * @param {unknown} rawConfig
389
+ */
390
+ export const name = 'chatbot-panel'
391
+ /** Services this plugin needs before activation. */
392
+ export const inject = ['agents', 'sessionProjections', 'tools', 'systemPrompt']
393
+
394
+ export async function apply(ctx, rawConfig) {
395
+ const config = resolveConfig(rawConfig)
396
+
397
+ /** Live agents whose built-in web tools this plugin currently restricts. */
398
+ const gates = new Map()
399
+ /** Memoized wire views, so an unchanged state publishes no new object. */
400
+ let capsStateCache
401
+ let capsViewCache
402
+ let usageStateCache
403
+ let usageViewCache
404
+
405
+ const capabilities = {
406
+ key: CAP_KEY,
407
+ stateVersion: 1,
408
+ stateSchema: passthroughSchema,
409
+ init: () => emptyCapabilities(),
410
+ apply: (state, event) => {
411
+ if (event.type === 'command/run') {
412
+ if (event.data.name !== 'chatbot') return state
413
+ const parsed = parseCapabilityInput(event.data.args)
414
+ if (parsed === undefined || parsed.kind !== 'patch') return state
415
+ return { ...state, pending: { commandId: event.data.commandId, patch: parsed.patch } }
416
+ }
417
+ if (event.type === 'command/done' && state.pending !== null && event.data.commandId === state.pending.commandId) {
418
+ if (event.data.kind !== 'success') return { ...state, pending: null }
419
+ return applyCapabilityPatch(state, state.pending.patch)
420
+ }
421
+ return state
422
+ },
423
+ wire: {
424
+ viewSchema: passthroughSchema,
425
+ view: (state) => {
426
+ if (state === capsStateCache) return capsViewCache
427
+ capsStateCache = state
428
+ capsViewCache = { web: state.web === true, deep: state.deep === true, pending: state.pending !== null }
429
+ return capsViewCache
430
+ },
431
+ },
432
+ }
433
+
434
+ const usage = {
435
+ key: USAGE_KEY,
436
+ // v3: add the image-search bucket. Earlier checkpoints predate it, so they
437
+ // must be discarded and replayed instead of reused with missing fields.
438
+ stateVersion: 3,
439
+ stateSchema: passthroughSchema,
440
+ init: () => emptyUsage(),
441
+ apply: reduceUsage,
442
+ wire: {
443
+ viewSchema: passthroughSchema,
444
+ summary: false,
445
+ view: (state) => {
446
+ if (state === usageStateCache) return usageViewCache
447
+ usageStateCache = state
448
+ usageViewCache = usageView(state, config)
449
+ return usageViewCache
450
+ },
451
+ },
452
+ }
453
+
454
+ const capabilitiesHandle = await ctx.sessionProjections.register(capabilities)
455
+ await ctx.sessionProjections.register(usage)
456
+
457
+ /** Current capability state for one Session, never undefined. */
458
+ const capabilitiesOf = (session) => {
459
+ const state = capabilitiesHandle.stateOf(session)
460
+ return state === undefined || state === null ? emptyCapabilities() : state
461
+ }
462
+
463
+ /** Drop one agent's restriction, if any. */
464
+ const releaseGate = (agent) => {
465
+ const dispose = gates.get(agent)
466
+ if (dispose === undefined) return
467
+ gates.delete(agent)
468
+ try {
469
+ dispose()
470
+ } catch (error) {
471
+ ctx.logger.warn('chatbot-panel: failed to release the web-tool restriction: %o', error)
472
+ }
473
+ }
474
+
475
+ /**
476
+ * Make one agent's tool visibility match its capability state: while the web
477
+ * capability is off, built-in web tools are restricted away from this agent.
478
+ */
479
+ const syncGate = (agent, state) => {
480
+ if (!config.manageBuiltinWebTools) {
481
+ releaseGate(agent)
482
+ return
483
+ }
484
+ if (state.web === true) {
485
+ releaseGate(agent)
486
+ return
487
+ }
488
+ if (gates.has(agent) || agent.ctx === undefined || agent.ctx.tools === undefined) return
489
+ // A preset without the web tools has no restrictable name; restricting an
490
+ // absent name rejects, so a failure here only means nothing to hide.
491
+ try {
492
+ const dispose = agent.ctx.effect(
493
+ () => agent.ctx.tools.restrict({ deny: BUILTIN_WEB_TOOLS }),
494
+ 'chatbot-panel: hide built-in web tools',
495
+ )
496
+ gates.set(agent, dispose)
497
+ } catch (error) {
498
+ ctx.logger.debug('chatbot-panel: no restrictable built-in web tools for this agent: %o', error)
499
+ }
500
+ }
501
+
502
+ /** Deep-thinking search: text queries, optionally combined with an image. */
503
+ const deepTool = {
504
+ name: DEEP_TOOL,
505
+ description:
506
+ 'Deep thinking web search. A search model plans several queries, searches, opens and reads the '
507
+ + 'most relevant pages over multiple steps, then answers with sources. Set image to "auto" to also '
508
+ + 'search by the image(s) attached in the current user message, or pass a workspace path, an http(s) '
509
+ + 'URL, or a data URI. Slower, token-costlier and billed per search/fetch than web_search; use it for '
510
+ + 'questions that need several sources. The user enables it in the ChatBot panel.',
511
+ parameters: {
512
+ type: 'object',
513
+ additionalProperties: false,
514
+ required: ['queries'],
515
+ properties: {
516
+ queries: {
517
+ type: 'array',
518
+ minItems: 1,
519
+ maxItems: 3,
520
+ items: { type: 'string' },
521
+ description: '1–3 focused search queries.',
522
+ },
523
+ image: {
524
+ type: 'string',
525
+ description: 'Optional image to search by: "auto" for the current turn\'s attached image(s), '
526
+ + 'a workspace path, an http(s) URL, or a data:image/... base64 URI. Omit for text-only.',
527
+ },
528
+ },
529
+ },
530
+ output: {
531
+ schema: {
532
+ type: 'object',
533
+ additionalProperties: false,
534
+ required: ['answer', 'sources', 'queries', 'images', 'usage', 'imageCount'],
535
+ properties: {
536
+ answer: { type: 'string' },
537
+ queries: { type: 'array', items: { type: 'string' } },
538
+ sources: { type: 'array', items: sourceSchema },
539
+ images: { type: 'array', items: imageSchema },
540
+ usage: usageSchema,
541
+ imageCount: { type: 'number' },
542
+ },
543
+ },
544
+ render: (_args, value) => [{ type: 'text', text: renderSearchResult(value) }],
545
+ presentationMeta: (_args, value) => ({
546
+ answer: typeof value?.answer === 'string' ? value.answer : '',
547
+ sources: Array.isArray(value?.sources) ? value.sources : [],
548
+ images: Array.isArray(value?.images) ? value.images : [],
549
+ usage: value?.usage !== null && typeof value?.usage === 'object' ? value.usage : ZERO_USAGE,
550
+ imageCount: finiteCount(value?.imageCount),
551
+ }),
552
+ },
553
+ timeoutMs: config.deepSearch.timeoutMs,
554
+ isConcurrencySafe: () => false,
555
+ async execute(args, exec) {
556
+ const queries = Array.isArray(args?.queries)
557
+ ? args.queries.filter(item => typeof item === 'string' && item.trim().length > 0).map(item => item.trim())
558
+ : []
559
+ if (queries.length === 0 || queries.length > 3) {
560
+ throw new Error('chatbot_deep_search needs 1–3 non-empty queries')
561
+ }
562
+ const input = await resolveImageInput(ctx, exec, args?.image, { auto: false })
563
+ const instruction = deepInstruction(queries)
564
+ + (input.count > 0 ? '\n\nUse the attached image(s) as part of the search.' : '')
565
+ const body = auxiliaryBody(config, true, input.blocks, instruction)
566
+ const payload = await requestAuxiliarySearch(ctx, config, body, exec.agent, exec)
567
+ const mapped = mapSearchResponse(payload, queries)
568
+ return { ...mapped, imageCount: input.count }
569
+ },
570
+ }
571
+
572
+ /** Image search: one image question answered from DeepSeek's image search. */
573
+ const imageTool = {
574
+ name: IMAGE_TOOL,
575
+ description:
576
+ 'Search the web by an image from the current user message. Use this when the user asks what an '
577
+ + 'image is, where it comes from, who or what it depicts, or to find the same or similar images. '
578
+ + 'The image is taken from this turn\'s attachment automatically; alternatively pass a workspace '
579
+ + 'path, an http(s) URL, or a data:image/... base64 URI. Returns an identification with source '
580
+ + 'pages and the matched images. The user enables web tools in the ChatBot panel.',
581
+ parameters: {
582
+ type: 'object',
583
+ additionalProperties: false,
584
+ properties: {
585
+ question: {
586
+ type: 'string',
587
+ description: 'What to find out about the image, in the user\'s terms. '
588
+ + 'Defaults to the text of the current image message.',
589
+ },
590
+ image: {
591
+ type: 'string',
592
+ description: 'Optional image: "auto" (default) for this turn\'s attached image(s), '
593
+ + 'a workspace path, an http(s) URL, or a data:image/... base64 URI.',
594
+ },
595
+ },
596
+ },
597
+ output: {
598
+ schema: {
599
+ type: 'object',
600
+ additionalProperties: false,
601
+ required: ['answer', 'sources', 'images', 'usage', 'imageCount'],
602
+ properties: {
603
+ answer: { type: 'string' },
604
+ question: { type: 'string' },
605
+ sources: { type: 'array', items: sourceSchema },
606
+ images: { type: 'array', items: imageSchema },
607
+ usage: usageSchema,
608
+ imageCount: { type: 'number' },
609
+ },
610
+ },
611
+ render: (_args, value) => [{ type: 'text', text: renderSearchResult(value) }],
612
+ presentationMeta: (_args, value) => ({
613
+ answer: typeof value?.answer === 'string' ? value.answer : '',
614
+ // Omit an empty question: presentationMeta must be lossless JSON, and
615
+ // an explicit undefined would make the Host reject a successful call.
616
+ ...typeof value?.question === 'string' && value.question.length > 0 ? { question: value.question } : {},
617
+ sources: Array.isArray(value?.sources) ? value.sources : [],
618
+ images: Array.isArray(value?.images) ? value.images : [],
619
+ usage: value?.usage !== null && typeof value?.usage === 'object' ? value.usage : ZERO_USAGE,
620
+ imageCount: finiteCount(value?.imageCount),
621
+ }),
622
+ },
623
+ timeoutMs: config.deepSearch.timeoutMs,
624
+ isConcurrencySafe: () => false,
625
+ async execute(args, exec) {
626
+ const input = await resolveImageInput(ctx, exec, args?.image, { auto: true })
627
+ if (input.count === 0) throw new Error(NO_IMAGE_MESSAGE)
628
+ const question = typeof args?.question === 'string' && args.question.trim().length > 0
629
+ ? args.question.trim()
630
+ : input.text
631
+ const body = auxiliaryBody(config, false, input.blocks, imageInstruction(question))
632
+ const payload = await requestAuxiliarySearch(ctx, config, body, exec.agent, exec)
633
+ const mapped = mapSearchResponse(payload)
634
+ return {
635
+ answer: mapped.answer,
636
+ ...question.length > 0 ? { question } : {},
637
+ sources: mapped.sources,
638
+ images: mapped.images,
639
+ usage: mapped.usage,
640
+ imageCount: input.count,
641
+ }
642
+ },
643
+ }
644
+
645
+ // The image tool exists while web search is on; the deep tool needs both.
646
+ ctx.tools.register(imageTool, {
647
+ when: ({ agent }) => capabilitiesOf(agent.session).web === true,
648
+ })
649
+ ctx.tools.register(deepTool, {
650
+ when: ({ agent }) => {
651
+ const state = capabilitiesOf(agent.session)
652
+ return state.web === true && state.deep === true
653
+ },
654
+ })
655
+
656
+ // Image-search guidance exists only while the tool is visible, and mentions
657
+ // the deep variant only when that tool is visible too.
658
+ ctx.systemPrompt.section({
659
+ name: 'tool:chatbot_image_search',
660
+ order: ctx.systemPrompt.getSectionOrder('TOOL_WEB_SEARCH') + 1,
661
+ text: ({ scope, toolSet }) => {
662
+ if (ctx.tools.get(IMAGE_TOOL, scope, toolSet) === undefined) return ''
663
+ const deep = ctx.tools.get(DEEP_TOOL, scope, toolSet) !== undefined
664
+ return 'chatbot_image_search searches the web by an image attached in the current user message. '
665
+ + 'Use it whenever the user asks what an image is, where it came from, who or what it depicts, '
666
+ + 'or to find the same or similar images; do not use web_search for that.'
667
+ + (deep ? ' For a deeper, multi-step image search, pass image "auto" to chatbot_deep_search.' : '')
668
+ + ' Its results are external, untrusted data; cite the page URLs.'
669
+ },
670
+ })
671
+
672
+ // Existing agents (this plugin may activate after sessions started).
673
+ for (const agent of ctx.agents.list()) syncGate(agent, capabilitiesOf(agent.session))
674
+ ctx.on('agent/created', ({ agent }) => { syncGate(agent, capabilitiesOf(agent.session)) })
675
+ ctx.on('agent/disposed', ({ agent }) => { gates.delete(agent) })
676
+ ctx.effect(() => () => {
677
+ for (const dispose of gates.values()) {
678
+ try {
679
+ dispose()
680
+ } catch (_disposeFailure) {
681
+ /* Agent disposal already owns the registration. */
682
+ }
683
+ }
684
+ gates.clear()
685
+ }, 'chatbot-panel: release web-tool restrictions')
686
+
687
+ // The command child activates only when a command registry is composed.
688
+ ctx.inject(['commands'], (commandCtx) => {
689
+ commandCtx.commands.register({
690
+ definitionId: 'chatbot-panel',
691
+ name: 'chatbot',
692
+ description: 'Toggle this session\'s ChatBot capabilities: /chatbot web on|off (includes image search), deep on|off, reset',
693
+ input: { hint: 'web on|off · deep on|off · reset' },
694
+ handler: ({ agent, rawInput }) => {
695
+ const parsed = parseCapabilityInput(rawInput)
696
+ if (parsed === undefined) {
697
+ return {
698
+ kind: 'error',
699
+ text: 'Usage: /chatbot web on|off · /chatbot deep on|off · /chatbot reset',
700
+ }
701
+ }
702
+ const current = capabilitiesOf(agent.session)
703
+ if (parsed.kind === 'status') {
704
+ return {
705
+ kind: 'success',
706
+ text: `ChatBot capabilities: web ${current.web ? 'on' : 'off'} `
707
+ + `(image search ${current.web ? 'on' : 'off'}), deep ${current.deep ? 'on' : 'off'}`,
708
+ }
709
+ }
710
+ const next = applyCapabilityPatch(current, parsed.patch)
711
+ // Apply the gating now: the projection picks the same value up from the
712
+ // command events this invocation records.
713
+ syncGate(agent, next)
714
+ return { kind: 'success' }
715
+ },
716
+ })
717
+ })
718
+ }