bothouse-dsh 0.1.48 → 0.1.50
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 +3390 -397
- package/collab/bot-port.mjs +30 -0
- package/collab/cli-member.mjs +3 -0
- package/collab/clis.mjs +11 -4
- package/collab/cloudweb.mjs +73 -17
- package/collab/dsh-member.mjs +25 -6
- package/collab/gate-context.mjs +80 -6
- package/collab/hub.mjs +42 -1
- package/collab/memory-sources.mjs +159 -0
- package/collab/page.html +425 -6
- package/collab/pairing.mjs +674 -0
- package/collab/panel.mjs +10 -1
- package/collab/plugin.mjs +84 -21
- package/collab/roster.mjs +27 -6
- package/collab/store.mjs +40 -1
- package/collab/tasks.mjs +192 -16
- package/collab/team-mode-rules.md +1 -1
- 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
|
/**
|
|
@@ -60,6 +60,12 @@ const MAX_NAME = 64;
|
|
|
60
60
|
|
|
61
61
|
export function roomKey(roomId) { return `room-${roomId}`; }
|
|
62
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
|
+
|
|
63
69
|
/** Room id from a frame's reply_ctx or session_key ("<platform>:room-<id>"), or undefined. */
|
|
64
70
|
export function roomOf(frame, store) {
|
|
65
71
|
const sk = frame.session_key;
|
|
@@ -98,19 +104,21 @@ export function progressCardOf(p) {
|
|
|
98
104
|
lines.push(`${it.kind === 'tool_use' ? '🔧' : it.kind === 'error' ? '❌' : '•'} ${tool ? tool + ' — ' : ''}${text.slice(0, 120)}`);
|
|
99
105
|
}
|
|
100
106
|
const agent = String(p.agent ?? 'Agent').trim() || 'Agent';
|
|
101
|
-
const
|
|
102
|
-
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 }) };
|
|
103
111
|
// Bridge task card (cloud-web public_progress.go): its members feed the task board (tasks.mjs fromCard).
|
|
104
112
|
return Array.isArray(p.task_members) ? { ...card, members: p.task_members } : card;
|
|
105
113
|
}
|
|
106
114
|
|
|
107
115
|
/**
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
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.
|
|
112
120
|
*/
|
|
113
|
-
function
|
|
121
|
+
function toolSteps(items) {
|
|
114
122
|
const steps = [];
|
|
115
123
|
const open = tool => {
|
|
116
124
|
for (let i = steps.length - 1; i >= 0; i--) if (steps[i].state === 0 && (tool === '' || steps[i].tool === tool)) return i;
|
|
@@ -118,19 +126,33 @@ function briefSteps(items, state) {
|
|
|
118
126
|
};
|
|
119
127
|
const failed = it => typeof it.success === 'boolean' ? !it.success
|
|
120
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
|
+
};
|
|
121
134
|
for (const it of items) {
|
|
122
135
|
if (it === null || typeof it !== 'object') continue;
|
|
123
136
|
const tool = String(it.tool ?? '').trim();
|
|
124
|
-
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: '' });
|
|
125
138
|
else if (it.kind === 'tool_result') {
|
|
126
139
|
const st = failed(it) ? 2 : 1;
|
|
127
140
|
const i = open(tool);
|
|
128
|
-
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) });
|
|
129
143
|
} else if (it.kind === 'error') {
|
|
130
144
|
const i = open('');
|
|
131
|
-
if (i >= 0) steps[i]
|
|
145
|
+
if (i >= 0) Object.assign(steps[i], { state: 2, output: clip(it.text) });
|
|
132
146
|
}
|
|
133
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) {
|
|
134
156
|
return steps.map(s => {
|
|
135
157
|
const name = (s.tool || '工具').slice(0, 120);
|
|
136
158
|
if (s.state === 1) return `✅ ${name} · 成功`;
|
|
@@ -139,6 +161,16 @@ function briefSteps(items, state) {
|
|
|
139
161
|
});
|
|
140
162
|
}
|
|
141
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
|
+
|
|
142
174
|
/** reply_to_* frame fields for a reply (store.quote result); {} for a plain message. */
|
|
143
175
|
function replyToFields(quoted, store) {
|
|
144
176
|
if (quoted === undefined) return {};
|
|
@@ -233,7 +265,8 @@ export class CloudWebGateway {
|
|
|
233
265
|
// avatar=<built-in slug> or an http(s) image URL (cc-connect's own library); anything else falls back to the initial badge.
|
|
234
266
|
let avatar = '';
|
|
235
267
|
try { avatar = checkAvatar(url.searchParams.get('avatar'), { allowUrl: true }); } catch { avatar = ''; }
|
|
236
|
-
|
|
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 };
|
|
237
270
|
const bot = { ws, member };
|
|
238
271
|
const old = this.bots.get(id);
|
|
239
272
|
this.bots.set(id, bot);
|
|
@@ -270,6 +303,12 @@ export class CloudWebGateway {
|
|
|
270
303
|
for (const row of this.hub.store.authorCards('role', memberId)) {
|
|
271
304
|
let card;
|
|
272
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
|
+
}
|
|
273
312
|
if (card?.state !== 'running') continue;
|
|
274
313
|
const note = '⏹ 已中断(桥重启),这一步不会再更新';
|
|
275
314
|
const brief = typeof card.brief === 'string' && card.brief !== '' ? `${card.brief}\n${note}` : note;
|
|
@@ -277,7 +316,9 @@ export class CloudWebGateway {
|
|
|
277
316
|
// so the card keeps its agent name.
|
|
278
317
|
const label = ` · ${CARD_STATES[card.state] ?? ''}`;
|
|
279
318
|
const agent = typeof card.title === 'string' && card.title.endsWith(label) ? card.title.slice(0, -label.length) : (card.title ?? 'Agent');
|
|
280
|
-
|
|
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 }));
|
|
281
322
|
}
|
|
282
323
|
}
|
|
283
324
|
|
|
@@ -313,14 +354,18 @@ export class CloudWebGateway {
|
|
|
313
354
|
// A reply addressed to other bots is not this bot's to take (reply_routing.go accept=false).
|
|
314
355
|
if (only !== null && !only.has(member.id)) continue;
|
|
315
356
|
let content = forMember(text, member, members);
|
|
316
|
-
|
|
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);
|
|
317
360
|
if (fromAgent && !content) continue;
|
|
318
361
|
if (fromAgent) content = `【${msg.name}】${quote}${content}`;
|
|
319
362
|
else if (hit && !content) content = quote + BARE_MENTION;
|
|
320
363
|
else content = quote + content;
|
|
321
364
|
const frame = {
|
|
322
|
-
type: 'message', msg_id: `hub-${msg.id}`, chat_id: roomKey(msg.room_id), chat_type:
|
|
323
|
-
|
|
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),
|
|
324
369
|
mentioned: hit, from_bot: fromAgent, ...attached, ...replyTo,
|
|
325
370
|
};
|
|
326
371
|
if (room !== undefined) frame.chat_name = room.name.replace(/\s+/g, ' ');
|
|
@@ -340,7 +385,7 @@ export class CloudWebGateway {
|
|
|
340
385
|
const members = this.hub.routableIn(msg.room_id);
|
|
341
386
|
const room = this.hub.store.room(msg.room_id);
|
|
342
387
|
const frame = {
|
|
343
|
-
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),
|
|
344
389
|
user_id: msg.author, user_name: msg.name, content: forMember(msg.text, bot.member, members) || msg.text,
|
|
345
390
|
reply_ctx: roomKey(msg.room_id), mentioned: true, from_bot: false,
|
|
346
391
|
};
|
|
@@ -519,6 +564,14 @@ export class CloudWebGateway {
|
|
|
519
564
|
this.hub.post(room, { authorKind: 'role', author: member.id, name: member.name, text: '', attachments });
|
|
520
565
|
return;
|
|
521
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
|
+
}
|
|
522
575
|
case 'typing_start':
|
|
523
576
|
case 'typing_stop': {
|
|
524
577
|
const room = roomOf(d, this.hub.store);
|
|
@@ -611,6 +664,9 @@ function send(ws, frame) {
|
|
|
611
664
|
try { ws.send(JSON.stringify(frame)); } catch { /* socket already closing; its close handler cleans up */ }
|
|
612
665
|
}
|
|
613
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
|
+
|
|
614
670
|
function clean(s) { return String(s ?? '').replace(/[\x00-\x1f\x7f]/g, '').trim(); }
|
|
615
671
|
|
|
616
672
|
function timingSafeEqualString(a, b) {
|
package/collab/dsh-member.mjs
CHANGED
|
@@ -17,7 +17,9 @@ import { GateContext } from './gate-context.mjs';
|
|
|
17
17
|
import { MemoryIndex } from './memory-index.mjs';
|
|
18
18
|
// Shared with the bridge (bridge/core/team_mode.go): a common part, then one
|
|
19
19
|
// "## <mode>" section per dispatch mode; a member sees only its room's section.
|
|
20
|
-
|
|
20
|
+
// A Windows checkout (git core.autocrlf) turns the file to CRLF: "## solo\r\n" then misses the
|
|
21
|
+
// `${mode}\n` match below and every member turn failed with "reading 'slice'". Read it as LF.
|
|
22
|
+
const TEAM_MODE_FILE = readFileSync(new URL('./team-mode-rules.md', import.meta.url), 'utf8').replace(/\r\n?/g, '\n');
|
|
21
23
|
export function teamModeRules(mode = DEFAULT_DISPATCH_MODE) {
|
|
22
24
|
const [common, ...parts] = TEAM_MODE_FILE.split(/^## /m);
|
|
23
25
|
const section = parts.find(p => p.startsWith(`${mode}\n`)) ?? parts.find(p => p.startsWith(`${DEFAULT_DISPATCH_MODE}\n`));
|
|
@@ -199,7 +201,7 @@ const INTERRUPTED = Symbol('interrupted');
|
|
|
199
201
|
export function friendlyFailure(raw) {
|
|
200
202
|
if (raw?.code === 'COLLAB_CLI_START') return raw.message;
|
|
201
203
|
const text = String(raw);
|
|
202
|
-
if (/no.{0,12}(api.?key|key)|missing.{0,12}(key|credential)|api.?key.{0,12}(missing|not set|required)|未配置.*key/i.test(text)) return '
|
|
204
|
+
if (/no.{0,12}(api.?key|key)|missing.{0,12}(key|credential)|api.?key.{0,12}(missing|not set|required)|未配置.*key/i.test(text)) return '还没填模型密钥,填好就能用。';
|
|
203
205
|
if (/model.{0,30}(not found|unavailable|not available|unsupported)|unknown model|模型.*不可用|404/i.test(text)) return '这个模型暂时不可用,请检查模型名称或换一个模型。';
|
|
204
206
|
// An unreachable provider is a setting or network problem. Echoing the transport wording
|
|
205
207
|
// ("Connection error.") behind "请检查配置" told the user nothing about which setting to fix.
|
|
@@ -460,7 +462,7 @@ export class DshMembers {
|
|
|
460
462
|
const running = [];
|
|
461
463
|
for (const job of this.store.openJobs()) {
|
|
462
464
|
if (job.trigger_id !== triggerId) continue;
|
|
463
|
-
if (job.state === 'pending') { this.store.finishJob(job.id, 'interrupted'); continue; }
|
|
465
|
+
if (job.state === 'pending') { this.store.finishJob(job.id, 'interrupted'); this.syncQueue(job.member_id, job.room_id); continue; }
|
|
464
466
|
const w = this.watching.get(job.session_id);
|
|
465
467
|
const agent = job.input_id ? this.rt.ctx.agents.get(job.session_id) : undefined;
|
|
466
468
|
if (w?.jobId !== job.id || agent === undefined) { running.push(job.member_id); continue; }
|
|
@@ -583,14 +585,29 @@ export class DshMembers {
|
|
|
583
585
|
/** Serialize work per (member, room): runs, recoveries, and Session resumes never overlap. */
|
|
584
586
|
enqueue(job, task) {
|
|
585
587
|
const key = `${job.member_id}\u0000${job.room_id}`;
|
|
586
|
-
const
|
|
588
|
+
const sync = () => this.syncQueue(job.member_id, job.room_id);
|
|
589
|
+
const tail = (this.queues.get(key) ?? Promise.resolve()).then(() => { sync(); return this.closed ? undefined : task(); }).catch((error) => {
|
|
587
590
|
this.rt.logger.warn('collab: DSH job %d failed: %s', job.id, error?.stack ?? error);
|
|
588
591
|
if (!this.closed) this.fail(job.id, error?.code === 'COLLAB_CLI_START' ? error : `出错了:${error?.message ?? error}`);
|
|
589
|
-
});
|
|
592
|
+
}).finally(sync);
|
|
590
593
|
this.queues.set(key, tail);
|
|
594
|
+
sync();
|
|
591
595
|
return tail;
|
|
592
596
|
}
|
|
593
597
|
|
|
598
|
+
/**
|
|
599
|
+
* #1020: tell the hub which of the owner's messages wait for this member here — its open
|
|
600
|
+
* jobs after the first (the one running or about to), still pending. Withdrawing one is the
|
|
601
|
+
* existing recall (#148 a queued job is closed).
|
|
602
|
+
*/
|
|
603
|
+
syncQueue(memberId, roomId) {
|
|
604
|
+
if (typeof this.hub.setQueue !== 'function') return;
|
|
605
|
+
const open = this.store.openJobs().filter(j => j.member_id === memberId && j.room_id === roomId);
|
|
606
|
+
const ids = open.slice(1).filter(j => j.state === 'pending').map(j => j.trigger_id)
|
|
607
|
+
.filter(id => this.store.message(id)?.author_kind === 'user');
|
|
608
|
+
this.hub.setQueue(roomId, memberId, ids);
|
|
609
|
+
}
|
|
610
|
+
|
|
594
611
|
async run(jobId) {
|
|
595
612
|
const job = this.store.job(jobId);
|
|
596
613
|
if (job === undefined || job.state !== 'pending') return;
|
|
@@ -655,7 +672,9 @@ export class DshMembers {
|
|
|
655
672
|
let entry = this.cliAgents.get(cliKey);
|
|
656
673
|
if (entry === undefined) {
|
|
657
674
|
// Persona goes through promptText every turn, so an edit applies without restarting the CLI.
|
|
658
|
-
entry = { memberId: member.id, cli: member.cli, agent: this.rt.createCliMember({ cli: member.cli, cwd, persona: ''
|
|
675
|
+
entry = { memberId: member.id, cli: member.cli, agent: this.rt.createCliMember({ cli: member.cli, cwd, persona: '',
|
|
676
|
+
// #1020: memvote.py records citations under these (memory-sources.mjs), named as the gate key (gate-context.mjs).
|
|
677
|
+
env: { CC_PROJECT: `dsh:${member.id}`, CC_SESSION_KEY: `dsh:${member.id}:${room.id}` } }) };
|
|
659
678
|
this.cliAgents.set(cliKey, entry);
|
|
660
679
|
}
|
|
661
680
|
this.hub.setError(member.id, undefined);
|
package/collab/gate-context.mjs
CHANGED
|
@@ -1,21 +1,95 @@
|
|
|
1
1
|
// Per-turn wiki pages and task board for DSH-native members, from the local gate's
|
|
2
2
|
// POST /context (bridge/contrib/gate/gate.py -> ctxsvc.context): the same source and the same
|
|
3
|
-
// block the bridge puts before a bot's turn (bridge/core/gate.go turnContext).
|
|
4
|
-
//
|
|
5
|
-
// gate
|
|
3
|
+
// block the bridge puts before a bot's turn (bridge/core/gate.go turnContext).
|
|
4
|
+
//
|
|
5
|
+
// The gate's port: GATE_PORT, which bothouse sets on the `dsh web` it starts to the port it runs the
|
|
6
|
+
// gate on (core/gate_proc.go gatePort). A `dsh web` started another way (reload-dsh.py reusing an old
|
|
7
|
+
// environment, the desktop app) has no GATE_PORT, so it is read from the same config.toml the bridge
|
|
8
|
+
// itself reads: [gate] port (core/gate_proc.go LocalGatePort), default 9930 when the file has no
|
|
9
|
+
// [gate] table (that is gate.py's own PORT). Without a config file either, no port: a turn goes out
|
|
10
|
+
// as before, and the log says why instead of the badge and the pages vanishing without a word.
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { dataDirCandidates } from './memory-sources.mjs';
|
|
6
15
|
|
|
7
16
|
// Same as the bridge's wait for /context (core/gate.go turnContext: 2s).
|
|
8
17
|
export const GATE_CONTEXT_TIMEOUT_MS = 2_000;
|
|
18
|
+
// 来源:contrib/gate/gate.py PORT = 9930,core/setup_wizard_warnings.go defaultGatePort 同值
|
|
19
|
+
// (LocalGatePort 在 config [gate] port 缺省或为 0 时也用它)。
|
|
20
|
+
export const DEFAULT_GATE_PORT = 9930;
|
|
21
|
+
|
|
22
|
+
/** `port\0where` of the log lines already written: one line per process, not one per member. */
|
|
23
|
+
const announced = new Set();
|
|
24
|
+
|
|
25
|
+
/** `[gate] port` of a bridge config's text, as Go reads cfg.Gate.Port; 0 when it has no such table. */
|
|
26
|
+
export function gatePortSetting(text) {
|
|
27
|
+
let inside = false;
|
|
28
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
29
|
+
const header = /^\s*\[\s*([^\]]+?)\s*\]/.exec(line);
|
|
30
|
+
if (header !== null) { inside = header[1] === 'gate'; continue; }
|
|
31
|
+
if (!inside) continue;
|
|
32
|
+
const kv = /^\s*port\s*=\s*(\d+)\s*(?:#.*)?$/.exec(line);
|
|
33
|
+
if (kv === null) continue;
|
|
34
|
+
const port = Number(kv[1]);
|
|
35
|
+
if (Number.isInteger(port) && port > 0) return port;
|
|
36
|
+
}
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The bridge config this process should read: $BOTHOUSE_CONFIG (the bridge's --config), else
|
|
42
|
+
* <its data dir>/config.toml, the data dir being the one the gate itself would pick
|
|
43
|
+
* (bridge/brand.go dataDir, bridge/contrib/gate/datadir.py data_dir). The process's own ./config.toml
|
|
44
|
+
* is deliberately not tried: that is the bridge's rule for the bridge's cwd, not for a plugin's.
|
|
45
|
+
* @param {NodeJS.ProcessEnv} env
|
|
46
|
+
* @param {string} home
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
export function bridgeConfigFile(env = process.env, home = homedir()) {
|
|
50
|
+
const explicit = String(env.BOTHOUSE_CONFIG ?? '').trim();
|
|
51
|
+
if (explicit !== '' && explicit !== '~' && !explicit.startsWith('~/')) return explicit;
|
|
52
|
+
if (explicit !== '') return join(home, explicit.slice(2));
|
|
53
|
+
return join(dataDirCandidates(env, home)[0], 'config.toml');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The gate port to use, and where it came from (for the log line).
|
|
58
|
+
* @param {NodeJS.ProcessEnv} env
|
|
59
|
+
* @param {string} home
|
|
60
|
+
* @param {(file: string, encoding: string) => string} readFile
|
|
61
|
+
* @returns {{ port: string, why: string }}
|
|
62
|
+
*/
|
|
63
|
+
export function resolveGatePort(env = process.env, home = homedir(), readFile = readFileSync) {
|
|
64
|
+
const fromEnv = String(env.GATE_PORT ?? '').trim();
|
|
65
|
+
if (fromEnv !== '') return { port: fromEnv, why: 'GATE_PORT' };
|
|
66
|
+
const file = bridgeConfigFile(env, home);
|
|
67
|
+
let text;
|
|
68
|
+
try { text = readFile(file, 'utf8'); } catch { return { port: '', why: `没有配置文件 ${file}` }; }
|
|
69
|
+
const port = gatePortSetting(text);
|
|
70
|
+
if (port !== 0) return { port: String(port), why: `${file} [gate] port` };
|
|
71
|
+
return { port: String(DEFAULT_GATE_PORT), why: `${file} 没有 [gate] port,用默认端口` };
|
|
72
|
+
}
|
|
9
73
|
|
|
10
74
|
export class GateContext {
|
|
11
75
|
/**
|
|
12
|
-
* @param options - { port?: gate port (default
|
|
76
|
+
* @param options - { port?: gate port (default: GATE_PORT, else the bridge config's [gate] port),
|
|
77
|
+
* timeoutMs?, fetch?, logger? }.
|
|
13
78
|
*/
|
|
14
|
-
constructor({ port
|
|
15
|
-
|
|
79
|
+
constructor({ port, timeoutMs = GATE_CONTEXT_TIMEOUT_MS, fetch: fetchFn = globalThis.fetch, logger } = {}) {
|
|
80
|
+
const from = port === undefined ? resolveGatePort() : { port: String(port ?? '').trim(), why: 'port 参数' };
|
|
81
|
+
this.port = String(from.port ?? '').trim();
|
|
16
82
|
this.timeoutMs = timeoutMs;
|
|
17
83
|
this.fetch = fetchFn;
|
|
18
84
|
this.logger = logger;
|
|
85
|
+
// One line per process (a member each builds its own GateContext): which port the context goes
|
|
86
|
+
// to and from where, so a missing one is a line in the log instead of a silently missing block.
|
|
87
|
+
const line = `${this.port}\u0000${from.why}`;
|
|
88
|
+
if (!announced.has(line)) {
|
|
89
|
+
announced.add(line);
|
|
90
|
+
if (this.port !== '') this.logger?.info?.('collab: gate context: port %s(%s)', this.port, from.why);
|
|
91
|
+
else this.logger?.warn?.('collab: gate context: 没有 gate 端口(%s);每轮不带 wiki 页和任务板', from.why);
|
|
92
|
+
}
|
|
19
93
|
/** `${member}\0${room}` -> { session, epoch }: the name of that conversation's context for the gate. */
|
|
20
94
|
this.sent = new Map();
|
|
21
95
|
}
|
package/collab/hub.mjs
CHANGED
|
@@ -17,6 +17,8 @@ export class Hub {
|
|
|
17
17
|
this.members = new Map();
|
|
18
18
|
/** roomId -> Set<memberId> currently working */
|
|
19
19
|
this.thinking = new Map();
|
|
20
|
+
/** #1020: room id -> member id -> ids of the owner's messages waiting behind that member's running turn. */
|
|
21
|
+
this.queued = new Map();
|
|
20
22
|
/** id -> optional busy fields published by the team-status reader (#876). */
|
|
21
23
|
this.presence = new Map();
|
|
22
24
|
this.listeners = [];
|
|
@@ -27,6 +29,9 @@ export class Hub {
|
|
|
27
29
|
memberList() { return [...this.members.values()]; }
|
|
28
30
|
|
|
29
31
|
membersInRoom(roomId) {
|
|
32
|
+
// #1023: a private chat has the owner and its one agent, whatever that agent's channel list says.
|
|
33
|
+
const dm = this.store.dmMember?.(roomId);
|
|
34
|
+
if (dm !== undefined) return this.memberList().filter(m => m.kind === 'human' || m.id === dm);
|
|
30
35
|
const channel = this.store.settingsRoom(roomId);
|
|
31
36
|
return this.memberList().filter(m => !m.rooms?.length || m.rooms.includes(channel));
|
|
32
37
|
}
|
|
@@ -45,6 +50,9 @@ export class Hub {
|
|
|
45
50
|
|
|
46
51
|
/** Channel selection, inherited by workspaces; only unset channels use the legacy global choice. */
|
|
47
52
|
primaryIn(roomId) {
|
|
53
|
+
// #1023: in a private chat the one agent answers every owner message, @ or not.
|
|
54
|
+
const dm = this.store.dmMember?.(roomId);
|
|
55
|
+
if (dm !== undefined) return this.routableIn(roomId).find(m => m.id === dm);
|
|
48
56
|
const id = this.store.leadId(roomId);
|
|
49
57
|
const member = id === null ? this.selectedPrimaryMember() : this.members.get(id);
|
|
50
58
|
return member && this.routableIn(roomId).some(m => m.id === member.id) ? member : undefined;
|
|
@@ -66,6 +74,7 @@ export class Hub {
|
|
|
66
74
|
if (!this.members.delete(id)) return;
|
|
67
75
|
this.presence.delete(id);
|
|
68
76
|
for (const room of this.thinking.keys()) this.setThinking(room, id, false);
|
|
77
|
+
for (const room of [...this.queued.keys()]) this.setQueue(room, id, []);
|
|
69
78
|
this.emit({ type: 'members', members: this.memberView() });
|
|
70
79
|
}
|
|
71
80
|
|
|
@@ -76,6 +85,7 @@ export class Hub {
|
|
|
76
85
|
if (!online) {
|
|
77
86
|
this.presence.delete(id);
|
|
78
87
|
for (const room of this.thinking.keys()) this.setThinking(room, id, false);
|
|
88
|
+
for (const room of [...this.queued.keys()]) this.setQueue(room, id, []);
|
|
79
89
|
}
|
|
80
90
|
this.emit({ type: 'members', members: this.memberView() });
|
|
81
91
|
}
|
|
@@ -114,6 +124,21 @@ export class Hub {
|
|
|
114
124
|
this.emit({ type: 'thinking', room_id: roomId, members: [...set] });
|
|
115
125
|
}
|
|
116
126
|
|
|
127
|
+
/** #1020: replace the queue a member has in a room (ids in queue order; [] clears it). */
|
|
128
|
+
setQueue(roomId, id, ids) {
|
|
129
|
+
const room = this.queued.get(roomId) ?? new Map();
|
|
130
|
+
const before = room.get(id) ?? [];
|
|
131
|
+
if (before.length === ids.length && before.every((x, i) => x === ids[i])) return;
|
|
132
|
+
if (ids.length) room.set(id, [...ids]); else room.delete(id);
|
|
133
|
+
if (room.size) this.queued.set(roomId, room); else this.queued.delete(roomId);
|
|
134
|
+
this.emit({ type: 'queue', room_id: roomId, queued: this.queueView(roomId) });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** #1020: { member id: [message ids] } waiting in a room. */
|
|
138
|
+
queueView(roomId) {
|
|
139
|
+
return Object.fromEntries([...(this.queued.get(roomId) ?? new Map())]);
|
|
140
|
+
}
|
|
141
|
+
|
|
117
142
|
memberView() {
|
|
118
143
|
return this.memberList().map(({ id, name, kind, online, model, cli, permissionPreset, persona, nameCustomized, avatar, avatarVersion, source, error, rooms }) => ({
|
|
119
144
|
id, name, kind, ...(rooms ? { rooms } : {}), online: online !== false && !error, ...(error ? { error } : {}), ...(avatar ? { avatar, ...(avatarVersion ? { avatarVersion } : {}) } : {}),
|
|
@@ -161,6 +186,7 @@ export class Hub {
|
|
|
161
186
|
return r;
|
|
162
187
|
});
|
|
163
188
|
if (!result.created) return result;
|
|
189
|
+
result.message = this.withMemories(result.message);
|
|
164
190
|
for (const next of after) {
|
|
165
191
|
Promise.resolve().then(next).catch((error) => {
|
|
166
192
|
this.logger.warn('collab: post-commit step failed: %s', error?.stack ?? error);
|
|
@@ -195,7 +221,7 @@ export class Hub {
|
|
|
195
221
|
return c;
|
|
196
222
|
});
|
|
197
223
|
if (r === undefined) return undefined;
|
|
198
|
-
const row = r.message;
|
|
224
|
+
const row = r.first ? this.withMemories(r.message) : r.message;
|
|
199
225
|
this.emit({ type: 'message', message: publicMessage(row, this.store) });
|
|
200
226
|
if (!r.first) return row;
|
|
201
227
|
for (const next of after) {
|
|
@@ -211,6 +237,21 @@ export class Hub {
|
|
|
211
237
|
return row;
|
|
212
238
|
}
|
|
213
239
|
|
|
240
|
+
/**
|
|
241
|
+
* #1020: attach to a member's reply the memory entries it cited here since its last reply
|
|
242
|
+
* (memory-sources.mjs). Command answers (from_turn 0), cards and drafts take none.
|
|
243
|
+
*/
|
|
244
|
+
withMemories(row) {
|
|
245
|
+
if (!this.memorySources || row?.author_kind !== 'role' || row.card != null || row.draft || row.from_turn === 0) return row;
|
|
246
|
+
const voter = this.members.get(row.author)?.voter ?? `dsh:${row.author}`;
|
|
247
|
+
let items;
|
|
248
|
+
try { items = this.memorySources.take(voter, row.room_id); } catch (error) {
|
|
249
|
+
this.logger.warn('collab: memory sources failed: %s', error?.message ?? error);
|
|
250
|
+
return row;
|
|
251
|
+
}
|
|
252
|
+
return items.length ? (this.store.setMemories(row.id, items) ?? row) : row;
|
|
253
|
+
}
|
|
254
|
+
|
|
214
255
|
/** Edit/delete a row the same author wrote, and tell browsers. */
|
|
215
256
|
edit(id, authorKind, author, text, card) {
|
|
216
257
|
const row = this.store.edit(id, authorKind, author, text, card);
|