bothouse-dsh 0.1.0 → 0.1.41

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.
@@ -0,0 +1,80 @@
1
+ // Per-turn wiki pages and task board for DSH-native members, from the local gate's
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). The gate's port
4
+ // comes from GATE_PORT, which bothouse sets on the `dsh web` it starts to the port it runs the
5
+ // gate on (core/gate_proc.go gatePort); without it, or with the gate down, a turn goes out as before.
6
+
7
+ // Same as the bridge's wait for /context (core/gate.go turnContext: 2s).
8
+ export const GATE_CONTEXT_TIMEOUT_MS = 2_000;
9
+
10
+ export class GateContext {
11
+ /**
12
+ * @param options - { port?: gate port (default process.env.GATE_PORT), timeoutMs?, fetch?, logger? }.
13
+ */
14
+ constructor({ port = process.env.GATE_PORT, timeoutMs = GATE_CONTEXT_TIMEOUT_MS, fetch: fetchFn = globalThis.fetch, logger } = {}) {
15
+ this.port = String(port ?? '').trim();
16
+ this.timeoutMs = timeoutMs;
17
+ this.fetch = fetchFn;
18
+ this.logger = logger;
19
+ /** `${member}\0${room}` -> { session, paths: Set } : pages already in that conversation's context. */
20
+ this.sent = new Map();
21
+ }
22
+
23
+ /**
24
+ * The block of pages this (member, room) conversation has not been given yet, and a commit()
25
+ * that records them as given (call it once the prompt is handed to the agent). The bridge keeps
26
+ * one list per session (core/gate.go UpdateTurnCtx); here a new `session` starts the list over.
27
+ * @returns {Promise<{ block: string, commit: () => void }>} block is '' when there is nothing new.
28
+ */
29
+ async turn({ member, room, session, text }) {
30
+ const none = { block: '', commit: () => {} };
31
+ if (!this.port || typeof this.fetch !== 'function') return none;
32
+ let out;
33
+ try {
34
+ const res = await this.fetch(`http://127.0.0.1:${this.port}/context`, {
35
+ method: 'POST',
36
+ headers: { 'Content-Type': 'application/json' },
37
+ // bot = dsh:<member id>: the gate finds the kind "dsh" team entry with that id (lead.member),
38
+ // apart from the bridge bots' keys, which may be the same word.
39
+ body: JSON.stringify({ key: `dsh:${member}:${room}`, text: text ?? '', bot: `dsh:${member}` }),
40
+ signal: AbortSignal.timeout(this.timeoutMs),
41
+ });
42
+ if (!res.ok) throw new Error(`status ${res.status}`);
43
+ out = await res.json();
44
+ } catch (error) {
45
+ this.logger?.debug?.('collab: gate context fetch failed: %s', error?.message ?? error);
46
+ return none;
47
+ }
48
+ const key = `${member}\u0000${room}`;
49
+ let entry = this.sent.get(key);
50
+ if (entry !== undefined && entry.session !== session) entry = undefined;
51
+ const known = entry?.paths ?? new Set();
52
+ const fresh = (Array.isArray(out?.wiki) ? out.wiki : [])
53
+ .filter(w => w && typeof w.path === 'string' && !known.has(w.path));
54
+ if (fresh.length === 0) return none;
55
+ const block = '(可能相关的 wiki 页,需要时去读)' + fresh.map(w => `\n- ${w.path} — ${w.desc ?? ''}`).join('');
56
+ return {
57
+ block,
58
+ commit: () => {
59
+ let cur = this.sent.get(key);
60
+ if (cur === undefined || cur.session !== session) { cur = { session, paths: new Set() }; this.sent.set(key, cur); }
61
+ for (const w of fresh) cur.paths.add(w.path);
62
+ },
63
+ };
64
+ }
65
+
66
+ /** /new for a (member, room) whose session key does not change (a local CLI member): start it over. */
67
+ reset(member, room) {
68
+ this.sent.delete(`${member}\u0000${room}`);
69
+ }
70
+
71
+ /** This member's conversation in room lost its context (a CLI session that could not be resumed): give it again. */
72
+ forget(member, room) {
73
+ this.sent.delete(`${member}\u0000${room}`);
74
+ }
75
+
76
+ /** A released workspace starts its conversations over: give every page again. */
77
+ resetRoom(room) {
78
+ for (const key of this.sent.keys()) if (key.endsWith(`\u0000${room}`)) this.sent.delete(key);
79
+ }
80
+ }
@@ -0,0 +1,122 @@
1
+ // Handoff checks driven by handoff-rules.json, the single copy of the rules (its source is
2
+ // bridge/contrib/lead/handoff-rules.json; the bridge copies it next to this file when it unpacks or
3
+ // refreshes the DSH plugin, core/setup_dsh_embed.go). ws-handoff runs the same rules in Python
4
+ // (contrib/lead/_handoff_rules.py); contrib/lead/test_handoff_rules.py runs both on one set of fixtures.
5
+ // No section names, patterns or messages live here. A missing or broken rules file throws: a handoff is
6
+ // refused, never let through unchecked.
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const HERE = new URL('./', import.meta.url);
11
+ // Installed: next to this file. The repo paths are for running from a checkout (dsh/collab and the
12
+ // bundle copy bridge/core/dsh_bundle/collab); an installed plugin has no checkout around it.
13
+ const CANDIDATES = ['handoff-rules.json', '../../bridge/contrib/lead/handoff-rules.json', '../../../contrib/lead/handoff-rules.json']
14
+ .map(p => fileURLToPath(new URL(p, HERE)));
15
+
16
+ export class HandoffRulesError extends Error {}
17
+
18
+ /** Reads the rules (every call, so an edited file applies without a restart). */
19
+ export function loadRules(path) {
20
+ const file = path ?? CANDIDATES.find(p => existsSync(p));
21
+ if (file === undefined) throw new HandoffRulesError(`交接规则文件读不到:${CANDIDATES[0]}(重新运行 bothouse 让它解包 DSH 插件)`);
22
+ try {
23
+ return JSON.parse(readFileSync(file, 'utf8'));
24
+ } catch (e) {
25
+ throw new HandoffRulesError(`交接规则文件读不到:${file}(${e.message})`);
26
+ }
27
+ }
28
+
29
+ const head = l => l.trimStart().startsWith('#');
30
+ const stripLead = (l, lead) => { let i = 0; while (i < l.length && lead.includes(l[i])) i++; return l.slice(i); };
31
+ // Python's s.rsplit(':', 1)[0]: the whole string when it has no colon.
32
+ const beforeColon = s => (s.includes(':') ? s.slice(0, s.lastIndexOf(':')) : s);
33
+ const escape = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
34
+
35
+ function sections(lines, name, lead, inline = false) {
36
+ const out = [];
37
+ lines.forEach((l, i) => {
38
+ if (!((head(l) && l.includes(name)) || (inline && stripLead(l, lead).startsWith(name)))) return;
39
+ const seg = [l];
40
+ for (const x of lines.slice(i + 1)) { if (head(x)) break; seg.push(x); }
41
+ out.push(seg);
42
+ });
43
+ return out;
44
+ }
45
+
46
+ const missing = (c, names) => c.msg.replaceAll('{missing}', names.map(n => (c.item ?? '{}').replace('{}', n)).join(c.sep ?? '、'));
47
+ const captures = (segs, rx, lead) => segs.flatMap(seg => seg.slice(1)).map(x => rx.exec(stripLead(x, lead))?.[1]).filter(v => v !== undefined);
48
+
49
+ function run(c, lines, lead, key = 'msg') {
50
+ switch (c.type) {
51
+ case 'headings': {
52
+ const miss = c.names.filter(n => !lines.some(l => head(l) && l.includes(n)));
53
+ return miss.length ? missing(c, miss) : null;
54
+ }
55
+ case 'ref': {
56
+ const none = new RegExp(c.none_re), path = new RegExp(c.path_re);
57
+ for (const seg of sections(lines, c.section, lead, Boolean(c.inline))) {
58
+ if (seg.some(x => none.test(x))) return null;
59
+ if (seg.some(x => [...x.matchAll(new RegExp(c.ref_re, 'g'))].some(m => path.test(beforeColon(m[0]))))) return null;
60
+ }
61
+ return c[key];
62
+ }
63
+ case 'kinds': {
64
+ const found = captures(sections(lines, c.section, lead), new RegExp(c.line_re), lead);
65
+ const got = new Set(found);
66
+ const miss = c.need.filter(k => !got.has(k));
67
+ return miss.length ? missing(c, miss) : null;
68
+ }
69
+ case 'values': {
70
+ const ok = new RegExp(c.ok_re), alt = new RegExp(c.or_re);
71
+ const vals = captures(sections(lines, c.section, lead), new RegExp(c.line_re), lead).map(v => v.trim());
72
+ if (!vals.length) return c.msg;
73
+ return vals.some(v => !ok.test(v) && !alt.test(v)) ? c.bad_msg : null;
74
+ }
75
+ case 'heading_needs_field': {
76
+ if (!lines.some(l => head(l) && c.headings.some(h => l.includes(h)))) return null;
77
+ const rx = new RegExp(`^${escape(c.field)}\\s*[::]\\s*\\S`);
78
+ return lines.some(l => rx.test(stripLead(l, lead))) ? null : c[key];
79
+ }
80
+ default: throw new HandoffRulesError(`交接规则文件里有不认识的检查类型:${c.type}`);
81
+ }
82
+ }
83
+
84
+ // Same lines as Python's str.splitlines for \n, \r\n and \r (no trailing empty line).
85
+ const split = text => { const a = String(text ?? '').split(/\r\n|\r|\n/); if (a.at(-1) === '') a.pop(); return a; };
86
+
87
+ /** A full handoff body (ws-handoff file, workspace handoff reason): the first failing check's message, or null. */
88
+ export function checkHandoff(text, rules = loadRules()) {
89
+ const lines = split(text);
90
+ for (const c of rules.checks) {
91
+ const err = run(c, lines, rules.lead);
92
+ if (err) return err;
93
+ }
94
+ return null;
95
+ }
96
+
97
+ /**
98
+ * A field-only dispatch (/tasks: title, ref, base): the fields become a small handoff (title as its
99
+ * heading, ref as the reference section, base as the base-branch line) and only the checks listed in
100
+ * rules.dispatch.checks run, with their dispatch_msg.
101
+ */
102
+ export function checkDispatch(fields, rules = loadRules()) {
103
+ const byId = new Map(rules.checks.map(c => [c.id, c]));
104
+ const lines = [`# ${String(fields.title ?? '').trim()}`];
105
+ for (const [id, field] of Object.entries(rules.dispatch.fields)) {
106
+ const c = byId.get(id), v = String(fields[field] ?? '').trim();
107
+ if (c?.type === 'ref') lines.push(`## ${c.section}`, ...(v ? split(v) : []));
108
+ else if (c?.type === 'heading_needs_field' && v) lines.push(`${c.field}:${v}`);
109
+ }
110
+ for (const id of rules.dispatch.checks) {
111
+ const c = byId.get(id);
112
+ if (c === undefined) throw new HandoffRulesError(`交接规则文件 dispatch 里的检查不存在:${id}`);
113
+ const err = run(c, lines, rules.lead, 'dispatch_msg');
114
+ if (err) return err;
115
+ }
116
+ return null;
117
+ }
118
+
119
+ /** Section names a handoff must carry, in order, for prompts that tell a member how to write one. */
120
+ export function requiredSections(rules = loadRules()) {
121
+ return rules.checks.flatMap(c => (c.type === 'headings' ? c.names : c.type === 'ref' ? [c.section] : []));
122
+ }
package/collab/hub.mjs CHANGED
@@ -24,6 +24,13 @@ export class Hub {
24
24
 
25
25
  memberList() { return [...this.members.values()]; }
26
26
 
27
+ membersInRoom(roomId) {
28
+ const channel = this.store.settingsRoom(roomId);
29
+ return this.memberList().filter(m => !m.rooms?.length || m.rooms.includes(channel));
30
+ }
31
+
32
+ routableIn(roomId) { return this.membersInRoom(roomId).filter(m => m.kind !== 'human'); }
33
+
27
34
  /** Members whose @-forms are parsed in channel text (agents only; humans are not routed). */
28
35
  routable() { return this.memberList().filter(m => m.kind !== 'human'); }
29
36
 
@@ -34,6 +41,13 @@ export class Hub {
34
41
  return member !== undefined && member.kind !== 'human' ? member : undefined;
35
42
  }
36
43
 
44
+ /** Channel selection, inherited by workspaces; only unset channels use the legacy global choice. */
45
+ primaryIn(roomId) {
46
+ const id = this.store.leadId(roomId);
47
+ const member = id === null ? this.selectedPrimaryMember() : this.members.get(id);
48
+ return member && this.routableIn(roomId).some(m => m.id === member.id) ? member : undefined;
49
+ }
50
+
37
51
  /** UI-only fallback; callers that route messages must use selectedPrimaryMember(). */
38
52
  primaryMember() {
39
53
  const fallback = this.fallbackLead === '' ? undefined : this.members.get(this.fallbackLead);
@@ -41,6 +55,7 @@ export class Hub {
41
55
  }
42
56
 
43
57
  setMember(member) {
58
+ if (member.avatar?.startsWith('upload:')) member.avatarVersion = Math.random().toString(36).slice(2);
44
59
  this.members.set(member.id, member);
45
60
  this.emit({ type: 'members', members: this.memberView() });
46
61
  }
@@ -76,9 +91,9 @@ export class Hub {
76
91
  }
77
92
 
78
93
  memberView() {
79
- return this.memberList().map(({ id, name, kind, online, model, cli, persona, avatar, source, error }) => ({
80
- id, name, kind, online: online !== false && !error, ...(error ? { error } : {}), ...(avatar ? { avatar } : {}),
81
- ...(kind === 'dsh' ? { model: model ?? null, ...(cli ? { cli } : {}), persona: persona ?? '', editable: source === 'ui' } : {}),
94
+ return this.memberList().map(({ id, name, kind, online, model, cli, permissionPreset, persona, nameCustomized, avatar, avatarVersion, source, error, rooms }) => ({
95
+ id, name, kind, ...(rooms ? { rooms } : {}), online: online !== false && !error, ...(error ? { error } : {}), ...(avatar ? { avatar, ...(avatarVersion ? { avatarVersion } : {}) } : {}),
96
+ ...(kind === 'dsh' ? { model: model ?? null, ...(cli ? { cli, ...(permissionPreset ? { permissionPreset } : {}) } : {}), ...(nameCustomized === undefined ? {} : { nameCustomized }), persona: persona ?? '', editable: source === 'ui' } : {}),
82
97
  }));
83
98
  }
84
99
 
@@ -93,7 +108,7 @@ export class Hub {
93
108
  if (msg.reply_to == null || msg.author_kind !== 'user') return null;
94
109
  const ref = this.store.message(msg.reply_to);
95
110
  const refAgent = ref !== undefined && ref.room_id === msg.room_id && ref.author_kind === 'role' ? ref.author : undefined;
96
- const explicit = mentions(msg.text, this.routable());
111
+ const explicit = mentions(msg.text, this.routableIn(msg.room_id));
97
112
  if (explicit.size > 0) return refAgent === undefined ? explicit : new Set([...explicit, refAgent]);
98
113
  if (ref === undefined || ref.room_id !== msg.room_id) return new Set();
99
114
  if (refAgent !== undefined) return new Set([refAgent]);
@@ -201,6 +216,10 @@ export class Hub {
201
216
  }
202
217
 
203
218
  emit(event) {
219
+ if (event.type === 'rooms') event = { ...event, rooms: event.rooms.map(room => ({ ...room,
220
+ lead_id: this.store.leadId(room.id), effective_lead_id: this.primaryIn(room.id)?.id ?? null,
221
+ workspace_path: this.store.workspacePath(room.id, this.workspacePath),
222
+ })) };
204
223
  for (const s of this.subscribers) {
205
224
  try { s(event); } catch (error) { this.logger.warn('collab: subscriber failed: %s', error?.message ?? error); }
206
225
  }
@@ -0,0 +1,146 @@
1
+ // Durable one-shot wakes for every channel member. Registration goes through the
2
+ // plugin's HTTP route and is bound to (member, room) by an HMAC ticket the plugin
3
+ // hands each member with its prompt; the plugin owns delivery through the member routers.
4
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
5
+ import { closeSync, fsyncSync, openSync, readFileSync, renameSync, unlinkSync, writeSync } from 'node:fs';
6
+ import { dirname, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { CollabError } from './store.mjs';
9
+
10
+ function schema(db) {
11
+ db.exec(`CREATE TABLE IF NOT EXISTS member_later (
12
+ id INTEGER PRIMARY KEY, member_id TEXT NOT NULL, room_id INTEGER NOT NULL,
13
+ due_at INTEGER NOT NULL, title TEXT NOT NULL, todo TEXT NOT NULL,
14
+ message_id INTEGER, announced INTEGER NOT NULL DEFAULT 0, delivered INTEGER NOT NULL DEFAULT 0)`);
15
+ }
16
+ export function registerLater(db, { member, room, delay, title, todo }, now = Date.now()) {
17
+ const match = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(String(delay));
18
+ const ms = match && ((Number(match[1] ?? 0) * 3600 + Number(match[2] ?? 0) * 60 + Number(match[3] ?? 0)) * 1000);
19
+ if (!ms || !Number.isSafeInteger(now + ms) || typeof member !== 'string' || !member.trim() ||
20
+ typeof title !== 'string' || !title.trim() || typeof todo !== 'string' || !todo.trim() ||
21
+ !Number.isSafeInteger(room) || !db.prepare('SELECT id FROM rooms WHERE id = ?').get(room)) {
22
+ throw new CollabError('invalid', 'later requires member, room, positive delay (10m/1h30m), title and todo');
23
+ }
24
+ schema(db);
25
+ return db.prepare('INSERT INTO member_later (member_id, room_id, due_at, title, todo) VALUES (?, ?, ?, ?, ?) RETURNING *')
26
+ .get(member, room, now + ms, title.trim(), todo.trim());
27
+ }
28
+ /**
29
+ * Load the plugin's ticket key, creating it (0600) when missing; ':memory:' stores get a per-process key.
30
+ * A key file that does not decode to 32 bytes (empty, truncated, garbage) is replaced atomically
31
+ * (temp 0600 -> fsync -> rename) and re-read, so racing processes settle on the renamed file.
32
+ * Never falls back to an empty key: an unreadable result throws.
33
+ */
34
+ function loadKey(file) {
35
+ if (!file) return randomBytes(32);
36
+ const read = () => {
37
+ try { const key = Buffer.from(readFileSync(file, 'utf8').trim(), 'hex'); return key.length === 32 ? key : undefined; } catch (error) { if (error.code === 'ENOENT') return undefined; throw error; }
38
+ };
39
+ const existing = read();
40
+ if (existing) return existing;
41
+ const tmp = `${file}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
42
+ try {
43
+ const fd = openSync(tmp, 'wx', 0o600);
44
+ try { writeSync(fd, randomBytes(32).toString('hex')); fsyncSync(fd); } finally { closeSync(fd); }
45
+ renameSync(tmp, file);
46
+ } catch (error) { try { unlinkSync(tmp); } catch {} throw error; }
47
+ const key = read();
48
+ if (!key) throw new Error(`later: ticket key ${file} is not 32 bytes after regeneration`);
49
+ return key;
50
+ }
51
+ const b64 = value => Buffer.from(value).toString('base64url');
52
+ /** Unverified decode, for the local command to find the room; the plugin verifies. */
53
+ export function ticketClaims(ticket) {
54
+ try { const [payload] = String(ticket).split('.'); const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); return claims && typeof claims === 'object' ? claims : undefined; } catch { return undefined; }
55
+ }
56
+ export class Later {
57
+ constructor(hub, filename, wake, { now = Date.now, start = true, keyFile = filename === ':memory:' ? undefined : join(dirname(filename), 'later.key') } = {}) {
58
+ this.hub = hub; this.db = hub.store.db; this.filename = filename; this.wake = wake; this.now = now;
59
+ this.key = loadKey(keyFile);
60
+ /** Local origin of the DSH web server, learned from the first request the plugin serves. */
61
+ this.origin = undefined;
62
+ schema(this.db);
63
+ if (start) this.start();
64
+ }
65
+ start() {
66
+ this.tick();
67
+ this.timer = setInterval(() => this.tick(), 1000);
68
+ this.timer.unref();
69
+ }
70
+ sign(payload) { return createHmac('sha256', this.key).update(payload).digest('base64url'); }
71
+ /** Ticket binding one member to one room: base64url({m, r}).HMAC. */
72
+ ticket(member, room) {
73
+ const payload = b64(JSON.stringify({ m: String(member), r: Number(room) }));
74
+ return `${payload}.${this.sign(payload)}`;
75
+ }
76
+ /** Verified (member, room) of a ticket, or undefined; constant-time signature check. */
77
+ verify(ticket) {
78
+ if (typeof ticket !== 'string') return undefined;
79
+ const [payload, mac, extra] = ticket.split('.');
80
+ if (!payload || !mac || extra !== undefined) return undefined;
81
+ const want = Buffer.from(this.sign(payload)); const got = Buffer.from(mac);
82
+ if (want.length !== got.length || !timingSafeEqual(want, got)) return undefined;
83
+ const claims = ticketClaims(ticket);
84
+ if (typeof claims?.m !== 'string' || !Number.isSafeInteger(claims.r)) return undefined;
85
+ return { member: claims.m, room: claims.r };
86
+ }
87
+ instructions(member, room) {
88
+ const quote = value => "'" + String(value).replaceAll("'", "'\\''") + "'";
89
+ const ticket = this.ticket(member, room);
90
+ const local = this.origin ? `用本机命令登记(重启后仍触发):\n${quote(process.execPath)} ${quote(fileURLToPath(import.meta.url))} ${quote(this.origin)} ${quote(ticket)} 10m '事情' '到点要做什么'\n` : '';
91
+ return `\n【一次性回查】要过会儿回来,${local}或 POST /collab/api/rooms/${room}/later(头 X-Later-Ticket: ${ticket},JSON: delay, title, todo)。凭据只对你和本房间有效。登记成功才说已挂;到点在原房间唤醒你。\n`;
92
+ }
93
+ /**
94
+ * #597 G24: how a DSH member opens a workspace and hands work to it (one call: workspace, task,
95
+ * ten-minute watch, wake). Only DSH members get this (dsh-member.mjs); bridge bots use
96
+ * `bothouse workspace new`, so it stays out of instructions().
97
+ */
98
+ workspaceInstructions(member, room) {
99
+ const quote = value => "'" + String(value).replaceAll("'", "'\\''") + "'";
100
+ const ticket = this.ticket(member, room);
101
+ const local = this.origin ? `首选本机命令(直接复制,改后三个参数):\n${quote(process.execPath)} ${quote(fileURLToPath(import.meta.url))} workspace ${quote(this.origin)} ${quote(ticket)} '任务标题' '接活人id(空串=你自己)' '交接说明'\n` : '';
102
+ // #597 G26: a member once sent the ticket to /tasks and gave up on the 401; spell out the one call.
103
+ const url = `${this.origin ?? 'http://<本机 DSH 地址>'}/collab/api/rooms/${room}/workspaces`;
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
+ return `\n【开工作区】整块活要交出去时,建工作区=这一条,任务和十分钟看门会自动开,不要另调 /tasks(它不认票据)。${local}或等价的接口 POST /collab/api/rooms/${room}/workspaces:\n${curl}\n一步建好工作区、开任务、挂十分钟看门并叫醒接活人;不要再在频道里 @ 同一件活。返回不是 200 就把返回原样告诉用户,不要悄悄自己做。工作区里的接活人是新会话,只看得到交接说明:里面要写清用户原话、参照的现成实现、以哪个分支为底、验收标准。\n`;
106
+ }
107
+ tick() {
108
+ for (const row of this.db.prepare('SELECT * FROM member_later WHERE announced = 0').all()) {
109
+ this.hub.post(row.room_id, { authorKind: 'system', author: 'system', name: '回查登记',
110
+ clientKey: `member-later-${row.id}-registered`,
111
+ text: `${row.member_id}「${row.title}」:下次看 ${new Date(row.due_at).toISOString()},到点:${row.todo}` });
112
+ this.db.prepare('UPDATE member_later SET announced = 1 WHERE id = ?').run(row.id);
113
+ }
114
+ for (const row of this.db.prepare('SELECT * FROM member_later WHERE delivered = 0 AND due_at <= ? ORDER BY id').all(this.now())) {
115
+ if (this.hub.quota?.cooling(row.member_id)) continue;
116
+ let message;
117
+ if (row.message_id === null) {
118
+ // A stable client key makes a crash before receipt persistence reuse the same message.
119
+ message = this.hub.post(row.room_id, { authorKind: 'system', author: 'system', name: '回查提醒',
120
+ clientKey: `member-later-${row.id}`, text: `【later 到点】${row.title}:${row.todo}` }).message;
121
+ this.db.prepare('UPDATE member_later SET message_id = ? WHERE id = ?').run(message.id, row.id);
122
+ } else message = this.hub.store.message(row.message_id);
123
+ if (message && this.wake(row.member_id, message)) this.db.prepare('UPDATE member_later SET delivered = 1 WHERE id = ?').run(row.id);
124
+ }
125
+ }
126
+ close() { clearInterval(this.timer); }
127
+ }
128
+ if (process.argv[1] === fileURLToPath(import.meta.url) && process.argv[2] === 'workspace') {
129
+ const [origin, ticket, title, owner, reason] = process.argv.slice(3);
130
+ const room = ticketClaims(ticket)?.r;
131
+ if (!origin || !Number.isSafeInteger(room) || !title) { console.error('usage: later.mjs workspace <origin> <ticket> <title> [owner] [reason] (ticket from your prompt; owner \'\' = yourself)'); process.exit(2); }
132
+ const res = await fetch(new URL(`/collab/api/rooms/${room}/workspaces`, origin), { method: 'POST',
133
+ headers: { 'content-type': 'application/json', 'x-later-ticket': ticket }, body: JSON.stringify({ title, ...(owner ? { owner } : {}), ...(reason ? { reason } : {}) }) });
134
+ const body = await res.text();
135
+ if (!res.ok) { console.error(`workspace: ${res.status} ${body}`); process.exit(1); }
136
+ console.log(body);
137
+ } else if (process.argv[1] === fileURLToPath(import.meta.url)) {
138
+ const [origin, ticket, delay, title, todo] = process.argv.slice(2);
139
+ const room = ticketClaims(ticket)?.r;
140
+ if (!origin || !Number.isSafeInteger(room)) { console.error('usage: later.mjs <origin> <ticket> <delay> <title> <todo> (ticket from your prompt)'); process.exit(2); }
141
+ const res = await fetch(new URL(`/collab/api/rooms/${room}/later`, origin), { method: 'POST',
142
+ headers: { 'content-type': 'application/json', 'x-later-ticket': ticket }, body: JSON.stringify({ delay, title, todo }) });
143
+ const body = await res.text();
144
+ if (!res.ok) { console.error(`later: ${res.status} ${body}`); process.exit(1); }
145
+ console.log(body);
146
+ }
@@ -0,0 +1,61 @@
1
+ // A DSH member's own wiki index on the first turn of each conversation, as the bridge gives a role
2
+ // its MEMORY.md (bridge/core/memoryindex.go memoryBlock, project option memory_index): same head line,
3
+ // same once-per-fresh-context rule. The member's wiki is <BOTHOUSE_DATA_DIR>/roles/<member id>/wiki,
4
+ // where bothouse makes it (bridge/core/setup_dsh_wiki.go). No wiki, no block.
5
+ import { readFileSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { join } from 'node:path';
8
+
9
+ // bridge/core/memoryindex.go memoryIndexHead
10
+ export const MEMORY_INDEX_HEAD = '(你的记忆 wiki 目录页';
11
+
12
+ export class MemoryIndex {
13
+ /** @param options - { dataDir?: bothouse data dir (default process.env.BOTHOUSE_DATA_DIR), home? }. */
14
+ constructor({ dataDir = process.env.BOTHOUSE_DATA_DIR, home = homedir() } = {}) {
15
+ this.dataDir = String(dataDir ?? '').trim();
16
+ this.home = home;
17
+ /** `${member}\0${room}` -> session the index was given in (undefined for a local CLI member). */
18
+ this.given = new Map();
19
+ }
20
+
21
+ /** The member's MEMORY.md path ('' when there is no data dir). */
22
+ path(member) {
23
+ return this.dataDir ? join(this.dataDir, 'roles', String(member), 'wiki', 'MEMORY.md') : '';
24
+ }
25
+
26
+ /**
27
+ * The index block when this (member, room) conversation has not had it (a new `session` is a fresh
28
+ * context and gets it again), and a commit() that records it as given once the prompt is handed over.
29
+ * @returns {{ block: string, commit: () => void }}
30
+ */
31
+ turn({ member, room, session }) {
32
+ const none = { block: '', commit: () => {} };
33
+ const key = `${member}\u0000${room}`;
34
+ if (this.given.has(key) && this.given.get(key) === session) return none;
35
+ const file = this.path(member);
36
+ if (!file) return none;
37
+ let body;
38
+ try { body = readFileSync(file, 'utf8').trim(); } catch { return none; }
39
+ if (!body) return none;
40
+ const shown = this.home && file.startsWith(this.home + '/') ? '~' + file.slice(this.home.length) : file;
41
+ return {
42
+ block: `${MEMORY_INDEX_HEAD} ${shown},新会话系统自动附上,不用回应)\n${body}`,
43
+ commit: () => { this.given.set(key, session); },
44
+ };
45
+ }
46
+
47
+ /** /new for a (member, room) whose session key does not change (a local CLI member): start it over. */
48
+ reset(member, room) {
49
+ this.given.delete(`${member}\u0000${room}`);
50
+ }
51
+
52
+ /** This member's conversation in room lost its context (a CLI session that could not be resumed): give it again. */
53
+ forget(member, room) {
54
+ this.given.delete(`${member}\u0000${room}`);
55
+ }
56
+
57
+ /** A released workspace starts its conversations over: give the index again. */
58
+ resetRoom(room) {
59
+ for (const key of this.given.keys()) if (key.endsWith(`\u0000${room}`)) this.given.delete(key);
60
+ }
61
+ }
@@ -44,6 +44,14 @@ export function mentions(text, members) {
44
44
  return new Set(split(text, members).map(([, id]) => id).filter(Boolean));
45
45
  }
46
46
 
47
+ /** text with the @-mentions (and blanks) it opens with taken out. */
48
+ export function withoutLeadingMentions(text, members) {
49
+ const parts = split(text, members);
50
+ let i = 0;
51
+ while (i < parts.length && (parts[i][1] !== null || !parts[i][0].trim())) i++;
52
+ return parts.slice(i).map(([seg]) => seg).join('').trimStart();
53
+ }
54
+
47
55
  /** Stored/displayed form: every mention becomes @name. */
48
56
  export function forDisplay(text, members) {
49
57
  const byId = new Map(members.map(m => [m.id, m]));
package/collab/page.html CHANGED
@@ -31,6 +31,7 @@
31
31
  .card { border-left: 4px solid var(--accent); background: var(--side); padding: 6px 10px; border-radius: 4px; margin-top: 2px; }
32
32
  .card.completed { border-color: var(--ok); } .card.failed { border-color: #f23f43; }
33
33
  .card .title { font-weight: 600; font-size: 13px; }
34
+ .card .actions { display: flex; gap: 6px; margin-top: 6px; } .card .actions button { padding: 3px 10px; border-radius: 4px; border: 1px solid #ccc; cursor: pointer; }
34
35
  form { padding: 0 16px 20px; }
35
36
  textarea { width: 100%; resize: none; border: 0; border-radius: 8px; padding: 11px 14px; background: var(--input); color: var(--text); font: inherit; max-height: 40vh; }
36
37
  textarea:focus { outline: 2px solid var(--accent); }
@@ -57,6 +58,13 @@
57
58
  <script>
58
59
  const state = { me: 'owner', rooms: [], members: [], thinking: {}, room: null, synced: 0, mode: 'mixed' };
59
60
  const $ = id => document.getElementById(id);
61
+ // crypto.randomUUID only exists in secure contexts (https/localhost); plain-http LAN/Tailscale pages fall back to getRandomValues.
62
+ const uuid = () => {
63
+ if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
64
+ const b = crypto.getRandomValues(new Uint8Array(16)); b[6] = (b[6] & 15) | 64; b[8] = (b[8] & 63) | 128;
65
+ const h = Array.from(b, x => x.toString(16).padStart(2, '0')).join('');
66
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
67
+ };
60
68
  const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
61
69
  const palette = ['#5865f2', '#eb459e', '#3ba55c', '#faa61a', '#ed4245', '#00a8fc', '#9b59b6', '#e67e22'];
62
70
  const color = id => palette[[...id].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7) % palette.length];
@@ -85,7 +93,10 @@ function renderRooms() {
85
93
 
86
94
  function renderMembers() {
87
95
  const busy = new Set(state.thinking[state.room] || []);
88
- const groups = [['Agent', state.members.filter(m => m.kind !== 'human')], ['真人', state.members.filter(m => m.kind === 'human')]];
96
+ const room = state.rooms.find(r => r.id === state.room);
97
+ const channel = room?.parent_id ?? room?.id;
98
+ const visible = state.members.filter(m => !m.rooms?.length || m.rooms.includes(channel));
99
+ const groups = [['Agent', visible.filter(m => m.kind !== 'human')], ['真人', visible.filter(m => m.kind === 'human')]];
89
100
  const nodes = [];
90
101
  for (const [title, list] of groups) {
91
102
  if (!list.length) continue;
@@ -102,7 +113,7 @@ function renderMembers() {
102
113
  }
103
114
  }
104
115
  $('members').replaceChildren(...nodes);
105
- const names = [...busy].map(id => (state.members.find(m => m.id === id) || { name: id }).name);
116
+ const names = visible.filter(m => busy.has(m.id)).map(m => m.name);
106
117
  $('thinking').textContent = names.length ? `${names.join('、')} 正在处理…` : '';
107
118
  }
108
119
 
@@ -120,6 +131,16 @@ function messageNode(m) {
120
131
  let meta = {}; try { meta = JSON.parse(m.card); } catch {}
121
132
  const card = el('div', 'card ' + (meta.state || ''));
122
133
  card.append(el('div', 'title', meta.title || ''), el('div', 'body', m.text));
134
+ // Pending permission card: one button per action, posting the decision (same route as a text reply).
135
+ if (meta.kind === 'permission' && meta.state === 'pending' && Array.isArray(meta.actions)) {
136
+ const bar = el('div', 'actions');
137
+ for (const a of meta.actions) {
138
+ const b = el('button', '', a.label);
139
+ b.onclick = () => { bar.querySelectorAll('button').forEach(x => { x.disabled = true; }); api(`/collab/api/messages/${m.id}/permission`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ decision: a.decision }) }); };
140
+ bar.append(b);
141
+ }
142
+ card.append(bar);
143
+ }
123
144
  col.append(card);
124
145
  } else col.append(el('div', 'body', m.text));
125
146
  row.append(col);
@@ -206,7 +227,7 @@ $('form').addEventListener('submit', async (e) => {
206
227
  try {
207
228
  const { message } = await api(`/collab/api/rooms/${state.room}/messages`, {
208
229
  method: 'POST', headers: { 'content-type': 'application/json' },
209
- body: JSON.stringify({ text, client_key: crypto.randomUUID() }),
230
+ body: JSON.stringify({ text, client_key: uuid() }),
210
231
  });
211
232
  upsert(message);
212
233
  } catch (err) { $('input').value = text; alert('发送失败:' + err.message); }
@@ -0,0 +1,4 @@
1
+ // Generated from bridge/core/persona_presets.go; verified by TestDSHPersonaCatalog.
2
+ import { readFileSync } from 'node:fs';
3
+ const catalog = JSON.parse(readFileSync(new URL('./personas.json', import.meta.url), 'utf8'));
4
+ export function personaLibrary(lang) { return catalog[lang.toLowerCase().startsWith('zh') ? 'zh' : 'en']; }