privateboard 0.1.12 → 0.1.13

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/dist/cli.js CHANGED
@@ -502,6 +502,14 @@ ALTER TABLE rooms ADD COLUMN vote_trigger TEXT NOT NULL DEFAULT 'auto';
502
502
  }
503
503
  });
504
504
 
505
+ // src/storage/migrations/033_room_members_removed_at.sql
506
+ var room_members_removed_at_default;
507
+ var init_room_members_removed_at = __esm({
508
+ "src/storage/migrations/033_room_members_removed_at.sql"() {
509
+ room_members_removed_at_default = "-- Soft-delete column on room_members so excused directors stay\n-- queryable for chat-history rendering + voice replay. NULL marks\n-- an active member; a non-null timestamp records when the chair\n-- excused them from the room. Prior to this migration a director\n-- removal hard-DELETEd the row, which dropped the director from\n-- `listRoomMembers` and broke speaker-name lookups + voice profile\n-- resolution for their past messages.\nALTER TABLE room_members ADD COLUMN removed_at INTEGER;\n";
510
+ }
511
+ });
512
+
505
513
  // src/storage/db.ts
506
514
  var db_exports = {};
507
515
  __export(db_exports, {
@@ -591,6 +599,7 @@ var init_db = __esm({
591
599
  init_minimax_region();
592
600
  init_agent_persona_spec();
593
601
  init_room_vote_trigger();
602
+ init_room_members_removed_at();
594
603
  MIGRATIONS = [
595
604
  { name: "001_init.sql", sql: init_default },
596
605
  { name: "002_default_opus.sql", sql: default_opus_default },
@@ -623,7 +632,8 @@ var init_db = __esm({
623
632
  { name: "029_web_search_provider_pref.sql", sql: web_search_provider_pref_default },
624
633
  { name: "030_minimax_region.sql", sql: minimax_region_default },
625
634
  { name: "031_agent_persona_spec.sql", sql: agent_persona_spec_default },
626
- { name: "032_room_vote_trigger.sql", sql: room_vote_trigger_default }
635
+ { name: "032_room_vote_trigger.sql", sql: room_vote_trigger_default },
636
+ { name: "033_room_members_removed_at.sql", sql: room_members_removed_at_default }
627
637
  ];
628
638
  _db = null;
629
639
  }
@@ -3598,11 +3608,11 @@ async function callLLMWithUsage(req) {
3598
3608
 
3599
3609
  // src/storage/reconcile-models.ts
3600
3610
  var PRIMARY_BY_CARRIER = {
3601
- openrouter: "opus-4-7",
3602
- anthropic: "sonnet-4-6",
3603
- openai: "gpt-5-5",
3604
- google: "gemini-3-flash",
3605
- xai: "grok-4-3"
3611
+ openrouter: "opus-4-6-fast",
3612
+ anthropic: "haiku-4-5",
3613
+ openai: "gpt-5-4-mini",
3614
+ google: "gemini-3-1-flash",
3615
+ xai: "grok-4-1-fast"
3606
3616
  };
3607
3617
  var CARRIER_PRIORITY = ["openrouter", "anthropic", "openai", "google", "xai"];
3608
3618
  function reachableModelVs() {
@@ -3650,23 +3660,21 @@ function activeCarrier() {
3650
3660
  }
3651
3661
  return null;
3652
3662
  }
3653
- function activeCarrierPrimary() {
3654
- const carrier = activeCarrier();
3655
- if (!carrier) return null;
3656
- return PRIMARY_BY_CARRIER[carrier] ?? null;
3657
- }
3658
3663
  function reconcileAgentModels(opts = {}) {
3659
3664
  const reachable = reachableModelVs();
3660
- const primary = activeCarrierPrimary();
3665
+ const carrier = activeCarrier();
3666
+ const primary = carrier ? PRIMARY_BY_CARRIER[carrier] ?? null : null;
3661
3667
  const forcePrimary = opts.forcePrimary === true;
3662
3668
  const switched = [];
3663
3669
  const cleared = [];
3664
3670
  for (const agent of listAllAgents()) {
3665
3671
  const v = (agent.modelV || "").trim();
3666
3672
  if (!forcePrimary && v && reachable.has(v)) continue;
3667
- if (primary) {
3668
- if (v === primary) continue;
3669
- updateAgent(agent.id, { modelV: primary });
3673
+ if (primary && carrier) {
3674
+ const isChair = agent.roleKind === "moderator";
3675
+ const target = isChair ? primary : pickRandomFastModel(carrier) ?? primary;
3676
+ if (v === target) continue;
3677
+ updateAgent(agent.id, { modelV: target });
3670
3678
  switched.push(agent.id);
3671
3679
  } else {
3672
3680
  if (v === "") continue;
@@ -3734,6 +3742,42 @@ var PROVIDER_FLAGSHIP = {
3734
3742
  minimax: null,
3735
3743
  elevenlabs: null
3736
3744
  };
3745
+ var PROVIDER_FAST = {
3746
+ anthropic: "haiku-4-5",
3747
+ openai: "gpt-5-4-mini",
3748
+ google: "gemini-3-1-flash",
3749
+ xai: "grok-4-1-fast",
3750
+ deepseek: "deepseek-v4-flash",
3751
+ openrouter: "opus-4-6-fast",
3752
+ brave: null,
3753
+ tavily: null,
3754
+ minimax: null,
3755
+ elevenlabs: null
3756
+ };
3757
+ var FAST_POOL_BY_CARRIER = {
3758
+ openrouter: [
3759
+ "opus-4-6-fast",
3760
+ "haiku-4-5",
3761
+ "gpt-5-4-mini",
3762
+ "gemini-3-flash",
3763
+ "gemini-3-1-flash",
3764
+ "grok-4-1-fast",
3765
+ "deepseek-v4-flash"
3766
+ ],
3767
+ anthropic: ["opus-4-6-fast", "haiku-4-5"],
3768
+ openai: ["gpt-5-4-mini"],
3769
+ google: ["gemini-3-flash", "gemini-3-1-flash"],
3770
+ xai: ["grok-4-1-fast"]
3771
+ };
3772
+ function pickRandomFastModel(carrier) {
3773
+ if (!carrier) return null;
3774
+ const pool = FAST_POOL_BY_CARRIER[carrier];
3775
+ if (!pool || pool.length === 0) return null;
3776
+ const reachable = new Set(reachableModels().map((m) => m.modelV));
3777
+ const candidates = pool.filter((v) => reachable.has(v));
3778
+ const list = candidates.length > 0 ? candidates : pool;
3779
+ return list[Math.floor(Math.random() * list.length)] ?? null;
3780
+ }
3737
3781
  var FLAGSHIP_TIER = /* @__PURE__ */ new Set([
3738
3782
  // Anthropic
3739
3783
  "opus-4-7",
@@ -3766,13 +3810,21 @@ function defaultModelFor(keys = getProviderKeyState()) {
3766
3810
  if (reachable.length === 0) return null;
3767
3811
  if (!keys.hasOpenRouter && keys.directProviders.size === 1) {
3768
3812
  const provider = Array.from(keys.directProviders)[0];
3813
+ const fast = PROVIDER_FAST[provider];
3814
+ if (fast && reachable.find((m) => m.modelV === fast)) return fast;
3769
3815
  const flagship = PROVIDER_FLAGSHIP[provider];
3770
3816
  if (flagship && reachable.find((m) => m.modelV === flagship)) return flagship;
3771
3817
  }
3772
3818
  if (keys.hasOpenRouter) {
3819
+ const fast = reachable.find((m) => m.modelV === "opus-4-6-fast");
3820
+ if (fast) return fast.modelV;
3773
3821
  const opus = reachable.find((m) => m.modelV === "opus-4-7");
3774
3822
  if (opus) return opus.modelV;
3775
3823
  }
3824
+ for (const provider of keys.directProviders) {
3825
+ const fast = PROVIDER_FAST[provider];
3826
+ if (fast && reachable.find((m) => m.modelV === fast)) return fast;
3827
+ }
3776
3828
  for (const provider of keys.directProviders) {
3777
3829
  const flagship = PROVIDER_FLAGSHIP[provider];
3778
3830
  if (flagship && reachable.find((m) => m.modelV === flagship)) return flagship;
@@ -6903,7 +6955,7 @@ function agentsRouter() {
6903
6955
  const base = handle.replace(/^\/+/, "");
6904
6956
  handle = uniqueHandle(base || slugifyHandle(name));
6905
6957
  }
6906
- const modelV = typeof b.modelV === "string" && isModelV(b.modelV) ? b.modelV : effectiveDefaultModel() ?? "opus-4-7";
6958
+ const modelV = typeof b.modelV === "string" && isModelV(b.modelV) ? b.modelV : pickRandomFastModel(activeCarrier()) ?? effectiveDefaultModel() ?? "opus-4-6-fast";
6907
6959
  const roleTag = typeof b.roleTag === "string" && b.roleTag.trim().length > 0 ? b.roleTag.trim().slice(0, 80) : "director";
6908
6960
  const bio = typeof b.bio === "string" && b.bio.trim().length >= BIO_MIN ? b.bio.trim().slice(0, BIO_MAX) : partial.description ? partial.description.slice(0, BIO_MAX) : `A custom director built via deep persona replication.`;
6909
6961
  const coverQuote = typeof b.coverQuote === "string" ? b.coverQuote.trim().slice(0, 220) : null;
@@ -12165,7 +12217,12 @@ function mapRow7(row) {
12165
12217
  };
12166
12218
  }
12167
12219
  function mapMember(row) {
12168
- return { agentId: row.agent_id, position: row.position, joinedAt: row.joined_at };
12220
+ return {
12221
+ agentId: row.agent_id,
12222
+ position: row.position,
12223
+ joinedAt: row.joined_at,
12224
+ removedAt: row.removed_at
12225
+ };
12169
12226
  }
12170
12227
  function listRooms() {
12171
12228
  const rows = getDb().prepare(`SELECT ${ROOM_COLS} FROM rooms ORDER BY created_at DESC`).all();
@@ -12177,7 +12234,13 @@ function getRoom(id) {
12177
12234
  }
12178
12235
  function listRoomMembers(roomId) {
12179
12236
  const rows = getDb().prepare(
12180
- "SELECT agent_id, position, joined_at FROM room_members WHERE room_id = ? ORDER BY position ASC"
12237
+ "SELECT agent_id, position, joined_at, removed_at FROM room_members WHERE room_id = ? AND removed_at IS NULL ORDER BY position ASC"
12238
+ ).all(roomId);
12239
+ return rows.map(mapMember);
12240
+ }
12241
+ function listAllRoomMembers(roomId) {
12242
+ const rows = getDb().prepare(
12243
+ "SELECT agent_id, position, joined_at, removed_at FROM room_members WHERE room_id = ? ORDER BY position ASC"
12181
12244
  ).all(roomId);
12182
12245
  return rows.map(mapMember);
12183
12246
  }
@@ -12252,18 +12315,26 @@ function setRoomStatus(roomId, status, ts = {}) {
12252
12315
  }
12253
12316
  function addRoomMember(roomId, agentId) {
12254
12317
  const db = getDb();
12255
- const existing = db.prepare("SELECT agent_id, position, joined_at FROM room_members WHERE room_id = ? AND agent_id = ?").get(roomId, agentId);
12256
- if (existing) return mapMember(existing);
12318
+ const existing = db.prepare("SELECT agent_id, position, joined_at, removed_at FROM room_members WHERE room_id = ? AND agent_id = ?").get(roomId, agentId);
12319
+ if (existing) {
12320
+ if (existing.removed_at !== null) {
12321
+ db.prepare("UPDATE room_members SET removed_at = NULL WHERE room_id = ? AND agent_id = ?").run(roomId, agentId);
12322
+ return { agentId, position: existing.position, joinedAt: existing.joined_at, removedAt: null };
12323
+ }
12324
+ return mapMember(existing);
12325
+ }
12257
12326
  const maxRow = db.prepare("SELECT COALESCE(MAX(position), -1) AS p FROM room_members WHERE room_id = ?").get(roomId);
12258
12327
  const position = maxRow.p + 1;
12259
12328
  const now = Date.now();
12260
12329
  db.prepare(
12261
- "INSERT INTO room_members (room_id, agent_id, position, joined_at) VALUES (?, ?, ?, ?)"
12330
+ "INSERT INTO room_members (room_id, agent_id, position, joined_at, removed_at) VALUES (?, ?, ?, ?, NULL)"
12262
12331
  ).run(roomId, agentId, position, now);
12263
- return { agentId, position, joinedAt: now };
12332
+ return { agentId, position, joinedAt: now, removedAt: null };
12264
12333
  }
12265
12334
  function removeRoomMember(roomId, agentId) {
12266
- const result = getDb().prepare("DELETE FROM room_members WHERE room_id = ? AND agent_id = ?").run(roomId, agentId);
12335
+ const result = getDb().prepare(
12336
+ "UPDATE room_members SET removed_at = ? WHERE room_id = ? AND agent_id = ? AND removed_at IS NULL"
12337
+ ).run(Date.now(), roomId, agentId);
12267
12338
  return result.changes > 0;
12268
12339
  }
12269
12340
  function setRoomIncognito(roomId, incognito) {
@@ -14920,7 +14991,7 @@ Does the chair need to ask a clarifying question before opening the room?`
14920
14991
  return { shouldAsk: ask, rationale };
14921
14992
  }
14922
14993
  async function pickRoundWrap(opts) {
14923
- const { history, roundNum, signal } = opts;
14994
+ const { history, roundNum, room, signal } = opts;
14924
14995
  const transcript = history.slice(-20).filter((m) => {
14925
14996
  if (!m.body || !m.body.trim()) return false;
14926
14997
  const meta = m.meta;
@@ -14961,7 +15032,12 @@ async function pickRoundWrap(opts) {
14961
15032
  "your phrasing across calls; don't lean on the same opener twice.",
14962
15033
  "",
14963
15034
  "Reply with STRICT JSON ONLY (no prose, no fences):",
14964
- '{ "recommendation": "end" | "continue", "rationale": "\u2264120 chars \xB7 one tight sentence on the load-bearing reason" }'
15035
+ '{ "recommendation": "end" | "continue", "rationale": "\u2264120 chars \xB7 one tight sentence on the load-bearing reason" }',
15036
+ // Target-language LANGUAGE LOCK · the rationale must be in the
15037
+ // room's working language so the round-prompt the chair posts
15038
+ // afterwards is consistent with the rest of a zh / en room.
15039
+ // Appended at the tail of the system prompt (recency bias).
15040
+ ...room ? [languageLockBlock(detectRoomLang(room))] : []
14965
15041
  ].join("\n")
14966
15042
  };
14967
15043
  const userMsg = {
@@ -15007,7 +15083,7 @@ async function pickRoundWrap(opts) {
15007
15083
  return { recommendation: rec, rationale };
15008
15084
  }
15009
15085
  async function pickNextSpeaker(opts) {
15010
- const { candidates, history, signal } = opts;
15086
+ const { candidates, history, room, signal } = opts;
15011
15087
  if (candidates.length < 2) return { agentId: null, rationale: "", intervention: null };
15012
15088
  const roster = candidates.map((a) => `- ${a.id} \xB7 ${a.name} (${a.handle}) \xB7 ${a.roleTag}
15013
15089
  ${a.bio}`).join("\n");
@@ -15073,12 +15149,24 @@ async function pickNextSpeaker(opts) {
15073
15149
  ' "agent_id": "<exact id from roster>" | null,',
15074
15150
  ' "rationale": "\u2264120 chars \xB7 why this lens fits next",',
15075
15151
  ' "intervention": "\u2264200 chars \xB7 the one-sentence note" | null',
15076
- "}"
15152
+ "}",
15153
+ // Target-language LANGUAGE LOCK · the intervention must match
15154
+ // the room's working language. Earlier "detect from transcript"
15155
+ // wording was unreliable in feedback-loop scenarios (one past
15156
+ // English director turn would re-bias the detector). Locked to
15157
+ // room.subject via the helper. Appended at the tail (recency).
15158
+ ...room ? [languageLockBlock(detectRoomLang(room))] : []
15077
15159
  ].join("\n")
15078
15160
  };
15079
15161
  const userMsg = {
15080
15162
  role: "user",
15081
15163
  content: [
15164
+ // Surface room.subject at the TOP of the user message so the
15165
+ // picker has the canonical language signal alongside the
15166
+ // candidate roster + transcript. Without this, the prompt's
15167
+ // only language signal was "recent transcript" — which a
15168
+ // single English chair drift could pollute.
15169
+ ...room?.subject ? [`Room subject: ${room.subject}`, ``] : [],
15082
15170
  `Candidates (queued, in current order):`,
15083
15171
  roster,
15084
15172
  ``,
@@ -15404,6 +15492,26 @@ function renderActiveSkillsBlock(used) {
15404
15492
  }
15405
15493
 
15406
15494
  // src/orchestrator/prompt.ts
15495
+ function detectRoomLang(room) {
15496
+ return /[一-鿿]/.test(room.subject || "") ? "zh" : "en";
15497
+ }
15498
+ function languageLockBlock(roomLang) {
15499
+ if (roomLang === "zh") {
15500
+ return [
15501
+ "",
15502
+ "\u2500\u2500\u2500 \u8BED\u8A00\u9501\u5B9A (LANGUAGE LOCK) \u2500\u2500\u2500",
15503
+ "\u672C\u5BF9\u8BDD\u7684\u5DE5\u4F5C\u8BED\u8A00\u5DF2\u9501\u5B9A\u4E3A\u3010\u4E2D\u6587\u3011\u3002",
15504
+ "\u4F60\u7684\u6240\u6709\u8F93\u51FA\u5FC5\u987B\u4F7F\u7528\u4E2D\u6587\u3002\u7981\u6B62\u4F7F\u7528\u82F1\u6587\u3002\u7981\u6B62\u4E2D\u82F1\u6DF7\u5408\u3002",
15505
+ "\u6B64\u89C4\u5219\u8986\u76D6\u6240\u6709\u4E0A\u6587 \u2014 \u5373\u4F7F\u672C\u63D0\u793A\u8BCD\u662F\u82F1\u6587\u5199\u7684\uFF0C\u4E5F\u5FC5\u987B\u7528\u4E2D\u6587\u56DE\u590D\u3002",
15506
+ "(This room's working language is LOCKED to Chinese. Your entire output MUST be in Chinese. No English, no mixed languages. This rule overrides everything above \u2014 even though this prompt is written in English, you MUST reply in Chinese.)"
15507
+ ].join("\n");
15508
+ }
15509
+ return [
15510
+ "",
15511
+ "\u2500\u2500\u2500 LANGUAGE LOCK \u2500\u2500\u2500",
15512
+ "This room's working language is LOCKED to English. Your entire output MUST be in English. No mixed languages."
15513
+ ].join("\n");
15514
+ }
15407
15515
  function buildFollowUpPriorContext(opts) {
15408
15516
  const { parentRoomNumber, parentRoomSubject, parentBrief, parentSignals, language } = opts;
15409
15517
  const isZh = language === "zh";
@@ -15908,15 +16016,25 @@ Name: ${prefs.name}
15908
16016
  `\xB7 When the user's most recent input is already in the room (visible above as a [${prefs.name || "You"}] turn), you may acknowledge it ONCE in the opening sweep \u2014 never again. On any later turn, do NOT open with "Since you asked \u2026" / "As you requested \u2026" / "\u65E2\u7136\u4F60\u8981\u6C42\u4E86 \u2026" / "\u6309\u4F60\u8BF4\u7684 \u2026" / "\u65E2\u7136\u4F60\u63D0\u51FA \u2026" / "\u4F60\u65E2\u7136\u8BA9\u6211 \u2026" or any rephrasing. The user's direction is absorbed context now; engage with the discussion, don't re-preface every turn \u2014 that loops. If you've already spoken once on this user input, your next turn must move PAST that acknowledgment.`,
15909
16017
  `\xB7 If you genuinely have NOTHING substantive to add this turn \u2014 the room has exhausted your angle, every point you'd make has already been made \u2014 return an EMPTY response (no text at all). Do NOT narrate your silence. Never output "\uFF08\u6C89\u9ED8\uFF09", "(silent)", "\u6211\u6CA1\u6709\u66F4\u591A\u8981\u8865\u5145\u7684", "I have nothing to add", "pass this round", "skip this turn", "abstain", or any variant. Those bubbles read as "the director gave up" and pollute the transcript; the system handles silent turns gracefully and moves the queue on. Return empty OR find one genuinely fresh angle (a different lens, a sharper edge case, a counter-frame, a missing trade-off) \u2014 never the meta-narration in between.`,
15910
16018
  `\xB7 The TONE and INTENSITY blocks above are the room's working agreement \u2014 they OVERRIDE ${toneOverrideTarget} The user explicitly opted into this register; staying in role is the helpful behaviour, not breaking it for trained politeness or trained adversariness.`,
15911
- // Persona reflection checklist · the ABSOLUTE LAST entry in
15912
- // the system prompt so it's the freshest context the model
15913
- // reads before generating. Empty string (no-op) for Signal-mode
15914
- // and seeded directors · zero per-turn cost. The checklist is
16019
+ // Persona reflection checklist · last persona-tuned entry in
16020
+ // the system prompt. Empty string (no-op) for Signal-mode and
16021
+ // seeded directors · zero per-turn cost. The checklist is
15915
16022
  // tuned per-persona by Phase 6 of the build pipeline · catches
15916
16023
  // failure modes specific to THIS director (e.g. "Am I
15917
16024
  // repeating @another_director's mechanism point?" for a
15918
16025
  // Historian).
15919
- renderPersonaReflectionBlock(speaker)
16026
+ renderPersonaReflectionBlock(speaker),
16027
+ // Target-language LANGUAGE LOCK · TRULY the last block in the
16028
+ // system prompt so it's the freshest signal in the LLM's
16029
+ // attention. Written in the room's working language (Chinese
16030
+ // for zh rooms, English for en rooms), which strongly biases
16031
+ // the LLM toward producing output in the matching language.
16032
+ // Replaces the weaker English-only "Reply in the SAME LANGUAGE"
16033
+ // rule earlier in this prompt as the load-bearing directive —
16034
+ // that rule sits above 30+ lines of HOUSE RULES + voice mode
16035
+ // copy, so by the time the LLM gets to generating it has been
16036
+ // long-decayed. See languageLockBlock at top of this file.
16037
+ languageLockBlock(detectRoomLang(room))
15920
16038
  ].join("\n")
15921
16039
  };
15922
16040
  const out = [system];
@@ -16015,7 +16133,15 @@ function buildChairSystem(opts, task) {
16015
16133
  `Inside those constraints, write **spoken table talk** \u2014 \u5927\u767D\u8BDD / natural conversational English: very short clauses, everyday connectors, sparse fillers. Avoid written-report register (\u7EFC\u4E0A\u6240\u8FF0 / \u9274\u4E8E\u6B64 / "It is worth noting\u2026"). Host lines should sound awake \u2014 not chapter-length.`
16016
16134
  ] : [],
16017
16135
  "",
16018
- task
16136
+ task,
16137
+ // Target-language LANGUAGE LOCK · APPENDED AT THE TAIL of every
16138
+ // chair system prompt so it's the freshest instruction in the
16139
+ // LLM's attention (recency bias). The earlier English LANGUAGE
16140
+ // block above describes detection logic; this tail block STATES
16141
+ // the result in the target language and forbids drift. Both
16142
+ // blocks are kept (defense in depth). See detectRoomLang /
16143
+ // languageLockBlock at top of this file.
16144
+ languageLockBlock(detectRoomLang(room))
16019
16145
  ].join("\n")
16020
16146
  };
16021
16147
  }
@@ -17363,7 +17489,12 @@ async function pumpQueue(roomId) {
17363
17489
  if (candidates.length >= 2) {
17364
17490
  try {
17365
17491
  emitChairPending(roomId, "next-speaker");
17366
- const pick = await pickNextSpeaker({ candidates, history: recent });
17492
+ const pickRoom = getRoom(roomId);
17493
+ const pick = await pickNextSpeaker({
17494
+ candidates,
17495
+ history: recent,
17496
+ room: pickRoom ?? void 0
17497
+ });
17367
17498
  const stillSameQueue = state.queue.length === queueSnapshot.length && state.queue.every((q, i) => q.agentId === queueSnapshot[i].agentId);
17368
17499
  if (stillSameQueue) {
17369
17500
  if (pick.agentId && pick.agentId !== state.queue[0].agentId) {
@@ -17576,7 +17707,7 @@ async function pumpQueue(roomId) {
17576
17707
  let recommendation;
17577
17708
  try {
17578
17709
  const recent = listRecentMessages(roomId, 30);
17579
- const wrap = await pickRoundWrap({ history: recent, roundNum: wrappedRound });
17710
+ const wrap = await pickRoundWrap({ history: recent, roundNum: wrappedRound, room });
17580
17711
  recommendation = { kind: wrap.recommendation, rationale: wrap.rationale };
17581
17712
  } catch (e) {
17582
17713
  rlog(roomId, "round-wrap-error", {
@@ -17999,13 +18130,19 @@ function appendSystemMessage(roomId, body) {
17999
18130
  function getRoomFullState(roomId) {
18000
18131
  const room = getRoom(roomId);
18001
18132
  if (!room) return null;
18002
- const memberRows = listRoomMembers(roomId);
18003
- const all = memberRows.map((m) => getAgent(m.agentId)).filter((a) => a !== null);
18004
- const members = all.filter((a) => a.roleKind === "director");
18005
- const chair = all.find((a) => a.roleKind === "moderator") ?? null;
18133
+ const allRows = listAllRoomMembers(roomId);
18134
+ const activeAgents = allRows.filter((m) => m.removedAt === null).map((m) => getAgent(m.agentId)).filter((a) => a !== null);
18135
+ const members = activeAgents.filter((a) => a.roleKind === "director");
18136
+ const chair = activeAgents.find((a) => a.roleKind === "moderator") ?? null;
18137
+ const historicalMembers = [];
18138
+ for (const m of allRows) {
18139
+ const a = getAgent(m.agentId);
18140
+ if (!a || a.roleKind !== "director") continue;
18141
+ historicalMembers.push({ ...a, joinedAt: m.joinedAt, removedAt: m.removedAt });
18142
+ }
18006
18143
  const messages = listRecentMessages(roomId, 200);
18007
18144
  const keyPoints = listKeyPointsForRoom(roomId);
18008
- return { room, members, chair, messages, keyPoints };
18145
+ return { room, members, historicalMembers, chair, messages, keyPoints };
18009
18146
  }
18010
18147
  function getRoomQueueSnapshot(roomId) {
18011
18148
  const s = _state.get(roomId);
@@ -18787,57 +18924,98 @@ async function emitChairAnnouncementVoice(roomId, messageId, body) {
18787
18924
  `);
18788
18925
  }
18789
18926
  }
18790
- var ROUND_OPENERS = [
18927
+ var ROUND_OPENERS_EN = [
18791
18928
  "Round done.",
18792
18929
  "That closes the round.",
18793
18930
  "End of round.",
18794
18931
  "Round wrapped."
18795
18932
  ];
18796
- var END_TAILS_WITH_RATIONALE = [
18933
+ var ROUND_OPENERS_ZH = [
18934
+ "\u672C\u8F6E\u7ED3\u675F\u3002",
18935
+ "\u8FD9\u4E00\u8F6E\u544A\u4E00\u6BB5\u843D\u3002",
18936
+ "\u521A\u624D\u8FD9\u4E00\u8F6E\u7ED3\u675F\u3002",
18937
+ "\u672C\u8F6E\u6536\u5C3E\u3002"
18938
+ ];
18939
+ var END_TAILS_WITH_RATIONALE_EN = [
18797
18940
  "Ready to file \u2014 or push once more.",
18798
18941
  "I'd wrap here. Another sweep is fair.",
18799
18942
  "Enough to file. Continue if there's more.",
18800
18943
  "File now, or run another round."
18801
18944
  ];
18802
- var END_TAILS_BARE = [
18945
+ var END_TAILS_WITH_RATIONALE_ZH = [
18946
+ "\u53EF\u4EE5\u5F52\u6863\u4E86 \u2014 \u6216\u8005\u518D\u6765\u4E00\u8F6E\u3002",
18947
+ "\u6211\u503E\u5411\u6536\u5C3E\uFF0C\u4F46\u518D\u8BA8\u8BBA\u4E00\u8F6E\u4E5F\u5408\u7406\u3002",
18948
+ "\u591F\u5F52\u6863\u4E86\u3002\u5982\u679C\u8FD8\u6709\u8981\u8865\u7684\u5C31\u7EE7\u7EED\u3002",
18949
+ "\u73B0\u5728\u5F52\u6863\uFF0C\u6216\u8005\u518D\u8BA8\u8BBA\u4E00\u8F6E\u3002"
18950
+ ];
18951
+ var END_TAILS_BARE_EN = [
18803
18952
  "Looks ready to file \u2014 or another sweep.",
18804
18953
  "Vote and wrap, or push for more.",
18805
18954
  "Ready to file. Continue if you want.",
18806
18955
  "Wrap here, or another round."
18807
18956
  ];
18808
- var CONTINUE_TAILS_WITH_RATIONALE = [
18957
+ var END_TAILS_BARE_ZH = [
18958
+ "\u770B\u6765\u53EF\u4EE5\u5F52\u6863\u4E86 \u2014 \u6216\u518D\u8BA8\u8BBA\u4E00\u8F6E\u3002",
18959
+ "\u6295\u7968\u6536\u5C3E\uFF0C\u6216\u7EE7\u7EED\u63A8\u8FDB\u3002",
18960
+ "\u53EF\u4EE5\u5F52\u6863\u4E86\u3002\u8981\u7EE7\u7EED\u5C31\u7EE7\u7EED\u3002",
18961
+ "\u8FD9\u91CC\u6536\u5C3E\uFF0C\u6216\u518D\u6765\u4E00\u8F6E\u3002"
18962
+ ];
18963
+ var CONTINUE_TAILS_WITH_RATIONALE_EN = [
18809
18964
  "Worth another pass \u2014 or call it.",
18810
18965
  "I'd push once more, or end here.",
18811
18966
  "One more sweep earns its keep \u2014 or wrap.",
18812
18967
  "Another round, or file now."
18813
18968
  ];
18814
- var CONTINUE_TAILS_BARE = [
18969
+ var CONTINUE_TAILS_WITH_RATIONALE_ZH = [
18970
+ "\u503C\u5F97\u518D\u8BA8\u8BBA\u4E00\u8F6E \u2014 \u6216\u8005\u5C31\u6B64\u6253\u4F4F\u3002",
18971
+ "\u6211\u503E\u5411\u518D\u63A8\u4E00\u8F6E\uFF0C\u6216\u8005\u5C31\u6B64\u7ED3\u675F\u3002",
18972
+ "\u518D\u8BA8\u8BBA\u4E00\u8F6E\u662F\u503C\u5F97\u7684 \u2014 \u6216\u8005\u6536\u5C3E\u3002",
18973
+ "\u518D\u6765\u4E00\u8F6E\uFF0C\u6216\u73B0\u5728\u5F52\u6863\u3002"
18974
+ ];
18975
+ var CONTINUE_TAILS_BARE_EN = [
18815
18976
  "Worth another pass \u2014 or call it.",
18816
18977
  "One more sweep, or wrap.",
18817
18978
  "Push another round, or end here.",
18818
18979
  "Another pass, or file now."
18819
18980
  ];
18820
- var NEUTRAL_TAILS = [
18981
+ var CONTINUE_TAILS_BARE_ZH = [
18982
+ "\u503C\u5F97\u518D\u8BA8\u8BBA\u4E00\u8F6E \u2014 \u6216\u5C31\u6B64\u6253\u4F4F\u3002",
18983
+ "\u518D\u8BA8\u8BBA\u4E00\u8F6E\uFF0C\u6216\u8005\u6536\u5C3E\u3002",
18984
+ "\u63A8\u8FDB\u4E0B\u4E00\u8F6E\uFF0C\u6216\u5728\u8FD9\u91CC\u7ED3\u675F\u3002",
18985
+ "\u518D\u6765\u4E00\u8F6E\uFF0C\u6216\u73B0\u5728\u5F52\u6863\u3002"
18986
+ ];
18987
+ var NEUTRAL_TAILS_EN = [
18821
18988
  "Vote a point, or roll on.",
18822
18989
  "Weight a point with a vote, or continue.",
18823
18990
  "Vote to bias the next round \u2014 or skip.",
18824
18991
  "Vote, or continue without one."
18825
18992
  ];
18993
+ var NEUTRAL_TAILS_ZH = [
18994
+ "\u4E3A\u5173\u952E\u70B9\u6295\u7968\uFF0C\u6216\u7EE7\u7EED\u3002",
18995
+ "\u7528\u6295\u7968\u7ED9\u67D0\u4E2A\u70B9\u52A0\u6743\uFF0C\u6216\u76F4\u63A5\u7EE7\u7EED\u3002",
18996
+ "\u6295\u7968\u5F71\u54CD\u4E0B\u4E00\u8F6E \u2014 \u6216\u8DF3\u8FC7\u3002",
18997
+ "\u6295\u7968\uFF0C\u6216\u4E0D\u6295\u7968\u76F4\u63A5\u7EE7\u7EED\u3002"
18998
+ ];
18826
18999
  var pickByRound = (arr, seed) => arr[(seed % arr.length + arr.length) % arr.length];
19000
+ function poolFor(en, zh, lang) {
19001
+ return lang === "zh" ? zh : en;
19002
+ }
18827
19003
  async function announceRoundPrompt(roomId, roundNum, recommendation) {
18828
19004
  const chair = getChairAgent();
18829
19005
  if (!chair) return;
18830
- const opener = pickByRound(ROUND_OPENERS, roundNum);
19006
+ const room = getRoom(roomId);
19007
+ const roomLang = detectRoomLang(room || {});
19008
+ const opener = pickByRound(poolFor(ROUND_OPENERS_EN, ROUND_OPENERS_ZH, roomLang), roundNum);
18831
19009
  let body;
18832
19010
  if (recommendation) {
18833
19011
  const rationale = recommendation.rationale.trim();
18834
19012
  if (recommendation.kind === "end") {
18835
- body = rationale ? `${opener} ${rationale} ${pickByRound(END_TAILS_WITH_RATIONALE, roundNum)}` : `${opener} ${pickByRound(END_TAILS_BARE, roundNum)}`;
19013
+ body = rationale ? `${opener} ${rationale} ${pickByRound(poolFor(END_TAILS_WITH_RATIONALE_EN, END_TAILS_WITH_RATIONALE_ZH, roomLang), roundNum)}` : `${opener} ${pickByRound(poolFor(END_TAILS_BARE_EN, END_TAILS_BARE_ZH, roomLang), roundNum)}`;
18836
19014
  } else {
18837
- body = rationale ? `${opener} ${rationale} ${pickByRound(CONTINUE_TAILS_WITH_RATIONALE, roundNum)}` : `${opener} ${pickByRound(CONTINUE_TAILS_BARE, roundNum)}`;
19015
+ body = rationale ? `${opener} ${rationale} ${pickByRound(poolFor(CONTINUE_TAILS_WITH_RATIONALE_EN, CONTINUE_TAILS_WITH_RATIONALE_ZH, roomLang), roundNum)}` : `${opener} ${pickByRound(poolFor(CONTINUE_TAILS_BARE_EN, CONTINUE_TAILS_BARE_ZH, roomLang), roundNum)}`;
18838
19016
  }
18839
19017
  } else {
18840
- body = `${opener} ${pickByRound(NEUTRAL_TAILS, roundNum)}`;
19018
+ body = `${opener} ${pickByRound(poolFor(NEUTRAL_TAILS_EN, NEUTRAL_TAILS_ZH, roomLang), roundNum)}`;
18841
19019
  }
18842
19020
  const m = insertMessage({
18843
19021
  roomId,
@@ -20676,7 +20854,7 @@ function voicesRouter() {
20676
20854
  init_paths();
20677
20855
 
20678
20856
  // src/version.ts
20679
- var VERSION = "0.1.12";
20857
+ var VERSION = "0.1.13";
20680
20858
 
20681
20859
  // src/server.ts
20682
20860
  function createApp() {