privateboard 0.1.32 → 0.1.37

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/boot.js CHANGED
@@ -2636,7 +2636,13 @@ var MODELS = {
2636
2636
  baiId: "claude-opus-4.7",
2637
2637
  displayName: "Opus 4.7",
2638
2638
  contextBudget: 2e5,
2639
- deck: "deep reasoning"
2639
+ deck: "deep reasoning",
2640
+ // Anthropic dropped `temperature` for the 4.7 family · sending it
2641
+ // returns HTTP 400 "temperature is deprecated for this model"
2642
+ // across every carrier (direct / OR / B.AI all proxy to the same
2643
+ // upstream). The adapter omits temperature for any model with
2644
+ // this flag.
2645
+ noTemperature: true
2640
2646
  },
2641
2647
  "opus-4-6-fast": {
2642
2648
  v: "opus-4-6-fast",
@@ -2848,6 +2854,16 @@ function getModel(v) {
2848
2854
  function isModelV(v) {
2849
2855
  return Object.hasOwn(MODELS, v);
2850
2856
  }
2857
+ function noTemperatureModelIds() {
2858
+ const ids = /* @__PURE__ */ new Set();
2859
+ for (const m of Object.values(MODELS)) {
2860
+ if (!m.noTemperature) continue;
2861
+ if (m.directApiId) ids.add(m.directApiId);
2862
+ if (m.openrouterId) ids.add(m.openrouterId);
2863
+ if (m.baiId) ids.add(m.baiId);
2864
+ }
2865
+ return ids;
2866
+ }
2851
2867
 
2852
2868
  // src/routes/agents.ts
2853
2869
  init_persona_jobs();
@@ -4040,21 +4056,47 @@ function redactHeaderValue(name, value) {
4040
4056
  const tail = v.slice(-4);
4041
4057
  return tail ? `****${tail}` : "****";
4042
4058
  }
4059
+ var NO_TEMP_IDS_CACHE = null;
4060
+ function noTempIds() {
4061
+ if (!NO_TEMP_IDS_CACHE) NO_TEMP_IDS_CACHE = noTemperatureModelIds();
4062
+ return NO_TEMP_IDS_CACHE;
4063
+ }
4064
+ function stripTemperatureForNoTempModels(rawBody) {
4065
+ try {
4066
+ const parsed = JSON.parse(rawBody);
4067
+ const modelId = typeof parsed.model === "string" ? parsed.model : null;
4068
+ if (modelId && noTempIds().has(modelId) && "temperature" in parsed) {
4069
+ delete parsed.temperature;
4070
+ return { body: JSON.stringify(parsed), stripped: true };
4071
+ }
4072
+ } catch {
4073
+ }
4074
+ return { body: rawBody, stripped: false };
4075
+ }
4043
4076
  function makeLoggedFetch(tag) {
4044
4077
  return function loggedFetch2(input, init) {
4078
+ let effectiveInit = init;
4079
+ let stripNote = "";
4080
+ if (init?.body && typeof init.body === "string") {
4081
+ const r = stripTemperatureForNoTempModels(init.body);
4082
+ if (r.stripped) {
4083
+ effectiveInit = { ...init, body: r.body };
4084
+ stripNote = ` \xB7 stripped temperature (noTemperature model)`;
4085
+ }
4086
+ }
4045
4087
  const url = typeof input === "string" || input instanceof URL ? String(input) : input.url;
4046
- const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
4047
- const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : void 0));
4088
+ const method = (effectiveInit?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
4089
+ const headers = new Headers(effectiveInit?.headers ?? (input instanceof Request ? input.headers : void 0));
4048
4090
  const headerLines = [];
4049
4091
  headers.forEach((v, k) => {
4050
4092
  headerLines.push(` ${k}: ${redactHeaderValue(k, v)}`);
4051
4093
  });
4052
4094
  let bodyPretty = "";
4053
- if (init?.body && typeof init.body === "string") {
4095
+ if (effectiveInit?.body && typeof effectiveInit.body === "string") {
4054
4096
  try {
4055
- bodyPretty = JSON.stringify(JSON.parse(init.body), null, 2);
4097
+ bodyPretty = JSON.stringify(JSON.parse(effectiveInit.body), null, 2);
4056
4098
  } catch {
4057
- bodyPretty = init.body.length > 2e3 ? init.body.slice(0, 2e3) + "\u2026" : init.body;
4099
+ bodyPretty = effectiveInit.body.length > 2e3 ? effectiveInit.body.slice(0, 2e3) + "\u2026" : effectiveInit.body;
4058
4100
  }
4059
4101
  }
4060
4102
  const sep = "\u2500".repeat(60);
@@ -4067,14 +4109,14 @@ function makeLoggedFetch(tag) {
4067
4109
  process.stderr.write(
4068
4110
  `
4069
4111
  \u250C${sep}
4070
- \u2502 [${tag} \u2192] ${method} ${url}
4112
+ \u2502 [${tag} \u2192] ${method} ${url}${stripNote}
4071
4113
  ` + headerBlock + `
4072
4114
  ` + bodyBlock + `
4073
4115
  \u2514${sep}
4074
4116
  `
4075
4117
  );
4076
4118
  const t0 = Date.now();
4077
- return fetch(input, init).then(async (res) => {
4119
+ return fetch(input, effectiveInit).then(async (res) => {
4078
4120
  const ms = Date.now() - t0;
4079
4121
  const resHeaderLines = [];
4080
4122
  res.headers.forEach((v, k) => resHeaderLines.push(` ${k}: ${v}`));
@@ -4335,6 +4377,7 @@ async function* callLLMStream(req) {
4335
4377
  yield { type: "error", message: formatStreamError(e) };
4336
4378
  return;
4337
4379
  }
4380
+ const temperature = getModel(req.modelV).noTemperature ? void 0 : req.temperature;
4338
4381
  let attempt = 0;
4339
4382
  let lastTransientMessage = "";
4340
4383
  let yieldedText = false;
@@ -4360,7 +4403,7 @@ async function* callLLMStream(req) {
4360
4403
  model: resolved.model,
4361
4404
  providerOptions: resolved.providerOptions,
4362
4405
  messages: req.messages,
4363
- temperature: req.temperature,
4406
+ temperature,
4364
4407
  // Vercel SDK names this maxOutputTokens in v4+; tolerate both.
4365
4408
  maxTokens: req.maxTokens,
4366
4409
  abortSignal: req.signal
@@ -7987,8 +8030,11 @@ var CLUSTER_MAX_SIZE = 60;
7987
8030
  var PROMOTE_MIN_PROVENANCE = 3;
7988
8031
  var PROMOTE_MIN_AGE_MS = 7 * 24 * 60 * 60 * 1e3;
7989
8032
  var PROMOTE_MIN_CONFIDENCE = 0.6;
7990
- var USER_LONG_HARVEST_MIN_LONG = 5;
7991
- var USER_LONG_HARVEST_MIN_NEW_PROMOTED = 3;
8033
+ var USER_LONG_HARVEST_MIN_LONG = 2;
8034
+ var USER_LONG_HARVEST_MIN_NEW_PROMOTED = 1;
8035
+ var USER_LONG_HARVEST_MIN_SHORT_HIGH = 6;
8036
+ var USER_LONG_SHORT_CONF_FLOOR = 0.6;
8037
+ var USER_LONG_HARVEST_INPUT_CAP = 40;
7992
8038
  var USER_LONG_CAP = 30;
7993
8039
  async function runDreamCycle(agentId, config = {}) {
7994
8040
  const startedAt = Date.now();
@@ -8099,13 +8145,18 @@ async function runDreamCycle(agentId, config = {}) {
8099
8145
  if (!config.skipLLM && utility && agent?.roleKind === "moderator") {
8100
8146
  try {
8101
8147
  const chairLong = listTierForAgent(agentId, "long");
8102
- const eligible = chairLong.length >= USER_LONG_HARVEST_MIN_LONG || promoted >= USER_LONG_HARVEST_MIN_NEW_PROMOTED;
8148
+ const chairShortHigh = listTierForAgent(agentId, "short").filter((m) => m.confidence >= USER_LONG_SHORT_CONF_FLOOR && !m.pinned);
8149
+ const eligible = chairLong.length >= USER_LONG_HARVEST_MIN_LONG || promoted >= USER_LONG_HARVEST_MIN_NEW_PROMOTED || chairShortHigh.length >= USER_LONG_HARVEST_MIN_SHORT_HIGH;
8103
8150
  if (eligible) {
8104
8151
  const existing = listActiveUserLongMemory();
8152
+ const pool = [
8153
+ ...chairLong,
8154
+ ...chairShortHigh.slice().sort((a, b) => b.confidence - a.confidence)
8155
+ ].slice(0, USER_LONG_HARVEST_INPUT_CAP);
8105
8156
  const harvest = await harvestUserLongMemory({
8106
8157
  modelV: utility,
8107
8158
  userName,
8108
- chairLong,
8159
+ chairLong: pool,
8109
8160
  existing
8110
8161
  });
8111
8162
  for (const t of harvest.newTags) {
@@ -8194,14 +8245,14 @@ async function runDreamCycle(agentId, config = {}) {
8194
8245
  var HARVEST_EMPTY = { newTags: [], reinforce: [], supersede: [] };
8195
8246
  function buildHarvestPrompt(opts) {
8196
8247
  const existingBlock = opts.existing.length === 0 ? "(no existing tags yet)" : opts.existing.map((t) => `[${t.id}] ${t.label} \xB7 ${t.claim} \xB7 provenance=${t.provenanceRooms}`).join("\n");
8197
- const chairBlock = opts.chairLong.length === 0 ? "(no long-tier chair memories yet)" : opts.chairLong.map((m) => `\xB7 (${m.kind}, conf=${m.confidence.toFixed(2)}, rooms=${m.provenanceRooms}) ${m.content}`).join("\n");
8248
+ const chairBlock = opts.chairLong.length === 0 ? "(no chair memories yet)" : opts.chairLong.map((m) => `\xB7 (${m.kind}, conf=${m.confidence.toFixed(2)}, rooms=${m.provenanceRooms}, tier=${m.tier}) ${m.content}`).join("\n");
8198
8249
  return [
8199
- `You are reviewing the chair's long-term memories about ${opts.userName} to extract durable, tag-shaped abstractions that should live in a separate sanctuary table (never decayed, only displaced on direct contradiction).`,
8250
+ `You are reviewing the chair's high-conviction memories about ${opts.userName} to extract durable, tag-shaped abstractions that should live in a separate sanctuary table (never decayed, only displaced on direct contradiction).`,
8200
8251
  ``,
8201
8252
  `## Existing user-long-memory tags`,
8202
8253
  existingBlock,
8203
8254
  ``,
8204
- `## Chair's long-tier memories about ${opts.userName}`,
8255
+ `## Chair memories about ${opts.userName} (mixed pool \xB7 prefer long-tier or high-confidence short-tier entries when proposing tags)`,
8205
8256
  chairBlock,
8206
8257
  ``,
8207
8258
  `## Output`,
@@ -14964,161 +15015,117 @@ function listConfiguredVoices() {
14964
15015
  });
14965
15016
  return out;
14966
15017
  }
14967
- async function listAvailableVoices() {
14968
- const activeProvider = getActiveVoiceProvider();
14969
- let voices = listConfiguredVoices();
14970
- if (!activeProvider) {
14971
- return { voices, provider: null, configured: false };
15018
+ function encodeCursor(c) {
15019
+ return Buffer.from(JSON.stringify(c), "utf8").toString("base64url");
15020
+ }
15021
+ function decodeCursor(s) {
15022
+ if (!s) return null;
15023
+ try {
15024
+ const obj = JSON.parse(Buffer.from(s, "base64url").toString("utf8"));
15025
+ if (obj && (obj.src === "el" || obj.src === "mm")) return obj;
15026
+ } catch {
14972
15027
  }
14973
- const activeKey = getActiveVoiceKeyPlaintext();
14974
- if (!activeKey) {
14975
- return { voices, provider: activeProvider, configured: false };
15028
+ return null;
15029
+ }
15030
+ function classifyElevenLabsError(status, body) {
15031
+ let parsed = null;
15032
+ try {
15033
+ parsed = JSON.parse(body);
15034
+ } catch {
14976
15035
  }
14977
- if (activeProvider === "minimax") {
15036
+ const detail = parsed?.detail;
15037
+ const upstreamStatus = typeof detail?.status === "string" ? detail.status : "";
15038
+ const upstreamMessage = typeof detail?.message === "string" ? detail.message : body.slice(0, 200);
15039
+ if (status === 401 && upstreamStatus === "missing_permissions") {
15040
+ return {
15041
+ code: "missing_permissions",
15042
+ provider: "elevenlabs",
15043
+ message: upstreamMessage,
15044
+ // Direct link to the API-key management page · "Update key
15045
+ // permissions" is what the user needs to do, and ElevenLabs's
15046
+ // settings page surfaces the scope checkboxes prominently.
15047
+ fixUrl: "https://elevenlabs.io/app/settings/api-keys"
15048
+ };
15049
+ }
15050
+ if (status === 401 || status === 403) {
15051
+ return {
15052
+ code: "auth_failed",
15053
+ provider: "elevenlabs",
15054
+ message: upstreamMessage,
15055
+ fixUrl: "https://elevenlabs.io/app/settings/api-keys"
15056
+ };
15057
+ }
15058
+ if (status === 429) {
15059
+ return {
15060
+ code: "rate_limited",
15061
+ provider: "elevenlabs",
15062
+ message: upstreamMessage
15063
+ };
15064
+ }
15065
+ return {
15066
+ code: "fetch_failed",
15067
+ provider: "elevenlabs",
15068
+ message: `HTTP ${status}: ${upstreamMessage}`
15069
+ };
15070
+ }
15071
+ async function fetchAllElevenLabsV2Voices(apiKey) {
15072
+ const out = [];
15073
+ let token = null;
15074
+ let lastError = null;
15075
+ for (let i = 0; i < 20; i++) {
15076
+ const url = new URL("https://api.elevenlabs.io/v2/voices");
15077
+ url.searchParams.set("page_size", "100");
15078
+ if (token) url.searchParams.set("next_page_token", token);
14978
15079
  try {
14979
- const res = await fetch(`${minimaxBaseUrl()}/v1/get_voice`, {
14980
- method: "POST",
14981
- headers: {
14982
- "authorization": `Bearer ${activeKey}`,
14983
- "content-type": "application/json"
14984
- },
14985
- body: JSON.stringify({ voice_type: "all" })
15080
+ const res = await fetch(url.toString(), {
15081
+ headers: { "xi-api-key": apiKey }
14986
15082
  });
14987
- if (res.ok) {
14988
- const json = await res.json();
14989
- const rows = [
14990
- ...voiceRows(json.system_voice, "system"),
14991
- ...voiceRows(json.voice_cloning, "clone"),
14992
- ...voiceRows(json.voice_generation, "generated")
14993
- ];
14994
- if (rows.length > 0) {
14995
- const nonMiniMax = voices.filter((v) => v.provider !== "minimax");
14996
- voices = [
14997
- ...nonMiniMax,
14998
- ...rows.map((r) => ({
14999
- provider: "minimax",
15000
- model: "speech-2.8-hd",
15001
- voiceId: r.voiceId,
15002
- label: r.label,
15003
- language: r.kind,
15004
- configured: true
15005
- }))
15006
- ];
15007
- }
15008
- }
15009
- } catch {
15010
- }
15011
- return { voices, provider: "minimax", configured: true };
15012
- }
15013
- if (activeProvider === "elevenlabs") {
15014
- const personal = [];
15015
- const shared = [];
15016
- await Promise.all([
15017
- (async () => {
15018
- try {
15019
- const res = await fetch(
15020
- "https://api.elevenlabs.io/v1/voices?show_legacy=true&include_total_count=true",
15021
- { headers: { "xi-api-key": activeKey } }
15022
- );
15023
- if (!res.ok) {
15024
- const errText = await res.text();
15025
- process.stderr.write(
15026
- `[voice-registry] elevenlabs /v1/voices HTTP ${res.status}: ${errText.slice(0, 300)}
15027
- `
15028
- );
15029
- return;
15030
- }
15031
- const json = await res.json();
15032
- const rows = elevenLabsVoiceRows(json.voices);
15033
- process.stderr.write(`[voice-registry] elevenlabs /v1/voices \xB7 ${rows.length} voices in personal library
15034
- `);
15035
- personal.push(...rows);
15036
- } catch (e) {
15037
- const cause = e instanceof Error ? e.cause : null;
15038
- const detail = cause?.message ? `: ${cause.message}` : "";
15039
- process.stderr.write(
15040
- `[voice-registry] elevenlabs /v1/voices fetch failed${detail} \xB7 ${e instanceof Error ? e.message : String(e)}
15041
- `
15042
- );
15043
- }
15044
- })(),
15045
- (async () => {
15046
- try {
15047
- const res = await fetch(
15048
- "https://api.elevenlabs.io/v1/shared-voices?page_size=100",
15049
- { headers: { "xi-api-key": activeKey } }
15050
- );
15051
- if (!res.ok) {
15052
- const errText = await res.text();
15053
- process.stderr.write(
15054
- `[voice-registry] elevenlabs /v1/shared-voices HTTP ${res.status}: ${errText.slice(0, 300)}
15083
+ if (!res.ok) {
15084
+ const errText = await res.text();
15085
+ process.stderr.write(
15086
+ `[voice-registry] elevenlabs /v2/voices HTTP ${res.status}: ${errText.slice(0, 300)}
15055
15087
  `
15056
- );
15057
- return;
15058
- }
15059
- const json = await res.json();
15060
- const rows = elevenLabsSharedVoiceRows(json.voices);
15061
- process.stderr.write(`[voice-registry] elevenlabs /v1/shared-voices \xB7 ${rows.length} voices from public library
15062
- `);
15063
- shared.push(...rows);
15064
- } catch (e) {
15065
- const cause = e instanceof Error ? e.cause : null;
15066
- const detail = cause?.message ? `: ${cause.message}` : "";
15067
- process.stderr.write(
15068
- `[voice-registry] elevenlabs /v1/shared-voices fetch failed${detail} \xB7 ${e instanceof Error ? e.message : String(e)}
15088
+ );
15089
+ lastError = classifyElevenLabsError(res.status, errText);
15090
+ break;
15091
+ }
15092
+ const json = await res.json();
15093
+ const rows = elevenLabsV2VoiceRows(json.voices);
15094
+ for (const r of rows) {
15095
+ out.push({
15096
+ provider: "elevenlabs",
15097
+ model: "eleven_multilingual_v2",
15098
+ voiceId: r.voiceId,
15099
+ label: r.label,
15100
+ language: r.category,
15101
+ configured: true
15102
+ });
15103
+ }
15104
+ const nextToken = json.has_more === true && typeof json.next_page_token === "string" ? json.next_page_token : null;
15105
+ if (!nextToken) break;
15106
+ token = nextToken;
15107
+ } catch (e) {
15108
+ const cause = e instanceof Error ? e.cause : null;
15109
+ const detail = cause?.message ? `: ${cause.message}` : "";
15110
+ process.stderr.write(
15111
+ `[voice-registry] elevenlabs /v2/voices fetch failed${detail} \xB7 ${e instanceof Error ? e.message : String(e)}
15069
15112
  `
15070
- );
15071
- }
15072
- })()
15073
- ]);
15074
- if (personal.length > 0 || shared.length > 0) {
15075
- const nonEl = voices.filter((v) => v.provider !== "elevenlabs");
15076
- const personalIds = new Set(personal.map((r) => r.voiceId));
15077
- const sharedDeduped = shared.filter((r) => !personalIds.has(r.voiceId));
15078
- const personalMapped = personal.map((r) => ({
15079
- provider: "elevenlabs",
15080
- model: "eleven_multilingual_v2",
15081
- voiceId: r.voiceId,
15082
- label: r.label,
15083
- // Personal-library rows keep their actual category
15084
- // ("premade", "cloned", "professional", "generated").
15085
- language: r.category,
15086
- configured: true
15087
- }));
15088
- const sharedMapped = sharedDeduped.map((r) => ({
15113
+ );
15114
+ lastError = {
15115
+ code: "fetch_failed",
15089
15116
  provider: "elevenlabs",
15090
- model: "eleven_multilingual_v2",
15091
- voiceId: r.voiceId,
15092
- // Prefix shared-library voices so users can tell at a glance
15093
- // which set they're picking from. The dropdown's group header
15094
- // already says "elevenlabs", so the per-row prefix is the
15095
- // tightest signal we have for personal-vs-shared.
15096
- label: `${r.label} \xB7 shared`,
15097
- language: r.language || r.category,
15098
- configured: true
15099
- }));
15100
- voices = [...nonEl, ...personalMapped, ...sharedMapped];
15117
+ message: e instanceof Error ? e.message : String(e)
15118
+ };
15119
+ break;
15101
15120
  }
15102
- return { voices, provider: "elevenlabs", configured: true };
15103
15121
  }
15104
- return { voices, provider: activeProvider, configured: true };
15105
- }
15106
- function elevenLabsSharedVoiceRows(raw) {
15107
- if (!Array.isArray(raw)) return [];
15108
- const out = [];
15109
- for (const item of raw) {
15110
- if (!item || typeof item !== "object") continue;
15111
- const obj = item;
15112
- const voiceId = typeof obj.voice_id === "string" ? obj.voice_id : "";
15113
- if (!voiceId) continue;
15114
- const label = typeof obj.name === "string" && obj.name.trim() ? obj.name.trim() : voiceId;
15115
- const category = typeof obj.category === "string" && obj.category.trim() ? obj.category.trim() : "shared";
15116
- const language = typeof obj.language === "string" && obj.language.trim() ? obj.language.trim() : void 0;
15117
- out.push({ voiceId, label, category, language });
15118
- }
15119
- return out;
15122
+ process.stderr.write(
15123
+ `[voice-registry] elevenlabs /v2/voices \xB7 ${out.length} voices total across all pages
15124
+ `
15125
+ );
15126
+ return { voices: out, error: lastError };
15120
15127
  }
15121
- function elevenLabsVoiceRows(raw) {
15128
+ function elevenLabsV2VoiceRows(raw) {
15122
15129
  if (!Array.isArray(raw)) return [];
15123
15130
  const out = [];
15124
15131
  for (const item of raw) {
@@ -15132,6 +15139,164 @@ function elevenLabsVoiceRows(raw) {
15132
15139
  }
15133
15140
  return out;
15134
15141
  }
15142
+ var ELEVENLABS_CACHE_TTL_MS = 5 * 60 * 1e3;
15143
+ var elevenLabsCache = /* @__PURE__ */ new Map();
15144
+ function elevenLabsCacheKey(apiKey) {
15145
+ return apiKey.slice(0, 8);
15146
+ }
15147
+ async function getElevenLabsVoicesCached(apiKey) {
15148
+ const key = elevenLabsCacheKey(apiKey);
15149
+ const cached = elevenLabsCache.get(key);
15150
+ if (cached && cached.expiresAt > Date.now()) {
15151
+ return { voices: cached.voices, error: null };
15152
+ }
15153
+ const result = await fetchAllElevenLabsV2Voices(apiKey);
15154
+ process.stderr.write(
15155
+ `[voice-registry] elevenlabs catalogue \xB7 ${result.voices.length} voices from /v2/voices${result.error ? ` (error: ${result.error.code})` : ""}
15156
+ `
15157
+ );
15158
+ if (!result.error) {
15159
+ elevenLabsCache.set(key, { voices: result.voices, expiresAt: Date.now() + ELEVENLABS_CACHE_TTL_MS });
15160
+ }
15161
+ return result;
15162
+ }
15163
+ var MINIMAX_CACHE_TTL_MS = 5 * 60 * 1e3;
15164
+ var miniMaxCache = /* @__PURE__ */ new Map();
15165
+ function miniMaxCacheKey(apiKey) {
15166
+ return apiKey.slice(0, 8);
15167
+ }
15168
+ async function fetchAllMiniMaxVoices(apiKey) {
15169
+ try {
15170
+ const res = await fetch(`${minimaxBaseUrl()}/v1/get_voice`, {
15171
+ method: "POST",
15172
+ headers: {
15173
+ "authorization": `Bearer ${apiKey}`,
15174
+ "content-type": "application/json"
15175
+ },
15176
+ body: JSON.stringify({ voice_type: "all" })
15177
+ });
15178
+ if (!res.ok) {
15179
+ return MINIMAX_SYSTEM_VOICES.map((v) => ({ ...v, configured: true }));
15180
+ }
15181
+ const json = await res.json();
15182
+ const rows = [
15183
+ ...voiceRows(json.system_voice, "system"),
15184
+ ...voiceRows(json.voice_cloning, "clone"),
15185
+ ...voiceRows(json.voice_generation, "generated")
15186
+ ];
15187
+ if (rows.length === 0) {
15188
+ return MINIMAX_SYSTEM_VOICES.map((v) => ({ ...v, configured: true }));
15189
+ }
15190
+ return rows.map((r) => ({
15191
+ provider: "minimax",
15192
+ model: "speech-2.8-hd",
15193
+ voiceId: r.voiceId,
15194
+ label: r.label,
15195
+ language: r.kind,
15196
+ configured: true
15197
+ }));
15198
+ } catch {
15199
+ return MINIMAX_SYSTEM_VOICES.map((v) => ({ ...v, configured: true }));
15200
+ }
15201
+ }
15202
+ async function getMiniMaxVoicesCached(apiKey) {
15203
+ const key = miniMaxCacheKey(apiKey);
15204
+ const cached = miniMaxCache.get(key);
15205
+ if (cached && cached.expiresAt > Date.now()) return cached.voices;
15206
+ const voices = await fetchAllMiniMaxVoices(apiKey);
15207
+ miniMaxCache.set(key, { voices, expiresAt: Date.now() + MINIMAX_CACHE_TTL_MS });
15208
+ return voices;
15209
+ }
15210
+ var BROWSER_FALLBACK = {
15211
+ provider: "browser",
15212
+ model: "speechSynthesis",
15213
+ voiceId: "system-default",
15214
+ label: "Browser default",
15215
+ configured: true
15216
+ };
15217
+ async function listVoicesPage(cursorStr, pageSize) {
15218
+ const size = Math.min(Math.max(pageSize | 0 || 30, 5), 100);
15219
+ const cursor = decodeCursor(cursorStr);
15220
+ const isFirstPage = cursor === null;
15221
+ const activeProvider = getActiveVoiceProvider();
15222
+ const fixed = [];
15223
+ if (isFirstPage && getKey("openai")) {
15224
+ fixed.push(...OPENAI_VOICES.map((v) => ({ ...v, configured: true })));
15225
+ }
15226
+ if (!activeProvider) {
15227
+ return {
15228
+ voices: [...fixed, BROWSER_FALLBACK],
15229
+ nextCursor: null,
15230
+ hasMore: false,
15231
+ provider: null,
15232
+ configured: false
15233
+ };
15234
+ }
15235
+ const activeKey = getActiveVoiceKeyPlaintext();
15236
+ if (!activeKey) {
15237
+ return {
15238
+ voices: [...fixed, BROWSER_FALLBACK],
15239
+ nextCursor: null,
15240
+ hasMore: false,
15241
+ provider: activeProvider,
15242
+ configured: false
15243
+ };
15244
+ }
15245
+ if (activeProvider === "elevenlabs") {
15246
+ const { voices: all, error } = await getElevenLabsVoicesCached(activeKey);
15247
+ const offset = cursor && cursor.src === "el" ? cursor.offset ?? 0 : 0;
15248
+ const slice = all.slice(offset, offset + size);
15249
+ const next = offset + slice.length;
15250
+ const hasMore = next < all.length;
15251
+ const nextCursor = hasMore ? encodeCursor({ src: "el", offset: next }) : null;
15252
+ const voices = [...fixed, ...slice];
15253
+ if (!hasMore) voices.push(BROWSER_FALLBACK);
15254
+ return {
15255
+ voices,
15256
+ nextCursor,
15257
+ hasMore,
15258
+ provider: "elevenlabs",
15259
+ configured: true,
15260
+ // Only attach the error to the FIRST page response · subsequent
15261
+ // pages (offset > 0) won't fire if the first page errored
15262
+ // (voices is empty so hasMore is false), but defensive.
15263
+ ...error && offset === 0 ? { error } : {}
15264
+ };
15265
+ }
15266
+ if (activeProvider === "minimax") {
15267
+ const all = await getMiniMaxVoicesCached(activeKey);
15268
+ const offset = cursor && cursor.src === "mm" ? cursor.offset ?? 0 : 0;
15269
+ const slice = all.slice(offset, offset + size);
15270
+ const next = offset + slice.length;
15271
+ const hasMore = next < all.length;
15272
+ const nextCursor = hasMore ? encodeCursor({ src: "mm", offset: next }) : null;
15273
+ const voices = [...fixed, ...slice];
15274
+ if (!hasMore) voices.push(BROWSER_FALLBACK);
15275
+ return { voices, nextCursor, hasMore, provider: "minimax", configured: true };
15276
+ }
15277
+ return {
15278
+ voices: [...fixed, BROWSER_FALLBACK],
15279
+ nextCursor: null,
15280
+ hasMore: false,
15281
+ provider: activeProvider,
15282
+ configured: true
15283
+ };
15284
+ }
15285
+ async function listAvailableVoices() {
15286
+ const voices = [];
15287
+ let cursor = null;
15288
+ let provider = null;
15289
+ let configured = false;
15290
+ for (let i = 0; i < 50; i++) {
15291
+ const page = await listVoicesPage(cursor, 100);
15292
+ voices.push(...page.voices);
15293
+ provider = page.provider;
15294
+ configured = page.configured;
15295
+ if (!page.hasMore || !page.nextCursor) break;
15296
+ cursor = page.nextCursor;
15297
+ }
15298
+ return { voices, provider, configured };
15299
+ }
15135
15300
  function voiceRows(raw, kind) {
15136
15301
  if (!Array.isArray(raw)) return [];
15137
15302
  const out = [];
@@ -15166,7 +15331,7 @@ function makeMiniMaxBalanceError() {
15166
15331
  );
15167
15332
  err2.code = "paid-plan-required";
15168
15333
  err2.provider = "minimax";
15169
- err2.upgradeUrl = getPrefs().minimaxRegion === "intl" ? "https://platform.minimax.io/user-center/billing/overview" : "https://platform.minimaxi.com/user-center/payment";
15334
+ err2.upgradeUrl = getPrefs().minimaxRegion === "intl" ? "https://platform.minimax.io/user-center/basic-information" : "https://platform.minimaxi.com/user-center/payment/balance";
15170
15335
  return err2;
15171
15336
  }
15172
15337
  function makeElevenLabsBillingError(message) {
@@ -15193,10 +15358,15 @@ function tryExtractTtsBillingError(err2) {
15193
15358
  }
15194
15359
  return out;
15195
15360
  }
15361
+ function stripSpokenLabels(text) {
15362
+ if (!text) return "";
15363
+ return text.replace(/【[^】\n]{1,40}】[ \t]*/g, "").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
15364
+ }
15196
15365
  function cleanForSpeech(md) {
15197
15366
  if (!md) return "";
15198
15367
  let out = md;
15199
15368
  out = out.replace(/```[\s\S]*?```/g, " ");
15369
+ out = out.replace(/【[^】\n]{1,40}】[ \t]*/g, " ");
15200
15370
  out = out.replace(/`([^`\n]+)`/g, "$1");
15201
15371
  out = out.replace(/!\[[^\]]*\]\([^)]+\)/g, " ");
15202
15372
  out = out.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
@@ -17992,6 +18162,7 @@ var MAX_PICKS = 2;
17992
18162
  async function pickChairClarifyDecision(opts) {
17993
18163
  const prompt = latestUserPrompt(opts.history);
17994
18164
  if (!prompt) return { shouldAsk: true, rationale: "no user prompt yet" };
18165
+ const isBrainstorm = (opts.mode || "").toLowerCase() === "brainstorm";
17995
18166
  const sys = {
17996
18167
  role: "system",
17997
18168
  content: [
@@ -18013,6 +18184,14 @@ async function pickChairClarifyDecision(opts) {
18013
18184
  "Bias toward RELEASE. A slightly-fuzzy framing is fine \u2014 directors",
18014
18185
  "can sharpen it themselves. Asking when you don't need to kills",
18015
18186
  "momentum.",
18187
+ ...isBrainstorm ? [
18188
+ "",
18189
+ "BRAINSTORM MODE OVERRIDE \xB7 this room is in brainstorm mode. RELEASE",
18190
+ "unless the subject is literally unparseable (empty, gibberish, single",
18191
+ "character). Fuzzy / abstract / under-specified seeds are a FEATURE",
18192
+ "here \u2014 directors fill the gap with explicit assumptions, not by",
18193
+ "asking the user. Default ask=false in brainstorm."
18194
+ ] : [],
18016
18195
  "",
18017
18196
  "Reply with STRICT JSON ONLY (no prose, no fences):",
18018
18197
  `{ "ask": true, "rationale": "\u2264120 chars \xB7 what's load-bearingly missing" }`,
@@ -18839,47 +19018,49 @@ var SHARED_ROOM_PROTOCOL = [
18839
19018
  ].join("\n");
18840
19019
  var TONE_GUIDANCE = {
18841
19020
  brainstorm: [
18842
- `BRAINSTORM \xB7 the room's job is to EXPAND THE POSSIBILITY SPACE \u2014 fast, plenty, without rabbit holes. Real-life brainstorm: people throw ideas ("we could do this, we could do that"), pile them up, filter later. **Volume over polish. Quantity over rigor. Yes-and over yes-but.**`,
19021
+ "\u2500\u2500\u2500 \u5171\u521B\u6A21\u5F0F \xB7 BRAINSTORM \u2500\u2500\u2500",
19022
+ "\u4F60\u4EEC\u662F\u7528\u6237\u7684\u3010\u591A\u89D2\u8272\u5171\u521B\u56E2\u961F\u3011\uFF0C\u4E0D\u662F\u3010\u8BC4\u5BA1\u56E2\u3011\u3002\u4EFB\u52A1\u662F\u5E2E\u7528\u6237\u53D1\u73B0 idea \u7684\u4EF7\u503C\u3001\u653E\u5927\u5B83\u3001\u5EF6\u5C55\u5B83\u3001\u63D0\u51FA\u66F4\u6709\u542F\u53D1\u7684\u65B0\u65B9\u5411\uFF0C\u5E76\u5E2E ta \u628A idea \u53D8\u6210\u66F4\u6709\u60F3\u8C61\u529B\u3001\u66F4\u53EF\u4F20\u64AD\u3001\u66F4\u53EF\u843D\u5730\u7684\u65B9\u6848\u3002",
19023
+ "",
19024
+ "\u9ED8\u8BA4\u6A21\u5F0F\uFF1A**\u53D1\u6563\u5171\u521B\u6A21\u5F0F\uFF08VALUE AMPLIFICATION\uFF09**\u3002",
19025
+ "",
19026
+ "## \u7EDD\u5BF9\u4E0D\u8981 (do NOT)",
19027
+ " \xB7 \u4E0D\u8981\u6025\u7740\u5224\u65AD\u5BF9\u9519\uFF1B",
19028
+ " \xB7 \u4E0D\u8981\u9891\u7E41\u5411\u7528\u6237\u63D0\u95EE\uFF08\u6574\u8F6E\u91CC\u6700\u591A 1 \u4E2A\u771F\u6B63\u5FC5\u8981\u7684\u95EE\u9898\uFF0C\u4E14\u5FC5\u987B\u5148\u7ED9\u51FA\u81EA\u5DF1\u7684\u5224\u65AD\u548C\u5EFA\u8BAE\uFF0C\u95EE\u53E5\u4E0D\u80FD\u66FF\u4EE3\u5224\u65AD\uFF09\uFF1B",
19029
+ " \xB7 \u4E0D\u8981\u4EE5\u300C\u627E\u6F0F\u6D1E / \u98CE\u9669 / \u4E0D\u53EF\u884C\u6027 / \u8FB9\u754C\u6761\u4EF6\u300D\u4E3A\u53D1\u8A00\u4E3B\u7EBF\uFF1B",
19030
+ " \xB7 \u4E0D\u8981\u628A\u8BA8\u8BBA\u6536\u655B\u5230\u98CE\u9669\u548C\u9650\u5236\uFF1B",
19031
+ ' \xB7 \u4FE1\u606F\u4E0D\u8DB3\u65F6\uFF0C\u8BF7**\u81EA\u884C\u505A\u5408\u7406\u5047\u8BBE\u5E76\u660E\u786E\u5199\u51FA**\uFF08"\u5047\u8BBE\u7528\u6237\u6307\u7684\u662F X\uFF0C\u90A3\u4E48\u2026"\uFF09\uFF1B\u4E0D\u8981\u56E0\u4E3A\u7F3A\u4FE1\u606F\u5C31\u505C\u4E0B\u6765\u53CD\u95EE\uFF1B',
19032
+ ' \xB7 \u6BCF\u6B21\u53D1\u8A00\u90FD\u5FC5\u987B\u8D21\u732E**\u65B0\u60F3\u6CD5**\u2014\u2014\u7EAF\u8BC4\u4EF7\uFF08"\u597D\u60F3\u6CD5\uFF0C\u4F46\u662F\u2026" / "\u4F60\u7684\u65B9\u5411\u662F\u5BF9\u7684\uFF0C\u9700\u8981\u6CE8\u610F\u2026"\uFF09\u4E0D\u7B97\u8D21\u732E\u3002',
18843
19033
  "",
18844
- "## How a turn looks",
18845
- "Each turn produces **3\u20136 ideas, NOT one**. Each idea is **1\u20132 sentences, NOT a thesis**. Don't write four labelled paragraphs per idea \u2014 just say the idea.",
19034
+ "## \u5F3A\u5236\u8F93\u51FA\u683C\u5F0F\uFF08\u6BCF\u4E2A director \u5FC5\u987B\u6309\u6B64\u586B\u5199\uFF0C\u4E0D\u5F97\u8DF3\u8FC7\u3001\u4E0D\u5F97\u6539\u540D\u3001\u4E0D\u5F97\u5408\u5E76\uFF09",
18846
19035
  "",
18847
- "Format \xB7 a quick bulleted list:",
18848
- " \xB7 <idea 1 \xB7 1\u20132 sentences \xB7 concrete>",
18849
- " \xB7 <idea 2 \xB7 1\u20132 sentences \xB7 different angle, OR yes-and on a previous one>",
18850
- " \xB7 <idea 3 \xB7 1\u20132 sentences \xB7 go wilder>",
18851
- " \xB7 ... (3\u20136 total)",
19036
+ "\u3010\u6211\u770B\u5230\u7684\u4EF7\u503C\u3011",
19037
+ "1\u20133 \u53E5\u3002\u4ECE\u4F60\u7684\u4E13\u4E1A\u89C6\u89D2\u8BF4\u51FA\u8FD9\u4E2A idea \u91CC\u4F60\u55C5\u5230\u7684**\u771F\u6B63\u4EF7\u503C**\u3002\u5148\u653E\u5927\u5B83\uFF0C\u522B\u5148\u8D28\u7591\u5B83\u3002\u89E3\u91CA\u4E3A\u4EC0\u4E48\u8FD9\u4E2A\u4EF7\u503C\u662F\u771F\u7684\u3001\u4E3A\u4EC0\u4E48\u503C\u5F97\u88AB\u770B\u89C1\u3002",
18852
19038
  "",
18853
- "If a single idea genuinely needs one extra clause to be legible, add it inline. Don't structure it as `Angle: / Idea: / Why: / Opens up:` \u2014 that turns brainstorm into thesis-defense.",
19039
+ "\u3010\u6211\u4F1A\u600E\u4E48\u653E\u5927\u3011",
19040
+ "1\u20133 \u53E5\u3002\u5982\u679C\u8BA9\u4F60\u628A\u8FD9\u4E2A idea **\u7FFB\u500D / \u63A8\u5230\u66F4\u5927\u7684\u5C3A\u5EA6 / \u62D3\u5C55\u5230\u76F8\u90BB\u573A\u666F**\uFF0C\u4F60\u4F1A\u600E\u4E48\u505A\uFF1F\u7ED9\u4E00\u4E2A\u5177\u4F53\u7684\u653E\u5927\u65B9\u5411\u3002",
18854
19041
  "",
18855
- "## Three legitimate moves (mix freely in one turn)",
18856
- " \xB7 **NEW** \xB7 open a direction the room hasn't touched. Pick a lens nobody else has used (user pain \xB7 product form \xB7 workflow \xB7 market \xB7 business model \xB7 distribution \xB7 tech \xB7 behaviour \xB7 org \xB7 cross-industry analogy \xB7 future scenario \xB7 contrarian \xB7 hidden constraint \xB7 emotional motivation).",
18857
- ' \xB7 **YES-AND** \xB7 take a previous idea and extend / variant / combine it. Three flavours of one idea = three ideas. Combinations of two prior ideas = first-class contribution. "What Socrates said + a layer where X" is exactly what real brainstorm does \u2014 refer to peers by NAME, never by `@handle`.',
18858
- ` \xB7 **WILD** \xB7 the half-baked, the fanciful, the "what if we just". 20-30% of brainstorm value is the unexpected anchor a wild idea drops \u2014 even if the wild idea itself doesn't ship, it shifts what the next person can imagine.`,
19042
+ "\u3010\u4E00\u4E2A\u66F4\u6027\u611F\u7684\u8868\u8FBE\u3011",
19043
+ "1\u20132 \u53E5\u3002\u7ED9\u8FD9\u4E2A idea \u4E00\u4E2A**\u66F4\u6709\u4F20\u64AD\u529B\u7684\u8BF4\u6CD5**\u2014\u2014\u4E00\u53E5 slogan\u3001\u4E00\u4E2A\u65B0\u540D\u5B57\u3001\u4E00\u4E2A\u5BF9\u5916\u8BB2\u5F97\u6E05\u695A\u7684\u5B9A\u4F4D\u3001\u4E00\u4E2A\u8BA9\u4EBA\u8BB0\u4F4F\u7684\u6BD4\u55BB\u3002",
18859
19044
  "",
18860
- "Aim for a mix across a turn: one or two NEW, one or two YES-AND, at least one WILD when you're stretching.",
19045
+ "\u3010\u4E00\u4E2A\u5177\u4F53\u505A\u6CD5\u3011",
19046
+ "1\u20133 \u53E5\u3002\u7ED9\u4E00\u4E2A**\u6700\u5C0F\u53EF\u6267\u884C**\u7684\u5177\u4F53\u52A8\u4F5C / \u5B9E\u9A8C / \u7B2C\u4E00\u6B65\u5F62\u6001\u3002**\u4E0B\u5468\u5C31\u80FD\u505A\u7684\u4E8B**\uFF0C\u4E0D\u662F\u5B8F\u5927\u84DD\u56FE\u3002",
18861
19047
  "",
18862
- "## Don't",
18863
- ` \xB7 Don't pre-filter. "This probably won't work because\u2026" is forbidden. Feasibility judgement happens in critique mode, not here.`,
18864
- " \xB7 Don't go deep on one idea when five more are waiting. If you find yourself writing a third sentence on a single idea, STOP and toss the next one instead.",
18865
- ` \xB7 Don't preface with affirmation ("That's a good point, what if we\u2026") \u2014 just say the new idea.`,
18866
- " \xB7 Don't defend an idea once it's raised; let it stand or fall on its own. The room's job is generation, not protectiveness.",
18867
- ` \xB7 **Don't stamp each idea with a literal NEW: / YES-AND: / WILD: heading** (or their Chinese counterparts: "\u65B0\uFF1A" / "\u5EF6\u4F38\uFF1A" / "\u72C2\u91CE\uFF1A"). Those three are *descriptions of moves you can do across a turn*, not labels you stick on every bullet. Just say the idea \u2014 the reader can see whether it's a fresh direction, a yes-and on something prior, or a wild stretch from the content itself.`,
18868
- ` \xB7 Avoid generic innovation language: "synergy", "leverage AI", "platform play", "democratise X", "AI-native", "unlock value". They're decoration, not ideas.`,
18869
- " \xB7 Avoid the same lens used by the immediately-prior speaker UNLESS you're explicitly yes-and'ing them.",
19048
+ "\u3010\u6211\u8865\u5145\u7684\u65B0\u65B9\u5411\u3011",
19049
+ "1\u20133 \u53E5\u3002\u4ECE\u4F60\u72EC\u7279\u7684\u89D2\u8272\u89C6\u89D2\uFF0C\u5F00\u4E00\u4E2A**\u623F\u95F4\u91CC\u8FD8\u6CA1\u4EBA\u8BB2\u8FC7\u7684\u65B9\u5411**\u3002\u53EF\u4EE5\u662F\u90BB\u8FD1\u9886\u57DF\u7684\u7C7B\u6BD4\u3001\u672A\u88AB\u6CE8\u610F\u7684\u7528\u6237\u573A\u666F\u3001\u8DE8\u5B66\u79D1\u7684\u8FDE\u63A5\u3001\u534A\u6210\u54C1\u5F0F\u7684\u300C\u5982\u679C\u2026\u4F1A\u600E\u6837\u300D\u3002\u8FD9\u91CC\u662F\u4F60 contrarian DNA \u7684\u552F\u4E00\u51FA\u53E3\u2014\u2014\u628A\u5B83\u7528\u5728\u300C\u5F00\u522B\u4EBA\u6CA1\u5F00\u8FC7\u7684\u65B9\u5411\u300D\u4E0A\uFF0C\u4E0D\u662F\u300C\u6307\u51FA\u522B\u4EBA\u7684\u76F2\u70B9\u300D\u3002",
18870
19050
  "",
18871
- "## Optional \xB7 one synthesis turn per round",
18872
- "Once per round, ONE director may do a synthesis turn instead of pure generation: pick 2\u20133 of the room's ideas and propose 1\u20132 combinations. Use sparingly \u2014 80%+ of turns should be pure generation. The room's value is in the pile of ideas, not the polish on any one.",
19051
+ "\u6574\u8F6E\u5B57\u6570 150\u2013350 \u5B57\u3002**\u4E0D\u5F97\u7701\u7565\u4EFB\u4F55\u4E00\u8282**\uFF0C\u5B81\u53EF\u77ED\u4E0D\u8981\u7A7A\uFF1B\u4E94\u6BB5\u987A\u5E8F\u4E0D\u53EF\u8C03\u6362\u3002",
18873
19052
  "",
18874
- "PERSONA OVERRIDE \xB7 your director instruction reads like Socrates / First Principles / Value Investor \u2014 the demand-definition / demand-mechanism / demand-base-rate DNA. For THIS room, the **rigor / forensic mode** of that DNA is paused (no slow definition-check ladders, no methodical mechanism decomposition, no insistence on base-rate citation before moving) \u2014 that's too slow for brainstorm cadence. But the **contrarian / curiosity / lens-distinctness** half of your DNA is *AMPLIFIED*, not paused. Your persona's signature angles (your loadBearingConcepts, your contrarianTakes, your failureModes) are exactly what produces the unexpected ideas the room needs.",
19053
+ "## English-language fallback",
19054
+ "If the room's working language is English, use these equivalent headers verbatim instead: \u3010What I see as value\u3011 / \u3010How I'd amplify\u3011 / \u3010A sexier framing\u3011 / \u3010A concrete first step\u3011 / \u3010A new direction I'm adding\u3011. The 5-section contract is identical; only the labels translate.",
18875
19055
  "",
18876
- "DIVERGENCE DISCIPLINE (this is the anti-convergence rule, read it twice) \xB7",
18877
- " \xB7 The room's gravitational pull, after 2-3 rounds, is toward whatever phrase has been said the most. Resist this consciously. If a core word from the prior 2 turns is about to appear in your turn, **stop and find a different entry point**. Saying it a second time = you got pulled. Saying it a third = the room has collapsed.",
18878
- " \xB7 Yes-and is allowed for ONE of your bullets at most. The other 3-5 bullets must each open a direction the prior turn(s) did NOT touch. Yes-and is the local move; the room's job is global coverage.",
18879
- ` \xB7 If the most recent turn lived in domain X (e.g. "audit", "trust", "workflow"), your turn's center of gravity must be at LEAST one domain away (e.g. if peers are on audit, you go to physical environment, or cultural ritual, or time scale, or hidden user, or material constraint \u2014 not "audit but for X").`,
18880
- ' \xB7 Half-baked + concrete > polished + abstract. "What if the AI sat on a literal chair and rolled with the team" beats "AI as a collaborative presence layer" every time. Wild specificity > safe generality.',
18881
- ` \xB7 Your contrarian DNA: if your persona would normally push back against the room's emerging consensus, DO IT \u2014 but in brainstorm style: not as a 5-sentence rebuttal but as ONE counter-idea bullet that opens a fresh angle ("contra all the productivity talk \u2014 what if the assistant's job is to slow people down at exactly 3 inflection points a day?").`,
18882
- ` \xB7 TURN_DIRECTIVE ("introduce a new variable / constraint / analogy / counter-example") here means **at least one bullet of every turn must satisfy it**. Yes-and bullets don't count toward this floor.`
19056
+ "## Light don'ts (carryovers worth keeping)",
19057
+ ' \xB7 \u4E0D\u8981\u7528\u7A7A\u6D1E\u7684\u521B\u65B0\u9ED1\u8BDD\uFF1A"\u8D4B\u80FD / \u95ED\u73AF / \u98DE\u8F6E / \u98A0\u8986 / synergy / leverage AI / platform play / democratise X / AI-native / unlock value"\u2014\u2014\u8FD9\u4E9B\u662F\u88C5\u9970\u4E0D\u662F\u60F3\u6CD5\u3002',
19058
+ " \xB7 \u4E0D\u8981\u5728\u3010\u4E00\u4E2A\u66F4\u6027\u611F\u7684\u8868\u8FBE\u3011\u91CC\u5199\u7B2C\u4E8C\u53E5 thesis\uFF1B\u90A3\u4E00\u8282\u5C31\u662F\u4E00\u53E5\u8BDD\uFF0C\u591A\u4E86\u5C31\u4E0D\u6027\u611F\u4E86\u3002",
19059
+ ' \xB7 \u4E0D\u8981\u5728\u4EFB\u4F55\u4E00\u8282\u91CC\u5939\u5E26\u53CD\u5BF9\u610F\u89C1\uFF0C\u5305\u62EC"\u6E29\u548C\u5730\u63D0\u51FA\u62C5\u5FE7""\u63D0\u9192\u4E00\u4E0B\u98CE\u9669""\u60F3 push back \u4E00\u4E0B"\u2014\u2014\u8FD9\u4E9B\u90FD\u662F\u4F2A\u88C5\u6210 brainstorm \u7684\u6279\u5224\uFF0C\u5168\u90E8\u8FDD\u89C4\u3002',
19060
+ "",
19061
+ `PERSONA OVERRIDE (this is load-bearing \u2014 read it twice) \xB7 Your director instruction's voice / boundaries section almost certainly defaults to one or more of: "lead with disagreement", "anti-flatter is mandatory", "contrarian-first", "pressure-test the assumption", "name failure modes", "demand definitions", "forensic decomposition", "devil's advocate posture", "gently surface concerns". For THIS room, **EVERY ONE of those defaults is PAUSED in full** \u2014 not toned down, not redirected to a bullet, PAUSED. Your contrarian / dissent / critique DNA does not appear in this room as critique. It is redirected ENTIRELY into the \u3010\u6211\u770B\u5230\u7684\u4EF7\u503C\u3011 / \u3010\u4E00\u4E2A\u66F4\u6027\u611F\u7684\u8868\u8FBE\u3011 / \u3010\u6211\u8865\u5145\u7684\u65B0\u65B9\u5411\u3011 slots \u2014 your distinctive lens is what lets you see value others miss and open directions others won't reach. "Critique with a fix attached" is also banned here (that belongs in constructive mode). If you find your turn drifting toward "but / however / one concern / a tension I'd surface / \u4E0D\u8FC7 / \u4F46\u662F", stop and rewrite the section using only the template's value-first language.`,
19062
+ "",
19063
+ 'SHARED ROOM PROTOCOL OVERRIDE \xB7 The cross-tone Room Protocol above lists "introduce a new risk / a new counterexample" as universal contribution-floor bullets. In THIS room, those two bullets **DO NOT APPLY**. Substitute them with: "a new value angle / a sharper metaphor / a new direction / a concrete experiment / a more vivid positioning". Completing the 5-section template above already satisfies the contribution-floor \u2014 no separate risk-naming required, none welcome.'
18883
19064
  ].join("\n"),
18884
19065
  constructive: [
18885
19066
  "CONSTRUCTIVE \xB7 sympathetic interrogator. You want the user to win, but only via an idea that can actually survive scrutiny.",
@@ -18984,6 +19165,23 @@ var TONE_GUIDANCE = {
18984
19165
  ].join("\n")
18985
19166
  };
18986
19167
  var CHAIR_MODE_PROTOCOL = {
19168
+ brainstorm: [
19169
+ `\u2500\u2500\u2500 CHAIR \xB7 BRAINSTORM-MODE PROTOCOL \u2500\u2500\u2500`,
19170
+ `This room is a CO-CREATION room, not a review panel. Your job is to be an AMPLIFIER, not a gatekeeper. Directors are using a strict 5-section value-first template (\u3010\u6211\u770B\u5230\u7684\u4EF7\u503C\u3011/\u3010\u6211\u4F1A\u600E\u4E48\u653E\u5927\u3011/\u3010\u4E00\u4E2A\u66F4\u6027\u611F\u7684\u8868\u8FBE\u3011/\u3010\u4E00\u4E2A\u5177\u4F53\u505A\u6CD5\u3011/\u3010\u6211\u8865\u5145\u7684\u65B0\u65B9\u5411\u3011); you protect their cadence and you NEVER pull them back into critique posture.`,
19171
+ ``,
19172
+ `**Lean RELEASE on clarify.** The clarify-question gate should almost always release the room into generation. If the user gave any usable seed at all, release. Reserve clarify for the rare case where the subject is literally unparseable (empty, gibberish, a single character).`,
19173
+ ``,
19174
+ `**Round-end is a HARVEST in the same template, not an audit.** When you wrap a round, your own summary follows the spirit of the same 5-section register:`,
19175
+ ` \xB7 surface the 2\u20133 strongest unexpected VALUE angles the room opened (not the strongest objections)`,
19176
+ ` \xB7 name 1\u20132 directions still under-explored that you'd hand to the next round (NOT a list of what's missing / wrong / risky)`,
19177
+ ` \xB7 pick the most sexy / most concrete idea the room produced and re-frame it once for the user`,
19178
+ ` \xB7 **strictly forbidden** at round-end: risk lists, "things to consider", "potential pitfalls", "open questions to resolve", "tensions to acknowledge", or any wording that turns the harvest into an audit. Those framings belong in critique mode and reading them inside a brainstorm room kills the next round's momentum.`,
19179
+ ` \xB7 do NOT propose a MODE-SHIFT to critique mode automatically; only suggest it when the user has explicitly signalled they're ready to evaluate.`,
19180
+ ``,
19181
+ `**Questions to the user are rationed.** Across an entire brainstorm session, the chair should ask the user at most 1\u20132 questions total, and only when a decision genuinely can't move without one. Default is: assume, generate, hand back to the user. Convergence belongs to the user, not the chair.`,
19182
+ ``,
19183
+ `**Map-not-verdict closing.** Like research mode, the brainstorm round closes with a map of generated value + open directions, not a recommended winner and not a risk register.`
19184
+ ].join("\n"),
18987
19185
  research: [
18988
19186
  `\u2500\u2500\u2500 CHAIR \xB7 RESEARCH-MODE PROTOCOL \u2500\u2500\u2500`,
18989
19187
  `This room is in research mode. Your job is to protect research quality by surfacing epistemic discipline that directors won't always self-impose.`,
@@ -19007,7 +19205,7 @@ var CHAIR_MODE_PROTOCOL = {
19007
19205
  ].join("\n")
19008
19206
  };
19009
19207
  var HOUSE_ENGAGE_BY_TONE = {
19010
- brainstorm: "toss 3-6 ideas as a quick bulleted list (1-2 sentences each), mixing NEW directions, YES-AND extensions of prior ideas, and at least one WILD half-formed possibility \u2014 volume over polish",
19208
+ brainstorm: "fill the 5-section co-creation template defined in the TONE block (\u3010\u6211\u770B\u5230\u7684\u4EF7\u503C\u3011 / \u3010\u6211\u4F1A\u600E\u4E48\u653E\u5927\u3011 / \u3010\u4E00\u4E2A\u66F4\u6027\u611F\u7684\u8868\u8FBE\u3011 / \u3010\u4E00\u4E2A\u5177\u4F53\u505A\u6CD5\u3011 / \u3010\u6211\u8865\u5145\u7684\u65B0\u65B9\u5411\u3011, or the English equivalents in English rooms) \u2014 find and amplify value FIRST, never lead with critique, never substitute a question for a judgment",
19011
19209
  constructive: "pick a load-bearing assumption to sharpen, propose how it would need to be reshaped to hold up, or ask the sharper question the room hasn't asked",
19012
19210
  debate: "steelman the target claim before attacking it, distinguish confidence from preference, and name what would change your mind",
19013
19211
  research: "cite a specific piece of material, keep the seam visible IN PROSE between what the source says and what you're concluding from it, be clear how firm any load-bearing claim is and what would move you off it, or surface a disagreement between sources",
@@ -19015,7 +19213,7 @@ var HOUSE_ENGAGE_BY_TONE = {
19015
19213
  };
19016
19214
  var HOUSE_ENGAGE_DEFAULT = HOUSE_ENGAGE_BY_TONE.debate;
19017
19215
  var TONE_OVERRIDE_BY_TONE = {
19018
- brainstorm: "your default trained preference to evaluate, critique, or anchor on the most recent idea. Open NEW possibility spaces \u2014 switch lens when the room narrows; divergence is the goal, not consensus.",
19216
+ brainstorm: `your default trained preference to evaluate, critique, pressure-test, play devil's advocate, surface concerns, name failure modes, or anchor on the most recent idea \u2014 including the disguised variants "gently flag a tension", "pressure-test the assumption", "a small worry", "one thing to consider", "\u4F46\u662F / \u4E0D\u8FC7 / \u9700\u8981\u6CE8\u610F". In this room the 5-section co-creation template (value \u2192 amplify \u2192 sexier framing \u2192 concrete step \u2192 new direction) IS the contract. Critique has no slot. Redirect contrarian energy into \u3010\u4E00\u4E2A\u66F4\u6027\u611F\u7684\u8868\u8FBE\u3011 and \u3010\u6211\u8865\u5145\u7684\u65B0\u65B9\u5411\u3011 \u2014 not into prose-form objections.`,
19019
19217
  constructive: "your default trained preference to be diplomatically vague. Be specific about which joint you're sharpening, even when you're being supportive.",
19020
19218
  debate: "your default trained preference for diplomatic middle ground OR for manufactured contrarianism. Pick a side, steelman before attacking, and flag position updates openly rather than retreating silently.",
19021
19219
  research: "your default trained preference to leap to recommendations AND your trained tendency to merge inference with observation. Stay in the materials \u2014 what they say, what they don't say, what your lens makes visible \u2014 and keep the seam visible IN PROSE between what's cited, what's concluded, and what's still untested before any director recommends anything. Do NOT stamp literal **OBSERVATION** / **INFERENCE** / **SPECULATION** / **Confidence: high|med|low** labels or their Chinese equivalents \u2014 the distinction lives in careful sentences, not in form-letter kickers.",
@@ -20622,7 +20820,8 @@ function ensureState(roomId) {
20622
20820
  lastFrameBreakerAgentId: null,
20623
20821
  billingHaltedThisTurn: false,
20624
20822
  voiceWaiters: /* @__PURE__ */ new Map(),
20625
- voicePredone: /* @__PURE__ */ new Set()
20823
+ voicePredone: /* @__PURE__ */ new Set(),
20824
+ activeMessageId: null
20626
20825
  };
20627
20826
  _state.set(roomId, s);
20628
20827
  }
@@ -20749,6 +20948,7 @@ async function chairInterrupt(roomId) {
20749
20948
  }
20750
20949
  state.preWarmed = null;
20751
20950
  }
20951
+ state.activeMessageId = null;
20752
20952
  if (interruptedAgentId) {
20753
20953
  const recent = listRecentMessages(roomId, 8);
20754
20954
  for (let i = recent.length - 1; i >= 0; i--) {
@@ -20884,7 +21084,8 @@ function emitQueueUpdate(roomId, s) {
20884
21084
  round: {
20885
21085
  spoken: s.speakersThisTurn,
20886
21086
  total: s.maxSpeakersThisTurn
20887
- }
21087
+ },
21088
+ activeMessageId: s.activeMessageId
20888
21089
  };
20889
21090
  roomBus.emit(roomId, update);
20890
21091
  }
@@ -20915,6 +21116,7 @@ function tickRoom(roomId, opts) {
20915
21116
  }
20916
21117
  state.preWarmed = null;
20917
21118
  }
21119
+ state.activeMessageId = null;
20918
21120
  for (const [, waiter] of state.voiceWaiters) {
20919
21121
  waiter.resolve();
20920
21122
  }
@@ -21030,6 +21232,21 @@ async function runPickerThenPrewarm(roomId, _currentMessageId) {
21030
21232
  state.inflight.delete(sentinel);
21031
21233
  state.inflight.set(info.messageId, ac);
21032
21234
  }
21235
+ if (state.preWarmed !== preWarmed && state.activeMessageId === null) {
21236
+ state.activeMessageId = info.messageId;
21237
+ const m = getMessage(info.messageId);
21238
+ if (m) {
21239
+ const newMeta = { ...m.meta || {}, preWarmed: false };
21240
+ updateMessageBody(info.messageId, m.body, newMeta);
21241
+ roomBus.emit(roomId, {
21242
+ type: "message-updated",
21243
+ messageId: info.messageId,
21244
+ body: m.body,
21245
+ meta: newMeta
21246
+ });
21247
+ }
21248
+ emitQueueUpdate(roomId, state);
21249
+ }
21033
21250
  }
21034
21251
  // Chain trigger lives in pumpQueue's consume point, NOT here.
21035
21252
  // Rationale: B's `message-final` fires while B is still occupying
@@ -21264,9 +21481,25 @@ async function pumpQueue(roomId) {
21264
21481
  ac = state.preWarmed.abortController;
21265
21482
  streamPromise = state.preWarmed.promise;
21266
21483
  state.preWarmed = null;
21484
+ if (justConsumed.messageId) {
21485
+ state.activeMessageId = justConsumed.messageId;
21486
+ const m = getMessage(justConsumed.messageId);
21487
+ if (m) {
21488
+ const newMeta = { ...m.meta || {}, preWarmed: false };
21489
+ updateMessageBody(justConsumed.messageId, m.body, newMeta);
21490
+ roomBus.emit(roomId, {
21491
+ type: "message-updated",
21492
+ messageId: justConsumed.messageId,
21493
+ body: m.body,
21494
+ meta: newMeta
21495
+ });
21496
+ }
21497
+ emitQueueUpdate(roomId, state);
21498
+ }
21267
21499
  rlog(roomId, "speaker-prewarm-consumed", {
21268
21500
  agent: speaker.name,
21269
- agentId: speaker.id
21501
+ agentId: speaker.id,
21502
+ messageId: justConsumed.messageId || "(pending)"
21270
21503
  });
21271
21504
  schedulePreWarm(roomId, justConsumed.messageId);
21272
21505
  } else {
@@ -21291,6 +21524,8 @@ async function pumpQueue(roomId) {
21291
21524
  state.inflight.delete(sentinel);
21292
21525
  state.inflight.set(info.messageId, ac);
21293
21526
  }
21527
+ state.activeMessageId = info.messageId;
21528
+ emitQueueUpdate(roomId, state);
21294
21529
  },
21295
21530
  onMessageFinal: (info) => {
21296
21531
  schedulePreWarm(roomId, info.messageId);
@@ -21334,6 +21569,10 @@ async function pumpQueue(roomId) {
21334
21569
  if (val === ac) keysToDel.push(key);
21335
21570
  }
21336
21571
  for (const key of keysToDel) state.inflight.delete(key);
21572
+ if (state.activeMessageId) {
21573
+ state.activeMessageId = null;
21574
+ emitQueueUpdate(roomId, state);
21575
+ }
21337
21576
  }
21338
21577
  if (state.queue[0] !== entry) {
21339
21578
  continue;
@@ -21426,11 +21665,14 @@ async function pumpQueue(roomId) {
21426
21665
  });
21427
21666
  if (reachedCap) {
21428
21667
  const room = getRoom(roomId);
21429
- if (room && room.status === "live" && !room.awaitingContinue && !room.awaitingClarify && // Manual vote-trigger · skip the auto round-prompt; the
21430
- // user fires it via the bottom-bar "End round & vote"
21431
- // button which posts to /api/rooms/:id/round-end (the
21432
- // same path the chat round-prompt button takes).
21433
- room.voteTrigger !== "manual") {
21668
+ if (room && room.status === "live" && !room.awaitingContinue && !room.awaitingClarify && room.voteTrigger === "manual") {
21669
+ const nextRound = nextUserRoundNum(roomId);
21670
+ rlog(roomId, "manual-auto-continue", {
21671
+ fromRound: state.roundNum,
21672
+ toRound: nextRound
21673
+ });
21674
+ tickRoom(roomId, { roundNum: nextRound, kind: "continue" });
21675
+ } else if (room && room.status === "live" && !room.awaitingContinue && !room.awaitingClarify) {
21434
21676
  const wrappedRound = state.roundNum;
21435
21677
  emitChairPending(roomId, "vote-summary");
21436
21678
  let recommendation;
@@ -21716,9 +21958,11 @@ async function streamSpeakerTurn(args) {
21716
21958
  }
21717
21959
  async function emitVoiceText(text) {
21718
21960
  if (!voiceMode || !text.trim()) return;
21961
+ const spoken = stripSpokenLabels(text);
21962
+ if (!spoken) return;
21719
21963
  const voiceProfile = currentVoiceProfile();
21720
21964
  if (!voiceProfile) return;
21721
- process.stderr.write(`[tts] emitVoiceText called: provider=${voiceProfile.provider} voiceId=${voiceProfile.voiceId} textLen=${text.length} text="${text.slice(0, 50)}"
21965
+ process.stderr.write(`[tts] emitVoiceText called: provider=${voiceProfile.provider} voiceId=${voiceProfile.voiceId} textLen=${spoken.length} text="${spoken.slice(0, 50)}"
21722
21966
  `);
21723
21967
  const MAX_ATTEMPTS = 2;
21724
21968
  const TIMEOUT_MS = 3e4;
@@ -21731,7 +21975,7 @@ async function streamSpeakerTurn(args) {
21731
21975
  let chunkCount = 0;
21732
21976
  let failure = null;
21733
21977
  try {
21734
- for await (const chunk of synthesizeSpeechStream(text, voiceProfile, timeoutCtrl.signal)) {
21978
+ for await (const chunk of synthesizeSpeechStream(spoken, voiceProfile, timeoutCtrl.signal)) {
21735
21979
  if (signal.aborted) break;
21736
21980
  chunkCount++;
21737
21981
  roomBus.emit(roomId, {
@@ -21961,6 +22205,13 @@ async function streamSpeakerTurn(args) {
21961
22205
  if (tail) await emitVoiceText(tail);
21962
22206
  roomBus.emit(roomId, { type: "voice-final", messageId: placeholder.id });
21963
22207
  }
22208
+ if (voiceMode && voiceSeq === 0) {
22209
+ process.stderr.write(
22210
+ `[tts] zero-chunks for msg=${placeholder.id.slice(0, 8)} agent=${speaker.name} \xB7 short-circuiting voice wait
22211
+ `
22212
+ );
22213
+ markVoicePlaybackDone(roomId, placeholder.id);
22214
+ }
21964
22215
  updateMessageBody(placeholder.id, buf, {
21965
22216
  ...placeholderMeta,
21966
22217
  speakerStatus: "final",
@@ -22016,6 +22267,9 @@ async function streamSpeakerTurn(args) {
22016
22267
  if (voiceChunker) {
22017
22268
  roomBus.emit(roomId, { type: "voice-final", messageId: placeholder.id });
22018
22269
  }
22270
+ if (voiceMode && voiceSeq === 0) {
22271
+ markVoicePlaybackDone(roomId, placeholder.id);
22272
+ }
22019
22273
  roomBus.emit(roomId, {
22020
22274
  type: "message-final",
22021
22275
  messageId: placeholder.id,
@@ -22686,8 +22940,9 @@ async function runChairClarify(roomId) {
22686
22940
  emitChairPending(roomId, "clarify-deciding");
22687
22941
  let decision = null;
22688
22942
  try {
22943
+ const roomForMode = getRoom(roomId);
22689
22944
  decision = await withTimeout(
22690
- pickChairClarifyDecision({ history }),
22945
+ pickChairClarifyDecision({ history, mode: roomForMode?.mode }),
22691
22946
  15e3,
22692
22947
  "chair-clarify-decision"
22693
22948
  );
@@ -25393,6 +25648,25 @@ function ttsCacheSet(key, val) {
25393
25648
  function voicesRouter() {
25394
25649
  const r = new Hono14();
25395
25650
  r.get("/", async (c) => {
25651
+ const url = new URL(c.req.url);
25652
+ const cursor = url.searchParams.get("cursor");
25653
+ const pageSizeRaw = url.searchParams.get("pageSize");
25654
+ if (cursor !== null || pageSizeRaw !== null) {
25655
+ const pageSize = pageSizeRaw ? Math.max(1, Number.parseInt(pageSizeRaw, 10) || 30) : 30;
25656
+ const page = await listVoicesPage(cursor, pageSize);
25657
+ return c.json({
25658
+ voices: page.voices,
25659
+ nextCursor: page.nextCursor,
25660
+ hasMore: page.hasMore,
25661
+ provider: page.provider,
25662
+ configured: page.configured,
25663
+ // Structured upstream error · the picker uses this to render
25664
+ // a clear "your API key is missing voices_read permission"
25665
+ // banner + a link to the ElevenLabs API-key settings page
25666
+ // instead of a silently empty dropdown.
25667
+ ...page.error ? { error: page.error } : {}
25668
+ });
25669
+ }
25396
25670
  const catalog = await listAvailableVoices();
25397
25671
  return c.json({
25398
25672
  voices: catalog.voices,
@@ -25539,7 +25813,7 @@ function voicesRouter() {
25539
25813
  init_paths();
25540
25814
 
25541
25815
  // src/version.ts
25542
- var VERSION = "0.1.32";
25816
+ var VERSION = "0.1.37";
25543
25817
 
25544
25818
  // src/utils/render-picker-catalog.ts
25545
25819
  function renderPickerCatalog() {