bothouse-dsh 0.1.48 → 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 +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/wiki-galaxy.mjs +2 -1
- package/package.json +1 -1
package/collab/panel.mjs
CHANGED
|
@@ -89,7 +89,16 @@ function sendNotice(res, status, reason) {
|
|
|
89
89
|
* @returns {string}
|
|
90
90
|
*/
|
|
91
91
|
export function rewritePanelHTML(html, prefix = PANEL_PREFIX) {
|
|
92
|
-
|
|
92
|
+
// The page may itself sit under another proxy's prefix: the bridge management server mounts
|
|
93
|
+
// the whole DSH site at /dsh/ (core/management_dsh_proxy.go) and strips it before DSH sees the
|
|
94
|
+
// request, so this route cannot know it. A fixed <base href="/collab/panel/"> then sends the
|
|
95
|
+
// panel's scripts to the management server's own /collab/panel/..., which answers with its
|
|
96
|
+
// index.html, and the frame stays blank. The inline script moves <base> to wherever the page
|
|
97
|
+
// was actually loaded from, before any asset URL is resolved (it runs ahead of the panel's
|
|
98
|
+
// module scripts, and basePath.ts reads document.baseURI only when they run).
|
|
99
|
+
const mount = JSON.stringify(`${prefix}/`).replace(/</g, '\\u003c');
|
|
100
|
+
const fix = `<script>(()=>{const m=${mount},p=location.pathname,i=p.indexOf(m);if(i>0)document.querySelector("base").setAttribute("href",p.slice(0,i)+m)})()</script>`;
|
|
101
|
+
const head = `<base href="${prefix}/" />${fix}\n <meta name="bothouse-auth" content="proxy" />`;
|
|
93
102
|
if (/<base\s[^>]*>/i.test(html)) return html.replace(/<base\s[^>]*>/i, head);
|
|
94
103
|
return html.replace(/<head(\s[^>]*)?>/i, m => `${m}\n ${head}`);
|
|
95
104
|
}
|
package/collab/plugin.mjs
CHANGED
|
@@ -4,12 +4,13 @@ import { personaLibrary } from './personas.mjs';
|
|
|
4
4
|
// - insert:
|
|
5
5
|
// - id: conest-collab
|
|
6
6
|
// name: /abs/path/dsh/collab/plugin.mjs
|
|
7
|
-
// config: { dataDir, workspacePath, leadFile?, fallbackLead?, botTokenEnv?, ownerName?, rooms?, dshMembers?, maxRelay?, boardDir?, teamStatusDir?, wikiDir?, bridgeConfig? }
|
|
7
|
+
// config: { dataDir, workspacePath, leadFile?, fallbackLead?, botTokenEnv?, botPort?, ownerName?, rooms?, dshMembers?, maxRelay?, boardDir?, teamStatusDir?, wikiDir?, bridgeConfig? }
|
|
8
8
|
import { createRequire } from 'node:module';
|
|
9
9
|
import { readFileSync, realpathSync, statSync } from 'node:fs';
|
|
10
10
|
import { isAbsolute, join } from 'node:path';
|
|
11
11
|
import { pathToFileURL } from 'node:url';
|
|
12
12
|
import { Store, CollabError, publicMessage } from './store.mjs';
|
|
13
|
+
import { startBotPort } from './bot-port.mjs';
|
|
13
14
|
import { Hub } from './hub.mjs';
|
|
14
15
|
import { TeamExit } from './team-exit.mjs';
|
|
15
16
|
import { CloudWebGateway, WS_PATH } from './cloudweb.mjs';
|
|
@@ -17,6 +18,7 @@ import { DshMembers } from './dsh-member.mjs';
|
|
|
17
18
|
import { Roster, rowToMember } from './roster.mjs';
|
|
18
19
|
import { Tasks, WORKING } from './tasks.mjs';
|
|
19
20
|
import { createCliMember } from './cli-member.mjs';
|
|
21
|
+
import { MemorySources } from './memory-sources.mjs';
|
|
20
22
|
import { avatarImage, checkAvatar } from './avatars.mjs';
|
|
21
23
|
import { AvatarUploads } from './avatar-uploads.mjs';
|
|
22
24
|
import { handler as clisHandler } from './clis.mjs';
|
|
@@ -28,6 +30,8 @@ import { DeskEvents, DESK_NAMES_FILE, deskRoute } from './desk.mjs';
|
|
|
28
30
|
import { wikiGalaxyRoute } from './wiki-galaxy.mjs';
|
|
29
31
|
import { defaultBridgeConfig, panelRoute } from './panel.mjs';
|
|
30
32
|
import { TeamStatus } from './team-status.mjs';
|
|
33
|
+
import { ALIVE_EVERY_MS, PairClient, PairHost, PairListener, PAIR_PORT, REMOTE_OWNER, hostHandler, isLoopback, pairApiRoute, remoteProxyRoute, tailnetAddress } from './pairing.mjs';
|
|
34
|
+
import { homedir, hostname } from 'node:os';
|
|
31
35
|
|
|
32
36
|
export const name = 'conest-collab';
|
|
33
37
|
export const inject = [
|
|
@@ -36,6 +40,8 @@ export const inject = [
|
|
|
36
40
|
];
|
|
37
41
|
|
|
38
42
|
const PAGE = readFileSync(new URL('./page.html', import.meta.url), 'utf8');
|
|
43
|
+
// #131: 「其他电脑」page — pair this DSH with another one by a one-time number.
|
|
44
|
+
const DEVICES_PAGE = readFileSync(new URL('./devices.html', import.meta.url), 'utf8');
|
|
39
45
|
const MAX_BODY = 256 * 1024;
|
|
40
46
|
// #144: a message POST may carry base64 attachments. Same cap as the bot WebSocket
|
|
41
47
|
// maxPayload below (8 MiB), so a message that is accepted here still fits in the frame
|
|
@@ -79,7 +85,11 @@ export function resolveConfig(config, env = process.env) {
|
|
|
79
85
|
if (!Number.isSafeInteger(maxRelay) || maxRelay < 0) throw Error('conest-collab: maxRelay must be a non-negative integer');
|
|
80
86
|
const botTokenEnv = c.botTokenEnv ?? 'CONEST_COLLAB_BOT_TOKEN';
|
|
81
87
|
const botToken = env[botTokenEnv] ?? '';
|
|
82
|
-
|
|
88
|
+
// #142 桌面版宿主端口每次随机:给一个固定端口另开 bot 接入(bot-port.mjs)。配置或 CONEST_COLLAB_BOT_PORT,都没给就不开。
|
|
89
|
+
const botPortRaw = c.botPort ?? (String(env.CONEST_COLLAB_BOT_PORT ?? '').trim() || undefined);
|
|
90
|
+
const botPort = botPortRaw === undefined ? undefined : Number(botPortRaw);
|
|
91
|
+
if (botPort !== undefined && !(Number.isSafeInteger(botPort) && botPort > 0 && botPort < 65536)) throw Error('conest-collab: botPort must be a TCP port number');
|
|
92
|
+
const leadFile = c.leadFile ?? join(env.BOTHOUSE_DATA_DIR ?? join(env.HOME || homedir(), '.bothouse'), 'lead.json');
|
|
83
93
|
if (!isAbsolute(leadFile)) throw Error('conest-collab: leadFile must be an absolute path');
|
|
84
94
|
const fallbackLead = String(c.fallbackLead ?? env.CONEST_FALLBACK_LEAD ?? '').trim();
|
|
85
95
|
// #876: board events sit next to board.jsonl; BOARD_DIR is the same switch gate/board.py reads,
|
|
@@ -97,7 +107,7 @@ export function resolveConfig(config, env = process.env) {
|
|
|
97
107
|
// #105 侧栏「bothouse」:读这份主桥配置的 [management] 段,在服务端转发管理面板(令牌不进浏览器)。
|
|
98
108
|
const bridgeConfig = String(c.bridgeConfig ?? '').trim() || defaultBridgeConfig(env);
|
|
99
109
|
if (!isAbsolute(bridgeConfig)) throw Error('conest-collab: bridgeConfig must be an absolute path');
|
|
100
|
-
return { dataDir: c.dataDir, workspacePath: c.workspacePath, leadFile, fallbackLead, boardDir, teamStatusDir, wikiDir, bridgeConfig, rooms, dshMembers: members, maxRelay, botTokenEnv, botToken, ownerName: c.ownerName ?? '我' };
|
|
110
|
+
return { dataDir: c.dataDir, workspacePath: c.workspacePath, leadFile, fallbackLead, boardDir, teamStatusDir, wikiDir, bridgeConfig, rooms, dshMembers: members, maxRelay, botTokenEnv, botToken, botPort, ownerName: c.ownerName ?? '我' };
|
|
101
111
|
}
|
|
102
112
|
|
|
103
113
|
/** Resolve a package from the running DSH installation, never a second copy. */
|
|
@@ -132,6 +142,7 @@ export async function apply(ctx, rawConfig) {
|
|
|
132
142
|
store.migrateMemberRooms();
|
|
133
143
|
const leadState = new LeadState(config.leadFile, ctx.logger);
|
|
134
144
|
const hub = new Hub(store, ctx.logger, leadState, config.fallbackLead);
|
|
145
|
+
hub.memorySources = new MemorySources({ logger: ctx.logger }); // #1020 回复的记忆来源
|
|
135
146
|
hub.workspacePath = config.workspacePath;
|
|
136
147
|
hub.teamExit = new TeamExit(hub);
|
|
137
148
|
hub.setMember({ id: OWNER, name: config.ownerName, kind: 'human', online: true });
|
|
@@ -179,24 +190,47 @@ export async function apply(ctx, rawConfig) {
|
|
|
179
190
|
teamStatus?.start();
|
|
180
191
|
if (!config.botToken) ctx.logger.warn(`conest-collab: ${config.botTokenEnv} is unset; cc-connect bots cannot connect`);
|
|
181
192
|
|
|
182
|
-
|
|
193
|
+
// #131: a request the tailnet pairing listener authenticated with a device credential acts as
|
|
194
|
+
// the owner; everything else still needs this DSH's own sign-in.
|
|
195
|
+
const owner = req => req[REMOTE_OWNER] === true || ctx.connection.requestRejection(req) === undefined;
|
|
196
|
+
// Test-only overrides (same-machine two-instance check): address and port of the pairing listener.
|
|
197
|
+
const pairAddr = String(process.env.CONEST_COLLAB_PAIR_ADDR ?? '').trim();
|
|
198
|
+
const pairPort = Number(process.env.CONEST_COLLAB_PAIR_PORT) || PAIR_PORT;
|
|
199
|
+
const pairHost = new PairHost({ file: join(config.dataDir, 'pair-devices.json') });
|
|
200
|
+
const pairClient = new PairClient({ file: join(config.dataDir, 'pair-links.json'), port: pairPort });
|
|
201
|
+
const pairListener = new PairListener({
|
|
202
|
+
host: pairHost, port: pairPort, logger: ctx.logger,
|
|
203
|
+
address: pairAddr === '' ? () => tailnetAddress() : async () => pairAddr,
|
|
204
|
+
handler: hostHandler({ host: pairHost, name: hostname(), route: (req, res) => browserRoute(req, res), allowLoopback: isLoopback(pairAddr) }),
|
|
205
|
+
});
|
|
206
|
+
if (pairHost.list().length > 0) pairListener.ensure().catch(err => ctx.logger.warn('conest-collab: pairing listener: %s', err.message));
|
|
207
|
+
// 来源:码有效期按分钟计(pairing.mjs CODE_TTL_MS),每分钟收一次没人用的配对监听。
|
|
208
|
+
const pairSweep = setInterval(() => { pairListener.settle().catch(() => {}); }, 60_000);
|
|
209
|
+
pairSweep.unref?.();
|
|
210
|
+
// 「直到我断开」: drop links whose DSH window stopped beating (same cadence as the beat).
|
|
211
|
+
const aliveSweep = setInterval(() => { pairClient.sweep().catch(() => {}); }, ALIVE_EVERY_MS);
|
|
212
|
+
aliveSweep.unref?.();
|
|
183
213
|
const wss = new WebSocketServer({ noServer: true, maxPayload: BOT_WS_MAX_PAYLOAD });
|
|
184
214
|
const streams = new Set();
|
|
185
215
|
|
|
186
216
|
ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/collab', handler: (req, res) => browserRoute(req, res) }), 'conest-collab: /collab');
|
|
187
217
|
ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/api/rooms', handler: (req, res) => historyRoute(req, res) }), 'conest-collab: /api/rooms');
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
218
|
+
const botUpgrade = (req, socket, head) => {
|
|
219
|
+
const url = new URL(req.url ?? '/', 'http://collab.invalid');
|
|
220
|
+
later.origin ??= localOrigin(req);
|
|
221
|
+
if (!gateway.authorized(req, url)) {
|
|
222
|
+
socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
wss.handleUpgrade(req, socket, head, ws => gateway.accept(ws, url));
|
|
226
|
+
};
|
|
227
|
+
ctx.effect(() => ctx.webServer.registerUpgrade({ path: WS_PATH, handler: botUpgrade }), 'conest-collab: cloud-web upgrade');
|
|
228
|
+
if (config.botPort !== undefined && config.botToken) {
|
|
229
|
+
const listening = startBotPort({ port: config.botPort, path: WS_PATH, upgrade: botUpgrade, history: (req, res) => historyRoute(req, res), logger: ctx.logger })
|
|
230
|
+
.then(l => { ctx.logger.info('conest-collab: bot port 127.0.0.1:%d', l.port); return l; },
|
|
231
|
+
err => { ctx.logger.warn('conest-collab: bot port %d not opened: %s', config.botPort, err.message); return undefined; });
|
|
232
|
+
ctx.effect(() => async () => { (await listening)?.close(); }, 'conest-collab: bot port');
|
|
233
|
+
}
|
|
200
234
|
ctx.effect(() => async () => {
|
|
201
235
|
teamStatus?.stop();
|
|
202
236
|
desk.stop();
|
|
@@ -204,6 +238,9 @@ export async function apply(ctx, rawConfig) {
|
|
|
204
238
|
later.close();
|
|
205
239
|
tasks.close();
|
|
206
240
|
gateway.closeAll();
|
|
241
|
+
clearInterval(pairSweep);
|
|
242
|
+
clearInterval(aliveSweep);
|
|
243
|
+
await pairListener.close();
|
|
207
244
|
for (const end of streams) end();
|
|
208
245
|
wss.close();
|
|
209
246
|
// Drain DSH jobs first: a job still awaiting its Agent must not touch a closed store.
|
|
@@ -229,7 +266,8 @@ export async function apply(ctx, rawConfig) {
|
|
|
229
266
|
async function browserRoute(req, res) {
|
|
230
267
|
const url = new URL(req.url ?? '/', 'http://collab.invalid');
|
|
231
268
|
const path = url.pathname.replace(/\/+$/, '');
|
|
232
|
-
|
|
269
|
+
// A paired device reaches us on the tailnet listener; ticket URLs stay on this machine's own origin.
|
|
270
|
+
if (!req[REMOTE_OWNER]) later.origin ??= localOrigin(req);
|
|
233
271
|
const scheduled = laterRoute(req, res, url, { hub, gateway, owner, later });
|
|
234
272
|
if (scheduled !== undefined) return scheduled;
|
|
235
273
|
const routed = tasksRoute(req, res, url, { tasks, gateway, owner });
|
|
@@ -249,6 +287,14 @@ export async function apply(ctx, rawConfig) {
|
|
|
249
287
|
if (galaxyHandled !== undefined) return galaxyHandled;
|
|
250
288
|
const panelHandled = panelRoute(req, res, url, { bridgeConfig: config.bridgeConfig });
|
|
251
289
|
if (panelHandled !== undefined) return panelHandled;
|
|
290
|
+
// #131: pairing (this machine lets others in) and remote links (this machine drives others).
|
|
291
|
+
if (!req[REMOTE_OWNER]) {
|
|
292
|
+
if (path === '/collab/devices' && req.method === 'GET') return text(res, 200, 'text/html; charset=utf-8', DEVICES_PAGE);
|
|
293
|
+
const paired = pairApiRoute(req, res, url, { host: pairHost, client: pairClient, listener: pairListener });
|
|
294
|
+
if (paired !== undefined) return paired;
|
|
295
|
+
const remote = remoteProxyRoute(req, res, url, { client: pairClient });
|
|
296
|
+
if (remote !== undefined) return remote;
|
|
297
|
+
}
|
|
252
298
|
if (path === '/collab' && req.method === 'GET') return text(res, 200, 'text/html; charset=utf-8', PAGE);
|
|
253
299
|
if (path === '/collab/api/personas' && req.method === 'GET') return json(res, 200, personaLibrary(url.searchParams.get('lang') ?? 'zh'));
|
|
254
300
|
if (path === '/collab/api/state' && req.method === 'GET') {
|
|
@@ -256,7 +302,8 @@ export async function apply(ctx, rawConfig) {
|
|
|
256
302
|
effective_lead_id: hub.primaryMember()?.id ?? null,
|
|
257
303
|
rooms: store.roomsView().map(r => ({ ...r, lead_id: store.leadId(r.id), effective_lead_id: hub.primaryIn(r.id)?.id ?? null,
|
|
258
304
|
workspace_path: store.workspacePath(r.id, config.workspacePath) })), members: hub.memberView(),
|
|
259
|
-
thinking: Object.fromEntries([...hub.thinking].map(([k, v]) => [k, [...v]]))
|
|
305
|
+
thinking: Object.fromEntries([...hub.thinking].map(([k, v]) => [k, [...v]])),
|
|
306
|
+
queued: Object.fromEntries([...hub.queued.keys()].map(r => [r, hub.queueView(r)])) });
|
|
260
307
|
}
|
|
261
308
|
if (path === '/collab/api/events' && req.method === 'GET') return events(req, res);
|
|
262
309
|
if (path === '/collab/api/lead' && req.method === 'GET') {
|
|
@@ -409,6 +456,17 @@ export async function apply(ctx, rawConfig) {
|
|
|
409
456
|
return { room };
|
|
410
457
|
});
|
|
411
458
|
}
|
|
459
|
+
// #1023: open (or reopen) the private chat with one agent member.
|
|
460
|
+
if (path === '/collab/api/dms' && req.method === 'POST') {
|
|
461
|
+
return withBody(req, res, (body) => {
|
|
462
|
+
const id = String(body?.member ?? '');
|
|
463
|
+
const member = hub.members.get(id);
|
|
464
|
+
if (member === undefined || member.kind === 'human') throw new CollabError('invalid', 'member must be an agent in this channel list');
|
|
465
|
+
const room = store.openDm(id, member.name);
|
|
466
|
+
hub.emit({ type: 'rooms', rooms: store.roomsView() });
|
|
467
|
+
return { room };
|
|
468
|
+
});
|
|
469
|
+
}
|
|
412
470
|
if (path === '/collab/api/members' && req.method === 'POST') {
|
|
413
471
|
return withBody(req, res, async body => ({ member: await roster.create(body) }));
|
|
414
472
|
}
|
|
@@ -738,9 +796,14 @@ export function parseRange(header, size) {
|
|
|
738
796
|
|
|
739
797
|
/** Serves a stored attachment; honours single byte ranges so iOS Safari can play audio. */
|
|
740
798
|
export function sendAttachment(req, res, row, bytes) {
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
799
|
+
// #1023: a delivered web page (`?view=page`) opens inline for the delivery card's frame. The CSP
|
|
800
|
+
// sandbox gives it an opaque origin even when opened in its own tab, so its scripts never act
|
|
801
|
+
// with the owner's sign-in on this origin.
|
|
802
|
+
const page = /[?&]view=page(?:&|$)/.test(req.url ?? '') && (/^text\/html\b/i.test(row.mime) || /\.html?$/i.test(row.name));
|
|
803
|
+
const headers = { 'content-type': page ? 'text/html; charset=utf-8' : row.mime, 'accept-ranges': 'bytes',
|
|
804
|
+
'content-disposition': `${page || INLINE_IMAGE.test(row.mime) ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(row.name)}`,
|
|
805
|
+
'x-content-type-options': 'nosniff', 'cache-control': 'private, max-age=86400',
|
|
806
|
+
...(page ? { 'content-security-policy': 'sandbox allow-scripts allow-forms allow-popups allow-modals' } : {}) };
|
|
744
807
|
const range = parseRange(req.headers?.range, bytes.length);
|
|
745
808
|
if (range === null) {
|
|
746
809
|
res.writeHead(416, { ...headers, 'content-range': `bytes */${bytes.length}`, 'content-length': 0 });
|
package/collab/roster.mjs
CHANGED
|
@@ -64,6 +64,11 @@ export class Roster {
|
|
|
64
64
|
* as UI members, once: names already in the channel are skipped, then the file becomes *.imported.json with
|
|
65
65
|
* each role's member id (the new one, or the one already holding the name), so bothouse names the role's wiki
|
|
66
66
|
* after the id the member really has (bridge core/setup_dsh_wiki.go). Returns the names added.
|
|
67
|
+
* A default-named role (name_customized false) reuses a DSH member a former run made for it — same CLI,
|
|
68
|
+
* still automatic, named the role's name or name-N — so re-running the wizard does not add Codex-2, Codex-3…
|
|
69
|
+
* (11-05 Windows report); the one named exactly the role's name goes first, then the oldest. Each member
|
|
70
|
+
* serves one role per run, so two default Codex roles still get two members. The imported entry carries the
|
|
71
|
+
* member's real name, which the bridge writes into gate-team.json and lead.json.
|
|
67
72
|
*/
|
|
68
73
|
async importWizard(path, logger) {
|
|
69
74
|
if (!existsSync(path)) return [];
|
|
@@ -76,19 +81,22 @@ export class Roster {
|
|
|
76
81
|
}
|
|
77
82
|
const added = [];
|
|
78
83
|
const imported = [];
|
|
84
|
+
const claimed = new Set();
|
|
79
85
|
for (const m of Array.isArray(members) ? members : []) {
|
|
80
86
|
const name = String(m?.name ?? '').trim();
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (same !== undefined
|
|
85
|
-
|
|
87
|
+
// Custom and legacy names stay idempotent by name (a re-import must not duplicate them).
|
|
88
|
+
const same = m?.name_customized === false ? this.reusableWizardMember(name, m?.cli, claimed)
|
|
89
|
+
: this.hub.memberList().find(x => x.name.toLowerCase() === name.toLowerCase());
|
|
90
|
+
if (same !== undefined) {
|
|
91
|
+
claimed.add(same.id);
|
|
92
|
+
imported.push({ ...m, name: same.name, id: same.id });
|
|
86
93
|
continue;
|
|
87
94
|
}
|
|
88
95
|
try {
|
|
89
96
|
const avatar = m?.avatar && ['orange', 'whale', 'dragon', 'rainbow', 'lanxi'].includes(m.avatar) ? m.avatar : undefined;
|
|
90
97
|
const member = await this.create({ name, persona: m?.persona ?? '', ...(m?.cli ? { cli: m.cli } : {}), ...(m?.name_customized === undefined ? {} : { nameCustomized: Boolean(m.name_customized) }), ...(avatar ? { avatar } : {}) });
|
|
91
|
-
|
|
98
|
+
claimed.add(member.id);
|
|
99
|
+
imported.push({ ...m, name: member.name, id: member.id });
|
|
92
100
|
added.push(member.name);
|
|
93
101
|
} catch (error) {
|
|
94
102
|
imported.push(m);
|
|
@@ -100,6 +108,19 @@ export class Roster {
|
|
|
100
108
|
return added;
|
|
101
109
|
}
|
|
102
110
|
|
|
111
|
+
/** A UI member an earlier wizard run made for the default-named role `name` (see importWizard), or undefined. */
|
|
112
|
+
reusableWizardMember(name, cli, claimed) {
|
|
113
|
+
const stem = name.toLowerCase();
|
|
114
|
+
// The exact name is the same role whatever its flag says: members seeded on the plugin's first start
|
|
115
|
+
// (seedDefaults) and those made before name_customized was stored carry none, and skipping them
|
|
116
|
+
// made every re-run add 鲸鱼娘-2 / DSH-2. A suffixed name (Codex-2) is reused only when it is marked automatic.
|
|
117
|
+
const fits = this.store.members().filter(r => r.source === 'ui' && !claimed.has(r.id) && (r.cli ?? '') === (cli ?? '')
|
|
118
|
+
&& (r.name.toLowerCase() === stem
|
|
119
|
+
|| (r.name_customized != null && !Boolean(r.name_customized) && r.name.toLowerCase().replace(/-\d+$/, '') === stem)));
|
|
120
|
+
const pick = fits.find(r => r.name.toLowerCase() === stem) ?? fits[0];
|
|
121
|
+
return pick === undefined ? undefined : rowToMember(pick);
|
|
122
|
+
}
|
|
123
|
+
|
|
103
124
|
/**
|
|
104
125
|
* Plugin-only installs (`dsh plugin add bothouse-dsh`) have no setup wizard: on the very first start,
|
|
105
126
|
* with no member ever stored, add one preset role (鲸鱼娘) as a DSH member on DSH's own default model, so
|
package/collab/store.mjs
CHANGED
|
@@ -56,6 +56,10 @@ const COLUMNS = [
|
|
|
56
56
|
['rooms', 'claim_seq', 'INTEGER NOT NULL DEFAULT 0'],
|
|
57
57
|
['rooms', 'workspace_path', 'TEXT'], // Optional chat directory; workspace rooms inherit their current parent.
|
|
58
58
|
['messages', 'edited_at', 'REAL'], // v11 (#148): when the owner last edited their own message; NULL if never.
|
|
59
|
+
// #1020 (no version bump, so an older build still opens the file): JSON list of the memory entries a reply used ({id, ref, page, title}); NULL when none.
|
|
60
|
+
['messages', 'memories', 'TEXT'],
|
|
61
|
+
// #1023 (no version bump): a private chat is a chat room with exactly one agent member; NULL for every other room.
|
|
62
|
+
['rooms', 'dm_member', 'TEXT'],
|
|
59
63
|
// #597 G34: when a role last changed the row in place (a streamed draft growing, a progress card
|
|
60
64
|
// edited, the draft committed); NULL if never. The task watch counts it as progress (Tasks.progressed).
|
|
61
65
|
['messages', 'changed_at', 'REAL'],
|
|
@@ -90,6 +94,7 @@ export class Store {
|
|
|
90
94
|
this.q = {
|
|
91
95
|
room: this.db.prepare('SELECT id, name, kind, parent_id FROM rooms WHERE id = ?'),
|
|
92
96
|
rooms: this.db.prepare('SELECT id, name, kind, parent_id FROM rooms ORDER BY id'),
|
|
97
|
+
dms: this.db.prepare('SELECT id, dm_member FROM rooms WHERE dm_member IS NOT NULL'),
|
|
93
98
|
children: this.db.prepare("SELECT id, name FROM rooms WHERE kind = 'workspace' AND parent_id = ? ORDER BY id"),
|
|
94
99
|
// A new workspace is claimed with no task yet (claim_task 0): its first task is the handoff.
|
|
95
100
|
createWorkspace: this.db.prepare(`INSERT INTO rooms (id, name, kind, parent_id, claim_task, claim_bridge, claim_at)
|
|
@@ -104,6 +109,7 @@ export class Store {
|
|
|
104
109
|
byId: this.db.prepare('SELECT * FROM messages WHERE id = ? AND deleted = 0'),
|
|
105
110
|
edit: this.db.prepare('UPDATE messages SET text = ?, card = ?, changed_at = ? WHERE id = ? AND author_kind = ? AND author = ? AND deleted = 0 RETURNING *'),
|
|
106
111
|
commit: this.db.prepare('UPDATE messages SET text = ?, from_turn = ?, attachments = ?, draft = 0, changed_at = ? WHERE id = ? AND author_kind = ? AND author = ? AND deleted = 0 AND card IS NULL AND draft = 1 RETURNING *'),
|
|
112
|
+
setMemories: this.db.prepare('UPDATE messages SET memories = ? WHERE id = ? RETURNING *'),
|
|
107
113
|
editUser: this.db.prepare(`UPDATE messages SET text = ?, edited_at = ? WHERE id = ? AND author_kind = 'user' AND author = ?
|
|
108
114
|
AND deleted = 0 AND draft = 0 AND card IS NULL RETURNING *`),
|
|
109
115
|
del: this.db.prepare('UPDATE messages SET deleted = 1 WHERE id = ? AND author_kind = ? AND author = ? AND deleted = 0 RETURNING *'),
|
|
@@ -386,6 +392,24 @@ export class Store {
|
|
|
386
392
|
this.ensureRoomSettings(room.id);
|
|
387
393
|
return room;
|
|
388
394
|
}
|
|
395
|
+
/**
|
|
396
|
+
* #1023: the private chat with one agent member — its existing room, or a new chat room whose
|
|
397
|
+
* only agent is that member (Hub.membersInRoom). Same room every time, so the session carries on.
|
|
398
|
+
*/
|
|
399
|
+
openDm(memberId, name) {
|
|
400
|
+
if (typeof memberId !== 'string' || memberId === '') throw new CollabError('invalid', 'member id required');
|
|
401
|
+
const room = this.transaction(() => {
|
|
402
|
+
const found = this.db.prepare('SELECT id FROM rooms WHERE dm_member = ? ORDER BY id LIMIT 1').get(memberId);
|
|
403
|
+
if (found !== undefined) return { ...this.q.room.get(found.id), dm_member: memberId };
|
|
404
|
+
const created = this.q.createRoom.get(String(name || memberId).replace(/\s+/g, '').slice(0, 32) || memberId);
|
|
405
|
+
this.db.prepare('UPDATE rooms SET dm_member = ? WHERE id = ?').run(memberId, created.id);
|
|
406
|
+
return { ...created, dm_member: memberId };
|
|
407
|
+
});
|
|
408
|
+
this.ensureRoomSettings(room.id);
|
|
409
|
+
return room;
|
|
410
|
+
}
|
|
411
|
+
/** #1023: the agent member a private chat room is with; undefined for every other room. */
|
|
412
|
+
dmMember(roomId) { return this.db.prepare('SELECT dm_member FROM rooms WHERE id = ?').get(roomId)?.dm_member ?? undefined; }
|
|
389
413
|
/**
|
|
390
414
|
* Give a chat channel its next workspace: the lowest-numbered free 工作区<k> of the shared
|
|
391
415
|
* pool (released by any chat channel after its last task) if there is one, else a new
|
|
@@ -627,7 +651,11 @@ export class Store {
|
|
|
627
651
|
return saved || this.q.workspacePath.get(roomId)?.path || fallback;
|
|
628
652
|
}
|
|
629
653
|
room(id) { return this.q.room.get(id); }
|
|
630
|
-
rooms() {
|
|
654
|
+
rooms() {
|
|
655
|
+
// #1023: only a private chat carries dm_member, so every other room keeps its old shape.
|
|
656
|
+
const dms = new Map(this.q.dms.all().map(r => [r.id, r.dm_member]));
|
|
657
|
+
return this.q.rooms.all().map(r => (dms.has(r.id) ? { ...r, dm_member: dms.get(r.id) } : r));
|
|
658
|
+
}
|
|
631
659
|
/** Return stale, empty claims to the pool without taking bridge or paused work. */
|
|
632
660
|
releaseIdleWorkspaces() {
|
|
633
661
|
const idle = this.db.prepare(`SELECT id FROM rooms WHERE kind = 'workspace' AND free = 0
|
|
@@ -676,6 +704,9 @@ export class Store {
|
|
|
676
704
|
|
|
677
705
|
message(id) { return this.q.byId.get(id); }
|
|
678
706
|
|
|
707
|
+
/** #1020: record the memory entries a reply used; returns the updated row. */
|
|
708
|
+
setMemories(id, items) { return this.q.setMemories.get(items?.length ? JSON.stringify(items) : null, id); }
|
|
709
|
+
|
|
679
710
|
/**
|
|
680
711
|
* What a reply quotes: undefined for a plain message; `{ id, deleted: true }`
|
|
681
712
|
* when the original is gone (no author or text leaks); else id, author, and
|
|
@@ -802,9 +833,17 @@ export function publicMessage(row, store) {
|
|
|
802
833
|
if (quote !== undefined) out.reply_to = quote;
|
|
803
834
|
const attachments = messageAttachments(row);
|
|
804
835
|
if (attachments.length) out.attachments = attachments;
|
|
836
|
+
const memories = messageMemories(row);
|
|
837
|
+
if (memories.length) out.memories = memories;
|
|
805
838
|
return out;
|
|
806
839
|
}
|
|
807
840
|
|
|
841
|
+
/** #1020: the memory entries a reply used ([] when none or unreadable). */
|
|
842
|
+
export function messageMemories(row) {
|
|
843
|
+
if (!row?.memories) return [];
|
|
844
|
+
try { const list = JSON.parse(row.memories); return Array.isArray(list) ? list : []; } catch { return []; }
|
|
845
|
+
}
|
|
846
|
+
|
|
808
847
|
/** #144: the uploaded files a message carries ([] when none or unreadable). */
|
|
809
848
|
export function messageAttachments(row) {
|
|
810
849
|
if (!row?.attachments) return [];
|