dsh-bots 0.0.1 → 0.2.11
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/LICENSE +21 -0
- package/README.md +8 -16
- package/cordis.patch.yml +5 -0
- package/lib/client.js +2199 -0
- package/lib/gateway.js +240 -0
- package/lib/index.js +508 -0
- package/lib/shared.js +37 -0
- package/lib/sse.js +249 -0
- package/lib/types/client.d.ts +35 -0
- package/lib/types/gateway.d.ts +32 -0
- package/lib/types/index.d.ts +210 -0
- package/lib/types/shared.d.ts +191 -0
- package/lib/types/sse.d.ts +79 -0
- package/lib/types/unread.d.ts +74 -0
- package/lib/types/version.d.ts +28 -0
- package/lib/types/workspace.d.ts +49 -0
- package/lib/unread.js +196 -0
- package/lib/version.js +109 -0
- package/lib/workspace.js +129 -0
- package/package.json +50 -14
package/lib/gateway.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sdk-bots gateway client for the host half.
|
|
3
|
+
*
|
|
4
|
+
* The formal plugin runs inside the real host process, so this uses native
|
|
5
|
+
* `fetch` and `node:fs` — the curl-over-shell bridge from the dynamic-plugin
|
|
6
|
+
* prototype is gone. Discovery follows `gateway.json` in the sdk-bots data
|
|
7
|
+
* directory; `SAND_GATEWAY_TOKEN` (or the file's optional `token` field) pins
|
|
8
|
+
* auth when present.
|
|
9
|
+
* @module dsh-plugin-bots/gateway
|
|
10
|
+
*/
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { readFileSync } from 'node:fs';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { AVATAR_DATA_URL_MAX } from './shared.js';
|
|
15
|
+
const TOKEN_RE = /^[A-Za-z0-9._~+-]+$/;
|
|
16
|
+
export function expandHome(p) {
|
|
17
|
+
if (p === '~')
|
|
18
|
+
return homedir();
|
|
19
|
+
if (p.startsWith('~/'))
|
|
20
|
+
return join(homedir(), p.slice(2));
|
|
21
|
+
return p;
|
|
22
|
+
}
|
|
23
|
+
/** Read loopback discovery from `<dataDir>/gateway.json`; null when absent/stale. */
|
|
24
|
+
export function readDiscovery(dataDir) {
|
|
25
|
+
const file = join(expandHome(dataDir), 'gateway.json');
|
|
26
|
+
let raw;
|
|
27
|
+
try {
|
|
28
|
+
raw = readFileSync(file, 'utf-8');
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
let parsed;
|
|
34
|
+
try {
|
|
35
|
+
parsed = JSON.parse(raw);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
if (parsed === null || typeof parsed !== 'object' || !Number.isInteger(parsed.port) || parsed.port <= 0) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const token = typeof parsed.token === 'string' && TOKEN_RE.test(parsed.token)
|
|
44
|
+
? parsed.token
|
|
45
|
+
: (process.env['SAND_GATEWAY_TOKEN'] !== undefined && TOKEN_RE.test(process.env['SAND_GATEWAY_TOKEN'])
|
|
46
|
+
? process.env['SAND_GATEWAY_TOKEN']
|
|
47
|
+
: null);
|
|
48
|
+
return {
|
|
49
|
+
port: parsed.port,
|
|
50
|
+
pid: Number.isInteger(parsed.pid) ? parsed.pid : null,
|
|
51
|
+
token,
|
|
52
|
+
host: parsed.host === '0.0.0.0' ? '127.0.0.1' : (parsed.host ?? '127.0.0.1'),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** Discovery + `/health` probe with pid match validation. */
|
|
56
|
+
export async function discover(dataDir) {
|
|
57
|
+
const d = readDiscovery(dataDir);
|
|
58
|
+
if (d === null)
|
|
59
|
+
return { ok: false, reason: 'no-gateway-json' };
|
|
60
|
+
const baseUrl = `http://${d.host}:${d.port}`;
|
|
61
|
+
let health;
|
|
62
|
+
try {
|
|
63
|
+
const res = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(5000) });
|
|
64
|
+
if (!res.ok)
|
|
65
|
+
return { ok: false, reason: `health-http-${res.status}`, baseUrl, port: d.port, pid: d.pid };
|
|
66
|
+
health = await res.json();
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
return { ok: false, reason: 'health-failed', baseUrl, port: d.port, pid: d.pid };
|
|
70
|
+
}
|
|
71
|
+
if (d.pid !== null && health !== null && health.pid !== d.pid) {
|
|
72
|
+
return { ok: false, reason: 'stale-gateway-json', baseUrl, port: d.port, pid: d.pid };
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
ok: true,
|
|
76
|
+
baseUrl,
|
|
77
|
+
port: d.port,
|
|
78
|
+
pid: d.pid,
|
|
79
|
+
hasToken: d.token !== null,
|
|
80
|
+
health: {
|
|
81
|
+
pid: health !== null ? health.pid : null,
|
|
82
|
+
isBusy: Boolean(health?.isBusy),
|
|
83
|
+
activeAgentId: health?.activeAgentId ?? null,
|
|
84
|
+
startedAt: health?.startedAt ?? null,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** POST one `/api/<method>` command; unwraps `{result}` and throws on errors. */
|
|
89
|
+
export async function callGateway(dataDir, method, args = {}) {
|
|
90
|
+
const d = readDiscovery(dataDir);
|
|
91
|
+
if (d === null)
|
|
92
|
+
throw new Error('gateway.json not found — sdk-bots host 是否在运行?');
|
|
93
|
+
const baseUrl = `http://${d.host}:${d.port}`;
|
|
94
|
+
const headers = { 'content-type': 'application/json' };
|
|
95
|
+
if (d.token !== null)
|
|
96
|
+
headers['authorization'] = `Bearer ${d.token}`;
|
|
97
|
+
const res = await fetch(`${baseUrl}/api/${method}`, {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers,
|
|
100
|
+
body: JSON.stringify(args),
|
|
101
|
+
signal: AbortSignal.timeout(90_000),
|
|
102
|
+
});
|
|
103
|
+
if (res.status === 401)
|
|
104
|
+
throw new Error('gateway unauthorized — 需要 token');
|
|
105
|
+
let json;
|
|
106
|
+
try {
|
|
107
|
+
json = await res.json();
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
throw new Error(`bad-json-from-gateway (${res.status})`);
|
|
111
|
+
}
|
|
112
|
+
if (json !== null && typeof json === 'object' && json.error !== undefined) {
|
|
113
|
+
throw new Error(typeof json.error?.message === 'string' ? json.error.message : JSON.stringify(json.error));
|
|
114
|
+
}
|
|
115
|
+
return (json !== null && typeof json === 'object' && 'result' in json ? json.result : json);
|
|
116
|
+
}
|
|
117
|
+
/** Project a raw gateway agent onto the wire type. */
|
|
118
|
+
export function trimAgent(a) {
|
|
119
|
+
if (a === null || typeof a !== 'object')
|
|
120
|
+
return null;
|
|
121
|
+
const avatar = typeof a.avatarDataUrl === 'string' ? a.avatarDataUrl : null;
|
|
122
|
+
return {
|
|
123
|
+
id: String(a.id),
|
|
124
|
+
name: String(a.name ?? ''),
|
|
125
|
+
description: typeof a.description === 'string' ? a.description : '',
|
|
126
|
+
title: typeof a.title === 'string' ? a.title : '',
|
|
127
|
+
isGroup: Boolean(a.isGroup),
|
|
128
|
+
memberIds: Array.isArray(a.memberIds) ? a.memberIds.map(String) : [],
|
|
129
|
+
isRunning: Boolean(a.isRunning),
|
|
130
|
+
isActive: a.isActive !== false,
|
|
131
|
+
lastMessagePreview: typeof a.lastMessagePreview === 'string' ? a.lastMessagePreview : null,
|
|
132
|
+
updatedAt: a.updatedAt ?? null,
|
|
133
|
+
isComposingMessage: Boolean(a.isComposingMessage),
|
|
134
|
+
hasUnread: Boolean(a.hasUnread),
|
|
135
|
+
unreadCount: Number.isFinite(a.unreadCount) ? Number(a.unreadCount) : 0,
|
|
136
|
+
isHiddenFromSidebar: Boolean(a.isHiddenFromSidebar),
|
|
137
|
+
awaitingUserResponse: typeof a.awaitingUserResponse === 'string' ? a.awaitingUserResponse : null,
|
|
138
|
+
lastActivityAt: Number.isFinite(a.lastActivityAt) ? Number(a.lastActivityAt) : null,
|
|
139
|
+
// Oversized avatars are dropped, not truncated: a half data URL renders as
|
|
140
|
+
// a broken image, whereas null cleanly falls through to the initial chip.
|
|
141
|
+
avatarDataUrl: avatar !== null && avatar.length <= AVATAR_DATA_URL_MAX ? avatar : null,
|
|
142
|
+
avatarShape: typeof a.avatarShape === 'string' ? a.avatarShape : null,
|
|
143
|
+
avatarColor: typeof a.avatarColor === 'string' ? a.avatarColor : null,
|
|
144
|
+
hasAvatar: avatar !== null,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/** Pick the best display text out of any sdk-bots transcript entry shape. */
|
|
148
|
+
function entryText(en) {
|
|
149
|
+
if (typeof en.content === 'string' && en.content !== '')
|
|
150
|
+
return en.content;
|
|
151
|
+
const msg = en.message;
|
|
152
|
+
if (typeof msg === 'string')
|
|
153
|
+
return msg;
|
|
154
|
+
if (msg !== null && typeof msg === 'object' && typeof msg.content === 'string')
|
|
155
|
+
return msg.content;
|
|
156
|
+
if (typeof en.text === 'string')
|
|
157
|
+
return en.text;
|
|
158
|
+
if (typeof en.summary === 'string')
|
|
159
|
+
return en.summary;
|
|
160
|
+
return '';
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Millisecond wall-clock time for one transcript entry, or null.
|
|
164
|
+
*
|
|
165
|
+
* sdk-bots is not consistent about the field name or the unit: newer entries
|
|
166
|
+
* carry `timestampMs`, older ones `timestamp`/`createdAt`/`time`, and either
|
|
167
|
+
* can arrive as an ISO string or as *seconds*. The chat draws a reply clock
|
|
168
|
+
* off this value, so reading only one shape silently renders a whole
|
|
169
|
+
* conversation with no times at all.
|
|
170
|
+
*/
|
|
171
|
+
function entryTimestamp(en) {
|
|
172
|
+
for (const raw of [en?.timestampMs, en?.timestamp, en?.createdAt, en?.time]) {
|
|
173
|
+
const ms = toEpochMs(raw);
|
|
174
|
+
if (ms !== null)
|
|
175
|
+
return ms;
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
/** Seconds/milliseconds/ISO -> epoch ms. Anything unreadable is null. */
|
|
180
|
+
function toEpochMs(raw) {
|
|
181
|
+
if (typeof raw === 'number') {
|
|
182
|
+
if (!Number.isFinite(raw) || raw <= 0)
|
|
183
|
+
return null;
|
|
184
|
+
// Below ~1973 in milliseconds is a seconds stamp, never a real date.
|
|
185
|
+
return Math.round(raw < 1e11 ? raw * 1000 : raw);
|
|
186
|
+
}
|
|
187
|
+
if (typeof raw !== 'string' || raw === '')
|
|
188
|
+
return null;
|
|
189
|
+
if (/^\d+$/.test(raw))
|
|
190
|
+
return toEpochMs(Number(raw));
|
|
191
|
+
const parsed = Date.parse(raw);
|
|
192
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
193
|
+
}
|
|
194
|
+
/** Collapse a wire `kind` (plus role) onto the closed display union. */
|
|
195
|
+
function entryDisplay(kind, role) {
|
|
196
|
+
if (kind.includes('tool'))
|
|
197
|
+
return 'tool';
|
|
198
|
+
if (kind === 'thinking' || kind === 'reasoning' || kind === 'redacted-reasoning')
|
|
199
|
+
return 'thinking';
|
|
200
|
+
if (kind === 'send-message' || kind === 'agent-message' || kind === 'assistant-text')
|
|
201
|
+
return 'assistant';
|
|
202
|
+
if (kind === 'message' || kind === 'user-message')
|
|
203
|
+
return role === 'user' ? 'user' : 'assistant';
|
|
204
|
+
return 'event';
|
|
205
|
+
}
|
|
206
|
+
/** Normalize one transcript entry for the UI's closed render switch. */
|
|
207
|
+
export function trimEntry(en) {
|
|
208
|
+
const kind = String(en?.kind ?? '');
|
|
209
|
+
const role = typeof en?.role === 'string' ? en.role : null;
|
|
210
|
+
const display = entryDisplay(kind, role);
|
|
211
|
+
const failed = en?.isError === true || en?.status === 'error' || en?.status === 'failed';
|
|
212
|
+
return {
|
|
213
|
+
id: String(en?.id ?? ''),
|
|
214
|
+
kind,
|
|
215
|
+
display,
|
|
216
|
+
timestampMs: entryTimestamp(en),
|
|
217
|
+
role,
|
|
218
|
+
content: entryText(en ?? {}),
|
|
219
|
+
authorId: en?.author?.id ?? null,
|
|
220
|
+
authorName: en?.author?.name ?? null,
|
|
221
|
+
isStreaming: en?.isStreaming === true,
|
|
222
|
+
toolName: display === 'tool'
|
|
223
|
+
? String(en?.toolName ?? en?.name ?? en?.tool?.name ?? '工具')
|
|
224
|
+
: null,
|
|
225
|
+
toolStatus: display !== 'tool'
|
|
226
|
+
? null
|
|
227
|
+
: failed ? 'error' : (kind === 'tool-call' || en?.isStreaming === true) ? 'running' : 'ok',
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
/** `listAgents` returns a bare array (also tolerates `{agents:[...]}`). */
|
|
231
|
+
export function normalizeAgents(raw) {
|
|
232
|
+
const list = Array.isArray(raw) ? raw : (raw?.agents ?? []);
|
|
233
|
+
return list.map(trimAgent).filter((a) => a !== null);
|
|
234
|
+
}
|
|
235
|
+
let nonceCounter = 0;
|
|
236
|
+
/** Client nonce for idempotent create/send calls. */
|
|
237
|
+
export function nextNonce() {
|
|
238
|
+
nonceCounter += 1;
|
|
239
|
+
return `dsh-bots-${Date.now().toString(36)}-${nonceCounter}`;
|
|
240
|
+
}
|