@kingsoftcloud/agentkit-personal-assistant 0.0.0-stage → 0.1.7

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 (70) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +7 -0
  3. package/README.md +38 -2
  4. package/cordis.patch.yml +6 -0
  5. package/dist/client.js +6 -0
  6. package/package.json +83 -4
  7. package/personal-assistant.plugin.json +31 -0
  8. package/personal-assistant.plugin.schema.json +43 -0
  9. package/runtime/assistant-runtime.mjs +317 -0
  10. package/runtime/check-manifest.mjs +21 -0
  11. package/runtime/cloud-browser-cdp.mjs +313 -0
  12. package/runtime/cloud-browser.mjs +972 -0
  13. package/runtime/cloud-execution.mjs +320 -0
  14. package/runtime/cloud-session-channel.mjs +45 -0
  15. package/runtime/computer-agentd.mjs +488 -0
  16. package/runtime/desktop-clipboard-limit.mjs +2 -0
  17. package/runtime/desktop-launcher.mjs +106 -0
  18. package/runtime/desktop-permit.mjs +42 -0
  19. package/runtime/desktop-session.mjs +840 -0
  20. package/runtime/desktop-ws.mjs +371 -0
  21. package/runtime/prepare-dsh-profile.mjs +17 -0
  22. package/runtime/welcome-server.mjs +144 -0
  23. package/scripts/assistant-runtime-launcher.sh +3 -0
  24. package/scripts/build-artifacts.mjs +81 -0
  25. package/scripts/build-client.mjs +82 -0
  26. package/scripts/computer-agentd-launcher.sh +3 -0
  27. package/scripts/deterministic_tar.py +21 -0
  28. package/scripts/install-local.mjs +262 -0
  29. package/scripts/scan-secrets.mjs +41 -0
  30. package/scripts/sync-public.mjs +52 -0
  31. package/scripts/verification/gateway-desktop-client.mjs +142 -0
  32. package/scripts/verification/verify-desktop-applications-linux.mjs +46 -0
  33. package/scripts/verification/verify-desktop-input-linux.mjs +127 -0
  34. package/scripts/verification/verify-desktop-pod-gateway.mjs +204 -0
  35. package/scripts/verify-cloud-turn.mjs +18 -0
  36. package/scripts/verify-computer-broker.mjs +10 -0
  37. package/src/automation-tools.mjs +18 -0
  38. package/src/bot-private-tools.mjs +23 -0
  39. package/src/bot-registry.mjs +157 -0
  40. package/src/bot-tool-policy.mjs +48 -0
  41. package/src/bot-tool-scope.mjs +40 -0
  42. package/src/client/bot-directory.mjs +23 -0
  43. package/src/client/components/desktop-clipboard.mjs +194 -0
  44. package/src/client/conversation.mjs +558 -0
  45. package/src/client/desktop-control.mjs +70 -0
  46. package/src/client/desktop-view.mjs +68 -0
  47. package/src/client/entry.mjs +8 -0
  48. package/src/client/index.mjs +733 -0
  49. package/src/client/markdown.mjs +130 -0
  50. package/src/client/native-workbench.mjs +62 -0
  51. package/src/cloud-computer-tools.mjs +245 -0
  52. package/src/cloud-files.mjs +57 -0
  53. package/src/cloud-model.mjs +10 -0
  54. package/src/cloud-session-transport.mjs +76 -0
  55. package/src/computer-broker-protocol.mjs +116 -0
  56. package/src/computer-broker.mjs +220 -0
  57. package/src/desktop-tools.mjs +64 -0
  58. package/src/dsh-session-bridge.mjs +207 -0
  59. package/src/dsh-web-bridge.mjs +127 -0
  60. package/src/host-gateway.mjs +281 -0
  61. package/src/host-session-channel.mjs +410 -0
  62. package/src/implementation-capabilities.mjs +10 -0
  63. package/src/index.mjs +148 -0
  64. package/src/persistent-state.mjs +201 -0
  65. package/src/platform-manifest.mjs +24 -0
  66. package/src/plugin-manifest.mjs +82 -0
  67. package/src/schedule-manager.mjs +88 -0
  68. package/src/system-fetch.mjs +72 -0
  69. package/src/workspace-tools.mjs +22 -0
  70. package/src/workspace.mjs +22 -0
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Public protocol boundary between Personal Assistant Runtime and Computer.
3
+ *
4
+ * These helpers validate and shape messages. They do not open sockets, spawn a
5
+ * browser, control a display, or imply that a computer is connected.
6
+ */
7
+
8
+ export const COMPUTER_BROKER_PROTOCOL = 'personal-assistant.computer-broker/v1';
9
+ export const COMPUTER_BROKER_ACTIONS = Object.freeze([
10
+ 'computer.status',
11
+ 'computer.lease.acquire',
12
+ 'computer.lease.renew',
13
+ 'computer.lease.release',
14
+ 'computer.action',
15
+ ]);
16
+ export const COMPUTER_ACTION_NAMES = Object.freeze([
17
+ 'open',
18
+ 'snapshot',
19
+ 'screenshot',
20
+ 'stream',
21
+ 'click',
22
+ 'type',
23
+ 'press',
24
+ 'download',
25
+ 'scroll',
26
+ 'clipboard.read',
27
+ 'clipboard.write',
28
+ 'files.list',
29
+ 'files.read',
30
+ 'files.write',
31
+ 'desktop.capture',
32
+ 'desktop.input',
33
+ 'desktop.windows',
34
+ 'desktop.focus',
35
+ 'desktop.launch',
36
+ 'terminal.exec',
37
+ 'stop',
38
+ ]);
39
+
40
+ function text(value, field) {
41
+ const result = String(value ?? '').trim();
42
+ if (!result) throw new TypeError(`${field} must be a non-empty string`);
43
+ return result;
44
+ }
45
+
46
+ function positiveInteger(value, field, fallback) {
47
+ const result = value === undefined ? fallback : Number(value);
48
+ if (!Number.isInteger(result) || result <= 0) throw new TypeError(`${field} must be a positive integer`);
49
+ return result;
50
+ }
51
+
52
+ function object(value, field) {
53
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(`${field} must be an object`);
54
+ return value;
55
+ }
56
+
57
+ export function createLeaseRequest(input = {}) {
58
+ return Object.freeze({
59
+ protocol: COMPUTER_BROKER_PROTOCOL,
60
+ ...(input.userId === undefined ? {} : { userId: text(input.userId, 'userId') }),
61
+ assistantId: text(input.assistantId, 'assistantId'),
62
+ botId: text(input.botId, 'botId'),
63
+ computerId: text(input.computerId, 'computerId'),
64
+ taskId: text(input.taskId, 'taskId'),
65
+ idempotencyKey: text(input.idempotencyKey ?? `${input.assistantId}:${input.botId}:${input.taskId}`, 'idempotencyKey'),
66
+ ttlMs: positiveInteger(input.ttlMs, 'ttlMs', 60_000),
67
+ });
68
+ }
69
+
70
+ export function createReleaseRequest(input = {}) {
71
+ return Object.freeze({
72
+ protocol: COMPUTER_BROKER_PROTOCOL,
73
+ ...(input.userId === undefined ? {} : { userId: text(input.userId, 'userId') }),
74
+ assistantId: text(input.assistantId, 'assistantId'),
75
+ botId: text(input.botId, 'botId'),
76
+ computerId: text(input.computerId, 'computerId'),
77
+ taskId: text(input.taskId, 'taskId'),
78
+ leaseId: text(input.leaseId, 'leaseId'),
79
+ });
80
+ }
81
+
82
+ export function createRenewRequest(input = {}) {
83
+ const ttlMs = positiveInteger(input.ttlMs, 'ttlMs', 60_000);
84
+ if (ttlMs > 300_000) throw new TypeError('ttlMs must not exceed 300000');
85
+ return Object.freeze({ ...createReleaseRequest(input), ttlMs });
86
+ }
87
+
88
+ export function createActionRequest(input = {}) {
89
+ const action = text(input.action, 'action');
90
+ if (!COMPUTER_ACTION_NAMES.includes(action)) throw new TypeError(`Unsupported computer action: ${action}`);
91
+ return Object.freeze({
92
+ protocol: COMPUTER_BROKER_PROTOCOL,
93
+ ...(input.userId === undefined ? {} : { userId: text(input.userId, 'userId') }),
94
+ assistantId: text(input.assistantId, 'assistantId'),
95
+ botId: text(input.botId, 'botId'),
96
+ computerId: text(input.computerId, 'computerId'),
97
+ taskId: text(input.taskId, 'taskId'),
98
+ leaseId: text(input.leaseId, 'leaseId'),
99
+ action,
100
+ args: Object.freeze({ ...object(input.args ?? {}, 'args') }),
101
+ });
102
+ }
103
+
104
+ export function createStatusRequest(input = {}) {
105
+ return Object.freeze({
106
+ protocol: COMPUTER_BROKER_PROTOCOL,
107
+ ...(input.userId === undefined ? {} : { userId: text(input.userId, 'userId') }),
108
+ assistantId: text(input.assistantId, 'assistantId'),
109
+ botId: text(input.botId, 'botId'),
110
+ computerId: text(input.computerId, 'computerId'),
111
+ });
112
+ }
113
+
114
+ export function isComputerBrokerMessage(value) {
115
+ return Boolean(value && typeof value === 'object' && value.protocol === COMPUTER_BROKER_PROTOCOL);
116
+ }
@@ -0,0 +1,220 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import {
3
+ COMPUTER_BROKER_ACTIONS,
4
+ createActionRequest,
5
+ createLeaseRequest,
6
+ createReleaseRequest,
7
+ createRenewRequest,
8
+ createStatusRequest,
9
+ } from './computer-broker-protocol.mjs';
10
+
11
+ const BROKER_PATHS = Object.freeze({
12
+ 'computer.status': '/broker/status',
13
+ 'computer.lease.acquire': '/broker/lease/acquire',
14
+ 'computer.lease.renew': '/broker/lease/renew',
15
+ 'computer.lease.release': '/broker/lease/release',
16
+ 'computer.action': '/broker/action',
17
+ });
18
+
19
+ function loopbackHost(value) {
20
+ const host = String(value || '').replace(/^\[|\]$/gu, '').toLowerCase();
21
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1';
22
+ }
23
+
24
+ /**
25
+ * HTTP transport for the localhost Computer Broker sidecar.
26
+ *
27
+ * The runtime and Computer containers share a network namespace. Keeping this
28
+ * transport loopback-only prevents a bad deployment from turning the broker
29
+ * into a cluster-reachable control endpoint. Tests can inject fetchImpl, but
30
+ * production callers should use the default fetch implementation.
31
+ */
32
+ export function createHttpComputerBrokerTransport({ baseUrl, fetchImpl = globalThis.fetch, timeoutMs = 30_000 } = {}) {
33
+ if (typeof baseUrl !== 'string' || !baseUrl.trim()) throw new TypeError('baseUrl must be a non-empty string');
34
+ let endpoint;
35
+ try { endpoint = new URL(baseUrl); } catch { throw new TypeError('baseUrl must be a valid URL'); }
36
+ if (!['http:', 'https:'].includes(endpoint.protocol)) throw new TypeError('baseUrl must use http or https');
37
+ if (endpoint.username || endpoint.password) throw new TypeError('baseUrl must not contain credentials');
38
+ if (!loopbackHost(endpoint.hostname)) throw new TypeError('Computer Broker URL must resolve to loopback');
39
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 300_000) throw new TypeError('timeoutMs must be between 1 and 300000');
40
+ if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
41
+
42
+ return Object.freeze({
43
+ endpoint: endpoint.toString(),
44
+ async request(operation, payload, { signal } = {}) {
45
+ const path = BROKER_PATHS[operation];
46
+ if (!path) throw new TypeError(`Unsupported computer broker operation: ${operation}`);
47
+ const target = new URL(path, endpoint);
48
+ const controller = new AbortController();
49
+ // 长命令的执行期限由 Computer 自身约束;RPC 留出终止进程组及回传结果的余量。
50
+ const requested = payload?.action === 'terminal.exec' ? (payload.args?.timeoutMs ?? 120_000) : 0;
51
+ const executionMs = Number.isInteger(requested) && requested > 0 ? Math.min(requested, 300_000) : 0;
52
+ const deadlineMs = executionMs ? Math.max(timeoutMs, executionMs + 5000) : timeoutMs;
53
+ const timer = setTimeout(() => controller.abort(), deadlineMs);
54
+ const combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
55
+ try {
56
+ const response = await fetchImpl(target, {
57
+ method: 'POST',
58
+ headers: { accept: 'application/json', 'content-type': 'application/json' },
59
+ body: JSON.stringify(payload),
60
+ signal: combined,
61
+ });
62
+ const body = await response.json().catch(error => {
63
+ if (combined.aborted) throw error;
64
+ throw Object.assign(new Error(`Computer Broker returned non-JSON response (${response.status})`), { code: 'COMPUTER_BROKER_FAILED', status: response.ok ? 502 : response.status });
65
+ });
66
+ if (!response.ok) throw Object.assign(new Error(body.message || body.error || `Computer Broker returned HTTP ${response.status}`), { code: body.error || 'COMPUTER_BROKER_FAILED', status: response.status, ...(body.outcomeUnknown ? { outcomeUnknown: true } : {}) });
67
+ return body;
68
+ } catch (error) {
69
+ if (error.status) throw error;
70
+ const wrapped = new Error(`Computer Broker request failed: ${error?.message || 'network error'}`);
71
+ wrapped.code = signal?.aborted ? (typeof signal.reason?.code === 'string' ? signal.reason.code : 'COMPUTER_ACTION_ABORTED') : controller.signal.aborted ? 'COMPUTER_BROKER_TIMEOUT' : 'COMPUTER_BROKER_UNAVAILABLE';
72
+ if (operation === 'computer.action') { wrapped.outcomeUnknown = true; wrapped.message += ';动作可能已部分执行,请先核对结果,勿盲目重放'; }
73
+ if (signal?.reason?.status) wrapped.status = signal.reason.status;
74
+ wrapped.cause = error;
75
+ throw wrapped;
76
+ } finally {
77
+ clearTimeout(timer);
78
+ }
79
+ },
80
+ });
81
+ }
82
+
83
+ export class ComputerBrokerUnavailableError extends Error {
84
+ constructor(message = 'Cloud Computer Broker is not connected') {
85
+ super(message);
86
+ this.name = 'ComputerBrokerUnavailableError';
87
+ this.code = 'COMPUTER_BROKER_UNAVAILABLE';
88
+ }
89
+ }
90
+
91
+ export class ComputerLeaseConflictError extends Error {
92
+ constructor(computerId) {
93
+ super(`Computer is already leased: ${computerId}`);
94
+ this.name = 'ComputerLeaseConflictError';
95
+ this.code = 'COMPUTER_LEASE_CONFLICT';
96
+ }
97
+ }
98
+
99
+ function requestTransport(transport) {
100
+ if (!transport || typeof transport.request !== 'function') throw new ComputerBrokerUnavailableError();
101
+ return transport;
102
+ }
103
+
104
+ /**
105
+ * Create the runtime-side client. A real transport is intentionally required;
106
+ * the default is disconnected so a deployment cannot claim computer access.
107
+ */
108
+ export function createComputerBrokerClient({ transport, now = () => Date.now() } = {}) {
109
+ const transportOrNull = transport ?? null;
110
+ const call = async (operation, payload, options) => {
111
+ const active = requestTransport(transportOrNull);
112
+ if (!COMPUTER_BROKER_ACTIONS.includes(operation)) throw new TypeError(`Unsupported computer broker operation: ${operation}`);
113
+ return active.request(operation, payload, options);
114
+ };
115
+ return Object.freeze({
116
+ connected: () => Boolean(transportOrNull),
117
+ status: input => call('computer.status', createStatusRequest(input)),
118
+ acquireLease: input => call('computer.lease.acquire', createLeaseRequest(input)),
119
+ renewLease: (input, options) => call('computer.lease.renew', createRenewRequest(input), options),
120
+ releaseLease: input => call('computer.lease.release', createReleaseRequest(input)),
121
+ action: (input, options) => call('computer.action', createActionRequest(input), options),
122
+ now,
123
+ });
124
+ }
125
+
126
+ /**
127
+ * In-memory lease manager used by adapters and tests. It does not execute a
128
+ * computer action; it only serializes access to one computer ID.
129
+ */
130
+ export class ComputerLeaseManager {
131
+ constructor({ now = () => Date.now(), leaseId = () => randomUUID() } = {}) {
132
+ this.now = now;
133
+ this.leaseId = leaseId;
134
+ this.leases = new Map();
135
+ this.idempotency = new Map();
136
+ }
137
+
138
+ _expire() {
139
+ const now = this.now();
140
+ for (const [computerId, lease] of this.leases) {
141
+ if (lease.expiresAt <= now) {
142
+ this.leases.delete(computerId);
143
+ this.idempotency.delete(lease.idempotencyKey);
144
+ }
145
+ }
146
+ }
147
+
148
+ acquire(input) {
149
+ this._expire();
150
+ const existingByKey = this.idempotency.get(input.idempotencyKey);
151
+ if (existingByKey && existingByKey.expiresAt > this.now()) return existingByKey;
152
+ const existing = this.leases.get(input.computerId);
153
+ if (existing) throw new ComputerLeaseConflictError(input.computerId);
154
+ const lease = Object.freeze({
155
+ leaseId: this.leaseId(),
156
+ ...(input.userId === undefined ? {} : { userId: input.userId }),
157
+ computerId: input.computerId,
158
+ assistantId: input.assistantId,
159
+ botId: input.botId,
160
+ taskId: input.taskId,
161
+ idempotencyKey: input.idempotencyKey,
162
+ expiresAt: this.now() + input.ttlMs,
163
+ });
164
+ this.leases.set(lease.computerId, lease);
165
+ this.idempotency.set(lease.idempotencyKey, lease);
166
+ return lease;
167
+ }
168
+
169
+ release(input) {
170
+ this._expire();
171
+ const lease = this.leases.get(input.computerId);
172
+ if (!lease || lease.leaseId !== input.leaseId) return false;
173
+ this.leases.delete(input.computerId);
174
+ this.idempotency.delete(lease.idempotencyKey);
175
+ return true;
176
+ }
177
+
178
+ renew(input) {
179
+ const lease = this.assert(input);
180
+ if (lease.assistantId !== input.assistantId || lease.botId !== input.botId || lease.userId !== input.userId) throw new Error('COMPUTER_LEASE_REQUIRED');
181
+ const renewed = Object.freeze({ ...lease, expiresAt: this.now() + input.ttlMs });
182
+ this.leases.set(lease.computerId, renewed); this.idempotency.set(lease.idempotencyKey, renewed);
183
+ return renewed;
184
+ }
185
+
186
+ assert(input) {
187
+ this._expire();
188
+ const lease = this.leases.get(input.computerId);
189
+ if (!lease || lease.leaseId !== input.leaseId || lease.taskId !== input.taskId) {
190
+ throw new Error('COMPUTER_LEASE_REQUIRED');
191
+ }
192
+ return lease;
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Explicitly simulated transport for unit tests and local contract demos.
198
+ * The response includes `simulated: true` to prevent it being mistaken for a
199
+ * real Cloud Computer connection.
200
+ */
201
+ export function createMockComputerBroker({ computerId = 'mock-computer', now = () => Date.now() } = {}) {
202
+ const leases = new ComputerLeaseManager({ now, leaseId: () => `mock-lease-${Math.random().toString(16).slice(2)}` });
203
+ const actions = [];
204
+ const transport = {
205
+ async request(operation, payload) {
206
+ if (operation === 'computer.status') return { protocol: payload.protocol, computerId, connected: true, simulated: true };
207
+ if (operation === 'computer.lease.acquire') return { protocol: payload.protocol, simulated: true, lease: leases.acquire(payload) };
208
+ if (operation === 'computer.lease.renew') return { protocol: payload.protocol, simulated: true, lease: leases.renew(payload) };
209
+ if (operation === 'computer.lease.release') return { protocol: payload.protocol, simulated: true, released: leases.release(payload) };
210
+ if (operation === 'computer.action') {
211
+ leases.assert(payload);
212
+ const event = Object.freeze({ protocol: payload.protocol, simulated: true, computerId, taskId: payload.taskId, action: payload.action, args: payload.args });
213
+ actions.push(event);
214
+ return { ...event, accepted: true };
215
+ }
216
+ throw new Error(`Unsupported mock operation: ${operation}`);
217
+ },
218
+ };
219
+ return Object.freeze({ client: createComputerBrokerClient({ transport, now }), actions, leases });
220
+ }
@@ -0,0 +1,64 @@
1
+ // 模型只通过 Broker 操作与用户看到的同一桌面;没有本机 shell 或第二个浏览器。
2
+ const actions = ['screenshot', 'click', 'double_click', 'right_click', 'type', 'key', 'scroll', 'drag', 'windows', 'focus', 'launch'];
3
+ const field = (value, name, max = 16384) => {
4
+ if (typeof value !== 'string' || !value.length || Buffer.byteLength(value) > max) throw new TypeError(`${name} is invalid`);
5
+ return value;
6
+ };
7
+ const point = (args, x = 'x', y = 'y') => {
8
+ if (!Number.isInteger(args[x]) || args[x] < 0 || args[x] >= 1280 || !Number.isInteger(args[y]) || args[y] < 0 || args[y] >= 800) throw new TypeError('coordinates must be desktop pixels within 1280x800');
9
+ return { x: args[x], y: args[y] };
10
+ };
11
+ export function createDesktopTool({ action, agent } = {}) {
12
+ return {
13
+ name: 'cloud_computer',
14
+ description: 'Operate the entire shared cloud desktop, including browser, terminal and file manager. Use windows/launch/focus or screenshot to inspect, then click/type/key/scroll/drag. Coordinates are 1280x800 desktop pixels. Human takeover pauses new actions until control is returned. Screenshot requires an image-capable cloud model; never pretend to see an image when unavailable.',
15
+ parameters: { type: 'object', additionalProperties: false, required: ['action'], properties: {
16
+ action: { type: 'string', enum: actions }, x: { type: 'integer' }, y: { type: 'integer' }, toX: { type: 'integer' }, toY: { type: 'integer' },
17
+ text: { type: 'string' }, key: { type: 'string' }, deltaY: { type: 'integer', minimum: -20, maximum: 20 },
18
+ windowId: { type: 'string' }, application: { type: 'string', enum: ['browser', 'terminal', 'files'] },
19
+ } },
20
+ output: { schema: { type: 'object', required: ['result'], properties: {
21
+ result: { type: 'string' }, image: { type: 'object', required: ['attachmentId', 'mediaType', 'bytes', 'width', 'height'], properties: {
22
+ attachmentId: { type: 'string' }, mediaType: { type: 'string' }, bytes: { type: 'number' }, width: { type: 'number' }, height: { type: 'number' }, name: { type: 'string' },
23
+ } },
24
+ } }, render: (_args, value) => [{ type: 'text', text: value.result }, ...(value.image ? [{ type: 'image', attachment: value.image }] : [])] },
25
+ async execute(args, execution = {}) {
26
+ if (!actions.includes(args.action)) throw new TypeError('unsupported desktop action');
27
+ const send = event => action('desktop.input', { event }, execution);
28
+ if (args.action === 'screenshot') {
29
+ const attachments = agent?.ctx?.get?.('attachments'), llm = agent?.ctx?.get?.('llm');
30
+ const route = execution.agent?.session?.requestHeader?.()?.config;
31
+ const info = await llm?.resolveModelInfo(route?.provider ?? agent?.options?.provider, route?.model ?? agent?.options?.model, execution.signal);
32
+ if (!info?.inputModalities?.includes('image')) throw new Error('当前云端模型不支持图片输入,请使用 windows、页面 snapshot 或切换视觉模型;此回合没有取得或看见截图。');
33
+ if (!attachments?.saveImage) throw new Error('云端图片附件服务尚未就绪');
34
+ const { image } = await action('desktop.capture', {}, execution);
35
+ const data = Buffer.from(image.data, 'base64');
36
+ if (data.length > 307200 || data.length !== image.bytes || image.mediaType !== 'image/jpeg') throw new Error('invalid desktop image');
37
+ const ref = await attachments.saveImage({ data, mediaType: image.mediaType, name: 'cloud-desktop.jpg' });
38
+ return { result: `共享云桌面:${image.width}x${image.height},坐标与截图一致。`, image: ref };
39
+ }
40
+ let result;
41
+ if (args.action === 'windows') result = await action('desktop.windows', {}, execution);
42
+ else if (args.action === 'focus') result = await action('desktop.focus', { windowId: field(args.windowId, 'windowId', 32) }, execution);
43
+ else if (args.action === 'launch') {
44
+ if (!['browser', 'terminal', 'files'].includes(args.application)) throw new TypeError('unsupported application');
45
+ result = await action('desktop.launch', { application: args.application }, execution);
46
+ } else if (args.action === 'type') result = await send({ type: 'text', text: field(args.text, 'text') });
47
+ else if (args.action === 'key') result = await send({ type: 'key', key: field(args.key, 'key', 64), action: 'tap' });
48
+ else if (args.action === 'scroll') {
49
+ if (!Number.isInteger(args.deltaY) || args.deltaY < -20 || args.deltaY > 20) throw new TypeError('invalid scroll delta');
50
+ await send({ type: 'pointermove', ...point(args) }); result = await send({ type: 'wheel', deltaY: args.deltaY });
51
+ } else {
52
+ const start = point(args), events = [{ type: 'pointermove', ...start }];
53
+ if (args.action === 'drag') events.push({ type: 'button', button: 1, action: 'down', ...start }, { type: 'pointermove', ...point(args, 'toX', 'toY') }, { type: 'button', button: 1, action: 'up' });
54
+ else if (args.action === 'double_click') events.push({ type: 'button', button: 1, action: 'double', ...start });
55
+ else {
56
+ const button = args.action === 'right_click' ? 3 : 1;
57
+ events.push({ type: 'button', button, action: 'down', ...start }, { type: 'button', button, action: 'up' });
58
+ }
59
+ result = await action('desktop.input', { events }, execution);
60
+ }
61
+ return { result: JSON.stringify(result) };
62
+ },
63
+ };
64
+ }
@@ -0,0 +1,207 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { registerBotPrivateTools } from './bot-private-tools.mjs';
3
+
4
+ const BOT_ID = /^[a-z][a-z0-9._-]{0,63}$/u;
5
+ const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
6
+
7
+ /**
8
+ * Raised when the DSH Host did not inject its public Session Controller.
9
+ *
10
+ * The standalone cloud HTTP runtime intentionally does not manufacture a
11
+ * controller. It must be hosted by DSH (or be given an explicit adapter) before
12
+ * a turn can be called "executing".
13
+ */
14
+ export class DshSessionControllerUnavailableError extends Error {
15
+ constructor(message = 'DSH Session Controller is not available') {
16
+ super(message);
17
+ this.name = 'DshSessionControllerUnavailableError';
18
+ this.code = 'DSH_SESSION_CONTROLLER_UNAVAILABLE';
19
+ this.status = 503;
20
+ }
21
+ }
22
+
23
+ function serviceFromContext(ctx) {
24
+ if (ctx && typeof ctx.get === 'function') {
25
+ try { return ctx.get('sessionController'); } catch { return undefined; }
26
+ }
27
+ return ctx?.sessionController;
28
+ }
29
+
30
+ function requiredIdentifier(value, field, pattern) {
31
+ const text = String(value ?? '').trim();
32
+ if (!pattern.test(text)) throw Object.assign(new TypeError(`${field} is invalid`), { code: 'DSH_TURN_INPUT_INVALID' });
33
+ return text;
34
+ }
35
+
36
+ function contentOf(input) {
37
+ const text = String(input ?? '').trim();
38
+ if (!text) throw Object.assign(new TypeError('input is required'), { code: 'DSH_TURN_INPUT_INVALID' });
39
+ return [{ type: 'text', text }];
40
+ }
41
+
42
+ /**
43
+ * Adapt the public DSH Host SessionController to the Personal Assistant turn
44
+ * contract. The controller remains the source of truth for execution and
45
+ * history; this bridge only performs session admission and prompt delivery.
46
+ */
47
+ export function createDshTurnBridge({ ctx, sessionController, registry, platformPrompt = '', setupAgent, requestIdFactory = randomUUID } = {}) {
48
+ const controller = sessionController ?? serviceFromContext(ctx);
49
+ const available = Boolean(controller && typeof controller.prompt === 'function');
50
+ const installedPrompts = new WeakMap();
51
+ let memoryProvider;
52
+ let memoryWriter;
53
+ let sessionOwnerResolver;
54
+ const pending = new Map();
55
+ const sessionOwners = new Map();
56
+ const busySessions = new Set();
57
+ const unavailable = () => { throw new DshSessionControllerUnavailableError(); };
58
+ const memories = async botId => {
59
+ const rows = memoryProvider ? await memoryProvider(botId) : [];
60
+ const selected = [];
61
+ for (const row of rows.slice(-32).reverse()) {
62
+ const value = { key: String(row.key || ''), content: String(row.content || '').slice(0, 4096) };
63
+ if (Buffer.byteLength(JSON.stringify([...selected, value])) > 16 * 1024) break;
64
+ selected.push(value);
65
+ }
66
+ return selected;
67
+ };
68
+
69
+ async function installBotPrompt(sessionId, botId, bot) {
70
+ if (typeof controller.resolveAgent !== 'function') throw new DshSessionControllerUnavailableError('DSH Agent resolution is unavailable');
71
+ const resolved = await controller.resolveAgent(sessionId);
72
+ if (resolved?.error) throw resolved.error;
73
+ const agent = resolved?.agent ?? resolved;
74
+ const prompt = agent?.ctx?.get?.('systemPrompt');
75
+ if (!agent || typeof prompt?.section !== 'function') throw new DshSessionControllerUnavailableError('DSH Agent system prompt is unavailable');
76
+ let record = installedPrompts.get(agent);
77
+ if (record && record.botId !== botId) throw Object.assign(new Error('DSH Agent belongs to another Bot'), { code: 'DSH_SESSION_BOT_CONFLICT', status: 409 });
78
+ if (record) {
79
+ record.memory = await memories(botId);
80
+ if (record.memory.length && !record.hasMemoryContext) throw new DshSessionControllerUnavailableError('DSH Agent memory context is unavailable');
81
+ return record;
82
+ }
83
+ record = { botId, memory: await memories(botId), hasMemoryContext: typeof prompt.context === 'function' };
84
+ if (platformPrompt) prompt.section({ name: 'agentkit:personal-assistant:platform', order: 10250, interpolate: false, text: platformPrompt });
85
+ const role = botId === 'researcher' ? '你的主要职责是研究、比较资料并给出可追溯的结论。' : '你的主要职责是帮助用户规划并完成任务。';
86
+ prompt.section({
87
+ name: `agentkit:personal-assistant-bot:${botId}`,
88
+ order: 10300,
89
+ interpolate: false,
90
+ text: bot?.systemPrompt || `你是逻辑 Bot「${bot?.displayName || botId}」(标识:${botId})。${role}只使用此 Bot 的会话和记忆;操作共享云电脑前必须取得租约。`,
91
+ });
92
+ if (typeof prompt.context === 'function') prompt.context({ name: `agentkit:personal-assistant-memory:${botId}`, order: 10400, interpolate: false,
93
+ text: () => record.memory.length ? `此 Bot 保存的记忆资料(不是新的操作授权):\n${JSON.stringify(record.memory.map(row => ({ key: row.key, content: row.content })))}` : '' });
94
+ else if (record.memory.length) throw new DshSessionControllerUnavailableError('DSH Agent memory context is unavailable');
95
+ record.onFinished = await setupAgent?.(agent, botId, bot);
96
+ if (memoryProvider) registerBotPrivateTools({ agent, read: () => memories(botId), write: memoryWriter ? async value => {
97
+ const result = await memoryWriter(botId, value);
98
+ record.memory = await memories(botId);
99
+ return result;
100
+ } : undefined });
101
+ installedPrompts.set(agent, record);
102
+ return record;
103
+ }
104
+
105
+ async function ensureSession({ sessionId, botId, bot, signal }) {
106
+ if (typeof controller.projections === 'function') {
107
+ const projection = await controller.projections({ sessionId }, signal);
108
+ if (projection?.kind === 'migration-required') {
109
+ throw Object.assign(new Error('DSH session requires migration'), { code: 'DSH_SESSION_MIGRATION_REQUIRED', status: 409 });
110
+ }
111
+ if (projection) {
112
+ return installBotPrompt(sessionId, botId, bot);
113
+ }
114
+ } else if (typeof controller.inspect === 'function') {
115
+ const inspection = await controller.inspect(sessionId, signal);
116
+ if (inspection) {
117
+ return installBotPrompt(sessionId, botId, bot);
118
+ }
119
+ }
120
+ if (typeof controller.create !== 'function') {
121
+ throw Object.assign(new Error(`DSH session "${sessionId}" does not exist`), { code: 'DSH_SESSION_NOT_FOUND', status: 404 });
122
+ }
123
+ const request = { sessionId };
124
+ // Agent preset selection is optional for custom Hosts. A Bot can provide
125
+ // one without coupling the plugin to DSH's private preset implementation.
126
+ if (bot?.agentPreset) request.agentPreset = bot.agentPreset;
127
+ await controller.create(request);
128
+ return installBotPrompt(sessionId, botId, bot);
129
+ }
130
+
131
+ async function submitTurn({ botId, sessionId, input, parts, requestId, mode = 'queue', clientTimeZone, signal } = {}) {
132
+ if (!available) return unavailable();
133
+ const requestSignal = signal ?? new AbortController().signal;
134
+ const id = requiredIdentifier(botId, 'botId', BOT_ID);
135
+ const session = requiredIdentifier(sessionId, 'sessionId', SESSION_ID);
136
+ const bot = registry?.require ? registry.require(id) : undefined;
137
+ if (!['queue', 'steer'].includes(mode)) throw Object.assign(new TypeError('mode must be queue or steer'), { code: 'DSH_TURN_INPUT_INVALID' });
138
+ // parts 允许调用方直接给出完整 content(例如带图片的对话);缺省仍按纯文本处理。
139
+ const content = Array.isArray(parts) && parts.length > 0 ? parts : contentOf(input);
140
+ const rpcId = requiredIdentifier(requestId ?? requestIdFactory(), 'requestId', SESSION_ID);
141
+ const owner = sessionOwners.get(session) ?? (sessionOwnerResolver ? await sessionOwnerResolver(session) : undefined);
142
+ if (owner && owner !== id) throw Object.assign(new Error('session belongs to another Bot'), { code: 'DSH_SESSION_BOT_CONFLICT', status: 409 });
143
+ if (busySessions.has(session)) throw Object.assign(new Error('session has an active turn'), { code: 'DSH_SESSION_BUSY', status: 409 });
144
+ busySessions.add(session);
145
+ try {
146
+ const resources = await ensureSession({ sessionId: session, botId: id, bot, signal: requestSignal });
147
+ sessionOwners.set(session, id);
148
+ const baseline = typeof controller.inspect === 'function' ? (await controller.inspect(session, requestSignal))?.events?.length || 0 : 0;
149
+ const request = { sessionId: session, requestId: rpcId, mode, content };
150
+ if (clientTimeZone !== undefined) request.clientTimeZone = String(clientTimeZone);
151
+ const result = await controller.prompt(request, requestSignal);
152
+ if (result?.accepted !== true) {
153
+ throw Object.assign(new Error('DSH Session Controller did not accept the turn'), { code: 'DSH_TURN_NOT_ACCEPTED', status: 503 });
154
+ }
155
+ pending.set(rpcId, { sessionId: session, botId: id, baseline, onFinished: resources?.onFinished });
156
+ return Object.freeze({ accepted: true, execution: 'dsh-session-controller', sessionId: session, botId: id, requestId: rpcId });
157
+ } catch (error) { busySessions.delete(session); throw error; }
158
+ }
159
+
160
+ async function waitForTurn({ botId, sessionId, requestId, timeoutMs = 300000, onProgress } = {}) {
161
+ const entry = pending.get(requestId);
162
+ if (!entry || entry.botId !== botId || entry.sessionId !== sessionId || typeof controller.inspect !== 'function') throw new DshSessionControllerUnavailableError('DSH turn observation is unavailable');
163
+ const deadline = Date.now() + timeoutMs;
164
+ let progressSeq = -1;
165
+ try {
166
+ while (Date.now() < deadline) {
167
+ const inspection = await controller.inspect(sessionId, new AbortController().signal);
168
+ const events = (inspection?.events || []).slice(entry.baseline);
169
+ const latest = events.findLast(event => event.type === 'tool/call' || event.type === 'tool/result');
170
+ if (latest && latest.seq !== progressSeq) {
171
+ progressSeq = latest.seq;
172
+ // DSH 的结果事件承载 ToolResultMessage,关联键在 message.toolCallId。
173
+ const called = latest.type === 'tool/call' ? latest : events.findLast(event => event.type === 'tool/call' && event.data?.callId === latest.data?.message?.toolCallId);
174
+ await onProgress?.({ tool: String(called?.data?.name || '').slice(0, 64), state: latest.type === 'tool/call' ? 'running' : 'settled' });
175
+ }
176
+ const end = events.find(event => event.type === 'turn/end');
177
+ if (end) {
178
+ const completed = events.slice(0, events.indexOf(end));
179
+ const messages = completed.filter(event => event.type === 'assistant/message').map(event => {
180
+ const content = event.data?.message?.content;
181
+ return typeof content === 'string' ? content : (Array.isArray(content) ? content.filter(part => part.type === 'text').map(part => part.text || '').join('') : '');
182
+ }).filter(Boolean);
183
+ const reason = end.data?.reason;
184
+ return { status: reason?.kind === 'completed' ? 'completed' : 'failed', messages, error: reason?.kind === 'completed' ? null : reason };
185
+ }
186
+ await new Promise(resolve => setTimeout(resolve, 250));
187
+ }
188
+ throw Object.assign(new Error('cloud DSH turn observation timed out'), { code: 'DSH_TURN_TIMEOUT' });
189
+ } finally {
190
+ try { if (typeof entry.onFinished === 'function') await entry.onFinished(); }
191
+ finally { pending.delete(requestId); busySessions.delete(sessionId); }
192
+ }
193
+ }
194
+
195
+ return Object.freeze({
196
+ available,
197
+ controller,
198
+ submitTurn,
199
+ waitForTurn,
200
+ setMemoryProvider(provider, writer) { memoryProvider = provider; memoryWriter = writer; },
201
+ setSessionOwnerResolver(resolveOwner) { sessionOwnerResolver = resolveOwner; },
202
+ });
203
+ }
204
+
205
+ export function sessionControllerFromContext(ctx) {
206
+ return serviceFromContext(ctx);
207
+ }