bothouse-dsh 0.1.47 → 0.1.48
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 +22 -2
- package/collab/cloudweb.mjs +34 -1
- package/collab/desk/index.html +271 -31
- package/collab/dsh-member.mjs +22 -1
- package/collab/later.mjs +21 -0
- package/collab/page.html +36 -0
- package/collab/plugin.mjs +76 -3
- package/collab/store.mjs +8 -0
- package/package.json +1 -1
package/collab/dsh-member.mjs
CHANGED
|
@@ -26,7 +26,7 @@ export function teamModeRules(mode = DEFAULT_DISPATCH_MODE) {
|
|
|
26
26
|
/** #597 G24: the open-workspace command, for the selected primary role only, never in bridge prompts. */
|
|
27
27
|
export function workspaceHint(hub, later, memberId, roomId) {
|
|
28
28
|
if (!later?.workspaceInstructions || hub?.primaryIn?.(roomId)?.id !== memberId) return '';
|
|
29
|
-
return later.workspaceInstructions(memberId, roomId);
|
|
29
|
+
return later.workspaceInstructions(memberId, roomId) + (later.teamInstructions?.(memberId, roomId) ?? '');
|
|
30
30
|
}
|
|
31
31
|
/**
|
|
32
32
|
* #597 G24: DSH members have no bridge session, so the shared line about `bothouse workspace new` /
|
|
@@ -201,6 +201,9 @@ export function friendlyFailure(raw) {
|
|
|
201
201
|
const text = String(raw);
|
|
202
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 '还没有配置模型的 API Key,请先去配置模型。';
|
|
203
203
|
if (/model.{0,30}(not found|unavailable|not available|unsupported)|unknown model|模型.*不可用|404/i.test(text)) return '这个模型暂时不可用,请检查模型名称或换一个模型。';
|
|
204
|
+
// An unreachable provider is a setting or network problem. Echoing the transport wording
|
|
205
|
+
// ("Connection error.") behind "请检查配置" told the user nothing about which setting to fix.
|
|
206
|
+
if (/connection error|econnrefused|econnreset|enotfound|etimedout|eai_again|fetch failed|socket hang up|network ?(error|request failed)|timed out|timeout/i.test(text)) return '连不上模型服务:中转站地址或网络不通。请检查设置里的模型地址和 Key,或换一个中转站再试。';
|
|
204
207
|
if (/login|log.?in|auth|unauthorized|401|未登录/i.test(text)) return '模型或 CLI 还没有登录,或登录已过期。请先登录后再试。';
|
|
205
208
|
if (/exit|ENOENT|spawn|没有输出文字/i.test(text)) return '本机 CLI 未能正常运行或已退出,请检查安装和登录状态。';
|
|
206
209
|
// A Host older than this plugin has no scope lease on its preset registry, so no member can be
|
|
@@ -863,6 +866,24 @@ export class DshMembers {
|
|
|
863
866
|
if (this.closed) return;
|
|
864
867
|
const job = this.store.job(jobId);
|
|
865
868
|
if (job === undefined || !this.store.finishJob(jobId, state)) return;
|
|
869
|
+
// A restart is not a configuration problem: the member is healthy and only the turn it was
|
|
870
|
+
// running is lost, so it takes a retry button and leaves the member online. Reporting it as
|
|
871
|
+
// "成员出错 / 去配置模型" also cooled the provider quota for what was a process restart.
|
|
872
|
+
if (state === 'interrupted') {
|
|
873
|
+
this.hub.setError(job.member_id, undefined);
|
|
874
|
+
this.hub.post(job.room_id, {
|
|
875
|
+
authorKind: 'system', author: 'system', name: 'system', text: String(notice), clientKey: `dsh-job-${jobId}-${state}-${Date.now()}`,
|
|
876
|
+
card: JSON.stringify({
|
|
877
|
+
title: '被中断',
|
|
878
|
+
state: 'failed',
|
|
879
|
+
action: 'retry',
|
|
880
|
+
job_id: jobId,
|
|
881
|
+
member_id: job.member_id,
|
|
882
|
+
trigger_id: job.trigger_id,
|
|
883
|
+
}),
|
|
884
|
+
});
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
866
887
|
this.hub.quota?.observe(job.member_id, job.room_id, { error: String(notice) });
|
|
867
888
|
const error = friendlyFailure(notice);
|
|
868
889
|
this.hub.setError(job.member_id, error);
|
package/collab/later.mjs
CHANGED
|
@@ -104,6 +104,18 @@ export class Later {
|
|
|
104
104
|
const curl = `curl -sS -X POST ${quote(url)} -H 'content-type: application/json' -H ${quote(`X-Later-Ticket: ${ticket}`)} -d '{"title":"任务标题","owner":"接活人id(省略=你自己)","reason":"交接说明"}'`;
|
|
105
105
|
return `\n【开工作区】整块活要交出去时,建工作区=这一条,任务和十分钟看门会自动开,不要另调 /tasks(它不认票据)。${local}或等价的接口 POST /collab/api/rooms/${room}/workspaces:\n${curl}\n一步建好工作区、开任务、挂十分钟看门并叫醒接活人;不要再在频道里 @ 同一件活。返回不是 200 就把返回原样告诉用户,不要悄悄自己做。工作区里的接活人是新会话,只看得到交接说明:里面要写清用户原话、参照的现成实现、以哪个分支为底、验收标准。\n`;
|
|
106
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* 一句话拉团队 (user 11-04): how the primary role builds a channel of new roles in one call.
|
|
109
|
+
* Primary-only like workspaceInstructions (dsh-member.mjs workspaceHint).
|
|
110
|
+
*/
|
|
111
|
+
teamInstructions(member, room) {
|
|
112
|
+
const quote = value => "'" + String(value).replaceAll("'", "'\\''") + "'";
|
|
113
|
+
const ticket = this.ticket(member, room);
|
|
114
|
+
const json = '{"channel":"小团队","model":"模型id(用户没说就省略=DSH默认模型)","members":[{"name":"写手","persona":"你是写手,负责写和改博客文章。"},{"name":"程序员","persona":"你是程序员,负责写代码和审代码。"}]}';
|
|
115
|
+
const local = this.origin ? `本机命令(改 JSON 里的内容):\n${quote(process.execPath)} ${quote(fileURLToPath(import.meta.url))} team ${quote(this.origin)} ${quote(ticket)} ${quote(json)}\n` : '';
|
|
116
|
+
const url = `${this.origin ?? 'http://<本机 DSH 地址>'}/collab/api/rooms/${room}/team`;
|
|
117
|
+
return `\n【建团队】用户要你建几个角色、拉个群/频道时,一条就建好:新频道、每个角色(名字、人设、模型),你是新频道的主角色。${local}或 POST ${url}(头 X-Later-Ticket: ${ticket},JSON 同上)。名字不带空格和 @;人设一两句写清分工;用户说了模型就填 model(如 deepseek-v4.1-flash),没说就省略。返回不是 200 就把返回原样告诉用户。建好后告诉用户频道名、每个角色和模型,问要不要丢个题目试跑。\n`;
|
|
118
|
+
}
|
|
107
119
|
tick() {
|
|
108
120
|
for (const row of this.db.prepare('SELECT * FROM member_later WHERE announced = 0').all()) {
|
|
109
121
|
this.hub.post(row.room_id, { authorKind: 'system', author: 'system', name: '回查登记',
|
|
@@ -134,6 +146,15 @@ if (process.argv[1] === fileURLToPath(import.meta.url) && process.argv[2] === 'w
|
|
|
134
146
|
const body = await res.text();
|
|
135
147
|
if (!res.ok) { console.error(`workspace: ${res.status} ${body}`); process.exit(1); }
|
|
136
148
|
console.log(body);
|
|
149
|
+
} else if (process.argv[1] === fileURLToPath(import.meta.url) && process.argv[2] === 'team') {
|
|
150
|
+
const [origin, ticket, spec] = process.argv.slice(3);
|
|
151
|
+
const room = ticketClaims(ticket)?.r;
|
|
152
|
+
if (!origin || !Number.isSafeInteger(room) || !spec) { console.error('usage: later.mjs team <origin> <ticket> \'{"channel":"…","model":"…","members":[{"name":"…","persona":"…"}]}\''); process.exit(2); }
|
|
153
|
+
const res = await fetch(new URL(`/collab/api/rooms/${room}/team`, origin), { method: 'POST',
|
|
154
|
+
headers: { 'content-type': 'application/json', 'x-later-ticket': ticket }, body: spec });
|
|
155
|
+
const body = await res.text();
|
|
156
|
+
if (!res.ok) { console.error(`team: ${res.status} ${body}`); process.exit(1); }
|
|
157
|
+
console.log(body);
|
|
137
158
|
} else if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
138
159
|
const [origin, ticket, delay, title, todo] = process.argv.slice(2);
|
|
139
160
|
const room = ticketClaims(ticket)?.r;
|
package/collab/page.html
CHANGED
|
@@ -258,6 +258,27 @@
|
|
|
258
258
|
font-variant-numeric: tabular-nums;
|
|
259
259
|
}
|
|
260
260
|
.msg.cont:hover .stamp { display: block; }
|
|
261
|
+
/* 自己的消息:悬停时右上角出现「撤回」,撤回会连同这条消息发起的回答一起停下。 */
|
|
262
|
+
.msg .ops {
|
|
263
|
+
position: absolute;
|
|
264
|
+
top: 2px;
|
|
265
|
+
right: max(var(--pad-x), env(safe-area-inset-right));
|
|
266
|
+
display: none;
|
|
267
|
+
gap: 4px;
|
|
268
|
+
}
|
|
269
|
+
.msg:hover .ops, .msg:focus-within .ops { display: flex; }
|
|
270
|
+
.msg .ops button {
|
|
271
|
+
font: inherit;
|
|
272
|
+
font-size: var(--fs-xs);
|
|
273
|
+
line-height: 1.4;
|
|
274
|
+
color: var(--muted);
|
|
275
|
+
background: var(--surface);
|
|
276
|
+
border: 1px solid var(--line);
|
|
277
|
+
border-radius: var(--r-sm);
|
|
278
|
+
padding: 1px 6px;
|
|
279
|
+
cursor: pointer;
|
|
280
|
+
}
|
|
281
|
+
.msg .ops button:hover { color: var(--text-strong); background: var(--hover); }
|
|
261
282
|
|
|
262
283
|
.avatar {
|
|
263
284
|
width: var(--avatar);
|
|
@@ -644,6 +665,21 @@ function messageNode(m) {
|
|
|
644
665
|
col.append(card);
|
|
645
666
|
} else col.append(el('div', 'body', m.text));
|
|
646
667
|
row.append(col);
|
|
668
|
+
// 自己发的普通消息:悬停出现「撤回」。撤回后后端广播 deleted,这一行随之消失;
|
|
669
|
+
// 这条消息触发的回答也会一起停下(后端 recall 会取消对应 turn)。
|
|
670
|
+
if (!system && !m.card && m.author === state.me) {
|
|
671
|
+
const ops = el('div', 'ops');
|
|
672
|
+
const b = el('button', '', '撤回');
|
|
673
|
+
b.type = 'button';
|
|
674
|
+
b.title = '撤回这条消息(正在进行的回答也会停下)';
|
|
675
|
+
b.onclick = async () => {
|
|
676
|
+
if (!confirm('撤回这条消息?正在进行的回答也会停下。')) return;
|
|
677
|
+
try { await api(`${ROOT}/collab/api/messages/${m.id}`, { method: 'DELETE' }); }
|
|
678
|
+
catch (err) { alert('撤回失败:' + err.message); }
|
|
679
|
+
};
|
|
680
|
+
ops.append(b);
|
|
681
|
+
row.append(ops);
|
|
682
|
+
}
|
|
647
683
|
return row;
|
|
648
684
|
}
|
|
649
685
|
|
package/collab/plugin.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { Hub } from './hub.mjs';
|
|
|
14
14
|
import { TeamExit } from './team-exit.mjs';
|
|
15
15
|
import { CloudWebGateway, WS_PATH } from './cloudweb.mjs';
|
|
16
16
|
import { DshMembers } from './dsh-member.mjs';
|
|
17
|
-
import { Roster } from './roster.mjs';
|
|
17
|
+
import { Roster, rowToMember } from './roster.mjs';
|
|
18
18
|
import { Tasks, WORKING } from './tasks.mjs';
|
|
19
19
|
import { createCliMember } from './cli-member.mjs';
|
|
20
20
|
import { avatarImage, checkAvatar } from './avatars.mjs';
|
|
@@ -236,6 +236,8 @@ export async function apply(ctx, rawConfig) {
|
|
|
236
236
|
if (routed !== undefined) return routed;
|
|
237
237
|
const opened = workspacesRoute(req, res, url, { store, hub, gateway, owner, later, tasks, dsh });
|
|
238
238
|
if (opened !== undefined) return opened;
|
|
239
|
+
const team = teamRoute(req, res, url, { store, hub, later, roster });
|
|
240
|
+
if (team !== undefined) return team;
|
|
239
241
|
if (!owner(req)) {
|
|
240
242
|
if ((path === '/collab' || path === '/collab/desk' || path === '/collab/wiki-galaxy') && req.method === 'GET') return text(res, 401, 'text/plain; charset=utf-8', '请先在同一个浏览器打开 DSH 网页登录,再回到 /collab。');
|
|
241
243
|
return refuse(req, res);
|
|
@@ -448,7 +450,10 @@ export function roomSettingsRoute(req, res, path, { store, hub, dsh, workspacePa
|
|
|
448
450
|
if (req.method === 'GET') return Promise.resolve(json(res, 200, view()));
|
|
449
451
|
if (req.method !== 'PATCH') return undefined;
|
|
450
452
|
return withBody(req, res, body => {
|
|
451
|
-
|
|
453
|
+
// The settings dialog's「未选择(按原配置)」option sends the select's empty value: clear the
|
|
454
|
+
// channel's own choice so the primary falls back to the instance-wide one, exactly like null.
|
|
455
|
+
const leadId = body?.lead_id === '' ? null : body?.lead_id;
|
|
456
|
+
if (leadId !== undefined && leadId !== null && !hub.routableIn(roomId).some(m => m.id === leadId)) {
|
|
452
457
|
throw new CollabError('invalid', '请选择本频道的主角色');
|
|
453
458
|
}
|
|
454
459
|
if (body?.workspace_path !== undefined) {
|
|
@@ -459,7 +464,7 @@ export function roomSettingsRoute(req, res, path, { store, hub, dsh, workspacePa
|
|
|
459
464
|
if (!stat.isDirectory()) throw new CollabError('invalid', '工作目录必须是目录,不能是文件');
|
|
460
465
|
}
|
|
461
466
|
store.transaction(() => {
|
|
462
|
-
if (
|
|
467
|
+
if (leadId !== undefined) store.setLead(roomId, leadId);
|
|
463
468
|
if (body?.workspace_path !== undefined) store.setWorkspacePath(roomId, body.workspace_path, workspacePath);
|
|
464
469
|
// #445: 6–20 only; an invalid value rolls the whole PATCH back.
|
|
465
470
|
if (body?.max_relay !== undefined) store.setMaxRelay(roomId, body.max_relay);
|
|
@@ -635,6 +640,74 @@ function ticketWorkspace(req, res, roomId, { store, hub, gateway, later, tasks,
|
|
|
635
640
|
});
|
|
636
641
|
}
|
|
637
642
|
|
|
643
|
+
/**
|
|
644
|
+
* 一句话拉团队 (user 11-04: DSH 用户一句话拉单模型多角色): the primary role of a channel, with its
|
|
645
|
+
* (member, room) later ticket, creates a new chat channel and its roles in one call.
|
|
646
|
+
* POST /collab/api/rooms/{id}/team { channel, members: [{ name, persona?, model? }], model? }
|
|
647
|
+
* model is a model id ("deepseek-v4.1-flash") or "provider/model"; absent = DSH's default model.
|
|
648
|
+
* The new roles join only the new channel; the caller becomes its primary role and joins it too.
|
|
649
|
+
*/
|
|
650
|
+
export function teamRoute(req, res, url, { store, hub, later, roster }) {
|
|
651
|
+
const m = /^\/collab\/api\/rooms\/(\d+)\/team\/?$/.exec(url.pathname);
|
|
652
|
+
if (m === null || req.method !== 'POST') return undefined;
|
|
653
|
+
const claims = req.headers['x-later-ticket'] === undefined ? undefined : later?.verify(req.headers['x-later-ticket']);
|
|
654
|
+
if (!claims) return Promise.resolve(json(res, 401, { error: 'unauthorized' }));
|
|
655
|
+
const roomId = Number(m[1]);
|
|
656
|
+
if (claims.room !== roomId) return Promise.resolve(json(res, 403, { error: 'ticket is for another room' }));
|
|
657
|
+
if (hub.primaryIn(roomId)?.id !== claims.member) return Promise.resolve(json(res, 403, { error: 'only the primary role builds teams' }));
|
|
658
|
+
return withBody(req, res, async body => buildTeam(body, claims.member, { store, hub, roster }));
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const TEAM_AVATARS = ['orange', 'dragon', 'rainbow', 'lanxi', 'whale'];
|
|
662
|
+
const MAX_TEAM = 8;
|
|
663
|
+
|
|
664
|
+
/** Find a model by id or provider/id among DSH's routes; undefined input = DSH default (no model on the member). */
|
|
665
|
+
export async function resolveTeamModel(roster, wanted) {
|
|
666
|
+
const want = String(wanted ?? '').trim();
|
|
667
|
+
if (!want) return undefined;
|
|
668
|
+
const routes = await roster.models();
|
|
669
|
+
const slash = want.indexOf('/');
|
|
670
|
+
for (const route of routes) {
|
|
671
|
+
for (const model of route.models) {
|
|
672
|
+
const exact = slash > 0 && route.provider === want.slice(0, slash) && model.id === want.slice(slash + 1);
|
|
673
|
+
if (exact || model.id.toLowerCase() === want.toLowerCase()) return { provider: route.provider, model: model.id };
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
const known = routes.flatMap(r => r.models.map(x => x.id)).slice(0, 20).join(', ') || '(DSH 没报出可用模型)';
|
|
677
|
+
throw new CollabError('invalid', `模型 ${want} 在 DSH 里找不到;可用:${known}`);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
export async function buildTeam(body, leadId, { store, hub, roster }) {
|
|
681
|
+
const channel = String(body?.channel ?? '').trim().replace(/^#/, '');
|
|
682
|
+
if (!channel || channel.length > 32 || /\s/.test(channel)) throw new CollabError('invalid', 'channel must be 1-32 characters without spaces');
|
|
683
|
+
const wanted = Array.isArray(body?.members) ? body.members : [];
|
|
684
|
+
if (wanted.length === 0 || wanted.length > MAX_TEAM) throw new CollabError('invalid', `members must list 1-${MAX_TEAM} roles`);
|
|
685
|
+
const names = wanted.map(x => String(x?.name ?? '').trim());
|
|
686
|
+
if (names.some(n => !n) || new Set(names.map(n => n.toLowerCase())).size !== names.length) throw new CollabError('invalid', 'every role needs a distinct name');
|
|
687
|
+
const shared = await resolveTeamModel(roster, body?.model);
|
|
688
|
+
const models = [];
|
|
689
|
+
for (const x of wanted) models.push(x?.model === undefined || x?.model === '' ? shared : await resolveTeamModel(roster, x.model));
|
|
690
|
+
// Check every role before anything is written, so a bad name leaves no half-built channel behind.
|
|
691
|
+
for (const [i, x] of wanted.entries()) await roster.validate({ name: names[i], persona: String(x?.persona ?? ''), avatar: TEAM_AVATARS[i % TEAM_AVATARS.length] });
|
|
692
|
+
const room = store.createRoom(channel);
|
|
693
|
+
const created = [];
|
|
694
|
+
for (const [i, x] of wanted.entries()) {
|
|
695
|
+
const model = models[i];
|
|
696
|
+
created.push(await roster.create({ name: names[i], persona: String(x?.persona ?? ''), rooms: [room.id], avatar: TEAM_AVATARS[i % TEAM_AVATARS.length], ...(model ? { model } : {}) }));
|
|
697
|
+
}
|
|
698
|
+
const lead = store.member(leadId);
|
|
699
|
+
if (lead?.source === 'ui' && lead.rooms && lead.rooms !== '[]') {
|
|
700
|
+
await roster.update(leadId, { ...rowToMember(lead), rooms: [...JSON.parse(lead.rooms), room.id] });
|
|
701
|
+
}
|
|
702
|
+
store.setLead(room.id, leadId);
|
|
703
|
+
hub.emit({ type: 'rooms', rooms: store.roomsView() });
|
|
704
|
+
const label = model => (model ? model.model : 'DSH 默认模型');
|
|
705
|
+
const leadName = hub.members.get(leadId)?.name ?? leadId;
|
|
706
|
+
hub.post(room.id, { authorKind: 'system', author: 'system', name: '建团队',
|
|
707
|
+
text: `【已建团队】${leadName} 拉好了 #${channel}:${created.map((c, i) => `${c.name}(${label(models[i])})`).join('、')}。${leadName} 是本频道主角色,直接在这里说需求。` });
|
|
708
|
+
return { room, lead: leadId, members: created.map((c, i) => ({ id: c.id, name: c.name, model: label(models[i]) })) };
|
|
709
|
+
}
|
|
710
|
+
|
|
638
711
|
const ERROR_STATUS = { 'room-missing': 404, 'member-missing': 404, 'task-missing': 404, conflict: 409, 'read-only': 403, forbidden: 403, offline: 503, 'member-busy': 409 };
|
|
639
712
|
|
|
640
713
|
/** Parse a JSON body (unless `parse` is false), run fn, and map CollabError codes to HTTP statuses. */
|
package/collab/store.mjs
CHANGED
|
@@ -121,6 +121,10 @@ export class Store {
|
|
|
121
121
|
AND draft = 1 AND deleted = 0 AND card IS NULL`),
|
|
122
122
|
authorDrafts: this.db.prepare(`SELECT * FROM messages WHERE author_kind = ? AND author = ?
|
|
123
123
|
AND draft = 1 AND deleted = 0 AND card IS NULL ORDER BY id`),
|
|
124
|
+
// #913: every live card row one author posted, in every room. A restarted bridge
|
|
125
|
+
// holds no handle on them, so they must be found without a message window.
|
|
126
|
+
authorCards: this.db.prepare(`SELECT * FROM messages WHERE author_kind = ? AND author = ?
|
|
127
|
+
AND card IS NOT NULL AND deleted = 0 ORDER BY id`),
|
|
124
128
|
addJob: this.db.prepare(`INSERT INTO jobs (member_id, room_id, trigger_id, state, created_at)
|
|
125
129
|
VALUES (?, ?, ?, 'pending', ?) ON CONFLICT(member_id, trigger_id) DO NOTHING RETURNING *`),
|
|
126
130
|
job: this.db.prepare('SELECT * FROM jobs WHERE id = ?'),
|
|
@@ -721,6 +725,10 @@ export class Store {
|
|
|
721
725
|
// WebSocket close events may arrive after plugin teardown has closed SQLite.
|
|
722
726
|
return this.closed ? [] : this.q.authorDrafts.all(authorKind, author);
|
|
723
727
|
}
|
|
728
|
+
/** Every live progress-card row one author posted (#913), oldest first. */
|
|
729
|
+
authorCards(authorKind, author) {
|
|
730
|
+
return this.closed ? [] : this.q.authorCards.all(authorKind, author);
|
|
731
|
+
}
|
|
724
732
|
|
|
725
733
|
/** Up to `limit` messages before `beforeId` (exclusive), oldest first. */
|
|
726
734
|
page(roomId, beforeId, limit) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bothouse-dsh",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.48",
|
|
4
4
|
"description": "BotHouse channels for DeepSeek Harness: Discord-style rooms where DSH models and local CLIs (Claude Code, Codex, Gemini) are members",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./collab/plugin.mjs",
|