bothouse-dsh 0.1.45 → 0.1.47

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,180 @@
1
+ // #876: read-only busy/idle view of cc-connect's team status files.
2
+ //
3
+ // cc-connect writes one <project>.json per bot under its team_status_dir
4
+ // (bridge/core/team.go:319). The hub only reads them: no writes, no bridge
5
+ // calls, no new event type. The existing `members` event carries the result.
6
+ import { readFileSync, readdirSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+
9
+ // Same cadence as bridge/core/team.go:26 teamPollInterval: the desk is never
10
+ // more than one writer tick behind.
11
+ export const TEAM_POLL_INTERVAL_MS = 2000;
12
+ // bridge/core/team.go:28 teamStaleAfter: three 20s heartbeats, then the bot
13
+ // is treated as gone rather than busy forever.
14
+ export const TEAM_STALE_AFTER_MS = 60_000;
15
+ const STATUS_FILE = /^[a-z0-9][a-z0-9_-]*\.json$/i;
16
+
17
+ function parseTime(value) {
18
+ const ms = Date.parse(value);
19
+ return Number.isFinite(ms) ? ms : undefined;
20
+ }
21
+
22
+ function text(value) {
23
+ return typeof value === 'string' ? value.trim() : '';
24
+ }
25
+
26
+ function iso(ms) {
27
+ return ms === undefined ? null : new Date(ms).toISOString();
28
+ }
29
+
30
+ /**
31
+ * Merge one member's status files into the optional fields on a member view.
32
+ * Every busy session is kept: a bot can work in several channels at once, and
33
+ * the desk needs all of them, not just the first.
34
+ */
35
+ export function presenceOf(entries) {
36
+ const items = [];
37
+ for (const entry of entries) {
38
+ const project = text(entry?.project);
39
+ for (const session of Array.isArray(entry?.sessions) ? entry.sessions : []) {
40
+ if (session?.busy !== true) continue;
41
+ items.push({ project, chat: text(session.chat) || null, task: text(session.task), since: parseTime(session.since) });
42
+ }
43
+ }
44
+ const chats = [...new Set(items.map(i => i.chat).filter(Boolean))].sort();
45
+ const since = items.map(i => i.since).filter(v => v !== undefined).sort((a, b) => a - b)[0];
46
+ const lead = items.find(i => i.chat !== null && i.task) ?? items.find(i => i.chat !== null) ?? items.find(i => i.task) ?? items[0];
47
+ const done = entries.map(e => parseTime(e?.done_at)).filter(v => v !== undefined).sort((a, b) => a - b).at(-1);
48
+ return {
49
+ busy: items.length > 0,
50
+ busy_since: iso(since),
51
+ busy_task: lead?.task ?? '',
52
+ busy_chat: lead?.chat ?? null,
53
+ busy_chats: chats,
54
+ busy_count: items.length,
55
+ busy_items: items.map(i => ({ project: i.project, chat: i.chat, task: i.task, since: iso(i.since) })),
56
+ ...(items.length === 0 && done !== undefined ? { busy_done_at: iso(done) } : {}),
57
+ };
58
+ }
59
+
60
+ /** Parse one status file; `error` is a reason the caller may warn about. */
61
+ export function readStatusFile(path, now, staleAfterMs = TEAM_STALE_AFTER_MS) {
62
+ let raw;
63
+ try {
64
+ raw = readFileSync(path, 'utf8');
65
+ } catch (error) {
66
+ return { error: error?.code === 'ENOENT' ? 'gone' : 'read' };
67
+ }
68
+ let entry;
69
+ try {
70
+ entry = JSON.parse(raw);
71
+ } catch {
72
+ return { error: 'json' };
73
+ }
74
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)
75
+ || typeof entry.project !== 'string' || entry.project.trim() === ''
76
+ || (entry.sessions != null && !Array.isArray(entry.sessions))) return { error: 'shape' };
77
+ const updated = parseTime(entry.updated);
78
+ if (updated === undefined) return { error: 'shape' };
79
+ if (now - updated > staleAfterMs) return { error: 'stale' };
80
+ return { entry, updated };
81
+ }
82
+
83
+ export class TeamStatus {
84
+ constructor(hub, dir, { logger, intervalMs = TEAM_POLL_INTERVAL_MS, staleAfterMs = TEAM_STALE_AFTER_MS, now = Date.now } = {}) {
85
+ this.hub = hub;
86
+ this.dir = dir;
87
+ this.logger = logger;
88
+ this.intervalMs = intervalMs;
89
+ this.staleAfterMs = staleAfterMs;
90
+ this.now = now;
91
+ this.timer = undefined;
92
+ this.published = new Set();
93
+ this.fileOwner = new Map();
94
+ this.warned = new Set();
95
+ }
96
+
97
+ start() {
98
+ if (!this.dir || this.timer !== undefined) return;
99
+ this.poll();
100
+ this.timer = setInterval(() => this.poll(), this.intervalMs);
101
+ this.timer.unref?.();
102
+ }
103
+
104
+ stop() {
105
+ if (this.timer !== undefined) clearInterval(this.timer);
106
+ this.timer = undefined;
107
+ this.published.clear();
108
+ }
109
+
110
+ warnOnce(key, format, ...args) {
111
+ if (this.warned.has(key)) return;
112
+ this.warned.add(key);
113
+ this.logger?.warn?.(format, ...args);
114
+ }
115
+
116
+ /** Match a status project to a hub member: id first, then display name. */
117
+ resolve(project, name) {
118
+ const members = this.hub.memberList().filter(m => m.kind !== 'human');
119
+ const direct = members.find(m => String(m.id).toLowerCase() === text(project).toLowerCase());
120
+ if (direct !== undefined) return direct;
121
+ const label = text(name).toLowerCase();
122
+ if (label === '') return undefined;
123
+ const same = members.filter(m => text(m.name).toLowerCase() === label);
124
+ if (same.length === 1) return same[0];
125
+ if (same.length > 1) this.warnOnce(`ambiguous:${project}`, 'conest-collab: team project %s matches %d members by name; busy state skipped', project, same.length);
126
+ return undefined;
127
+ }
128
+
129
+ poll() {
130
+ if (!this.dir) return;
131
+ let names;
132
+ try {
133
+ names = readdirSync(this.dir).sort();
134
+ } catch {
135
+ this.warnOnce('dir', 'conest-collab: team status directory is unreadable: %s', this.dir);
136
+ for (const id of this.published) this.hub.setPresence(id, undefined);
137
+ this.published.clear();
138
+ return;
139
+ }
140
+ this.warned.delete('dir');
141
+ const now = this.now();
142
+ const byMember = new Map();
143
+ const keep = new Set();
144
+ for (const name of names) {
145
+ if (!STATUS_FILE.test(name) || name.startsWith('board')) continue;
146
+ const path = join(this.dir, name);
147
+ const loaded = readStatusFile(path, now, this.staleAfterMs);
148
+ if (loaded.error !== undefined) {
149
+ if (loaded.error === 'stale' || loaded.error === 'gone') {
150
+ this.fileOwner.delete(name);
151
+ } else {
152
+ this.warnOnce(`file:${name}`, 'conest-collab: bad team status file %s', path);
153
+ // A writer may be mid-replace; keep the last good state, but only for the
154
+ // same 60s window a readable file would still be valid in.
155
+ const previous = this.fileOwner.get(name);
156
+ if (previous !== undefined && now - previous.updated <= this.staleAfterMs) keep.add(previous.id);
157
+ else this.fileOwner.delete(name);
158
+ }
159
+ continue;
160
+ }
161
+ this.warned.delete(`file:${name}`);
162
+ const { entry, updated } = loaded;
163
+ const member = this.resolve(entry.project, entry.name);
164
+ if (member === undefined) {
165
+ this.warnOnce(`member:${entry.project}`, 'conest-collab: no hub member for team project %s; busy state skipped', entry.project);
166
+ continue;
167
+ }
168
+ this.warned.delete(`member:${entry.project}`);
169
+ this.fileOwner.set(name, { id: member.id, updated });
170
+ if (member.online === false || member.error) continue;
171
+ const list = byMember.get(member.id) ?? [];
172
+ list.push(entry);
173
+ byMember.set(member.id, list);
174
+ }
175
+ // A stale, deleted or unmatched member must stop looking busy on the desk.
176
+ for (const id of this.published) if (!byMember.has(id) && !keep.has(id)) this.hub.setPresence(id, undefined);
177
+ for (const [id, entries] of byMember) this.hub.setPresence(id, presenceOf(entries));
178
+ this.published = new Set([...byMember.keys(), ...keep]);
179
+ }
180
+ }
@@ -0,0 +1,126 @@
1
+ // Wiki 星河:把一本 markdown wiki 画成星系。每页一颗星,[[链接]] 与相对 .md 链接是连线,
2
+ // 顶层目录是星团。只读:只扫配置的 wiki 目录,不写任何文件,不跟符号链接出目录。
3
+ import { readdirSync, readFileSync } from 'node:fs';
4
+ import { basename, join, posix, sep } from 'node:path';
5
+
6
+ // 来源:用户要求「点一颗星能看到这页标题、描述和前几行」(交接 wiki-galaxy-1103 总目标)。
7
+ export const EXCERPT_LINES = 5;
8
+
9
+ const WIKI_LINK = /\[\[([^\]\n]+?)\]\]/g;
10
+ const MD_LINK = /\]\(([^)\s]+?\.md)(?:#[^)]*)?\)/g;
11
+ const HTML = 'text/html; charset=utf-8';
12
+ const JSON_TYPE = 'application/json; charset=utf-8';
13
+
14
+ /** 去掉 frontmatter,返回 { meta, body };只认顶层 name/description 两个键。 */
15
+ export function splitFrontmatter(text) {
16
+ const meta = {};
17
+ if (!text.startsWith('---\n')) return { meta, body: text };
18
+ const end = text.indexOf('\n---', 4);
19
+ if (end < 0) return { meta, body: text };
20
+ for (const line of text.slice(4, end).split('\n')) {
21
+ const m = /^(name|description):\s*(.*)$/.exec(line);
22
+ if (m) meta[m[1]] = m[2].trim().replace(/^(['"])(.*)\1$/, '$2');
23
+ }
24
+ const after = text.indexOf('\n', end + 1);
25
+ return { meta, body: after < 0 ? '' : text.slice(after + 1) };
26
+ }
27
+
28
+ function walk(root, rel, out) {
29
+ let entries;
30
+ try { entries = readdirSync(join(root, rel), { withFileTypes: true }); } catch { return; }
31
+ entries.sort((a, b) => a.name.localeCompare(b.name));
32
+ for (const entry of entries) {
33
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
34
+ // 不跟符号链接:wiki 目录外的东西一律不读。
35
+ if (entry.isSymbolicLink()) continue;
36
+ const child = rel ? `${rel}/${entry.name}` : entry.name;
37
+ if (entry.isDirectory()) walk(root, child, out);
38
+ else if (entry.isFile() && entry.name.endsWith('.md')) out.push(child);
39
+ }
40
+ }
41
+
42
+ /** 扫 wiki 目录,出 { nodes, links, groups }。 */
43
+ export function scanWiki(root) {
44
+ const files = [];
45
+ walk(root, '', files);
46
+ const nodes = [];
47
+ const bodies = [];
48
+ for (const path of files) {
49
+ let text;
50
+ try { text = readFileSync(join(root, path), 'utf8'); } catch { continue; }
51
+ const { meta, body } = splitFrontmatter(text);
52
+ const heading = /^#\s+(.+)$/m.exec(body)?.[1]?.trim();
53
+ const parts = path.split('/');
54
+ const excerpt = body.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#')).slice(0, EXCERPT_LINES).join('\n');
55
+ nodes.push({
56
+ id: path,
57
+ name: meta.name || basename(path, '.md'),
58
+ title: heading || meta.name || basename(path, '.md'),
59
+ description: meta.description ?? '',
60
+ group: parts.length > 1 ? parts[0] : '',
61
+ cluster: parts.slice(0, -1).join('/'),
62
+ lines: text.split('\n').length,
63
+ excerpt,
64
+ });
65
+ bodies.push(body);
66
+ }
67
+
68
+ // [[name]] 先按 frontmatter name,再按文件名;README 也能用目录名指到。
69
+ const byName = new Map();
70
+ const claim = (key, id) => { if (key && !byName.has(key)) byName.set(key, id); };
71
+ for (const n of nodes) claim(n.name, n.id);
72
+ for (const n of nodes) claim(basename(n.id, '.md'), n.id);
73
+ for (const n of nodes) if (basename(n.id) === 'README.md' && n.cluster) claim(basename(n.cluster), n.id);
74
+ const ids = new Set(nodes.map(n => n.id));
75
+
76
+ const seen = new Set();
77
+ const links = [];
78
+ const add = (source, target) => {
79
+ if (!target || source === target) return;
80
+ const key = source < target ? `${source}\n${target}` : `${target}\n${source}`;
81
+ if (seen.has(key)) return;
82
+ seen.add(key);
83
+ links.push({ source, target });
84
+ };
85
+ nodes.forEach((n, i) => {
86
+ for (const m of bodies[i].matchAll(WIKI_LINK)) add(n.id, byName.get(m[1].split(/[|#]/)[0].trim()));
87
+ for (const m of bodies[i].matchAll(MD_LINK)) {
88
+ if (/^[a-z]+:/i.test(m[1])) continue;
89
+ let target;
90
+ try { target = posix.normalize(posix.join(posix.dirname(n.id), decodeURI(m[1]))); } catch { continue; }
91
+ if (ids.has(target)) add(n.id, target);
92
+ }
93
+ });
94
+
95
+ const groups = {};
96
+ for (const n of nodes) groups[n.group] = (groups[n.group] ?? 0) + 1;
97
+ return { nodes, links, groups };
98
+ }
99
+
100
+ export function wikiGalaxyPage() {
101
+ return readFileSync(new URL('./wiki-galaxy/index.html', import.meta.url), 'utf8');
102
+ }
103
+
104
+ function send(res, status, type, body) {
105
+ res.writeHead(status, { 'content-type': type, 'cache-control': 'no-store' });
106
+ res.end(body);
107
+ }
108
+
109
+ /** /collab/wiki-galaxy 与 /collab/api/wiki-galaxy;返回 true 表示已处理,未匹配返回 undefined。 */
110
+ export function wikiGalaxyRoute(req, res, url, { wikiDir }) {
111
+ const path = url.pathname.replace(/\/+$/, '');
112
+ if (path !== '/collab/wiki-galaxy' && path !== '/collab/api/wiki-galaxy') return undefined;
113
+ if (req.method !== 'GET') return undefined;
114
+ if (path === '/collab/wiki-galaxy') {
115
+ send(res, 200, HTML, wikiGalaxyPage()); // SPEC:HTTP 状态码,沿用 desk.mjs deskRoute
116
+ return true;
117
+ }
118
+ let graph;
119
+ try { graph = scanWiki(wikiDir); } catch {
120
+ send(res, 500, JSON_TYPE, JSON.stringify({ error: 'wiki scan failed' })); // SPEC:HTTP 状态码,沿用 desk.mjs deskRoute
121
+ return true;
122
+ }
123
+ const name = wikiDir.split(sep).filter(Boolean).pop() ?? 'wiki';
124
+ send(res, 200, JSON_TYPE, JSON.stringify({ name, ...graph })); // SPEC:HTTP 状态码,沿用 desk.mjs deskRoute
125
+ return true;
126
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bothouse-dsh",
3
- "version": "0.1.45",
3
+ "version": "0.1.47",
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",
@@ -25,6 +25,9 @@
25
25
  "files": [
26
26
  "collab/*.mjs",
27
27
  "collab/page.html",
28
+ "collab/*.json",
29
+ "collab/team-mode-rules.md",
30
+ "collab/desk/index.html",
28
31
  "collab/avatars/*.png",
29
32
  "client/client.js",
30
33
  "cordis.patch.yml",
@@ -59,7 +62,8 @@
59
62
  },
60
63
  "dependencies": {
61
64
  "@agentclientprotocol/sdk": "1.4.0",
62
- "@zed-industries/claude-code-acp": "^0.16.2"
65
+ "@zed-industries/claude-code-acp": "^0.16.2",
66
+ "ws": "8.22.1"
63
67
  },
64
68
  "optionalDependencies": {
65
69
  "@agentclientprotocol/codex-acp": "^2.2.2"