bothouse-dsh 0.1.47 → 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.
@@ -17,7 +17,9 @@ import { GateContext } from './gate-context.mjs';
17
17
  import { MemoryIndex } from './memory-index.mjs';
18
18
  // Shared with the bridge (bridge/core/team_mode.go): a common part, then one
19
19
  // "## <mode>" section per dispatch mode; a member sees only its room's section.
20
- const TEAM_MODE_FILE = readFileSync(new URL('./team-mode-rules.md', import.meta.url), 'utf8');
20
+ // A Windows checkout (git core.autocrlf) turns the file to CRLF: "## solo\r\n" then misses the
21
+ // `${mode}\n` match below and every member turn failed with "reading 'slice'". Read it as LF.
22
+ const TEAM_MODE_FILE = readFileSync(new URL('./team-mode-rules.md', import.meta.url), 'utf8').replace(/\r\n?/g, '\n');
21
23
  export function teamModeRules(mode = DEFAULT_DISPATCH_MODE) {
22
24
  const [common, ...parts] = TEAM_MODE_FILE.split(/^## /m);
23
25
  const section = parts.find(p => p.startsWith(`${mode}\n`)) ?? parts.find(p => p.startsWith(`${DEFAULT_DISPATCH_MODE}\n`));
@@ -26,7 +28,7 @@ export function teamModeRules(mode = DEFAULT_DISPATCH_MODE) {
26
28
  /** #597 G24: the open-workspace command, for the selected primary role only, never in bridge prompts. */
27
29
  export function workspaceHint(hub, later, memberId, roomId) {
28
30
  if (!later?.workspaceInstructions || hub?.primaryIn?.(roomId)?.id !== memberId) return '';
29
- return later.workspaceInstructions(memberId, roomId);
31
+ return later.workspaceInstructions(memberId, roomId) + (later.teamInstructions?.(memberId, roomId) ?? '');
30
32
  }
31
33
  /**
32
34
  * #597 G24: DSH members have no bridge session, so the shared line about `bothouse workspace new` /
@@ -199,8 +201,11 @@ const INTERRUPTED = Symbol('interrupted');
199
201
  export function friendlyFailure(raw) {
200
202
  if (raw?.code === 'COLLAB_CLI_START') return raw.message;
201
203
  const text = String(raw);
202
- if (/no.{0,12}(api.?key|key)|missing.{0,12}(key|credential)|api.?key.{0,12}(missing|not set|required)|未配置.*key/i.test(text)) return '还没有配置模型的 API Key,请先去配置模型。';
204
+ if (/no.{0,12}(api.?key|key)|missing.{0,12}(key|credential)|api.?key.{0,12}(missing|not set|required)|未配置.*key/i.test(text)) return '还没填模型密钥,填好就能用。';
203
205
  if (/model.{0,30}(not found|unavailable|not available|unsupported)|unknown model|模型.*不可用|404/i.test(text)) return '这个模型暂时不可用,请检查模型名称或换一个模型。';
206
+ // An unreachable provider is a setting or network problem. Echoing the transport wording
207
+ // ("Connection error.") behind "请检查配置" told the user nothing about which setting to fix.
208
+ if (/connection error|econnrefused|econnreset|enotfound|etimedout|eai_again|fetch failed|socket hang up|network ?(error|request failed)|timed out|timeout/i.test(text)) return '连不上模型服务:中转站地址或网络不通。请检查设置里的模型地址和 Key,或换一个中转站再试。';
204
209
  if (/login|log.?in|auth|unauthorized|401|未登录/i.test(text)) return '模型或 CLI 还没有登录,或登录已过期。请先登录后再试。';
205
210
  if (/exit|ENOENT|spawn|没有输出文字/i.test(text)) return '本机 CLI 未能正常运行或已退出,请检查安装和登录状态。';
206
211
  // A Host older than this plugin has no scope lease on its preset registry, so no member can be
@@ -457,7 +462,7 @@ export class DshMembers {
457
462
  const running = [];
458
463
  for (const job of this.store.openJobs()) {
459
464
  if (job.trigger_id !== triggerId) continue;
460
- if (job.state === 'pending') { this.store.finishJob(job.id, 'interrupted'); continue; }
465
+ if (job.state === 'pending') { this.store.finishJob(job.id, 'interrupted'); this.syncQueue(job.member_id, job.room_id); continue; }
461
466
  const w = this.watching.get(job.session_id);
462
467
  const agent = job.input_id ? this.rt.ctx.agents.get(job.session_id) : undefined;
463
468
  if (w?.jobId !== job.id || agent === undefined) { running.push(job.member_id); continue; }
@@ -580,14 +585,29 @@ export class DshMembers {
580
585
  /** Serialize work per (member, room): runs, recoveries, and Session resumes never overlap. */
581
586
  enqueue(job, task) {
582
587
  const key = `${job.member_id}\u0000${job.room_id}`;
583
- const tail = (this.queues.get(key) ?? Promise.resolve()).then(() => (this.closed ? undefined : task())).catch((error) => {
588
+ const sync = () => this.syncQueue(job.member_id, job.room_id);
589
+ const tail = (this.queues.get(key) ?? Promise.resolve()).then(() => { sync(); return this.closed ? undefined : task(); }).catch((error) => {
584
590
  this.rt.logger.warn('collab: DSH job %d failed: %s', job.id, error?.stack ?? error);
585
591
  if (!this.closed) this.fail(job.id, error?.code === 'COLLAB_CLI_START' ? error : `出错了:${error?.message ?? error}`);
586
- });
592
+ }).finally(sync);
587
593
  this.queues.set(key, tail);
594
+ sync();
588
595
  return tail;
589
596
  }
590
597
 
598
+ /**
599
+ * #1020: tell the hub which of the owner's messages wait for this member here — its open
600
+ * jobs after the first (the one running or about to), still pending. Withdrawing one is the
601
+ * existing recall (#148 a queued job is closed).
602
+ */
603
+ syncQueue(memberId, roomId) {
604
+ if (typeof this.hub.setQueue !== 'function') return;
605
+ const open = this.store.openJobs().filter(j => j.member_id === memberId && j.room_id === roomId);
606
+ const ids = open.slice(1).filter(j => j.state === 'pending').map(j => j.trigger_id)
607
+ .filter(id => this.store.message(id)?.author_kind === 'user');
608
+ this.hub.setQueue(roomId, memberId, ids);
609
+ }
610
+
591
611
  async run(jobId) {
592
612
  const job = this.store.job(jobId);
593
613
  if (job === undefined || job.state !== 'pending') return;
@@ -652,7 +672,9 @@ export class DshMembers {
652
672
  let entry = this.cliAgents.get(cliKey);
653
673
  if (entry === undefined) {
654
674
  // Persona goes through promptText every turn, so an edit applies without restarting the CLI.
655
- entry = { memberId: member.id, cli: member.cli, agent: this.rt.createCliMember({ cli: member.cli, cwd, persona: '' }) };
675
+ entry = { memberId: member.id, cli: member.cli, agent: this.rt.createCliMember({ cli: member.cli, cwd, persona: '',
676
+ // #1020: memvote.py records citations under these (memory-sources.mjs), named as the gate key (gate-context.mjs).
677
+ env: { CC_PROJECT: `dsh:${member.id}`, CC_SESSION_KEY: `dsh:${member.id}:${room.id}` } }) };
656
678
  this.cliAgents.set(cliKey, entry);
657
679
  }
658
680
  this.hub.setError(member.id, undefined);
@@ -863,6 +885,24 @@ export class DshMembers {
863
885
  if (this.closed) return;
864
886
  const job = this.store.job(jobId);
865
887
  if (job === undefined || !this.store.finishJob(jobId, state)) return;
888
+ // A restart is not a configuration problem: the member is healthy and only the turn it was
889
+ // running is lost, so it takes a retry button and leaves the member online. Reporting it as
890
+ // "成员出错 / 去配置模型" also cooled the provider quota for what was a process restart.
891
+ if (state === 'interrupted') {
892
+ this.hub.setError(job.member_id, undefined);
893
+ this.hub.post(job.room_id, {
894
+ authorKind: 'system', author: 'system', name: 'system', text: String(notice), clientKey: `dsh-job-${jobId}-${state}-${Date.now()}`,
895
+ card: JSON.stringify({
896
+ title: '被中断',
897
+ state: 'failed',
898
+ action: 'retry',
899
+ job_id: jobId,
900
+ member_id: job.member_id,
901
+ trigger_id: job.trigger_id,
902
+ }),
903
+ });
904
+ return;
905
+ }
866
906
  this.hub.quota?.observe(job.member_id, job.room_id, { error: String(notice) });
867
907
  const error = friendlyFailure(notice);
868
908
  this.hub.setError(job.member_id, error);
@@ -1,21 +1,95 @@
1
1
  // Per-turn wiki pages and task board for DSH-native members, from the local gate's
2
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.
3
+ // block the bridge puts before a bot's turn (bridge/core/gate.go turnContext).
4
+ //
5
+ // The gate's port: GATE_PORT, which bothouse sets on the `dsh web` it starts to the port it runs the
6
+ // gate on (core/gate_proc.go gatePort). A `dsh web` started another way (reload-dsh.py reusing an old
7
+ // environment, the desktop app) has no GATE_PORT, so it is read from the same config.toml the bridge
8
+ // itself reads: [gate] port (core/gate_proc.go LocalGatePort), default 9930 when the file has no
9
+ // [gate] table (that is gate.py's own PORT). Without a config file either, no port: a turn goes out
10
+ // as before, and the log says why instead of the badge and the pages vanishing without a word.
11
+ import { readFileSync } from 'node:fs';
12
+ import { homedir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { dataDirCandidates } from './memory-sources.mjs';
6
15
 
7
16
  // Same as the bridge's wait for /context (core/gate.go turnContext: 2s).
8
17
  export const GATE_CONTEXT_TIMEOUT_MS = 2_000;
18
+ // 来源:contrib/gate/gate.py PORT = 9930,core/setup_wizard_warnings.go defaultGatePort 同值
19
+ // (LocalGatePort 在 config [gate] port 缺省或为 0 时也用它)。
20
+ export const DEFAULT_GATE_PORT = 9930;
21
+
22
+ /** `port\0where` of the log lines already written: one line per process, not one per member. */
23
+ const announced = new Set();
24
+
25
+ /** `[gate] port` of a bridge config's text, as Go reads cfg.Gate.Port; 0 when it has no such table. */
26
+ export function gatePortSetting(text) {
27
+ let inside = false;
28
+ for (const line of String(text).split(/\r?\n/)) {
29
+ const header = /^\s*\[\s*([^\]]+?)\s*\]/.exec(line);
30
+ if (header !== null) { inside = header[1] === 'gate'; continue; }
31
+ if (!inside) continue;
32
+ const kv = /^\s*port\s*=\s*(\d+)\s*(?:#.*)?$/.exec(line);
33
+ if (kv === null) continue;
34
+ const port = Number(kv[1]);
35
+ if (Number.isInteger(port) && port > 0) return port;
36
+ }
37
+ return 0;
38
+ }
39
+
40
+ /**
41
+ * The bridge config this process should read: $BOTHOUSE_CONFIG (the bridge's --config), else
42
+ * <its data dir>/config.toml, the data dir being the one the gate itself would pick
43
+ * (bridge/brand.go dataDir, bridge/contrib/gate/datadir.py data_dir). The process's own ./config.toml
44
+ * is deliberately not tried: that is the bridge's rule for the bridge's cwd, not for a plugin's.
45
+ * @param {NodeJS.ProcessEnv} env
46
+ * @param {string} home
47
+ * @returns {string}
48
+ */
49
+ export function bridgeConfigFile(env = process.env, home = homedir()) {
50
+ const explicit = String(env.BOTHOUSE_CONFIG ?? '').trim();
51
+ if (explicit !== '' && explicit !== '~' && !explicit.startsWith('~/')) return explicit;
52
+ if (explicit !== '') return join(home, explicit.slice(2));
53
+ return join(dataDirCandidates(env, home)[0], 'config.toml');
54
+ }
55
+
56
+ /**
57
+ * The gate port to use, and where it came from (for the log line).
58
+ * @param {NodeJS.ProcessEnv} env
59
+ * @param {string} home
60
+ * @param {(file: string, encoding: string) => string} readFile
61
+ * @returns {{ port: string, why: string }}
62
+ */
63
+ export function resolveGatePort(env = process.env, home = homedir(), readFile = readFileSync) {
64
+ const fromEnv = String(env.GATE_PORT ?? '').trim();
65
+ if (fromEnv !== '') return { port: fromEnv, why: 'GATE_PORT' };
66
+ const file = bridgeConfigFile(env, home);
67
+ let text;
68
+ try { text = readFile(file, 'utf8'); } catch { return { port: '', why: `没有配置文件 ${file}` }; }
69
+ const port = gatePortSetting(text);
70
+ if (port !== 0) return { port: String(port), why: `${file} [gate] port` };
71
+ return { port: String(DEFAULT_GATE_PORT), why: `${file} 没有 [gate] port,用默认端口` };
72
+ }
9
73
 
10
74
  export class GateContext {
11
75
  /**
12
- * @param options - { port?: gate port (default process.env.GATE_PORT), timeoutMs?, fetch?, logger? }.
76
+ * @param options - { port?: gate port (default: GATE_PORT, else the bridge config's [gate] port),
77
+ * timeoutMs?, fetch?, logger? }.
13
78
  */
14
- constructor({ port = process.env.GATE_PORT, timeoutMs = GATE_CONTEXT_TIMEOUT_MS, fetch: fetchFn = globalThis.fetch, logger } = {}) {
15
- this.port = String(port ?? '').trim();
79
+ constructor({ port, timeoutMs = GATE_CONTEXT_TIMEOUT_MS, fetch: fetchFn = globalThis.fetch, logger } = {}) {
80
+ const from = port === undefined ? resolveGatePort() : { port: String(port ?? '').trim(), why: 'port 参数' };
81
+ this.port = String(from.port ?? '').trim();
16
82
  this.timeoutMs = timeoutMs;
17
83
  this.fetch = fetchFn;
18
84
  this.logger = logger;
85
+ // One line per process (a member each builds its own GateContext): which port the context goes
86
+ // to and from where, so a missing one is a line in the log instead of a silently missing block.
87
+ const line = `${this.port}\u0000${from.why}`;
88
+ if (!announced.has(line)) {
89
+ announced.add(line);
90
+ if (this.port !== '') this.logger?.info?.('collab: gate context: port %s(%s)', this.port, from.why);
91
+ else this.logger?.warn?.('collab: gate context: 没有 gate 端口(%s);每轮不带 wiki 页和任务板', from.why);
92
+ }
19
93
  /** `${member}\0${room}` -> { session, epoch }: the name of that conversation's context for the gate. */
20
94
  this.sent = new Map();
21
95
  }
package/collab/hub.mjs CHANGED
@@ -17,6 +17,8 @@ export class Hub {
17
17
  this.members = new Map();
18
18
  /** roomId -> Set<memberId> currently working */
19
19
  this.thinking = new Map();
20
+ /** #1020: room id -> member id -> ids of the owner's messages waiting behind that member's running turn. */
21
+ this.queued = new Map();
20
22
  /** id -> optional busy fields published by the team-status reader (#876). */
21
23
  this.presence = new Map();
22
24
  this.listeners = [];
@@ -27,6 +29,9 @@ export class Hub {
27
29
  memberList() { return [...this.members.values()]; }
28
30
 
29
31
  membersInRoom(roomId) {
32
+ // #1023: a private chat has the owner and its one agent, whatever that agent's channel list says.
33
+ const dm = this.store.dmMember?.(roomId);
34
+ if (dm !== undefined) return this.memberList().filter(m => m.kind === 'human' || m.id === dm);
30
35
  const channel = this.store.settingsRoom(roomId);
31
36
  return this.memberList().filter(m => !m.rooms?.length || m.rooms.includes(channel));
32
37
  }
@@ -45,6 +50,9 @@ export class Hub {
45
50
 
46
51
  /** Channel selection, inherited by workspaces; only unset channels use the legacy global choice. */
47
52
  primaryIn(roomId) {
53
+ // #1023: in a private chat the one agent answers every owner message, @ or not.
54
+ const dm = this.store.dmMember?.(roomId);
55
+ if (dm !== undefined) return this.routableIn(roomId).find(m => m.id === dm);
48
56
  const id = this.store.leadId(roomId);
49
57
  const member = id === null ? this.selectedPrimaryMember() : this.members.get(id);
50
58
  return member && this.routableIn(roomId).some(m => m.id === member.id) ? member : undefined;
@@ -66,6 +74,7 @@ export class Hub {
66
74
  if (!this.members.delete(id)) return;
67
75
  this.presence.delete(id);
68
76
  for (const room of this.thinking.keys()) this.setThinking(room, id, false);
77
+ for (const room of [...this.queued.keys()]) this.setQueue(room, id, []);
69
78
  this.emit({ type: 'members', members: this.memberView() });
70
79
  }
71
80
 
@@ -76,6 +85,7 @@ export class Hub {
76
85
  if (!online) {
77
86
  this.presence.delete(id);
78
87
  for (const room of this.thinking.keys()) this.setThinking(room, id, false);
88
+ for (const room of [...this.queued.keys()]) this.setQueue(room, id, []);
79
89
  }
80
90
  this.emit({ type: 'members', members: this.memberView() });
81
91
  }
@@ -114,6 +124,21 @@ export class Hub {
114
124
  this.emit({ type: 'thinking', room_id: roomId, members: [...set] });
115
125
  }
116
126
 
127
+ /** #1020: replace the queue a member has in a room (ids in queue order; [] clears it). */
128
+ setQueue(roomId, id, ids) {
129
+ const room = this.queued.get(roomId) ?? new Map();
130
+ const before = room.get(id) ?? [];
131
+ if (before.length === ids.length && before.every((x, i) => x === ids[i])) return;
132
+ if (ids.length) room.set(id, [...ids]); else room.delete(id);
133
+ if (room.size) this.queued.set(roomId, room); else this.queued.delete(roomId);
134
+ this.emit({ type: 'queue', room_id: roomId, queued: this.queueView(roomId) });
135
+ }
136
+
137
+ /** #1020: { member id: [message ids] } waiting in a room. */
138
+ queueView(roomId) {
139
+ return Object.fromEntries([...(this.queued.get(roomId) ?? new Map())]);
140
+ }
141
+
117
142
  memberView() {
118
143
  return this.memberList().map(({ id, name, kind, online, model, cli, permissionPreset, persona, nameCustomized, avatar, avatarVersion, source, error, rooms }) => ({
119
144
  id, name, kind, ...(rooms ? { rooms } : {}), online: online !== false && !error, ...(error ? { error } : {}), ...(avatar ? { avatar, ...(avatarVersion ? { avatarVersion } : {}) } : {}),
@@ -161,6 +186,7 @@ export class Hub {
161
186
  return r;
162
187
  });
163
188
  if (!result.created) return result;
189
+ result.message = this.withMemories(result.message);
164
190
  for (const next of after) {
165
191
  Promise.resolve().then(next).catch((error) => {
166
192
  this.logger.warn('collab: post-commit step failed: %s', error?.stack ?? error);
@@ -195,7 +221,7 @@ export class Hub {
195
221
  return c;
196
222
  });
197
223
  if (r === undefined) return undefined;
198
- const row = r.message;
224
+ const row = r.first ? this.withMemories(r.message) : r.message;
199
225
  this.emit({ type: 'message', message: publicMessage(row, this.store) });
200
226
  if (!r.first) return row;
201
227
  for (const next of after) {
@@ -211,6 +237,21 @@ export class Hub {
211
237
  return row;
212
238
  }
213
239
 
240
+ /**
241
+ * #1020: attach to a member's reply the memory entries it cited here since its last reply
242
+ * (memory-sources.mjs). Command answers (from_turn 0), cards and drafts take none.
243
+ */
244
+ withMemories(row) {
245
+ if (!this.memorySources || row?.author_kind !== 'role' || row.card != null || row.draft || row.from_turn === 0) return row;
246
+ const voter = this.members.get(row.author)?.voter ?? `dsh:${row.author}`;
247
+ let items;
248
+ try { items = this.memorySources.take(voter, row.room_id); } catch (error) {
249
+ this.logger.warn('collab: memory sources failed: %s', error?.message ?? error);
250
+ return row;
251
+ }
252
+ return items.length ? (this.store.setMemories(row.id, items) ?? row) : row;
253
+ }
254
+
214
255
  /** Edit/delete a row the same author wrote, and tell browsers. */
215
256
  edit(id, authorKind, author, text, card) {
216
257
  const row = this.store.edit(id, authorKind, author, text, card);
package/collab/later.mjs CHANGED
@@ -104,6 +104,18 @@ export class Later {
104
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
105
  return `\n【开工作区】整块活要交出去时,建工作区=这一条,任务和十分钟看门会自动开,不要另调 /tasks(它不认票据)。${local}或等价的接口 POST /collab/api/rooms/${room}/workspaces:\n${curl}\n一步建好工作区、开任务、挂十分钟看门并叫醒接活人;不要再在频道里 @ 同一件活。返回不是 200 就把返回原样告诉用户,不要悄悄自己做。工作区里的接活人是新会话,只看得到交接说明:里面要写清用户原话、参照的现成实现、以哪个分支为底、验收标准。\n`;
106
106
  }
107
+ /**
108
+ * 一句话拉团队 (user 11-04): how the primary role builds a channel of new roles in one call.
109
+ * Primary-only like workspaceInstructions (dsh-member.mjs workspaceHint).
110
+ */
111
+ teamInstructions(member, room) {
112
+ const quote = value => "'" + String(value).replaceAll("'", "'\\''") + "'";
113
+ const ticket = this.ticket(member, room);
114
+ const json = '{"channel":"小团队","model":"模型id(用户没说就省略=DSH默认模型)","members":[{"name":"写手","persona":"你是写手,负责写和改博客文章。"},{"name":"程序员","persona":"你是程序员,负责写代码和审代码。"}]}';
115
+ const local = this.origin ? `本机命令(改 JSON 里的内容):\n${quote(process.execPath)} ${quote(fileURLToPath(import.meta.url))} team ${quote(this.origin)} ${quote(ticket)} ${quote(json)}\n` : '';
116
+ const url = `${this.origin ?? 'http://<本机 DSH 地址>'}/collab/api/rooms/${room}/team`;
117
+ return `\n【建团队】用户要你建几个角色、拉个群/频道时,一条就建好:新频道、每个角色(名字、人设、模型),你是新频道的主角色。${local}或 POST ${url}(头 X-Later-Ticket: ${ticket},JSON 同上)。名字不带空格和 @;人设一两句写清分工;用户说了模型就填 model(如 deepseek-v4.1-flash),没说就省略。返回不是 200 就把返回原样告诉用户。建好后告诉用户频道名、每个角色和模型,问要不要丢个题目试跑。\n`;
118
+ }
107
119
  tick() {
108
120
  for (const row of this.db.prepare('SELECT * FROM member_later WHERE announced = 0').all()) {
109
121
  this.hub.post(row.room_id, { authorKind: 'system', author: 'system', name: '回查登记',
@@ -134,6 +146,15 @@ if (process.argv[1] === fileURLToPath(import.meta.url) && process.argv[2] === 'w
134
146
  const body = await res.text();
135
147
  if (!res.ok) { console.error(`workspace: ${res.status} ${body}`); process.exit(1); }
136
148
  console.log(body);
149
+ } else if (process.argv[1] === fileURLToPath(import.meta.url) && process.argv[2] === 'team') {
150
+ const [origin, ticket, spec] = process.argv.slice(3);
151
+ const room = ticketClaims(ticket)?.r;
152
+ if (!origin || !Number.isSafeInteger(room) || !spec) { console.error('usage: later.mjs team <origin> <ticket> \'{"channel":"…","model":"…","members":[{"name":"…","persona":"…"}]}\''); process.exit(2); }
153
+ const res = await fetch(new URL(`/collab/api/rooms/${room}/team`, origin), { method: 'POST',
154
+ headers: { 'content-type': 'application/json', 'x-later-ticket': ticket }, body: spec });
155
+ const body = await res.text();
156
+ if (!res.ok) { console.error(`team: ${res.status} ${body}`); process.exit(1); }
157
+ console.log(body);
137
158
  } else if (process.argv[1] === fileURLToPath(import.meta.url)) {
138
159
  const [origin, ticket, delay, title, todo] = process.argv.slice(2);
139
160
  const room = ticketClaims(ticket)?.r;
@@ -0,0 +1,159 @@
1
+ // #1020 回复的记忆来源:which pushed memory entries a reply used, shown as a badge under it.
2
+ // The gate pushes entries tagged [#编号] before each turn (bridge/contrib/gate/ctxsvc.py exp_tag) and the
3
+ // agent reports the ones it used with memvote.py (+编号), which appends a by="self" line to votes.jsonl
4
+ // ({t, voter: CC_PROJECT, id: "页#指纹", vote, by, key: CC_SESSION_KEY, agent}). This module tails that file
5
+ // and hands each new reply of a member the entries it cited in that room since its previous reply.
6
+ // Nothing is guessed: no citation, no badge (a member that never runs memvote gets none).
7
+ //
8
+ // Where the file is: MEMVOTE_DIR (memvote.py's own override), else <gate data dir>/gate, where bothouse
9
+ // installs the gate (bridge/core/gate_proc.go). The data dir is found the way the gate itself finds it
10
+ // (bridge/contrib/gate/datadir.py data_dir, Go brand.go dataDir): BOTHOUSE_DATA_DIR, GATE_SETUP_DIR,
11
+ // CC_DATA_DIR, else ~/.bothouse (~/.cc-connect only while that legacy layout is the one that exists).
12
+ // It must not depend on BOTHOUSE_DATA_DIR alone: a `dsh web` started by a script that reuses an old
13
+ // environment has only CC_DATA_DIR, and the badge used to vanish there silently.
14
+ // Page paths are relative to GATE_WIKI (~/wiki) or start with ~/ for a role wiki (memvote.full_id).
15
+ import { closeSync, existsSync, openSync, readSync, readFileSync, statSync } from 'node:fs';
16
+ import { createHash } from 'node:crypto';
17
+ import { homedir } from 'node:os';
18
+ import { join } from 'node:path';
19
+
20
+ const MAX_ITEMS = 12; // a badge lists at most this many entries
21
+ const TITLE_CHARS = 120;
22
+
23
+ /** `~/x` in a config value, as expanduser does for the gate (datadir.guard). */
24
+ const expand = (value, home) => String(value ?? '').trim().replace(/^~(?=\/|$)/, home);
25
+
26
+ /**
27
+ * The bothouse data dirs this machine may have, in the order the gate itself picks one
28
+ * (bridge/contrib/gate/datadir.py data_dir; Go brand.go dataDir for the last one).
29
+ * @param {NodeJS.ProcessEnv} env
30
+ * @param {string} home
31
+ * @returns {string[]}
32
+ */
33
+ export function dataDirCandidates(env = process.env, home = homedir()) {
34
+ const dirs = [];
35
+ for (const key of ['BOTHOUSE_DATA_DIR', 'GATE_SETUP_DIR', 'CC_DATA_DIR']) {
36
+ const dir = expand(env[key], home);
37
+ if (dir !== '') dirs.push(dir);
38
+ }
39
+ // ~/.cc-connect is the legacy layout, used only while ~/.bothouse does not exist and it does.
40
+ const preferred = join(home, '.bothouse');
41
+ const legacy = join(home, '.cc-connect');
42
+ dirs.push(!existsSync(preferred) && existsSync(legacy) ? legacy : preferred);
43
+ return dirs;
44
+ }
45
+
46
+ /**
47
+ * memvote.py's votes.jsonl: MEMVOTE_DIR when set (memvote's own override names the one file it writes),
48
+ * else <the gate's data dir>/gate. Same precedence as the gate reading its own directory, so a `dsh web`
49
+ * that inherited only CC_DATA_DIR reads the file the running gate actually appends to.
50
+ * @param {NodeJS.ProcessEnv} env
51
+ * @param {string} home
52
+ * @returns {string}
53
+ */
54
+ export function defaultVotesFile(env = process.env, home = homedir()) {
55
+ const dir = expand(env.MEMVOTE_DIR, home);
56
+ if (dir !== '') return join(dir, 'votes.jsonl');
57
+ return join(dataDirCandidates(env, home)[0], 'gate', 'votes.jsonl');
58
+ }
59
+
60
+ /** Room id a cite's session key names: bridge cloud-web "…room-<id>…", or the DSH gate key "dsh:<member>:<room>". */
61
+ export function keyRoom(key) {
62
+ const s = String(key ?? '');
63
+ const m = /(?:^|:)room-(\d+)(?::|$)/.exec(s) ?? /^dsh:[^:]+:(\d+)$/.exec(s);
64
+ return m ? Number(m[1]) : undefined;
65
+ }
66
+
67
+ export class MemorySources {
68
+ /** @param options - { file?: votes.jsonl path, wiki?, home?, logger? } */
69
+ constructor({ file = defaultVotesFile(), wiki = process.env.GATE_WIKI || join(homedir(), 'wiki'), home = homedir(), logger } = {}) {
70
+ this.file = file;
71
+ this.wiki = String(wiki).replace(/^~(?=\/|$)/, home);
72
+ this.home = home;
73
+ this.logger = logger;
74
+ // Say which file the badge reads, so a silent one is explained in the log instead of vanishing.
75
+ if (!this.file) this.logger?.warn?.('collab: memory sources: no votes.jsonl path (set BOTHOUSE_DATA_DIR, GATE_SETUP_DIR or CC_DATA_DIR, or MEMVOTE_DIR); replies carry no memory sources');
76
+ else if (existsSync(this.file)) this.logger?.info?.('collab: memory sources: reading %s', this.file);
77
+ else this.logger?.warn?.('collab: memory sources: %s does not exist yet (wrong data dir, or the gate has not recorded a cite); replies carry no memory sources until it appears', this.file);
78
+ // Only citations made after this process started belong to its replies.
79
+ this.offset = this.size();
80
+ this.rest = '';
81
+ /** `${voter}\0${room}` -> ordered unique entry ids cited and not yet shown. */
82
+ this.pending = new Map();
83
+ this.titles = new Map();
84
+ }
85
+
86
+ size() {
87
+ if (!this.file) return 0;
88
+ try { return statSync(this.file).size; } catch { return 0; }
89
+ }
90
+
91
+ /** Read the lines appended since the last call. */
92
+ poll() {
93
+ if (!this.file) return;
94
+ const size = this.size();
95
+ if (size < this.offset) { this.offset = 0; this.rest = ''; } // rotated or truncated
96
+ if (size === this.offset) return;
97
+ let fd;
98
+ try {
99
+ fd = openSync(this.file, 'r');
100
+ const buf = Buffer.alloc(size - this.offset);
101
+ readSync(fd, buf, 0, buf.length, this.offset);
102
+ this.offset = size;
103
+ const lines = (this.rest + buf.toString('utf8')).split('\n');
104
+ this.rest = lines.pop() ?? '';
105
+ for (const line of lines) this.take1(line);
106
+ } catch (error) {
107
+ this.logger?.warn?.('collab: memory sources: %s', error?.message ?? error);
108
+ } finally {
109
+ if (fd !== undefined) closeSync(fd);
110
+ }
111
+ }
112
+
113
+ take1(line) {
114
+ let r;
115
+ try { r = JSON.parse(line); } catch { return; }
116
+ // by="self" vote=1: the agent says it used this entry (memvote.py "+编号"); "-" (entry wrong) and "x" are not sources.
117
+ if (r?.by !== 'self' || r.vote !== 1 || typeof r.id !== 'string' || !r.id.includes('#')) return;
118
+ const room = keyRoom(r.key);
119
+ if (room === undefined || !r.voter) return;
120
+ const k = `${r.voter}\u0000${room}`;
121
+ const list = this.pending.get(k) ?? [];
122
+ if (!list.includes(r.id)) list.push(r.id);
123
+ this.pending.set(k, list);
124
+ }
125
+
126
+ /**
127
+ * The entries `voter` cited in `room` since its previous reply there, as [{ id, ref, page, title }]
128
+ * (ref = the short 编号 shown in the push), and forget them. [] when it cited none.
129
+ */
130
+ take(voter, room) {
131
+ this.poll();
132
+ const k = `${voter}\u0000${room}`;
133
+ const ids = this.pending.get(k);
134
+ if (!ids?.length) return [];
135
+ this.pending.delete(k);
136
+ return ids.slice(0, MAX_ITEMS).map(id => {
137
+ const [page, ref] = [id.slice(0, id.lastIndexOf('#')), id.slice(id.lastIndexOf('#') + 1)];
138
+ return { id, ref, page, title: this.title(page, ref) };
139
+ });
140
+ }
141
+
142
+ /** The wiki line an entry id points at (memvote.full_id: sha1 of the stripped line, first 10 hex), trimmed. */
143
+ title(page, ref) {
144
+ const key = `${page}#${ref}`;
145
+ if (this.titles.has(key)) return this.titles.get(key);
146
+ let out = '';
147
+ const path = page.startsWith('~/') ? join(this.home, page.slice(2)) : join(this.wiki, page);
148
+ try {
149
+ for (const raw of readFileSync(path, 'utf8').split('\n')) {
150
+ if (createHash('sha1').update(raw.trim()).digest('hex').slice(0, 10) !== ref) continue;
151
+ out = raw.trim().replace(/^[-*]\s+/, '').replace(/\s+/g, ' ');
152
+ break;
153
+ }
154
+ } catch { /* page moved or gone: the badge still shows its path */ }
155
+ if (out.length > TITLE_CHARS) out = out.slice(0, TITLE_CHARS - 1) + '…';
156
+ this.titles.set(key, out);
157
+ return out;
158
+ }
159
+ }