bothouse-dsh 0.1.43 → 0.1.44

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/client/client.js CHANGED
@@ -1,4 +1,4 @@
1
- // dsh-client-source-sha256: 79a9d7898c26fd8a4dbb904f053c9288ca8683a349abf407d1ba53a8ec300f85
1
+ // dsh-client-source-sha256: 51732729e2bd1e81bb4d9c84a793292ba99e9ca0bbed95f72393ce083fbaeadd
2
2
  // dsh-style-sha256: 8de64f320b70b31c6dba515e74187a98284317258d9cb57df3dc16f2eb345641
3
3
  window.__ModuleLoader__.load({
4
4
  id: "bothouse-dsh",
@@ -252,7 +252,8 @@ function createChannelSource(transport) {
252
252
  ...r,
253
253
  lead_id: event.lead_id,
254
254
  effective_lead_id: event.effective_lead_id,
255
- workspace_path: event.workspace_path
255
+ workspace_path: event.workspace_path,
256
+ max_relay: event.max_relay ?? r.max_relay
256
257
  } : r)
257
258
  };
258
259
  case "lead": return {
@@ -631,6 +632,15 @@ function createChannelSource(transport) {
631
632
  ...result
632
633
  } : r) });
633
634
  },
635
+ async setMaxRelay(roomId, maxRelay) {
636
+ const result = await request(`collab/api/rooms/${roomId}/settings`, json("PATCH", { max_relay: maxRelay }));
637
+ const chat = snapshot.rooms.find((r) => r.id === roomId)?.parent_id ?? roomId;
638
+ if (lifetime !== void 0) onEvent({
639
+ type: "room-settings",
640
+ room_id: chat,
641
+ ...result
642
+ });
643
+ },
634
644
  async setLead(id, roomId = snapshot.roomId) {
635
645
  if (roomId === void 0) throw new Error("No channel is open");
636
646
  const result = await request(`collab/api/rooms/${roomId}/settings`, json("PATCH", { lead_id: id }));
@@ -18272,6 +18282,8 @@ function MemberDialog({ member, members = NO_MEMBERS, listModels, listClis, list
18272
18282
  });
18273
18283
  }
18274
18284
  // src/client/ChannelSettingsDialog.tsx
18285
+ const RELAY_CAP_MIN = 6;
18286
+ const RELAY_CAP_MAX = 20;
18275
18287
  const tabs = [
18276
18288
  "basic",
18277
18289
  "dispatch",
@@ -18283,15 +18295,17 @@ const tabLabels = {
18283
18295
  workspace: "settingsWorkspace"
18284
18296
  };
18285
18297
  /** Drafts and save outcomes live for the whole dialog, independently of the selected tab. */
18286
- function ChannelSettingsDialog({ room, members, effectiveLead, renameRoom, setDispatchMode, setLead, setWorkspacePath, renewRoom, t, onClose }) {
18298
+ function ChannelSettingsDialog({ room, members, effectiveLead, renameRoom, setDispatchMode, setMaxRelay, setLead, setWorkspacePath, renewRoom, t, onClose }) {
18287
18299
  const [tab, setTab] = (0, react.useState)("basic");
18288
18300
  const [name, setName] = (0, react.useState)(room.name);
18289
18301
  const [mode, setMode] = (0, react.useState)(room.dispatch_mode ?? "mixed");
18302
+ const [relay, setRelay] = (0, react.useState)(String(room.max_relay ?? RELAY_CAP_MIN));
18290
18303
  const [lead, setLeadDraft] = (0, react.useState)(effectiveLead);
18291
18304
  const [directory, setDirectory] = (0, react.useState)(room.workspace_path ?? "");
18292
18305
  const [saved, setSaved] = (0, react.useState)({
18293
18306
  name: room.name,
18294
18307
  mode: room.dispatch_mode ?? "mixed",
18308
+ relay: String(room.max_relay ?? RELAY_CAP_MIN),
18295
18309
  lead: effectiveLead,
18296
18310
  directory: room.workspace_path ?? ""
18297
18311
  });
@@ -18304,9 +18318,10 @@ function ChannelSettingsDialog({ room, members, effectiveLead, renameRoom, setDi
18304
18318
  (0, react.useEffect)(() => {
18305
18319
  dialogRef.current?.focus();
18306
18320
  }, []);
18321
+ const relayValid = /^\d+$/.test(relay) && Number(relay) >= RELAY_CAP_MIN && Number(relay) <= RELAY_CAP_MAX;
18307
18322
  const dirty = {
18308
18323
  basic: room.kind !== "workspace" && name !== saved.name,
18309
- dispatch: mode !== saved.mode || lead !== saved.lead,
18324
+ dispatch: mode !== saved.mode || relay !== saved.relay || lead !== saved.lead,
18310
18325
  workspace: directory !== saved.directory
18311
18326
  };
18312
18327
  const close = () => {
@@ -18327,6 +18342,7 @@ function ChannelSettingsDialog({ room, members, effectiveLead, renameRoom, setDi
18327
18342
  const save = async (target) => {
18328
18343
  if (saving[target] || !dirty[target]) return;
18329
18344
  if (target === "basic" && (!name || name.length > 32 || /\s/.test(name))) return;
18345
+ if (target === "dispatch" && !relayValid) return;
18330
18346
  if (target === "workspace" && (!directory || renewing || renewPending)) return;
18331
18347
  setSaving((previous) => ({
18332
18348
  ...previous,
@@ -18355,6 +18371,13 @@ function ChannelSettingsDialog({ room, members, effectiveLead, renameRoom, setDi
18355
18371
  lead
18356
18372
  }));
18357
18373
  }
18374
+ if (relay !== saved.relay) {
18375
+ await setMaxRelay(room.id, Number(relay));
18376
+ setSaved((previous) => ({
18377
+ ...previous,
18378
+ relay
18379
+ }));
18380
+ }
18358
18381
  } else {
18359
18382
  await setWorkspacePath(room.id, directory);
18360
18383
  setSaved((previous) => ({
@@ -18473,51 +18496,75 @@ function ChannelSettingsDialog({ room, members, effectiveLead, renameRoom, setDi
18473
18496
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t(room.kind === "workspace" ? "workspaceNameFixed" : "channelNameHint") })
18474
18497
  ]
18475
18498
  }) }),
18476
- tab === "dispatch" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18477
- className: ChannelsPage_module_default.field,
18478
- role: "radiogroup",
18479
- "aria-label": t("dispatchMode"),
18480
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("dispatchMode") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18481
- className: ChannelsPage_module_default.choiceColumn,
18499
+ tab === "dispatch" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
18500
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18501
+ className: ChannelsPage_module_default.field,
18502
+ role: "radiogroup",
18503
+ "aria-label": t("dispatchMode"),
18504
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("dispatchMode") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18505
+ className: ChannelsPage_module_default.choiceColumn,
18506
+ children: [
18507
+ "solo",
18508
+ "team",
18509
+ "mixed"
18510
+ ].map((value) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
18511
+ className: ChannelsPage_module_default.choice,
18512
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
18513
+ type: "radio",
18514
+ name: "settings-dispatch-mode",
18515
+ disabled: saving.dispatch,
18516
+ checked: mode === value,
18517
+ onChange: () => {
18518
+ setMode(value);
18519
+ clearFeedback("dispatch");
18520
+ }
18521
+ }), t(value === "solo" ? "dispatchSolo" : value === "team" ? "dispatchTeam" : "dispatchMixed")]
18522
+ }, value))
18523
+ })]
18524
+ }),
18525
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
18526
+ className: ChannelsPage_module_default.field,
18527
+ children: [t("channelLead"), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
18528
+ className: ChannelsPage_module_default.fieldInput,
18529
+ "aria-label": t("channelLead"),
18530
+ value: lead,
18531
+ disabled: saving.dispatch,
18532
+ onChange: (event) => {
18533
+ setLeadDraft(event.target.value);
18534
+ clearFeedback("dispatch");
18535
+ },
18536
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
18537
+ value: "",
18538
+ disabled: true,
18539
+ children: t("leadNone")
18540
+ }), members.filter((member) => member.kind !== "human").map((member) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
18541
+ value: member.id,
18542
+ children: member.name
18543
+ }, member.id))]
18544
+ })]
18545
+ }),
18546
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
18547
+ className: ChannelsPage_module_default.field,
18482
18548
  children: [
18483
- "solo",
18484
- "team",
18485
- "mixed"
18486
- ].map((value) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
18487
- className: ChannelsPage_module_default.choice,
18488
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
18489
- type: "radio",
18490
- name: "settings-dispatch-mode",
18549
+ t("settingsRelay"),
18550
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
18551
+ className: ChannelsPage_module_default.fieldInput,
18552
+ type: "number",
18553
+ min: RELAY_CAP_MIN,
18554
+ max: RELAY_CAP_MAX,
18555
+ step: 1,
18556
+ "aria-label": t("settingsRelay"),
18557
+ value: relay,
18491
18558
  disabled: saving.dispatch,
18492
- checked: mode === value,
18493
- onChange: () => {
18494
- setMode(value);
18559
+ onChange: (event) => {
18560
+ setRelay(event.target.value);
18495
18561
  clearFeedback("dispatch");
18496
18562
  }
18497
- }), t(value === "solo" ? "dispatchSolo" : value === "team" ? "dispatchTeam" : "dispatchMixed")]
18498
- }, value))
18499
- })]
18500
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
18501
- className: ChannelsPage_module_default.field,
18502
- children: [t("channelLead"), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
18503
- className: ChannelsPage_module_default.fieldInput,
18504
- "aria-label": t("channelLead"),
18505
- value: lead,
18506
- disabled: saving.dispatch,
18507
- onChange: (event) => {
18508
- setLeadDraft(event.target.value);
18509
- clearFeedback("dispatch");
18510
- },
18511
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
18512
- value: "",
18513
- disabled: true,
18514
- children: t("leadNone")
18515
- }), members.filter((member) => member.kind !== "human").map((member) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
18516
- value: member.id,
18517
- children: member.name
18518
- }, member.id))]
18519
- })]
18520
- })] }),
18563
+ }),
18564
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("settingsRelayHint") })
18565
+ ]
18566
+ })
18567
+ ] }),
18521
18568
  tab === "workspace" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
18522
18569
  className: ChannelsPage_module_default.field,
18523
18570
  children: [
@@ -18604,7 +18651,7 @@ function ChannelSettingsDialog({ room, members, effectiveLead, renameRoom, setDi
18604
18651
  form: "channel-settings-form",
18605
18652
  "aria-busy": Boolean(saving[tab]),
18606
18653
  className: ChannelsPage_module_default.primaryButton,
18607
- disabled: saving[tab] || !dirty[tab] || tab === "basic" && (!name || name.length > 32 || /\s/.test(name)) || tab === "workspace" && (!directory || renewing || renewPending),
18654
+ disabled: saving[tab] || !dirty[tab] || tab === "basic" && (!name || name.length > 32 || /\s/.test(name)) || tab === "dispatch" && !relayValid || tab === "workspace" && (!directory || renewing || renewPending),
18608
18655
  children: t(saving[tab] ? "settingsSaving" : "messageSave")
18609
18656
  })]
18610
18657
  })
@@ -19052,7 +19099,7 @@ function MemberGroup({ title, members, busy, onPick, onEdit, onRemove, t }) {
19052
19099
  * @param props - framework, injected, and locale shares.
19053
19100
  * @returns the three-column channel view.
19054
19101
  */
19055
- function ChannelsPage({ useChannels, selectRoom, send, reviewTask, createRoom, renameRoom, listModels, listClis, listPersonas, addMember, updateMember, removeMember, setLead, setWorkspacePath, renewRoom, setDispatchMode, decidePermission, retryMessage, editMessage, withdrawMessage, openModelSettings, t }) {
19102
+ function ChannelsPage({ useChannels, selectRoom, send, reviewTask, createRoom, renameRoom, listModels, listClis, listPersonas, addMember, updateMember, removeMember, setLead, setWorkspacePath, renewRoom, setDispatchMode, setMaxRelay, decidePermission, retryMessage, editMessage, withdrawMessage, openModelSettings, t }) {
19056
19103
  const state = useChannels((snapshot) => snapshot);
19057
19104
  const [editing, setEditing] = (0, react.useState)(void 0);
19058
19105
  const [ownNotice, setOwnNotice] = (0, react.useState)(void 0);
@@ -19900,6 +19947,7 @@ function ChannelsPage({ useChannels, selectRoom, send, reviewTask, createRoom, r
19900
19947
  effectiveLead: room.effective_lead_id === void 0 ? state.effectiveLeadId ?? state.leadId ?? "" : room.effective_lead_id ?? "",
19901
19948
  renameRoom,
19902
19949
  setDispatchMode,
19950
+ setMaxRelay,
19903
19951
  setLead,
19904
19952
  setWorkspacePath,
19905
19953
  renewRoom,
@@ -19968,6 +20016,8 @@ const zh = {
19968
20016
  settingsSaved: "已保存",
19969
20017
  settingsSaving: "保存中…",
19970
20018
  settingsDiscardConfirm: "还有未保存的改动。关闭将丢弃这些改动,确定关闭吗?",
20019
+ settingsRelay: "接力上限",
20020
+ settingsRelayHint: "Agent 连续接力达到上限后暂停自动接力,等人来 @。6–20 的整数,默认 6。",
19971
20021
  workingDirectory: "工作目录",
19972
20022
  workingDirectoryHint: "请输入已存在的绝对目录。只影响写入范围和相对路径,不影响读取;保存后下次新建会话生效。工作区继承所属频道。",
19973
20023
  renewConfirm: "已更新工作目录,是否立即为该频道重建会话?重建会清空当前对话上下文。",
@@ -20218,6 +20268,8 @@ const en = {
20218
20268
  settingsSaved: "Saved",
20219
20269
  settingsSaving: "Saving…",
20220
20270
  settingsDiscardConfirm: "You have unsaved changes. Closing will discard them. Close anyway?",
20271
+ settingsRelay: "Relay cap",
20272
+ settingsRelayHint: "Agent-to-agent relays stop at this cap until a person @-mentions again. Integer 6–20, default 6.",
20221
20273
  workingDirectory: "Working directory",
20222
20274
  workingDirectoryHint: "Enter an existing absolute directory. This affects write scope and relative paths, not reads. It applies to new sessions; workspaces inherit their channel.",
20223
20275
  renewConfirm: "Working directory updated. Recreate this channel’s sessions now? This clears the current conversation context.",
@@ -20400,6 +20452,7 @@ function apply(ctx) {
20400
20452
  updateMember: channels.actions.updateMember,
20401
20453
  removeMember: channels.actions.removeMember,
20402
20454
  setDispatchMode: channels.actions.setDispatchMode,
20455
+ setMaxRelay: channels.actions.setMaxRelay,
20403
20456
  setLead: channels.actions.setLead,
20404
20457
  setWorkspacePath: channels.actions.setWorkspacePath,
20405
20458
  renewRoom: channels.actions.renewRoom,
@@ -510,10 +510,12 @@ export class DshMembers {
510
510
  for (const id of targets) this.enqueue({ member_id: id, room_id: msg.room_id }, () => this.renew(id, msg));
511
511
  };
512
512
  }
513
- if (msg.author_kind === 'role' && this.store.relayDepth(msg.room_id, msg.id) > this.maxRelay) {
513
+ // #445: a channel's saved cap wins; otherwise the instance default (this.maxRelay).
514
+ const cap = this.store.savedMaxRelay?.(msg.room_id) ?? this.maxRelay;
515
+ if (msg.author_kind === 'role' && this.store.relayDepth(msg.room_id, msg.id) > cap) {
514
516
  return () => this.hub.post(msg.room_id, {
515
517
  authorKind: 'system', author: 'system', name: 'system', clientKey: `relay-cap-${msg.id}`,
516
- text: `Agent 之间已经连续接力 ${this.maxRelay} 次以上,暂停自动接力;需要继续请人来 @。`,
518
+ text: `Agent 之间已经连续接力 ${cap} 次以上,暂停自动接力;需要继续请人来 @。`,
517
519
  });
518
520
  }
519
521
  const jobs = targets.map(id => this.store.addJob(id, msg.room_id, msg.id)).filter(Boolean);
@@ -79,6 +79,37 @@ function run(c, lines, lead, key = 'msg') {
79
79
  const rx = new RegExp(`^${escape(c.field)}\\s*[::]\\s*\\S`);
80
80
  return lines.some(l => rx.test(stripLead(l, lead))) ? null : c[key];
81
81
  }
82
+ case 'art_ref': {
83
+ const trig = new RegExp(c.trigger_re);
84
+ if (!c.trigger_sections.some(s => sections(lines, s, lead).some(seg => seg.some(x => trig.test(x))))) return null;
85
+ // The scope changes code files: work on image-related code, not image work.
86
+ const code = new RegExp(c.code_line_re);
87
+ if (sections(lines, c.code_section, lead).some(seg => seg.slice(1).some(x => code.test(stripLead(x, lead))))) return null;
88
+ const refs = new Set(sections(lines, c.section, lead, Boolean(c.inline))
89
+ .flatMap(seg => seg.flatMap(x => [...x.matchAll(new RegExp(c.image_re, 'g'))].map(m => m[0]))));
90
+ if (refs.size < c.min) return c.msg.replaceAll('{min}', String(c.min)).replaceAll('{n}', String(refs.size));
91
+ const all = lines.join('\n');
92
+ if (new RegExp(c.original_re).test(all) && !new RegExp(c.sample_re).test(all)) return c.original_msg;
93
+ return null;
94
+ }
95
+ case 'section_needs_table': {
96
+ const body = sections(lines, c.trigger_section, lead).flatMap(seg => seg.slice(1)).join('\n');
97
+ if (!(new RegExp(c.trigger_re).test(body) && new RegExp(c.object_re).test(body))) return null;
98
+ const row = new RegExp(c.row_re);
99
+ for (const seg of sections(lines, c.section, lead)) {
100
+ const rest = seg.slice(1), text = rest.join('\n');
101
+ if (rest.filter(x => row.test(stripLead(x, lead))).length >= c.rows && c.need_res.every(r => new RegExp(r).test(text))) return null;
102
+ }
103
+ return c[key];
104
+ }
105
+ case 'forbid': {
106
+ const rx = new RegExp(c.re);
107
+ return lines.some(x => rx.test(x)) ? c[key] : null;
108
+ }
109
+ case 'needs_if': {
110
+ const text = lines.join('\n');
111
+ return new RegExp(c.trigger_re).test(text) && !new RegExp(c.need_re).test(text) ? c[key] : null;
112
+ }
82
113
  default: throw new HandoffRulesError(`交接规则文件里有不认识的检查类型:${c.type}`);
83
114
  }
84
115
  }
package/collab/plugin.mjs CHANGED
@@ -102,7 +102,8 @@ export async function apply(ctx, rawConfig) {
102
102
  const { WebSocketServer } = require('ws');
103
103
  if (typeof createUserMessage !== 'function' || typeof WebSocketServer !== 'function') throw Error('conest-collab: unsupported DSH runtime');
104
104
 
105
- const store = new Store(join(config.dataDir, 'collab.sqlite3'));
105
+ // #445: the instance maxRelay stays the default for channels that never set their own cap.
106
+ const store = new Store(join(config.dataDir, 'collab.sqlite3'), { maxRelay: config.maxRelay });
106
107
  for (const room of config.rooms) {
107
108
  store.ensureRoom(room.id, store.room(room.id)?.name ?? room.name.trim(), room.workspacePath);
108
109
  store.ensureRoomSettings(room.id);
@@ -400,7 +401,7 @@ export function roomSettingsRoute(req, res, path, { store, hub, dsh, workspacePa
400
401
  const roomId = Number(match[1]);
401
402
  if (store.room(roomId) === undefined) return Promise.resolve(json(res, 404, { error: '频道不存在' }));
402
403
  const view = () => ({ lead_id: store.leadId(roomId), effective_lead_id: hub.primaryIn(roomId)?.id ?? null,
403
- workspace_path: store.workspacePath(roomId, workspacePath) });
404
+ workspace_path: store.workspacePath(roomId, workspacePath), max_relay: store.relaySetting(roomId).max_relay });
404
405
  if (match[2] === 'renew' && req.method === 'POST') {
405
406
  return withBody(req, res, async () => ({ members: await dsh.renewRoom(roomId) }), false);
406
407
  }
@@ -421,6 +422,8 @@ export function roomSettingsRoute(req, res, path, { store, hub, dsh, workspacePa
421
422
  store.transaction(() => {
422
423
  if (body?.lead_id !== undefined) store.setLead(roomId, body.lead_id);
423
424
  if (body?.workspace_path !== undefined) store.setWorkspacePath(roomId, body.workspace_path, workspacePath);
425
+ // #445: 6–20 only; an invalid value rolls the whole PATCH back.
426
+ if (body?.max_relay !== undefined) store.setMaxRelay(roomId, body.max_relay);
424
427
  });
425
428
  hub.emit({ type: 'room-settings', room_id: store.settingsRoom(roomId), ...view() });
426
429
  return view();
package/collab/store.mjs CHANGED
@@ -10,6 +10,11 @@ export const MAX_TEXT = 128 * 1024;
10
10
  // 「默认混合模型」); the same names as the bridge's dispatch_mode config.
11
11
  export const DISPATCH_MODES = ['solo', 'team', 'mixed'];
12
12
  export const DEFAULT_DISPATCH_MODE = 'mixed';
13
+ // #445: a chat channel may set its own relay cap; the Issue's acceptance fixes the range
14
+ // (5/21 rejected, 6/20 accepted) and the default (6).
15
+ export const MIN_CHANNEL_MAX_RELAY = 6;
16
+ export const MAX_CHANNEL_MAX_RELAY = 20;
17
+ export const DEFAULT_CHANNEL_MAX_RELAY = 6;
13
18
  // v7 is #133 (reply_to), v8 is #139 team mode A, v9 is #139 team mode B tasks, v10 is #177
14
19
  // (room_settings.dispatch_explicit); columns are added by ensureSchema() on every open
15
20
  // (checked with PRAGMA table_info), so either merge or release order ends with every column;
@@ -66,7 +71,13 @@ export const WORKSPACE_ID_MIN = 1000000;
66
71
  export const QUOTE_CHARS = 40;
67
72
 
68
73
  export class Store {
69
- constructor(path) {
74
+ /**
75
+ * @param path - SQLite file, or ':memory:'.
76
+ * @param options - { maxRelay? } instance default for channels that never set one (#445);
77
+ * kept configurable so instances that run a tighter loop keep working.
78
+ */
79
+ constructor(path, { maxRelay = DEFAULT_CHANNEL_MAX_RELAY } = {}) {
80
+ this.relayDefault = maxRelay;
70
81
  if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true });
71
82
  this.db = new DatabaseSync(path);
72
83
  this.closed = false;
@@ -188,6 +199,8 @@ export class Store {
188
199
  }
189
200
  if (!settingColumns.some(c => c.name === 'lead_id')) this.db.exec('ALTER TABLE room_settings ADD COLUMN lead_id TEXT');
190
201
  if (!settingColumns.some(c => c.name === 'workspace_path')) this.db.exec('ALTER TABLE room_settings ADD COLUMN workspace_path TEXT');
202
+ // #445: NULL means the channel never set a cap, so the instance default applies.
203
+ if (!settingColumns.some(c => c.name === 'max_relay')) this.db.exec('ALTER TABLE room_settings ADD COLUMN max_relay INTEGER');
191
204
  this.db.exec('DROP TABLE IF EXISTS collab_settings');
192
205
  if (version < SCHEMA_VERSION) this.db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
193
206
  });
@@ -540,6 +553,28 @@ export class Store {
540
553
  return mode;
541
554
  }
542
555
 
556
+ /** A channel's saved relay cap (a workspace reads its chat channel); null when it never set one (#445). */
557
+ savedMaxRelay(roomId) {
558
+ const row = this.db.prepare('SELECT max_relay FROM room_settings WHERE room_id = ?').get(this.settingsRoom(roomId));
559
+ return row?.max_relay ?? null;
560
+ }
561
+
562
+ /** The setting the client shows: the saved cap, else the instance default (#445). */
563
+ relaySetting(roomId) {
564
+ return { max_relay: this.savedMaxRelay(roomId) ?? this.relayDefault };
565
+ }
566
+
567
+ /** Set a chat channel's relay cap; only 6–20 (Issue #445), stored on the channel so its workspaces share it. */
568
+ setMaxRelay(roomId, value) {
569
+ if (!Number.isInteger(value) || value < MIN_CHANNEL_MAX_RELAY || value > MAX_CHANNEL_MAX_RELAY) {
570
+ throw new CollabError('invalid', `接力上限必须是 ${MIN_CHANNEL_MAX_RELAY}–${MAX_CHANNEL_MAX_RELAY} 的整数`);
571
+ }
572
+ roomId = this.settingsRoom(roomId); // set on a workspace = set on its chat channel
573
+ this.ensureRoomSettings(roomId);
574
+ this.db.prepare('UPDATE room_settings SET max_relay = ? WHERE room_id = ?').run(value, roomId);
575
+ return value;
576
+ }
577
+
543
578
  /** Keep legacy members visible in all channels, including future ones. */
544
579
  migrateMemberRooms() {
545
580
  return this.db.prepare("UPDATE members SET rooms = '[]' WHERE rooms IS NULL").run().changes > 0;
@@ -605,7 +640,7 @@ export class Store {
605
640
  this.releaseIdleWorkspaces();
606
641
  const counts = new Map(this.db.prepare(`SELECT room_id, COUNT(*) AS n FROM tasks
607
642
  WHERE status IN ('assigned', 'accepted', 'asked', 'rework') GROUP BY room_id`).all().map(t => [t.room_id, t.n]));
608
- return this.rooms().map(r => ({ ...r, active: counts.get(r.id) ?? 0, ...this.dispatchSetting(r.id) }));
643
+ return this.rooms().map(r => ({ ...r, active: counts.get(r.id) ?? 0, ...this.dispatchSetting(r.id), ...this.relaySetting(r.id) }));
609
644
  }
610
645
 
611
646
  /**
@@ -17,6 +17,7 @@ const SELF_CHECK = new RegExp(RULES.ask.self_check_re);
17
17
  const CATEGORY = new RegExp(RULES.ask.category_re);
18
18
  const EXEMPT = new RegExp(RULES.ask.exempt_re);
19
19
  const NO_CHECK = new RegExp(RULES.ask.no_check_re);
20
+ const JARGON_URL = new RegExp(RULES.jargon.url_re, 'g');
20
21
  const JARGON_LINE = new RegExp(RULES.jargon.line_re, 'g');
21
22
  const JARGON_TABLE = new RegExp(RULES.jargon.table_re);
22
23
  const BACKGROUND = new RegExp(RULES.summary.background_re);
@@ -102,7 +103,7 @@ export function summaryProblems(summary) {
102
103
  /** The part for the user names numbers, workspaces, branches, paths or identifiers, or has a table (Go: jargonProblems). */
103
104
  export function jargonProblems(line) {
104
105
  const problems = [];
105
- const hits = [...String(line).matchAll(JARGON_LINE)].map(m => m[0].replace(/^[^~/`#!_\-0-9A-Za-z#!工]+/u, ''));
106
+ const hits = [...String(line).replace(JARGON_URL, ' ').matchAll(JARGON_LINE)].map(m => m[0].replace(/^[^~/`#!_\-0-9A-Za-z#!工]+/u, ''));
106
107
  if (hits.length) problems.push(RULES.jargon.line.replaceAll('{hits}', hits.join('、')));
107
108
  if (JARGON_TABLE.test(line)) problems.push(RULES.jargon.table);
108
109
  return problems;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bothouse-dsh",
3
- "version": "0.1.43",
3
+ "version": "0.1.44",
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",