bothouse-dsh 0.1.40 → 0.1.42
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/team-exit.mjs +140 -59
- package/package.json +1 -1
package/collab/team-exit.mjs
CHANGED
|
@@ -1,23 +1,29 @@
|
|
|
1
1
|
// Durable workspace questions; system forwards never enter agent routing.
|
|
2
2
|
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
3
4
|
import { withoutLeadingMentions } from './mentions.mjs';
|
|
4
5
|
|
|
5
6
|
export function exitTarget(msg) {
|
|
6
7
|
return /^team-answer:([^:]+):/.exec(msg.client_key ?? '')?.[1];
|
|
7
8
|
}
|
|
8
9
|
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
10
|
+
// The workspace [ask]/[report] rules (first-line mark and #id format, the [ask] self-check, bounce
|
|
11
|
+
// texts) are one file, team-exit-rules.json next to this one; the Go bridge reads the same file
|
|
12
|
+
// (bridge/core/team_exit_rules.go, from its dsh_bundle copy) and both sides run the shared cases in
|
|
13
|
+
// test/team-exit-cases.json. No formats or texts live here. Regexes are written for both RE2 and JS.
|
|
14
|
+
export const RULES = JSON.parse(readFileSync(new URL('./team-exit-rules.json', import.meta.url), 'utf8'));
|
|
15
|
+
const HEADLINE = new RegExp(RULES.headline_re);
|
|
16
|
+
const SELF_CHECK = new RegExp(RULES.ask.self_check_re);
|
|
17
|
+
const CATEGORY = new RegExp(RULES.ask.category_re);
|
|
18
|
+
const EXEMPT = new RegExp(RULES.ask.exempt_re);
|
|
19
|
+
const NO_CHECK = new RegExp(RULES.ask.no_check_re);
|
|
20
|
+
const JARGON_LINE = new RegExp(RULES.jargon.line_re, 'g');
|
|
21
|
+
const JARGON_TABLE = new RegExp(RULES.jargon.table_re);
|
|
22
|
+
const BACKGROUND = new RegExp(RULES.summary.background_re);
|
|
23
|
+
const TODO = new RegExp(RULES.summary.todo_re);
|
|
24
|
+
const DETAIL = new RegExp(RULES.summary.detail_re);
|
|
19
25
|
// Consecutive bounces that still wake the asking member; later ones are only posted.
|
|
20
|
-
const BOUNCE_WAKE_LIMIT =
|
|
26
|
+
const BOUNCE_WAKE_LIMIT = RULES.bounce.wake_limit;
|
|
21
27
|
|
|
22
28
|
/** What an [ask] body lacks; [] means it may go to the user. */
|
|
23
29
|
export function askEvidenceProblems(body) {
|
|
@@ -32,42 +38,114 @@ export function askEvidenceProblems(body) {
|
|
|
32
38
|
check = rest.trim();
|
|
33
39
|
}
|
|
34
40
|
const problems = [];
|
|
35
|
-
if (!check) problems.push(
|
|
36
|
-
if (!category) problems.push(
|
|
37
|
-
if (check && category && NO_CHECK.test(check) && !EXEMPT.test(category)) problems.push(
|
|
41
|
+
if (!check) problems.push(RULES.ask.missing_self_check);
|
|
42
|
+
if (!category) problems.push(RULES.ask.missing_category);
|
|
43
|
+
if (check && category && NO_CHECK.test(check) && !EXEMPT.test(category)) problems.push(RULES.ask.no_check_not_exempt);
|
|
38
44
|
return problems;
|
|
39
45
|
}
|
|
40
46
|
|
|
41
|
-
|
|
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. */
|
|
47
|
+
/** { id, line, problems } of a marked body's first line; line is the part for the user (Go: exitHeadline). */
|
|
54
48
|
export function exitHeadline(body) {
|
|
55
49
|
const first = String(body ?? '').split('\n')[0].trim();
|
|
56
50
|
const m = HEADLINE.exec(first);
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
51
|
+
const { missing_id: noId, missing_line: noLine } = RULES.headline;
|
|
52
|
+
if (!m) return { id: 0, line: '', problems: first === '' || first.startsWith('#') ? [noId, noLine] : [noId] };
|
|
53
|
+
const { summary: line } = exitSplit(body);
|
|
54
|
+
return { id: Number(m[1]), line, problems: line ? [] : [noLine] };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* { summary, detail } of a marked body (Go: exitSplit): after the first line's #id, up to and
|
|
59
|
+
* including the 「要你做的:」 line is posted for the user; the lines after it go under that post.
|
|
60
|
+
*/
|
|
61
|
+
export function exitSplit(body) {
|
|
62
|
+
let lines = String(body ?? '').replaceAll('\r\n', '\n').split('\n');
|
|
63
|
+
const m = HEADLINE.exec(lines[0].trim());
|
|
64
|
+
if (m) lines[0] = m[2];
|
|
65
|
+
while (lines.length && !lines[0].trim()) lines.shift();
|
|
66
|
+
let end = lines.findIndex(l => TODO.test(l));
|
|
67
|
+
if (end < 0) {
|
|
68
|
+
const blank = lines.findIndex(l => !l.trim());
|
|
69
|
+
end = blank < 0 ? lines.length - 1 : blank - 1;
|
|
70
|
+
}
|
|
71
|
+
const summary = lines.slice(0, end + 1).join('\n').trim();
|
|
72
|
+
let rest = lines.slice(end + 1);
|
|
73
|
+
while (rest.length && !rest[0].trim()) rest.shift();
|
|
74
|
+
if (rest.length && DETAIL.test(rest[0].trim())) rest = rest.slice(1);
|
|
75
|
+
return { summary, detail: rest.join('\n').trim() };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** What the part for the user lacks: background, what was done, what the user is to do (Go: summaryProblems). */
|
|
79
|
+
export function summaryProblems(summary) {
|
|
80
|
+
const lines = String(summary).split('\n');
|
|
81
|
+
const problems = [];
|
|
82
|
+
if (!BACKGROUND.test(lines[0].trim())) problems.push(RULES.summary.missing_background);
|
|
83
|
+
const todo = lines.findIndex(l => TODO.test(l));
|
|
84
|
+
let done = false;
|
|
85
|
+
for (let i = 1; i < lines.length && !done; i++) {
|
|
86
|
+
let l = lines[i];
|
|
87
|
+
if (i === todo) l = l.slice(0, TODO.exec(l).index);
|
|
88
|
+
else if (todo >= 0 && i > todo) break;
|
|
89
|
+
done = l.trim() !== '';
|
|
90
|
+
}
|
|
91
|
+
if (!done) problems.push(RULES.summary.missing_done);
|
|
92
|
+
if (todo < 0) problems.push(RULES.summary.missing_todo);
|
|
93
|
+
return problems;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The part for the user names numbers, workspaces, branches, paths or identifiers, or has a table (Go: jargonProblems). */
|
|
97
|
+
export function jargonProblems(line) {
|
|
98
|
+
const problems = [];
|
|
99
|
+
const hits = [...String(line).matchAll(JARGON_LINE)].map(m => m[0].replace(/^[^~/`#!_\-0-9A-Za-z#!工]+/u, ''));
|
|
100
|
+
if (hits.length) problems.push(RULES.jargon.line.replaceAll('{hits}', hits.join('、')));
|
|
101
|
+
if (JARGON_TABLE.test(line)) problems.push(RULES.jargon.table);
|
|
102
|
+
return problems;
|
|
60
103
|
}
|
|
61
104
|
|
|
62
105
|
/** Why a workspace [ask]/[report] stayed in the workspace and how to resend it (Go: exitBounceText). */
|
|
63
106
|
export function exitBounceText(kind, problems) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
return `${t}。`;
|
|
107
|
+
const vars = { kind, problems: problems.join(RULES.bounce.sep), extra: RULES.bounce.extra[kind] ?? '' };
|
|
108
|
+
return RULES.bounce.text.replace(/\{(kind|problems|extra)\}/g, (_, k) => vars[k]);
|
|
67
109
|
}
|
|
68
110
|
|
|
69
|
-
|
|
70
|
-
|
|
111
|
+
/**
|
|
112
|
+
* How a marked body is judged (Go: judgeExit): { id, line, problems, legacy }. known(id): the id is a
|
|
113
|
+
* task; bounces: replies in a row already sent back. problems [] means it goes to the chat channel;
|
|
114
|
+
* legacy: the format is still wrong but bounces stopped waking the member (the bridge's chat lead
|
|
115
|
+
* rewrites it; DSH posts it without the headline).
|
|
116
|
+
*/
|
|
117
|
+
export function judgeExit(kind, body, { known, bounces = 0 }) {
|
|
118
|
+
const problems = kind === 'ask' ? askEvidenceProblems(body) : [];
|
|
119
|
+
let { id, line, problems: hp } = exitHeadline(body);
|
|
120
|
+
if (!hp.length && !known(id)) hp = [RULES.headline.unknown_id.replaceAll('{id}', String(id))];
|
|
121
|
+
else if (!hp.length) hp = [...summaryProblems(line), ...jargonProblems(line)];
|
|
122
|
+
// Format goes back only while bounces still wake the member; after that the chat lead rewrites it.
|
|
123
|
+
if (hp.length && !problems.length && bounces >= BOUNCE_WAKE_LIMIT) return { id, line, problems, legacy: true };
|
|
124
|
+
return { id, line, problems: [...problems, ...hp], legacy: false };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The task a workspace reply is about (Go: exitTaskPick): the one its first line names when that one
|
|
129
|
+
* is here (bound to this workspace), else the one bound here last; 0 when none is here.
|
|
130
|
+
* items: [{ id, here, bound }], bound compared as given.
|
|
131
|
+
*/
|
|
132
|
+
export function exitTaskPick(named, items) {
|
|
133
|
+
let pick = 0, last;
|
|
134
|
+
for (const it of items) {
|
|
135
|
+
if (!it.here) continue;
|
|
136
|
+
if (named > 0 && it.id === named) return named;
|
|
137
|
+
if (pick === 0 || it.bound > last) { pick = it.id; last = it.bound; }
|
|
138
|
+
}
|
|
139
|
+
return pick;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const escapeRe = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
143
|
+
const MARKS = RULES.kinds.map(k => escapeRe(RULES.mark.replace('{kind}', k))).join('|');
|
|
144
|
+
const FIRST_MARK = new RegExp(`^(${MARKS})([\\s\\S]*)$`, 'i');
|
|
145
|
+
// A mark with nothing after it is no mark (Go: parseExitKind).
|
|
146
|
+
const firstMark = t => { const m = FIRST_MARK.exec(t); return m?.[2].trim() ? m : null; };
|
|
147
|
+
const markKind = tag => RULES.kinds.find(k => RULES.mark.replace('{kind}', k).toLowerCase() === tag.toLowerCase());
|
|
148
|
+
const LINE_MARK = new RegExp(`^[ \\t]*(${MARKS})`, 'i');
|
|
71
149
|
|
|
72
150
|
/**
|
|
73
151
|
* The [ask] / [report] mark of a role reply: { kind, body } or undefined. Marked on the first
|
|
@@ -80,15 +158,15 @@ const LINE_MARK = /^[ \t]*\[(ask|report)\]/i;
|
|
|
80
158
|
*/
|
|
81
159
|
export function exitMark(text, { lastLine = false, team = [] } = {}) {
|
|
82
160
|
const t = withoutLeadingMentions(String(text ?? ''), team).trim();
|
|
83
|
-
let m =
|
|
161
|
+
let m = firstMark(t);
|
|
84
162
|
if (m === null && lastLine) {
|
|
85
163
|
const lines = t.split('\n');
|
|
86
164
|
for (let i = lines.length - 1; i > 0 && m === null; i--) {
|
|
87
165
|
const line = withoutLeadingMentions(lines[i], team);
|
|
88
|
-
if (LINE_MARK.test(line)) m =
|
|
166
|
+
if (LINE_MARK.test(line)) m = firstMark([line.trimStart(), ...lines.slice(i + 1)].join('\n'));
|
|
89
167
|
}
|
|
90
168
|
}
|
|
91
|
-
return m === null ? undefined : { kind: m[1]
|
|
169
|
+
return m === null ? undefined : { kind: markKind(m[1]), body: m[2].trim() };
|
|
92
170
|
}
|
|
93
171
|
|
|
94
172
|
export class TeamExit {
|
|
@@ -151,16 +229,11 @@ export class TeamExit {
|
|
|
151
229
|
const ask = mark?.kind === 'ask';
|
|
152
230
|
let headline;
|
|
153
231
|
if (mark) {
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
if (
|
|
157
|
-
headline.task = this.hub.tasks?.task?.(headline.id);
|
|
158
|
-
if (!headline.task) headline.problems.push(`${EXIT_UNKNOWN_ID}(#${headline.id})`);
|
|
159
|
-
}
|
|
232
|
+
const bounces = this.bounces.get(`${room.id}:${msg.author}`) ?? 0;
|
|
233
|
+
const v = judgeExit(mark.kind, mark.body, { known: id => Boolean(this.hub.tasks?.task?.(id)), bounces });
|
|
234
|
+
if (v.problems.length) return this.bounce(room, msg, v.problems, mark.kind);
|
|
160
235
|
// Once bounces stop waking the member, a reply still without a headline goes out as before.
|
|
161
|
-
if (
|
|
162
|
-
else problems.push(...headline.problems);
|
|
163
|
-
if (problems.length) return this.bounce(room, msg, problems, mark.kind);
|
|
236
|
+
if (!v.legacy) headline = { ...v, task: this.hub.tasks?.task?.(v.id) };
|
|
164
237
|
}
|
|
165
238
|
// Any reply without a bounced [ask] ends the run of bounces.
|
|
166
239
|
this.bounces.delete(`${room.id}:${msg.author}`);
|
|
@@ -178,12 +251,12 @@ export class TeamExit {
|
|
|
178
251
|
// #597 G35: a DSH member's [ask] keeps its task in work and watched (and its workspace, so the
|
|
179
252
|
// answer reaches the same session); a bridge bot's [ask] parks it as before (#280).
|
|
180
253
|
if (tasks && !this.markOpts(msg).lastLine) {
|
|
181
|
-
const
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const selected =
|
|
186
|
-
if (selected
|
|
254
|
+
const busy = tasks.q.allBusy.all('assigned', 'accepted', 'asked', 'rework');
|
|
255
|
+
const named = headline?.task;
|
|
256
|
+
const items = [...(named ? [named] : []), ...busy]
|
|
257
|
+
.map(t => ({ id: t.id, here: t.room_id === room.id, bound: t.updated_at }));
|
|
258
|
+
const selected = exitTaskPick(named?.id ?? 0, items);
|
|
259
|
+
if (selected) tasks.pauseTask(selected);
|
|
187
260
|
}
|
|
188
261
|
}
|
|
189
262
|
}
|
|
@@ -206,16 +279,24 @@ export class TeamExit {
|
|
|
206
279
|
*/
|
|
207
280
|
forward(room, msg, body, ask, headline) {
|
|
208
281
|
let text = `📬 来自 ${room.name} · ${msg.name}${ask ? ` · ${ask.id}` : ' · 阶段结果'}\n${body}`;
|
|
282
|
+
let detail = '';
|
|
209
283
|
if (headline) {
|
|
210
|
-
// The
|
|
284
|
+
// The three parts for the user, named by its task; DSH has no threads, so the details are a
|
|
285
|
+
// second post marked as such (Go: postWithDetail).
|
|
211
286
|
const name = headline.task?.title ?? '';
|
|
212
|
-
|
|
213
|
-
text =
|
|
214
|
-
|
|
287
|
+
detail = exitSplit(body).detail;
|
|
288
|
+
text = `📬「${name}」${ask ? `要你定\n${headline.line}\n(直接回复这条就能答复,编号 ${ask.id})` : `\n${headline.line}`}`;
|
|
289
|
+
if (detail) detail = `📬 细节「${name}」(来自 ${room.name} · ${msg.name},想看再展开,不用回)\n${detail}`;
|
|
215
290
|
}
|
|
216
|
-
|
|
291
|
+
const { message } = this.hub.post(room.parent_id, {
|
|
217
292
|
authorKind: 'system', author: 'system', name: 'system', clientKey: `team-exit:${msg.id}`, text,
|
|
218
|
-
})
|
|
293
|
+
});
|
|
294
|
+
if (detail) {
|
|
295
|
+
this.hub.post(room.parent_id, {
|
|
296
|
+
authorKind: 'system', author: 'system', name: 'system', clientKey: `team-exit-detail:${msg.id}`, text: detail,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
return message;
|
|
219
300
|
}
|
|
220
301
|
|
|
221
302
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bothouse-dsh",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.42",
|
|
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",
|