bothouse-dsh 0.1.0 → 0.1.40

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.
@@ -1,10 +1,96 @@
1
1
  // Durable workspace questions; system forwards never enter agent routing.
2
2
  import { randomBytes } from 'node:crypto';
3
+ import { withoutLeadingMentions } from './mentions.mjs';
3
4
 
4
5
  export function exitTarget(msg) {
5
6
  return /^team-answer:([^:]+):/.exec(msg.client_key ?? '')?.[1];
6
7
  }
7
8
 
9
+ // #574 B4: an [ask] must carry 「自验:」 (evidence) and 「类别:」 before it waits on
10
+ // the user; only 方向、花钱、需要用户看的成品、权限确认 may write 「自验:无需」.
11
+ // Same check in the Go bridge: bridge/core/team_ask_evidence.go askEvidenceProblems.
12
+ const SELF_CHECK = /自验\s*[::]/;
13
+ const CATEGORY = /类别\s*[::]/;
14
+ const EXEMPT = /方向|花钱|成品|权限/;
15
+ const NO_CHECK = /^(无需|不需要|无|免)([\s,。,.;;((]|$)/;
16
+ export const ASK_MISSING_SELF_CHECK = '缺「自验:」(附证据:截图或命令输出)';
17
+ export const ASK_MISSING_CATEGORY = '缺「类别:」(方向、花钱、需要用户看的成品、权限确认之一,或写别的类别)';
18
+ export const ASK_NO_CHECK_NOT_EXEMPT = '「自验:无需」只限方向、花钱、需要用户看的成品、权限确认四类,其它类别要附证据';
19
+ // Consecutive bounces that still wake the asking member; later ones are only posted.
20
+ const BOUNCE_WAKE_LIMIT = 2;
21
+
22
+ /** What an [ask] body lacks; [] means it may go to the user. */
23
+ export function askEvidenceProblems(body) {
24
+ const cat = CATEGORY.exec(body);
25
+ const category = cat ? body.slice(cat.index + cat[0].length).split('\n')[0].trim() : '';
26
+ const own = SELF_CHECK.exec(body);
27
+ let check = '';
28
+ if (own) {
29
+ let rest = body.slice(own.index + own[0].length);
30
+ const next = CATEGORY.exec(rest);
31
+ if (next) rest = rest.slice(0, next.index);
32
+ check = rest.trim();
33
+ }
34
+ const problems = [];
35
+ if (!check) problems.push(ASK_MISSING_SELF_CHECK);
36
+ if (!category) problems.push(ASK_MISSING_CATEGORY);
37
+ if (check && category && NO_CHECK.test(check) && !EXEMPT.test(category)) problems.push(ASK_NO_CHECK_NOT_EXEMPT);
38
+ return problems;
39
+ }
40
+
41
+ export function askBounceText(problems) {
42
+ return exitBounceText('ask', problems);
43
+ }
44
+
45
+ // Headline (user 2026-10-28 「做」): the first line is `[report] #<task id> <one or two sentences
46
+ // for the user>`; that sentence is what the chat channel shows first. Same rule in the Go bridge:
47
+ // bridge/core/team_exit_headline.go exitHeadline.
48
+ const HEADLINE = /^#(\d+)[\s\u3000::,,、.。]*(.*)$/;
49
+ export const EXIT_MISSING_ID = '第一行缺任务板编号(#编号)';
50
+ export const EXIT_MISSING_LINE = '第一行缺给用户的一两句话';
51
+ export const EXIT_UNKNOWN_ID = '任务板上没有这个编号';
52
+
53
+ /** { id, line, problems } of a marked body's first line. */
54
+ export function exitHeadline(body) {
55
+ const first = String(body ?? '').split('\n')[0].trim();
56
+ const m = HEADLINE.exec(first);
57
+ if (!m) return { id: 0, line: '', problems: first === '' || first.startsWith('#') ? [EXIT_MISSING_ID, EXIT_MISSING_LINE] : [EXIT_MISSING_ID] };
58
+ const line = m[2].trim();
59
+ return { id: Number(m[1]), line, problems: line ? [] : [EXIT_MISSING_LINE] };
60
+ }
61
+
62
+ /** Why a workspace [ask]/[report] stayed in the workspace and how to resend it (Go: exitBounceText). */
63
+ export function exitBounceText(kind, problems) {
64
+ let t = `这条 [${kind}] 没有转给用户:${problems.join(';')}。补上后重发:第一行写 [${kind}] #任务板编号 给用户的一两句话(系统原样贴到聊天频道),第二行起写细节`;
65
+ if (kind === 'ask') t += ',正文写「自验:」附证据和「类别:」;方向、花钱、需要用户看的成品、权限确认四类可写「自验:无需」';
66
+ return `${t}。`;
67
+ }
68
+
69
+ const FIRST_MARK = /^\[(ask|report)\][ \t]*\n?([\s\S]*\S[\s\S]*)$/i;
70
+ const LINE_MARK = /^[ \t]*\[(ask|report)\]/i;
71
+
72
+ /**
73
+ * The [ask] / [report] mark of a role reply: { kind, body } or undefined. Marked on the first
74
+ * line, as the bridge reads it (core/team_exit.go parseExitKind, first line only). lastLine (a
75
+ * DSH member, whose reply is one row for the whole turn: first line 【接】#id, then everything it
76
+ * said) also takes the line starting the turn's last part: the last line that opens with the mark.
77
+ * team (the routable members): @-mentions a reply or a part opens with are not part of it (#597 G37:
78
+ * a DSH reply comes back as 「@主 [report]…」, dsh-member.mjs returnToLead, also as a later part of
79
+ * the turn's row, #597 G40), as for its stance line.
80
+ */
81
+ export function exitMark(text, { lastLine = false, team = [] } = {}) {
82
+ const t = withoutLeadingMentions(String(text ?? ''), team).trim();
83
+ let m = FIRST_MARK.exec(t);
84
+ if (m === null && lastLine) {
85
+ const lines = t.split('\n');
86
+ for (let i = lines.length - 1; i > 0 && m === null; i--) {
87
+ const line = withoutLeadingMentions(lines[i], team);
88
+ if (LINE_MARK.test(line)) m = FIRST_MARK.exec([line.trimStart(), ...lines.slice(i + 1)].join('\n'));
89
+ }
90
+ }
91
+ return m === null ? undefined : { kind: m[1].toLowerCase(), body: m[2] };
92
+ }
93
+
8
94
  export class TeamExit {
9
95
  constructor(hub) {
10
96
  this.hub = hub;
@@ -15,9 +101,25 @@ export class TeamExit {
15
101
  id TEXT PRIMARY KEY, parent_id INTEGER NOT NULL, workspace_id INTEGER NOT NULL,
16
102
  member_id TEXT NOT NULL, forwarded_id INTEGER UNIQUE, source_id INTEGER UNIQUE,
17
103
  answer_source_id INTEGER UNIQUE, answered INTEGER NOT NULL DEFAULT 0)`);
104
+ this.bounces = new Map(); // `${room}:${member}` -> consecutive [ask] bounces
18
105
  hub.onMessage(msg => this.onMessage(msg));
19
106
  }
20
107
 
108
+ /** Post why the [ask] stayed in the workspace and wake its author to fix it. */
109
+ bounce(room, msg, problems, kind = 'ask') {
110
+ const key = `${room.id}:${msg.author}`;
111
+ const count = (this.bounces.get(key) ?? 0) + 1;
112
+ this.bounces.set(key, count);
113
+ const { message } = this.hub.post(room.id, {
114
+ authorKind: 'system', author: 'system', name: 'system', clientKey: `team-exit-bounce:${msg.id}`,
115
+ text: `📬 @${msg.name} ${exitBounceText(kind, problems)}`,
116
+ });
117
+ if (count > BOUNCE_WAKE_LIMIT) return;
118
+ for (const wake of this.hub.tasks?.wakers ?? []) {
119
+ try { if (wake(msg.author, message)) break; } catch (error) { this.hub.logger?.warn?.('collab: ask bounce wake failed: %s', error?.message ?? error); }
120
+ }
121
+ }
122
+
21
123
  answerFor(msg) {
22
124
  if (!this.enabled || msg.author_kind !== 'user' || exitTarget(msg)) return;
23
125
  const prior = this.db.prepare('SELECT * FROM team_exit_asks WHERE answer_source_id = ?').get(msg.id);
@@ -34,28 +136,104 @@ export class TeamExit {
34
136
  // Stable client key prevents duplicate delivery if acknowledgement is retried.
35
137
  const { message } = this.hub.post(answer.workspace_id, {
36
138
  authorKind: 'user', author: msg.author, name: msg.name,
37
- text: `@${answer.member_id} 用户回复 ${answer.id}:\n${msg.text}`,
139
+ text: `@${this.hub.members?.get(answer.member_id)?.name ?? answer.member_id} 用户回复 ${answer.id}:\n${msg.text}`,
38
140
  clientKey: `team-answer:${answer.member_id}:${answer.id}`,
39
141
  });
40
142
  this.db.prepare('UPDATE team_exit_asks SET answered = 1, answer_source_id = ? WHERE id = ?').run(msg.id, answer.id);
143
+ // #597 G35: the watch waited on the user (Tasks.watch awaitingUser); it starts again from here.
144
+ this.hub.tasks?.rewatch?.(answer.workspace_id, answer.member_id);
41
145
  return message;
42
146
  }
43
- if (msg.author_kind !== 'role' || msg.card != null) return;
44
- const mark = /^\[(ask|report)\]\s*\n([\s\S]+)$/i.exec(msg.text.trim());
147
+ if (msg.author_kind !== 'role') return;
148
+ const mark = msg.card == null ? exitMark(msg.text, this.markOpts(msg)) : undefined;
149
+ const room = this.workspaceOf(msg);
150
+ if (!room) return;
151
+ const ask = mark?.kind === 'ask';
152
+ let headline;
153
+ if (mark) {
154
+ const problems = ask ? askEvidenceProblems(mark.body) : [];
155
+ headline = exitHeadline(mark.body);
156
+ if (!headline.problems.length) {
157
+ headline.task = this.hub.tasks?.task?.(headline.id);
158
+ if (!headline.task) headline.problems.push(`${EXIT_UNKNOWN_ID}(#${headline.id})`);
159
+ }
160
+ // Once bounces stop waking the member, a reply still without a headline goes out as before.
161
+ if (headline.problems.length && !problems.length && (this.bounces.get(`${room.id}:${msg.author}`) ?? 0) >= BOUNCE_WAKE_LIMIT) headline = undefined;
162
+ else problems.push(...headline.problems);
163
+ if (problems.length) return this.bounce(room, msg, problems, mark.kind);
164
+ }
165
+ // Any reply without a bounced [ask] ends the run of bounces.
166
+ this.bounces.delete(`${room.id}:${msg.author}`);
45
167
  if (!mark) return;
46
- const room = this.db.prepare('SELECT * FROM rooms WHERE id = ?').get(msg.room_id);
47
- if (!room?.parent_id || room.parent_id === room.id) return;
48
- const ask = mark[1].toLowerCase() === 'ask';
49
168
  let record;
50
169
  if (ask) {
51
170
  this.db.prepare(`INSERT OR IGNORE INTO team_exit_asks (id, parent_id, workspace_id, member_id, source_id)
52
171
  VALUES (?, ?, ?, ?, ?)`).run(`ask-${randomBytes(4).toString('hex')}`, room.parent_id, room.id, msg.author, msg.id);
53
172
  record = this.db.prepare('SELECT * FROM team_exit_asks WHERE source_id = ?').get(msg.id);
54
173
  }
55
- const { message } = this.hub.post(room.parent_id, {
56
- authorKind: 'system', author: 'system', name: 'system', clientKey: `team-exit:${msg.id}`,
57
- text: `📬 来自 ${room.name} · ${msg.name}${ask ? ` · ${record.id}` : ' · 阶段结果'}\n${mark[2]}`,
58
- });
59
- if (ask) this.db.prepare('UPDATE team_exit_asks SET forwarded_id = ? WHERE id = ?').run(message.id, record.id);
174
+ const message = this.forward(room, msg, mark.body, record, headline);
175
+ if (ask) {
176
+ this.db.prepare('UPDATE team_exit_asks SET forwarded_id = ? WHERE id = ?').run(message.id, record.id);
177
+ const tasks = this.hub.tasks;
178
+ // #597 G35: a DSH member's [ask] keeps its task in work and watched (and its workspace, so the
179
+ // answer reaches the same session); a bridge bot's [ask] parks it as before (#280).
180
+ if (tasks && !this.markOpts(msg).lastLine) {
181
+ const taskId = /任务\s*#(\d+)\b/.exec(mark.body)?.[1];
182
+ const inRoom = tasks.q.allBusy.all('assigned', 'accepted', 'asked', 'rework')
183
+ .filter(t => t.room_id === room.id);
184
+ const marked = taskId ? tasks.task(Number(taskId)) : undefined;
185
+ const selected = marked?.room_id === room.id ? marked : inRoom[0];
186
+ if (selected?.room_id === room.id) tasks.pauseTask(selected.id);
187
+ }
188
+ }
189
+ }
190
+
191
+ /** A DSH member's one-row reply may carry its mark on a later line; a bridge bot's only on the first. */
192
+ markOpts(msg) {
193
+ return { lastLine: this.hub.members?.get(msg.author)?.kind === 'dsh', team: this.hub.routable?.() ?? [] };
194
+ }
195
+
196
+ /** The workspace room msg was posted in (with a chat channel above it), or undefined. */
197
+ workspaceOf(msg) {
198
+ if (!this.enabled) return undefined;
199
+ const room = this.db.prepare('SELECT * FROM rooms WHERE id = ?').get(msg.room_id);
200
+ return room?.parent_id && room.parent_id !== room.id ? room : undefined;
201
+ }
202
+
203
+ /**
204
+ * Copy a workspace reply into its chat channel (once per source row: a repeated client key
205
+ * returns the copy already posted). ask: its team_exit_asks record; otherwise a 阶段结果.
206
+ */
207
+ forward(room, msg, body, ask, headline) {
208
+ let text = `📬 来自 ${room.name} · ${msg.name}${ask ? ` · ${ask.id}` : ' · 阶段结果'}\n${body}`;
209
+ if (headline) {
210
+ // The headline as written, named by its task; the details follow (the DSH lead reads them here).
211
+ const name = headline.task?.title ?? '';
212
+ const rest = String(body).split('\n').slice(1).join('\n').trim();
213
+ text = `📬 #${headline.id}「${name}」${ask ? `要你定:${headline.line}(直接回复这条就能答复,编号 ${ask.id})` : `:${headline.line}`}`
214
+ + (rest ? `\n\n来自 ${room.name} · ${msg.name} 的细节:\n${rest}` : '');
215
+ }
216
+ return this.hub.post(room.parent_id, {
217
+ authorKind: 'system', author: 'system', name: 'system', clientKey: `team-exit:${msg.id}`, text,
218
+ }).message;
219
+ }
220
+
221
+ /**
222
+ * The delivery msg of a workspace task, copied into the chat channel (Tasks.handBack): its
223
+ * [report] body, already forwarded by onMessage, or else the reply without its stance line, so
224
+ * the chat channel always gets the result and not only the 交回 record. undefined outside a
225
+ * workspace and for an [ask] (a question, not a result).
226
+ */
227
+ forwardResult(msg) {
228
+ const room = this.workspaceOf(msg);
229
+ if (!room || msg.author_kind !== 'role' || msg.card != null) return undefined;
230
+ const mark = exitMark(msg.text, this.markOpts(msg));
231
+ if (mark?.kind === 'ask') return undefined;
232
+ const lines = String(msg.text).trim().split('\n');
233
+ if (/^\s*(?:@\S+\s+)*【(接|问|退|升)】/.test(lines[0])) lines.shift();
234
+ const body = mark?.body ?? (lines.join('\n').trim() || String(msg.text).trim());
235
+ const headline = mark ? exitHeadline(mark.body) : undefined;
236
+ if (headline && !headline.problems.length) headline.task = this.hub.tasks?.task?.(headline.id);
237
+ return this.forward(room, msg, body, undefined, headline?.task ? headline : undefined);
60
238
  }
61
239
  }
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ // Developer checks, not independent acceptance or a live desktop/QQ test.
3
+ import { mkdtempSync, readdirSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join, dirname, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { spawnSync } from 'node:child_process';
8
+
9
+ const collab = dirname(fileURLToPath(import.meta.url));
10
+ const data = mkdtempSync(join(tmpdir(), 'bothouse-multichannel-'));
11
+ const env = { ...process.env, CONEST_COLLAB_DATA: data };
12
+ console.log(`Isolated CONEST_COLLAB_DATA: ${data}`);
13
+ const cases = [
14
+ ['Legacy configuration', 'Automated coverage: default directory and global member scope'],
15
+ ['Upgrade visibility', 'Automated coverage: global legacy scope and restart idempotence'],
16
+ ['Channel routing and same-name bots', 'Automated coverage: channel routing and channel bots tests'],
17
+ ['New channel retains global members', 'Automated coverage: legacy and explicitly global members remain visible'],
18
+ ['Separate directories and workspace reuse', 'Automated coverage: native/CLI directory selection; real model file writes not exercised'],
19
+ ['Rename/model edit retains room scope', 'Automated coverage: rename and reload retain scope; model edit needs a dedicated check'],
20
+ ['Application shutdown', 'Automated coverage: late cleanup; real desktop shutdown not exercised'],
21
+ ['Never-spoken legacy member survives upgrade', 'Automated coverage: global migration regression'],
22
+ ['Post-upgrade new channel routes to primary', 'Automated coverage: plain message dispatch to the legacy primary'],
23
+ ];
24
+ for (const [i, [name, status]] of cases.entries()) console.log(`${i + 1}. ${name}: ${status}`);
25
+ const serverTests = readdirSync(collab, { withFileTypes: true })
26
+ .filter(entry => entry.isFile() && entry.name.endsWith('.test.mjs'))
27
+ .map(entry => entry.name).sort();
28
+ if (!serverTests.length) throw new Error(`No collab test files found in ${collab}`);
29
+ console.log(`Server suite: ${serverTests.length} test files (${serverTests.join(', ')})`);
30
+ const commands = [
31
+ [process.execPath, ['--test', ...serverTests], collab],
32
+ ['pnpm', ['test'], resolve(collab, '..')],
33
+ ];
34
+ for (const [command, args, cwd] of commands) {
35
+ console.log(`\nRunning ${command} ${args.join(' ')}`);
36
+ const result = spawnSync(command, args, { cwd, env, stdio: 'inherit' });
37
+ if (result.error) { console.error(result.error.message); process.exit(1); }
38
+ if (result.status !== 0) process.exit(result.status ?? 1);
39
+ }
40
+ console.log('\nAutomated suites passed. Acceptance remains incomplete: pending live checks and independent review above.');
41
+ console.log('Temporary data is retained for inspection; this script does not delete directories.');
42
+ process.exitCode = 2; // Distinguish partial verification from completed acceptance.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bothouse-dsh",
3
- "version": "0.1.0",
3
+ "version": "0.1.40",
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",
@@ -9,8 +9,18 @@
9
9
  "./client": "./client/client.js",
10
10
  "./package.json": "./package.json"
11
11
  },
12
- "keywords": ["dsh", "deepseek", "harness", "plugin", "bothouse"],
13
- "repository": { "type": "git", "url": "https://github.com/zuiho-kai/bothouse", "directory": "dsh/plugin-pkg" },
12
+ "keywords": [
13
+ "dsh",
14
+ "deepseek",
15
+ "harness",
16
+ "plugin",
17
+ "bothouse"
18
+ ],
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/zuiho-kai/bothouse",
22
+ "directory": "dsh/plugin-pkg"
23
+ },
14
24
  "license": "Apache-2.0",
15
25
  "files": [
16
26
  "collab/*.mjs",