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/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
- const head = `<base href="${prefix}/" />\n <meta name="bothouse-auth" content="proxy" />`;
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
- const leadFile = c.leadFile ?? join(env.BOTHOUSE_DATA_DIR ?? join(env.HOME ?? '.', '.bothouse'), 'lead.json');
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
- const owner = req => ctx.connection.requestRejection(req) === undefined;
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
- ctx.effect(() => ctx.webServer.registerUpgrade({
189
- path: WS_PATH,
190
- handler: (req, socket, head) => {
191
- const url = new URL(req.url ?? '/', 'http://collab.invalid');
192
- later.origin ??= localOrigin(req);
193
- if (!gateway.authorized(req, url)) {
194
- socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
195
- return;
196
- }
197
- wss.handleUpgrade(req, socket, head, ws => gateway.accept(ws, url));
198
- },
199
- }), 'conest-collab: cloud-web upgrade');
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
- later.origin ??= localOrigin(req);
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
- const headers = { 'content-type': row.mime, 'accept-ranges': 'bytes',
742
- 'content-disposition': `${INLINE_IMAGE.test(row.mime) ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(row.name)}`,
743
- 'x-content-type-options': 'nosniff', 'cache-control': 'private, max-age=86400' };
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
- const same = this.hub.memberList().find(x => x.name.toLowerCase() === name.toLowerCase());
82
- // A default-named role may become Codex-2 when Codex is already there; custom and legacy
83
- // names stay idempotent (a re-import must not duplicate them).
84
- if (same !== undefined && m?.name_customized !== false) {
85
- imported.push({ ...m, id: same.id });
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
- imported.push({ ...m, id: member.id });
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() { return this.q.rooms.all(); }
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 [];