bothouse-dsh 0.1.47 → 0.1.49
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/client/client.js +3411 -398
- package/collab/bot-port.mjs +30 -0
- package/collab/cli-member.mjs +3 -0
- package/collab/clis.mjs +11 -4
- package/collab/cloudweb.mjs +106 -17
- package/collab/desk/index.html +271 -31
- package/collab/dsh-member.mjs +47 -7
- package/collab/gate-context.mjs +80 -6
- package/collab/hub.mjs +42 -1
- package/collab/later.mjs +21 -0
- package/collab/memory-sources.mjs +159 -0
- package/collab/page.html +461 -6
- package/collab/pairing.mjs +674 -0
- package/collab/panel.mjs +10 -1
- package/collab/plugin.mjs +160 -24
- package/collab/roster.mjs +27 -6
- package/collab/store.mjs +48 -1
- package/collab/tasks.mjs +192 -16
- package/collab/wiki-galaxy.mjs +2 -1
- package/package.json +1 -1
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// 固定的 bot 接入端口(#142)。DSH 桌面版启动时把 Web 宿主端口写死成 0(每次随机,见
|
|
2
|
+
// DeepSeek Harness.app dsh-desktop-host main() 的 "--port","0"),主桥的 cloud_web 无法配置固定地址。
|
|
3
|
+
// 配了 botPort 时,插件另开一个只听 127.0.0.1 的小监听,只转两样东西:cloud-web 握手
|
|
4
|
+
// (WS_PATH,与宿主上同一个处理函数、同一口令)和离线补历史 /api/rooms/…;其余一律 404。
|
|
5
|
+
import { createServer } from 'node:http';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {{ port: number, path: string, upgrade: Function, history: Function, logger?: any }} options
|
|
9
|
+
* @returns {Promise<{ port: number, close: () => Promise<void> }>}
|
|
10
|
+
*/
|
|
11
|
+
export function startBotPort({ port, path, upgrade, history, logger }) {
|
|
12
|
+
const server = createServer((req, res) => {
|
|
13
|
+
const p = new URL(req.url ?? '/', 'http://collab.invalid').pathname;
|
|
14
|
+
if (p === '/api/rooms' || p.startsWith('/api/rooms/')) return history(req, res);
|
|
15
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end('{"error":"not found"}');
|
|
16
|
+
});
|
|
17
|
+
server.on('upgrade', (req, socket, head) => {
|
|
18
|
+
const p = new URL(req.url ?? '/', 'http://collab.invalid').pathname;
|
|
19
|
+
if (p !== path) { socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); return; }
|
|
20
|
+
upgrade(req, socket, head);
|
|
21
|
+
});
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
server.once('error', reject);
|
|
24
|
+
server.listen(port, '127.0.0.1', () => {
|
|
25
|
+
server.off('error', reject);
|
|
26
|
+
server.on('error', err => logger?.warn?.('conest-collab: bot port: %s', err.message));
|
|
27
|
+
resolve({ port: server.address().port, close: () => new Promise(done => { server.closeAllConnections?.(); server.close(() => done()); }) });
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
}
|
package/collab/cli-member.mjs
CHANGED
|
@@ -104,6 +104,9 @@ export class CliMember {
|
|
|
104
104
|
cwd: this.cwd,
|
|
105
105
|
stdio: ['pipe', 'pipe', 'inherit'],
|
|
106
106
|
env: { ...cleanEnv(), ...this.extraEnv },
|
|
107
|
+
// Windows: `gemini` / `npx` are npm .cmd shims, which only start through a shell (spawn without
|
|
108
|
+
// one gives ENOENT, and EINVAL for a .cmd path). Args here are fixed names and flags, no spaces.
|
|
109
|
+
shell: process.platform === 'win32' && cmd !== process.execPath,
|
|
107
110
|
})
|
|
108
111
|
this.proc = proc
|
|
109
112
|
const client = this._client()
|
package/collab/clis.mjs
CHANGED
|
@@ -3,11 +3,18 @@
|
|
|
3
3
|
import { execFile } from 'node:child_process';
|
|
4
4
|
import { CLIS } from './roster.mjs';
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Default lookup: `which <name>` → absolute path, or undefined when absent. Windows has no `which`:
|
|
8
|
+
* `where.exe` lists every match (CRLF), including npm's extensionless sh shim next to the .cmd, so the
|
|
9
|
+
* first one Windows can start (PATHEXT) is taken.
|
|
10
|
+
*/
|
|
11
|
+
export function which(name, platform = process.platform) {
|
|
12
|
+
const win = platform === 'win32';
|
|
8
13
|
return new Promise((resolve) => {
|
|
9
|
-
execFile('which', [name], (error, stdout) => {
|
|
10
|
-
const
|
|
14
|
+
execFile(win ? 'where.exe' : 'which', [name], (error, stdout) => {
|
|
15
|
+
const lines = String(stdout ?? '').split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
|
16
|
+
const exts = String(process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').toLowerCase().split(';').filter(Boolean);
|
|
17
|
+
const path = win ? (lines.find(l => exts.some(e => l.toLowerCase().endsWith(e))) ?? lines[0]) : lines[0];
|
|
11
18
|
resolve(error || !path ? undefined : path);
|
|
12
19
|
});
|
|
13
20
|
});
|
package/collab/cloudweb.mjs
CHANGED
|
@@ -16,7 +16,7 @@ export const WS_PATH = '/cloud-web/ws';
|
|
|
16
16
|
// a `buttons` frame (bridge platform/cloud-web SendWithButtons) instead of plain
|
|
17
17
|
// text (#120); cards are not declared, so cc-connect degrades them to text
|
|
18
18
|
// (bridge docs/cloud-web.md "Handshake").
|
|
19
|
-
export const CAPABILITIES = ['text', 'reconstruct_reply', 'typing', 'preview', 'update_message', 'delete_message', 'reply_in_place', 'buttons', 'tasks', 'dispatch_mode'];
|
|
19
|
+
export const CAPABILITIES = ['text', 'reconstruct_reply', 'typing', 'preview', 'update_message', 'delete_message', 'reply_in_place', 'buttons', 'tasks', 'dispatch_mode', 'queue'];
|
|
20
20
|
// Permission decisions the Channel page and text replies may send. Values are the
|
|
21
21
|
// button data cc-connect's sendPermissionPrompt uses (bridge core/engine.go).
|
|
22
22
|
/**
|
|
@@ -52,12 +52,20 @@ export const NOTICE_PREFIXES = ['📬', '✅', '↪', '⏳', '🔄', '⏹', '⏸
|
|
|
52
52
|
const FOOTER = /(?:^|\n+)\*[^*\n]*·[^*]*\*\s*$/s;
|
|
53
53
|
const BARE_MENTION = '(只 @ 了你,没写内容:看上面的频道消息判断要你做什么;看不出来就问)';
|
|
54
54
|
export const PROGRESS_PREFIX = '__cc_connect_progress_card_v1__:';
|
|
55
|
-
|
|
55
|
+
// cancelled (#913): a card whose turn was cut without an outcome — a restarted bridge, a
|
|
56
|
+
// cancelled DSH job. page.html styles it like any other card state.
|
|
57
|
+
const CARD_STATES = { running: '处理中', completed: '已完成', failed: '失败', cancelled: '已取消' };
|
|
56
58
|
const RESERVED_IDS = new Set(['user', 'system']);
|
|
57
59
|
const MAX_NAME = 64;
|
|
58
60
|
|
|
59
61
|
export function roomKey(roomId) { return `room-${roomId}`; }
|
|
60
62
|
|
|
63
|
+
/**
|
|
64
|
+
* #1023: chat_type for a frame from this room — "direct" for a private chat (the bridge's
|
|
65
|
+
* documented DM value, bridge/platform/cloud-web/cloudweb.go Direct), else "group".
|
|
66
|
+
*/
|
|
67
|
+
export function chatType(store, roomId) { return store?.dmMember?.(roomId) !== undefined ? 'direct' : 'group'; }
|
|
68
|
+
|
|
61
69
|
/** Room id from a frame's reply_ctx or session_key ("<platform>:room-<id>"), or undefined. */
|
|
62
70
|
export function roomOf(frame, store) {
|
|
63
71
|
const sk = frame.session_key;
|
|
@@ -96,19 +104,21 @@ export function progressCardOf(p) {
|
|
|
96
104
|
lines.push(`${it.kind === 'tool_use' ? '🔧' : it.kind === 'error' ? '❌' : '•'} ${tool ? tool + ' — ' : ''}${text.slice(0, 120)}`);
|
|
97
105
|
}
|
|
98
106
|
const agent = String(p.agent ?? 'Agent').trim() || 'Agent';
|
|
99
|
-
const
|
|
100
|
-
const
|
|
107
|
+
const run = toolSteps(items);
|
|
108
|
+
const brief = [lines[0], ...briefSteps(run, state)].join('\n');
|
|
109
|
+
const chips = stepChips(run, state);
|
|
110
|
+
const card = { text: lines.join('\n'), card: JSON.stringify({ title: `${agent} · ${CARD_STATES[state]}`, state, steps, elapsed_sec: sec, brief, chips }) };
|
|
101
111
|
// Bridge task card (cloud-web public_progress.go): its members feed the task board (tasks.mjs fromCard).
|
|
102
112
|
return Array.isArray(p.task_members) ? { ...card, members: p.task_members } : card;
|
|
103
113
|
}
|
|
104
114
|
|
|
105
115
|
/**
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
116
|
+
* Tool steps of a turn, in order: each tool_use opens a step, its tool_result (same tool, else
|
|
117
|
+
* the last open one) closes it as succeeded/failed, an error fails the last open step. Same
|
|
118
|
+
* pairing as the Discord card (bridge/platform/discord/progress.go buildDiscordProgressBrief,
|
|
119
|
+
* discordProgressItemFailed). Each step keeps its call and output text for the step chips.
|
|
110
120
|
*/
|
|
111
|
-
function
|
|
121
|
+
function toolSteps(items) {
|
|
112
122
|
const steps = [];
|
|
113
123
|
const open = tool => {
|
|
114
124
|
for (let i = steps.length - 1; i >= 0; i--) if (steps[i].state === 0 && (tool === '' || steps[i].tool === tool)) return i;
|
|
@@ -116,19 +126,33 @@ function briefSteps(items, state) {
|
|
|
116
126
|
};
|
|
117
127
|
const failed = it => typeof it.success === 'boolean' ? !it.success
|
|
118
128
|
: (Number.isInteger(it.exit_code) && it.exit_code !== 0) || ['failed', 'failure', 'error'].includes(String(it.status ?? '').trim().toLowerCase());
|
|
129
|
+
// 沿用 progressCardOf 正文每行截断 120 字。
|
|
130
|
+
const clip = v => {
|
|
131
|
+
const text = String(v ?? '').replace(/\s+/g, ' ').trim();
|
|
132
|
+
return text.length > 120 ? `${text.slice(0, 119)}…` : text;
|
|
133
|
+
};
|
|
119
134
|
for (const it of items) {
|
|
120
135
|
if (it === null || typeof it !== 'object') continue;
|
|
121
136
|
const tool = String(it.tool ?? '').trim();
|
|
122
|
-
if (it.kind === 'tool_use') steps.push({ tool, state: 0 });
|
|
137
|
+
if (it.kind === 'tool_use') steps.push({ tool, state: 0, input: clip(it.text), output: '' });
|
|
123
138
|
else if (it.kind === 'tool_result') {
|
|
124
139
|
const st = failed(it) ? 2 : 1;
|
|
125
140
|
const i = open(tool);
|
|
126
|
-
if (i >= 0) steps[i]
|
|
141
|
+
if (i >= 0) Object.assign(steps[i], { state: st, output: clip(it.text) });
|
|
142
|
+
else steps.push({ tool, state: st, input: '', output: clip(it.text) });
|
|
127
143
|
} else if (it.kind === 'error') {
|
|
128
144
|
const i = open('');
|
|
129
|
-
if (i >= 0) steps[i]
|
|
145
|
+
if (i >= 0) Object.assign(steps[i], { state: 2, output: clip(it.text) });
|
|
130
146
|
}
|
|
131
147
|
}
|
|
148
|
+
return steps;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The 简略 view of a progress card, one line per tool step: tool name and
|
|
153
|
+
* 成功/失败 (进行中 while the turn runs), no command text or output.
|
|
154
|
+
*/
|
|
155
|
+
function briefSteps(steps, state) {
|
|
132
156
|
return steps.map(s => {
|
|
133
157
|
const name = (s.tool || '工具').slice(0, 120);
|
|
134
158
|
if (s.state === 1) return `✅ ${name} · 成功`;
|
|
@@ -137,6 +161,16 @@ function briefSteps(items, state) {
|
|
|
137
161
|
});
|
|
138
162
|
}
|
|
139
163
|
|
|
164
|
+
/** Step chips for the DSH page (#125, owner's 11:53 sample): tool, outcome, call and output per step. */
|
|
165
|
+
function stepChips(steps, state) {
|
|
166
|
+
return steps.map(s => ({
|
|
167
|
+
tool: (s.tool || '工具').slice(0, 120),
|
|
168
|
+
state: s.state === 1 ? 'ok' : s.state === 2 ? 'failed' : state === 'running' ? 'running' : 'unknown',
|
|
169
|
+
...(s.input ? { input: s.input } : {}),
|
|
170
|
+
...(s.output ? { output: s.output } : {}),
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
173
|
+
|
|
140
174
|
/** reply_to_* frame fields for a reply (store.quote result); {} for a plain message. */
|
|
141
175
|
function replyToFields(quoted, store) {
|
|
142
176
|
if (quoted === undefined) return {};
|
|
@@ -231,7 +265,8 @@ export class CloudWebGateway {
|
|
|
231
265
|
// avatar=<built-in slug> or an http(s) image URL (cc-connect's own library); anything else falls back to the initial badge.
|
|
232
266
|
let avatar = '';
|
|
233
267
|
try { avatar = checkAvatar(url.searchParams.get('avatar'), { allowUrl: true }); } catch { avatar = ''; }
|
|
234
|
-
|
|
268
|
+
// voter: the bridge project, as which its agent cites memories (memvote.py CC_PROJECT; memory-sources.mjs).
|
|
269
|
+
const member = { id, name, rooms, kind: 'bot', mentionId: /^\d+$/.test(mentionId) ? mentionId : '', online: true, ...(avatar ? { avatar } : {}), voter: project || id };
|
|
235
270
|
const bot = { ws, member };
|
|
236
271
|
const old = this.bots.get(id);
|
|
237
272
|
this.bots.set(id, bot);
|
|
@@ -245,9 +280,48 @@ export class CloudWebGateway {
|
|
|
245
280
|
this.hub.dropDrafts('role', id);
|
|
246
281
|
// ...and its pending permission requests are gone with the old process.
|
|
247
282
|
this.approvals.expireWhere(p => p.meta?.gateway === this && p.meta.bot === id);
|
|
283
|
+
// ...and the progress cards it already posted would stay 「处理中」 forever: the old
|
|
284
|
+
// process held the only handle, and the turn it resumes posts a second card (#913).
|
|
285
|
+
this.closeStaleCards(id);
|
|
248
286
|
return bot;
|
|
249
287
|
}
|
|
250
288
|
|
|
289
|
+
/**
|
|
290
|
+
* #913: close the progress cards a bot left running. A restarted cc-connect keeps no
|
|
291
|
+
* message ids, so nothing will ever edit those rows again, while its resumed turn posts
|
|
292
|
+
* a fresh card; the room then shows two 「处理中」 and no way to tell which is alive.
|
|
293
|
+
* They are closed as 已取消 (page.html already styles that state) and keep their steps,
|
|
294
|
+
* plus a line saying the turn was cut, so the history stays readable.
|
|
295
|
+
*
|
|
296
|
+
* Every card row this member posted is looked at, not the last page of one room: the
|
|
297
|
+
* room may have carried a hundred messages since (a busy channel, a workshop), and a
|
|
298
|
+
* running card there must still be closed. Only this member's own rows are touched,
|
|
299
|
+
* and only rows still `running`: a card the resumed turn reopens by its own handle goes
|
|
300
|
+
* back to 处理中 on the next frame.
|
|
301
|
+
*/
|
|
302
|
+
closeStaleCards(memberId) {
|
|
303
|
+
for (const row of this.hub.store.authorCards('role', memberId)) {
|
|
304
|
+
let card;
|
|
305
|
+
try { card = JSON.parse(row.card); } catch { continue; }
|
|
306
|
+
// A permission card a hub restart left pending: its asker is gone, and next to the
|
|
307
|
+
// reconnected bot's new prompt it reads as the same request asked twice (#1017).
|
|
308
|
+
if (card?.kind === 'permission' && card.state === 'pending' && !this.approvals.pending.has(row.id)) {
|
|
309
|
+
this.approvals.close(row.id, 'expired', row);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (card?.state !== 'running') continue;
|
|
313
|
+
const note = '⏹ 已中断(桥重启),这一步不会再更新';
|
|
314
|
+
const brief = typeof card.brief === 'string' && card.brief !== '' ? `${card.brief}\n${note}` : note;
|
|
315
|
+
// The stored title is already rendered ("CC · 处理中"): swap only its state label,
|
|
316
|
+
// so the card keeps its agent name.
|
|
317
|
+
const label = ` · ${CARD_STATES[card.state] ?? ''}`;
|
|
318
|
+
const agent = typeof card.title === 'string' && card.title.endsWith(label) ? card.title.slice(0, -label.length) : (card.title ?? 'Agent');
|
|
319
|
+
// A cut turn reports no outcome for its open steps.
|
|
320
|
+
const chips = Array.isArray(card.chips) ? card.chips.map(c => c?.state === 'running' ? { ...c, state: 'unknown' } : c) : card.chips;
|
|
321
|
+
this.hub.edit(row.id, 'role', memberId, `${row.text}\n${note}`, JSON.stringify({ ...card, state: 'cancelled', title: `${agent} · ${CARD_STATES.cancelled}`, brief, chips }));
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
251
325
|
/** Forward one new channel message to every connected bot except its author. */
|
|
252
326
|
onNew(msg) {
|
|
253
327
|
if (msg.author_kind === 'system' || msg.card !== null || this.bots.size === 0 || this.hub.teamExit?.answerFor(msg)) return;
|
|
@@ -280,14 +354,18 @@ export class CloudWebGateway {
|
|
|
280
354
|
// A reply addressed to other bots is not this bot's to take (reply_routing.go accept=false).
|
|
281
355
|
if (only !== null && !only.has(member.id)) continue;
|
|
282
356
|
let content = forMember(text, member, members);
|
|
283
|
-
|
|
357
|
+
// #1023: an owner message in a private chat is addressed to its one agent.
|
|
358
|
+
const hit = target === member.id || only !== null || mentioned.has(member.id) || nameCalled(text, member.name)
|
|
359
|
+
|| (!fromAgent && this.hub.store.dmMember?.(msg.room_id) === member.id);
|
|
284
360
|
if (fromAgent && !content) continue;
|
|
285
361
|
if (fromAgent) content = `【${msg.name}】${quote}${content}`;
|
|
286
362
|
else if (hit && !content) content = quote + BARE_MENTION;
|
|
287
363
|
else content = quote + content;
|
|
288
364
|
const frame = {
|
|
289
|
-
type: 'message', msg_id: `hub-${msg.id}`, chat_id: roomKey(msg.room_id), chat_type:
|
|
290
|
-
|
|
365
|
+
type: 'message', msg_id: `hub-${msg.id}`, chat_id: roomKey(msg.room_id), chat_type: chatType(this.hub.store, msg.room_id),
|
|
366
|
+
// A bridge command (`@橘 /model x`) goes through bare: the bridge reads everything after the
|
|
367
|
+
// command word as its arguments (core/engine.go handleCommand), so no later instructions.
|
|
368
|
+
user_id: msg.author, user_name: msg.name, content: content + (hit && !isCommand(content) ? (this.later?.instructions(member.id, msg.room_id) ?? '') : ''), reply_ctx: roomKey(msg.room_id),
|
|
291
369
|
mentioned: hit, from_bot: fromAgent, ...attached, ...replyTo,
|
|
292
370
|
};
|
|
293
371
|
if (room !== undefined) frame.chat_name = room.name.replace(/\s+/g, ' ');
|
|
@@ -307,7 +385,7 @@ export class CloudWebGateway {
|
|
|
307
385
|
const members = this.hub.routableIn(msg.room_id);
|
|
308
386
|
const room = this.hub.store.room(msg.room_id);
|
|
309
387
|
const frame = {
|
|
310
|
-
type: 'message', msg_id: `hub-${msg.id}`, chat_id: roomKey(msg.room_id), chat_type:
|
|
388
|
+
type: 'message', msg_id: `hub-${msg.id}`, chat_id: roomKey(msg.room_id), chat_type: chatType(this.hub.store, msg.room_id),
|
|
311
389
|
user_id: msg.author, user_name: msg.name, content: forMember(msg.text, bot.member, members) || msg.text,
|
|
312
390
|
reply_ctx: roomKey(msg.room_id), mentioned: true, from_bot: false,
|
|
313
391
|
};
|
|
@@ -486,6 +564,14 @@ export class CloudWebGateway {
|
|
|
486
564
|
this.hub.post(room, { authorKind: 'role', author: member.id, name: member.name, text: '', attachments });
|
|
487
565
|
return;
|
|
488
566
|
}
|
|
567
|
+
case 'queue': {
|
|
568
|
+
// #1020: the owner's messages ("hub-<id>") waiting behind this bot's running turn here (bridge core/queue_report.go).
|
|
569
|
+
const room = roomOf(d, this.hub.store);
|
|
570
|
+
if (room === undefined || !Array.isArray(d.msg_ids)) return;
|
|
571
|
+
const ids = d.msg_ids.map(x => /^hub-(\d+)$/.exec(String(x))?.[1]).filter(Boolean).map(Number);
|
|
572
|
+
this.hub.setQueue(room, member.id, ids);
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
489
575
|
case 'typing_start':
|
|
490
576
|
case 'typing_stop': {
|
|
491
577
|
const room = roomOf(d, this.hub.store);
|
|
@@ -578,6 +664,9 @@ function send(ws, frame) {
|
|
|
578
664
|
try { ws.send(JSON.stringify(frame)); } catch { /* socket already closing; its close handler cleans up */ }
|
|
579
665
|
}
|
|
580
666
|
|
|
667
|
+
/** A bridge slash command as forwarded (`/stop`, `/model x`); quoted replies keep their prefix and are not. */
|
|
668
|
+
export function isCommand(content) { return /^\/[a-z]/i.test(content); }
|
|
669
|
+
|
|
581
670
|
function clean(s) { return String(s ?? '').replace(/[\x00-\x1f\x7f]/g, '').trim(); }
|
|
582
671
|
|
|
583
672
|
function timingSafeEqualString(a, b) {
|