privateboard 0.1.32 → 0.1.36

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/server.js CHANGED
@@ -1214,7 +1214,13 @@ var MODELS = {
1214
1214
  baiId: "claude-opus-4.7",
1215
1215
  displayName: "Opus 4.7",
1216
1216
  contextBudget: 2e5,
1217
- deck: "deep reasoning"
1217
+ deck: "deep reasoning",
1218
+ // Anthropic dropped `temperature` for the 4.7 family · sending it
1219
+ // returns HTTP 400 "temperature is deprecated for this model"
1220
+ // across every carrier (direct / OR / B.AI all proxy to the same
1221
+ // upstream). The adapter omits temperature for any model with
1222
+ // this flag.
1223
+ noTemperature: true
1218
1224
  },
1219
1225
  "opus-4-6-fast": {
1220
1226
  v: "opus-4-6-fast",
@@ -1426,6 +1432,16 @@ function getModel(v) {
1426
1432
  function isModelV(v) {
1427
1433
  return Object.hasOwn(MODELS, v);
1428
1434
  }
1435
+ function noTemperatureModelIds() {
1436
+ const ids = /* @__PURE__ */ new Set();
1437
+ for (const m of Object.values(MODELS)) {
1438
+ if (!m.noTemperature) continue;
1439
+ if (m.directApiId) ids.add(m.directApiId);
1440
+ if (m.openrouterId) ids.add(m.openrouterId);
1441
+ if (m.baiId) ids.add(m.baiId);
1442
+ }
1443
+ return ids;
1444
+ }
1429
1445
 
1430
1446
  // src/utils/agent-handle.ts
1431
1447
  var AGENT_HANDLE_SIGIL = "@";
@@ -3322,21 +3338,47 @@ function redactHeaderValue(name, value) {
3322
3338
  const tail = v.slice(-4);
3323
3339
  return tail ? `****${tail}` : "****";
3324
3340
  }
3341
+ var NO_TEMP_IDS_CACHE = null;
3342
+ function noTempIds() {
3343
+ if (!NO_TEMP_IDS_CACHE) NO_TEMP_IDS_CACHE = noTemperatureModelIds();
3344
+ return NO_TEMP_IDS_CACHE;
3345
+ }
3346
+ function stripTemperatureForNoTempModels(rawBody) {
3347
+ try {
3348
+ const parsed = JSON.parse(rawBody);
3349
+ const modelId = typeof parsed.model === "string" ? parsed.model : null;
3350
+ if (modelId && noTempIds().has(modelId) && "temperature" in parsed) {
3351
+ delete parsed.temperature;
3352
+ return { body: JSON.stringify(parsed), stripped: true };
3353
+ }
3354
+ } catch {
3355
+ }
3356
+ return { body: rawBody, stripped: false };
3357
+ }
3325
3358
  function makeLoggedFetch(tag) {
3326
3359
  return function loggedFetch2(input, init) {
3360
+ let effectiveInit = init;
3361
+ let stripNote = "";
3362
+ if (init?.body && typeof init.body === "string") {
3363
+ const r = stripTemperatureForNoTempModels(init.body);
3364
+ if (r.stripped) {
3365
+ effectiveInit = { ...init, body: r.body };
3366
+ stripNote = ` \xB7 stripped temperature (noTemperature model)`;
3367
+ }
3368
+ }
3327
3369
  const url = typeof input === "string" || input instanceof URL ? String(input) : input.url;
3328
- const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
3329
- const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : void 0));
3370
+ const method = (effectiveInit?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
3371
+ const headers = new Headers(effectiveInit?.headers ?? (input instanceof Request ? input.headers : void 0));
3330
3372
  const headerLines = [];
3331
3373
  headers.forEach((v, k) => {
3332
3374
  headerLines.push(` ${k}: ${redactHeaderValue(k, v)}`);
3333
3375
  });
3334
3376
  let bodyPretty = "";
3335
- if (init?.body && typeof init.body === "string") {
3377
+ if (effectiveInit?.body && typeof effectiveInit.body === "string") {
3336
3378
  try {
3337
- bodyPretty = JSON.stringify(JSON.parse(init.body), null, 2);
3379
+ bodyPretty = JSON.stringify(JSON.parse(effectiveInit.body), null, 2);
3338
3380
  } catch {
3339
- bodyPretty = init.body.length > 2e3 ? init.body.slice(0, 2e3) + "\u2026" : init.body;
3381
+ bodyPretty = effectiveInit.body.length > 2e3 ? effectiveInit.body.slice(0, 2e3) + "\u2026" : effectiveInit.body;
3340
3382
  }
3341
3383
  }
3342
3384
  const sep = "\u2500".repeat(60);
@@ -3349,14 +3391,14 @@ function makeLoggedFetch(tag) {
3349
3391
  process.stderr.write(
3350
3392
  `
3351
3393
  \u250C${sep}
3352
- \u2502 [${tag} \u2192] ${method} ${url}
3394
+ \u2502 [${tag} \u2192] ${method} ${url}${stripNote}
3353
3395
  ` + headerBlock + `
3354
3396
  ` + bodyBlock + `
3355
3397
  \u2514${sep}
3356
3398
  `
3357
3399
  );
3358
3400
  const t0 = Date.now();
3359
- return fetch(input, init).then(async (res) => {
3401
+ return fetch(input, effectiveInit).then(async (res) => {
3360
3402
  const ms = Date.now() - t0;
3361
3403
  const resHeaderLines = [];
3362
3404
  res.headers.forEach((v, k) => resHeaderLines.push(` ${k}: ${v}`));
@@ -3617,6 +3659,7 @@ async function* callLLMStream(req) {
3617
3659
  yield { type: "error", message: formatStreamError(e) };
3618
3660
  return;
3619
3661
  }
3662
+ const temperature = getModel(req.modelV).noTemperature ? void 0 : req.temperature;
3620
3663
  let attempt = 0;
3621
3664
  let lastTransientMessage = "";
3622
3665
  let yieldedText = false;
@@ -3642,7 +3685,7 @@ async function* callLLMStream(req) {
3642
3685
  model: resolved.model,
3643
3686
  providerOptions: resolved.providerOptions,
3644
3687
  messages: req.messages,
3645
- temperature: req.temperature,
3688
+ temperature,
3646
3689
  // Vercel SDK names this maxOutputTokens in v4+; tolerate both.
3647
3690
  maxTokens: req.maxTokens,
3648
3691
  abortSignal: req.signal
@@ -7264,8 +7307,11 @@ var CLUSTER_MAX_SIZE = 60;
7264
7307
  var PROMOTE_MIN_PROVENANCE = 3;
7265
7308
  var PROMOTE_MIN_AGE_MS = 7 * 24 * 60 * 60 * 1e3;
7266
7309
  var PROMOTE_MIN_CONFIDENCE = 0.6;
7267
- var USER_LONG_HARVEST_MIN_LONG = 5;
7268
- var USER_LONG_HARVEST_MIN_NEW_PROMOTED = 3;
7310
+ var USER_LONG_HARVEST_MIN_LONG = 2;
7311
+ var USER_LONG_HARVEST_MIN_NEW_PROMOTED = 1;
7312
+ var USER_LONG_HARVEST_MIN_SHORT_HIGH = 6;
7313
+ var USER_LONG_SHORT_CONF_FLOOR = 0.6;
7314
+ var USER_LONG_HARVEST_INPUT_CAP = 40;
7269
7315
  var USER_LONG_CAP = 30;
7270
7316
  async function runDreamCycle(agentId, config = {}) {
7271
7317
  const startedAt = Date.now();
@@ -7376,13 +7422,18 @@ async function runDreamCycle(agentId, config = {}) {
7376
7422
  if (!config.skipLLM && utility && agent?.roleKind === "moderator") {
7377
7423
  try {
7378
7424
  const chairLong = listTierForAgent(agentId, "long");
7379
- const eligible = chairLong.length >= USER_LONG_HARVEST_MIN_LONG || promoted >= USER_LONG_HARVEST_MIN_NEW_PROMOTED;
7425
+ const chairShortHigh = listTierForAgent(agentId, "short").filter((m) => m.confidence >= USER_LONG_SHORT_CONF_FLOOR && !m.pinned);
7426
+ const eligible = chairLong.length >= USER_LONG_HARVEST_MIN_LONG || promoted >= USER_LONG_HARVEST_MIN_NEW_PROMOTED || chairShortHigh.length >= USER_LONG_HARVEST_MIN_SHORT_HIGH;
7380
7427
  if (eligible) {
7381
7428
  const existing = listActiveUserLongMemory();
7429
+ const pool = [
7430
+ ...chairLong,
7431
+ ...chairShortHigh.slice().sort((a, b) => b.confidence - a.confidence)
7432
+ ].slice(0, USER_LONG_HARVEST_INPUT_CAP);
7382
7433
  const harvest = await harvestUserLongMemory({
7383
7434
  modelV: utility,
7384
7435
  userName,
7385
- chairLong,
7436
+ chairLong: pool,
7386
7437
  existing
7387
7438
  });
7388
7439
  for (const t of harvest.newTags) {
@@ -7471,14 +7522,14 @@ async function runDreamCycle(agentId, config = {}) {
7471
7522
  var HARVEST_EMPTY = { newTags: [], reinforce: [], supersede: [] };
7472
7523
  function buildHarvestPrompt(opts) {
7473
7524
  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");
7474
- 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");
7525
+ 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");
7475
7526
  return [
7476
- `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).`,
7527
+ `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).`,
7477
7528
  ``,
7478
7529
  `## Existing user-long-memory tags`,
7479
7530
  existingBlock,
7480
7531
  ``,
7481
- `## Chair's long-tier memories about ${opts.userName}`,
7532
+ `## Chair memories about ${opts.userName} (mixed pool \xB7 prefer long-tier or high-confidence short-tier entries when proposing tags)`,
7482
7533
  chairBlock,
7483
7534
  ``,
7484
7535
  `## Output`,
@@ -14201,161 +14252,117 @@ function listConfiguredVoices() {
14201
14252
  });
14202
14253
  return out;
14203
14254
  }
14204
- async function listAvailableVoices() {
14205
- const activeProvider = getActiveVoiceProvider();
14206
- let voices = listConfiguredVoices();
14207
- if (!activeProvider) {
14208
- return { voices, provider: null, configured: false };
14255
+ function encodeCursor(c) {
14256
+ return Buffer.from(JSON.stringify(c), "utf8").toString("base64url");
14257
+ }
14258
+ function decodeCursor(s) {
14259
+ if (!s) return null;
14260
+ try {
14261
+ const obj = JSON.parse(Buffer.from(s, "base64url").toString("utf8"));
14262
+ if (obj && (obj.src === "el" || obj.src === "mm")) return obj;
14263
+ } catch {
14209
14264
  }
14210
- const activeKey = getActiveVoiceKeyPlaintext();
14211
- if (!activeKey) {
14212
- return { voices, provider: activeProvider, configured: false };
14265
+ return null;
14266
+ }
14267
+ function classifyElevenLabsError(status, body) {
14268
+ let parsed = null;
14269
+ try {
14270
+ parsed = JSON.parse(body);
14271
+ } catch {
14213
14272
  }
14214
- if (activeProvider === "minimax") {
14273
+ const detail = parsed?.detail;
14274
+ const upstreamStatus = typeof detail?.status === "string" ? detail.status : "";
14275
+ const upstreamMessage = typeof detail?.message === "string" ? detail.message : body.slice(0, 200);
14276
+ if (status === 401 && upstreamStatus === "missing_permissions") {
14277
+ return {
14278
+ code: "missing_permissions",
14279
+ provider: "elevenlabs",
14280
+ message: upstreamMessage,
14281
+ // Direct link to the API-key management page · "Update key
14282
+ // permissions" is what the user needs to do, and ElevenLabs's
14283
+ // settings page surfaces the scope checkboxes prominently.
14284
+ fixUrl: "https://elevenlabs.io/app/settings/api-keys"
14285
+ };
14286
+ }
14287
+ if (status === 401 || status === 403) {
14288
+ return {
14289
+ code: "auth_failed",
14290
+ provider: "elevenlabs",
14291
+ message: upstreamMessage,
14292
+ fixUrl: "https://elevenlabs.io/app/settings/api-keys"
14293
+ };
14294
+ }
14295
+ if (status === 429) {
14296
+ return {
14297
+ code: "rate_limited",
14298
+ provider: "elevenlabs",
14299
+ message: upstreamMessage
14300
+ };
14301
+ }
14302
+ return {
14303
+ code: "fetch_failed",
14304
+ provider: "elevenlabs",
14305
+ message: `HTTP ${status}: ${upstreamMessage}`
14306
+ };
14307
+ }
14308
+ async function fetchAllElevenLabsV2Voices(apiKey) {
14309
+ const out = [];
14310
+ let token = null;
14311
+ let lastError = null;
14312
+ for (let i = 0; i < 20; i++) {
14313
+ const url = new URL("https://api.elevenlabs.io/v2/voices");
14314
+ url.searchParams.set("page_size", "100");
14315
+ if (token) url.searchParams.set("next_page_token", token);
14215
14316
  try {
14216
- const res = await fetch(`${minimaxBaseUrl()}/v1/get_voice`, {
14217
- method: "POST",
14218
- headers: {
14219
- "authorization": `Bearer ${activeKey}`,
14220
- "content-type": "application/json"
14221
- },
14222
- body: JSON.stringify({ voice_type: "all" })
14317
+ const res = await fetch(url.toString(), {
14318
+ headers: { "xi-api-key": apiKey }
14223
14319
  });
14224
- if (res.ok) {
14225
- const json = await res.json();
14226
- const rows = [
14227
- ...voiceRows(json.system_voice, "system"),
14228
- ...voiceRows(json.voice_cloning, "clone"),
14229
- ...voiceRows(json.voice_generation, "generated")
14230
- ];
14231
- if (rows.length > 0) {
14232
- const nonMiniMax = voices.filter((v) => v.provider !== "minimax");
14233
- voices = [
14234
- ...nonMiniMax,
14235
- ...rows.map((r) => ({
14236
- provider: "minimax",
14237
- model: "speech-2.8-hd",
14238
- voiceId: r.voiceId,
14239
- label: r.label,
14240
- language: r.kind,
14241
- configured: true
14242
- }))
14243
- ];
14244
- }
14245
- }
14246
- } catch {
14247
- }
14248
- return { voices, provider: "minimax", configured: true };
14249
- }
14250
- if (activeProvider === "elevenlabs") {
14251
- const personal = [];
14252
- const shared = [];
14253
- await Promise.all([
14254
- (async () => {
14255
- try {
14256
- const res = await fetch(
14257
- "https://api.elevenlabs.io/v1/voices?show_legacy=true&include_total_count=true",
14258
- { headers: { "xi-api-key": activeKey } }
14259
- );
14260
- if (!res.ok) {
14261
- const errText = await res.text();
14262
- process.stderr.write(
14263
- `[voice-registry] elevenlabs /v1/voices HTTP ${res.status}: ${errText.slice(0, 300)}
14264
- `
14265
- );
14266
- return;
14267
- }
14268
- const json = await res.json();
14269
- const rows = elevenLabsVoiceRows(json.voices);
14270
- process.stderr.write(`[voice-registry] elevenlabs /v1/voices \xB7 ${rows.length} voices in personal library
14271
- `);
14272
- personal.push(...rows);
14273
- } catch (e) {
14274
- const cause = e instanceof Error ? e.cause : null;
14275
- const detail = cause?.message ? `: ${cause.message}` : "";
14276
- process.stderr.write(
14277
- `[voice-registry] elevenlabs /v1/voices fetch failed${detail} \xB7 ${e instanceof Error ? e.message : String(e)}
14278
- `
14279
- );
14280
- }
14281
- })(),
14282
- (async () => {
14283
- try {
14284
- const res = await fetch(
14285
- "https://api.elevenlabs.io/v1/shared-voices?page_size=100",
14286
- { headers: { "xi-api-key": activeKey } }
14287
- );
14288
- if (!res.ok) {
14289
- const errText = await res.text();
14290
- process.stderr.write(
14291
- `[voice-registry] elevenlabs /v1/shared-voices HTTP ${res.status}: ${errText.slice(0, 300)}
14320
+ if (!res.ok) {
14321
+ const errText = await res.text();
14322
+ process.stderr.write(
14323
+ `[voice-registry] elevenlabs /v2/voices HTTP ${res.status}: ${errText.slice(0, 300)}
14292
14324
  `
14293
- );
14294
- return;
14295
- }
14296
- const json = await res.json();
14297
- const rows = elevenLabsSharedVoiceRows(json.voices);
14298
- process.stderr.write(`[voice-registry] elevenlabs /v1/shared-voices \xB7 ${rows.length} voices from public library
14299
- `);
14300
- shared.push(...rows);
14301
- } catch (e) {
14302
- const cause = e instanceof Error ? e.cause : null;
14303
- const detail = cause?.message ? `: ${cause.message}` : "";
14304
- process.stderr.write(
14305
- `[voice-registry] elevenlabs /v1/shared-voices fetch failed${detail} \xB7 ${e instanceof Error ? e.message : String(e)}
14325
+ );
14326
+ lastError = classifyElevenLabsError(res.status, errText);
14327
+ break;
14328
+ }
14329
+ const json = await res.json();
14330
+ const rows = elevenLabsV2VoiceRows(json.voices);
14331
+ for (const r of rows) {
14332
+ out.push({
14333
+ provider: "elevenlabs",
14334
+ model: "eleven_multilingual_v2",
14335
+ voiceId: r.voiceId,
14336
+ label: r.label,
14337
+ language: r.category,
14338
+ configured: true
14339
+ });
14340
+ }
14341
+ const nextToken = json.has_more === true && typeof json.next_page_token === "string" ? json.next_page_token : null;
14342
+ if (!nextToken) break;
14343
+ token = nextToken;
14344
+ } catch (e) {
14345
+ const cause = e instanceof Error ? e.cause : null;
14346
+ const detail = cause?.message ? `: ${cause.message}` : "";
14347
+ process.stderr.write(
14348
+ `[voice-registry] elevenlabs /v2/voices fetch failed${detail} \xB7 ${e instanceof Error ? e.message : String(e)}
14306
14349
  `
14307
- );
14308
- }
14309
- })()
14310
- ]);
14311
- if (personal.length > 0 || shared.length > 0) {
14312
- const nonEl = voices.filter((v) => v.provider !== "elevenlabs");
14313
- const personalIds = new Set(personal.map((r) => r.voiceId));
14314
- const sharedDeduped = shared.filter((r) => !personalIds.has(r.voiceId));
14315
- const personalMapped = personal.map((r) => ({
14316
- provider: "elevenlabs",
14317
- model: "eleven_multilingual_v2",
14318
- voiceId: r.voiceId,
14319
- label: r.label,
14320
- // Personal-library rows keep their actual category
14321
- // ("premade", "cloned", "professional", "generated").
14322
- language: r.category,
14323
- configured: true
14324
- }));
14325
- const sharedMapped = sharedDeduped.map((r) => ({
14350
+ );
14351
+ lastError = {
14352
+ code: "fetch_failed",
14326
14353
  provider: "elevenlabs",
14327
- model: "eleven_multilingual_v2",
14328
- voiceId: r.voiceId,
14329
- // Prefix shared-library voices so users can tell at a glance
14330
- // which set they're picking from. The dropdown's group header
14331
- // already says "elevenlabs", so the per-row prefix is the
14332
- // tightest signal we have for personal-vs-shared.
14333
- label: `${r.label} \xB7 shared`,
14334
- language: r.language || r.category,
14335
- configured: true
14336
- }));
14337
- voices = [...nonEl, ...personalMapped, ...sharedMapped];
14354
+ message: e instanceof Error ? e.message : String(e)
14355
+ };
14356
+ break;
14338
14357
  }
14339
- return { voices, provider: "elevenlabs", configured: true };
14340
14358
  }
14341
- return { voices, provider: activeProvider, configured: true };
14342
- }
14343
- function elevenLabsSharedVoiceRows(raw) {
14344
- if (!Array.isArray(raw)) return [];
14345
- const out = [];
14346
- for (const item of raw) {
14347
- if (!item || typeof item !== "object") continue;
14348
- const obj = item;
14349
- const voiceId = typeof obj.voice_id === "string" ? obj.voice_id : "";
14350
- if (!voiceId) continue;
14351
- const label = typeof obj.name === "string" && obj.name.trim() ? obj.name.trim() : voiceId;
14352
- const category = typeof obj.category === "string" && obj.category.trim() ? obj.category.trim() : "shared";
14353
- const language = typeof obj.language === "string" && obj.language.trim() ? obj.language.trim() : void 0;
14354
- out.push({ voiceId, label, category, language });
14355
- }
14356
- return out;
14359
+ process.stderr.write(
14360
+ `[voice-registry] elevenlabs /v2/voices \xB7 ${out.length} voices total across all pages
14361
+ `
14362
+ );
14363
+ return { voices: out, error: lastError };
14357
14364
  }
14358
- function elevenLabsVoiceRows(raw) {
14365
+ function elevenLabsV2VoiceRows(raw) {
14359
14366
  if (!Array.isArray(raw)) return [];
14360
14367
  const out = [];
14361
14368
  for (const item of raw) {
@@ -14369,6 +14376,164 @@ function elevenLabsVoiceRows(raw) {
14369
14376
  }
14370
14377
  return out;
14371
14378
  }
14379
+ var ELEVENLABS_CACHE_TTL_MS = 5 * 60 * 1e3;
14380
+ var elevenLabsCache = /* @__PURE__ */ new Map();
14381
+ function elevenLabsCacheKey(apiKey) {
14382
+ return apiKey.slice(0, 8);
14383
+ }
14384
+ async function getElevenLabsVoicesCached(apiKey) {
14385
+ const key = elevenLabsCacheKey(apiKey);
14386
+ const cached = elevenLabsCache.get(key);
14387
+ if (cached && cached.expiresAt > Date.now()) {
14388
+ return { voices: cached.voices, error: null };
14389
+ }
14390
+ const result = await fetchAllElevenLabsV2Voices(apiKey);
14391
+ process.stderr.write(
14392
+ `[voice-registry] elevenlabs catalogue \xB7 ${result.voices.length} voices from /v2/voices${result.error ? ` (error: ${result.error.code})` : ""}
14393
+ `
14394
+ );
14395
+ if (!result.error) {
14396
+ elevenLabsCache.set(key, { voices: result.voices, expiresAt: Date.now() + ELEVENLABS_CACHE_TTL_MS });
14397
+ }
14398
+ return result;
14399
+ }
14400
+ var MINIMAX_CACHE_TTL_MS = 5 * 60 * 1e3;
14401
+ var miniMaxCache = /* @__PURE__ */ new Map();
14402
+ function miniMaxCacheKey(apiKey) {
14403
+ return apiKey.slice(0, 8);
14404
+ }
14405
+ async function fetchAllMiniMaxVoices(apiKey) {
14406
+ try {
14407
+ const res = await fetch(`${minimaxBaseUrl()}/v1/get_voice`, {
14408
+ method: "POST",
14409
+ headers: {
14410
+ "authorization": `Bearer ${apiKey}`,
14411
+ "content-type": "application/json"
14412
+ },
14413
+ body: JSON.stringify({ voice_type: "all" })
14414
+ });
14415
+ if (!res.ok) {
14416
+ return MINIMAX_SYSTEM_VOICES.map((v) => ({ ...v, configured: true }));
14417
+ }
14418
+ const json = await res.json();
14419
+ const rows = [
14420
+ ...voiceRows(json.system_voice, "system"),
14421
+ ...voiceRows(json.voice_cloning, "clone"),
14422
+ ...voiceRows(json.voice_generation, "generated")
14423
+ ];
14424
+ if (rows.length === 0) {
14425
+ return MINIMAX_SYSTEM_VOICES.map((v) => ({ ...v, configured: true }));
14426
+ }
14427
+ return rows.map((r) => ({
14428
+ provider: "minimax",
14429
+ model: "speech-2.8-hd",
14430
+ voiceId: r.voiceId,
14431
+ label: r.label,
14432
+ language: r.kind,
14433
+ configured: true
14434
+ }));
14435
+ } catch {
14436
+ return MINIMAX_SYSTEM_VOICES.map((v) => ({ ...v, configured: true }));
14437
+ }
14438
+ }
14439
+ async function getMiniMaxVoicesCached(apiKey) {
14440
+ const key = miniMaxCacheKey(apiKey);
14441
+ const cached = miniMaxCache.get(key);
14442
+ if (cached && cached.expiresAt > Date.now()) return cached.voices;
14443
+ const voices = await fetchAllMiniMaxVoices(apiKey);
14444
+ miniMaxCache.set(key, { voices, expiresAt: Date.now() + MINIMAX_CACHE_TTL_MS });
14445
+ return voices;
14446
+ }
14447
+ var BROWSER_FALLBACK = {
14448
+ provider: "browser",
14449
+ model: "speechSynthesis",
14450
+ voiceId: "system-default",
14451
+ label: "Browser default",
14452
+ configured: true
14453
+ };
14454
+ async function listVoicesPage(cursorStr, pageSize) {
14455
+ const size = Math.min(Math.max(pageSize | 0 || 30, 5), 100);
14456
+ const cursor = decodeCursor(cursorStr);
14457
+ const isFirstPage = cursor === null;
14458
+ const activeProvider = getActiveVoiceProvider();
14459
+ const fixed = [];
14460
+ if (isFirstPage && getKey("openai")) {
14461
+ fixed.push(...OPENAI_VOICES.map((v) => ({ ...v, configured: true })));
14462
+ }
14463
+ if (!activeProvider) {
14464
+ return {
14465
+ voices: [...fixed, BROWSER_FALLBACK],
14466
+ nextCursor: null,
14467
+ hasMore: false,
14468
+ provider: null,
14469
+ configured: false
14470
+ };
14471
+ }
14472
+ const activeKey = getActiveVoiceKeyPlaintext();
14473
+ if (!activeKey) {
14474
+ return {
14475
+ voices: [...fixed, BROWSER_FALLBACK],
14476
+ nextCursor: null,
14477
+ hasMore: false,
14478
+ provider: activeProvider,
14479
+ configured: false
14480
+ };
14481
+ }
14482
+ if (activeProvider === "elevenlabs") {
14483
+ const { voices: all, error } = await getElevenLabsVoicesCached(activeKey);
14484
+ const offset = cursor && cursor.src === "el" ? cursor.offset ?? 0 : 0;
14485
+ const slice = all.slice(offset, offset + size);
14486
+ const next = offset + slice.length;
14487
+ const hasMore = next < all.length;
14488
+ const nextCursor = hasMore ? encodeCursor({ src: "el", offset: next }) : null;
14489
+ const voices = [...fixed, ...slice];
14490
+ if (!hasMore) voices.push(BROWSER_FALLBACK);
14491
+ return {
14492
+ voices,
14493
+ nextCursor,
14494
+ hasMore,
14495
+ provider: "elevenlabs",
14496
+ configured: true,
14497
+ // Only attach the error to the FIRST page response · subsequent
14498
+ // pages (offset > 0) won't fire if the first page errored
14499
+ // (voices is empty so hasMore is false), but defensive.
14500
+ ...error && offset === 0 ? { error } : {}
14501
+ };
14502
+ }
14503
+ if (activeProvider === "minimax") {
14504
+ const all = await getMiniMaxVoicesCached(activeKey);
14505
+ const offset = cursor && cursor.src === "mm" ? cursor.offset ?? 0 : 0;
14506
+ const slice = all.slice(offset, offset + size);
14507
+ const next = offset + slice.length;
14508
+ const hasMore = next < all.length;
14509
+ const nextCursor = hasMore ? encodeCursor({ src: "mm", offset: next }) : null;
14510
+ const voices = [...fixed, ...slice];
14511
+ if (!hasMore) voices.push(BROWSER_FALLBACK);
14512
+ return { voices, nextCursor, hasMore, provider: "minimax", configured: true };
14513
+ }
14514
+ return {
14515
+ voices: [...fixed, BROWSER_FALLBACK],
14516
+ nextCursor: null,
14517
+ hasMore: false,
14518
+ provider: activeProvider,
14519
+ configured: true
14520
+ };
14521
+ }
14522
+ async function listAvailableVoices() {
14523
+ const voices = [];
14524
+ let cursor = null;
14525
+ let provider = null;
14526
+ let configured = false;
14527
+ for (let i = 0; i < 50; i++) {
14528
+ const page = await listVoicesPage(cursor, 100);
14529
+ voices.push(...page.voices);
14530
+ provider = page.provider;
14531
+ configured = page.configured;
14532
+ if (!page.hasMore || !page.nextCursor) break;
14533
+ cursor = page.nextCursor;
14534
+ }
14535
+ return { voices, provider, configured };
14536
+ }
14372
14537
  function voiceRows(raw, kind) {
14373
14538
  if (!Array.isArray(raw)) return [];
14374
14539
  const out = [];
@@ -14403,7 +14568,7 @@ function makeMiniMaxBalanceError() {
14403
14568
  );
14404
14569
  err2.code = "paid-plan-required";
14405
14570
  err2.provider = "minimax";
14406
- err2.upgradeUrl = getPrefs().minimaxRegion === "intl" ? "https://platform.minimax.io/user-center/billing/overview" : "https://platform.minimaxi.com/user-center/payment";
14571
+ err2.upgradeUrl = getPrefs().minimaxRegion === "intl" ? "https://platform.minimax.io/user-center/basic-information" : "https://platform.minimaxi.com/user-center/payment/balance";
14407
14572
  return err2;
14408
14573
  }
14409
14574
  function makeElevenLabsBillingError(message) {
@@ -14430,10 +14595,15 @@ function tryExtractTtsBillingError(err2) {
14430
14595
  }
14431
14596
  return out;
14432
14597
  }
14598
+ function stripSpokenLabels(text) {
14599
+ if (!text) return "";
14600
+ return text.replace(/【[^】\n]{1,40}】[ \t]*/g, "").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
14601
+ }
14433
14602
  function cleanForSpeech(md) {
14434
14603
  if (!md) return "";
14435
14604
  let out = md;
14436
14605
  out = out.replace(/```[\s\S]*?```/g, " ");
14606
+ out = out.replace(/【[^】\n]{1,40}】[ \t]*/g, " ");
14437
14607
  out = out.replace(/`([^`\n]+)`/g, "$1");
14438
14608
  out = out.replace(/!\[[^\]]*\]\([^)]+\)/g, " ");
14439
14609
  out = out.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
@@ -17229,6 +17399,7 @@ var MAX_PICKS = 2;
17229
17399
  async function pickChairClarifyDecision(opts) {
17230
17400
  const prompt = latestUserPrompt(opts.history);
17231
17401
  if (!prompt) return { shouldAsk: true, rationale: "no user prompt yet" };
17402
+ const isBrainstorm = (opts.mode || "").toLowerCase() === "brainstorm";
17232
17403
  const sys = {
17233
17404
  role: "system",
17234
17405
  content: [
@@ -17250,6 +17421,14 @@ async function pickChairClarifyDecision(opts) {
17250
17421
  "Bias toward RELEASE. A slightly-fuzzy framing is fine \u2014 directors",
17251
17422
  "can sharpen it themselves. Asking when you don't need to kills",
17252
17423
  "momentum.",
17424
+ ...isBrainstorm ? [
17425
+ "",
17426
+ "BRAINSTORM MODE OVERRIDE \xB7 this room is in brainstorm mode. RELEASE",
17427
+ "unless the subject is literally unparseable (empty, gibberish, single",
17428
+ "character). Fuzzy / abstract / under-specified seeds are a FEATURE",
17429
+ "here \u2014 directors fill the gap with explicit assumptions, not by",
17430
+ "asking the user. Default ask=false in brainstorm."
17431
+ ] : [],
17253
17432
  "",
17254
17433
  "Reply with STRICT JSON ONLY (no prose, no fences):",
17255
17434
  `{ "ask": true, "rationale": "\u2264120 chars \xB7 what's load-bearingly missing" }`,
@@ -18076,47 +18255,49 @@ var SHARED_ROOM_PROTOCOL = [
18076
18255
  ].join("\n");
18077
18256
  var TONE_GUIDANCE = {
18078
18257
  brainstorm: [
18079
- `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.**`,
18258
+ "\u2500\u2500\u2500 \u5171\u521B\u6A21\u5F0F \xB7 BRAINSTORM \u2500\u2500\u2500",
18259
+ "\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",
18080
18260
  "",
18081
- "## How a turn looks",
18082
- "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.",
18261
+ "\u9ED8\u8BA4\u6A21\u5F0F\uFF1A**\u53D1\u6563\u5171\u521B\u6A21\u5F0F\uFF08VALUE AMPLIFICATION\uFF09**\u3002",
18083
18262
  "",
18084
- "Format \xB7 a quick bulleted list:",
18085
- " \xB7 <idea 1 \xB7 1\u20132 sentences \xB7 concrete>",
18086
- " \xB7 <idea 2 \xB7 1\u20132 sentences \xB7 different angle, OR yes-and on a previous one>",
18087
- " \xB7 <idea 3 \xB7 1\u20132 sentences \xB7 go wilder>",
18088
- " \xB7 ... (3\u20136 total)",
18263
+ "## \u7EDD\u5BF9\u4E0D\u8981 (do NOT)",
18264
+ " \xB7 \u4E0D\u8981\u6025\u7740\u5224\u65AD\u5BF9\u9519\uFF1B",
18265
+ " \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",
18266
+ " \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",
18267
+ " \xB7 \u4E0D\u8981\u628A\u8BA8\u8BBA\u6536\u655B\u5230\u98CE\u9669\u548C\u9650\u5236\uFF1B",
18268
+ ' \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',
18269
+ ' \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',
18089
18270
  "",
18090
- "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.",
18271
+ "## \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",
18091
18272
  "",
18092
- "## Three legitimate moves (mix freely in one turn)",
18093
- " \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).",
18094
- ' \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`.',
18095
- ` \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.`,
18273
+ "\u3010\u6211\u770B\u5230\u7684\u4EF7\u503C\u3011",
18274
+ "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",
18096
18275
  "",
18097
- "Aim for a mix across a turn: one or two NEW, one or two YES-AND, at least one WILD when you're stretching.",
18276
+ "\u3010\u6211\u4F1A\u600E\u4E48\u653E\u5927\u3011",
18277
+ "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",
18098
18278
  "",
18099
- "## Don't",
18100
- ` \xB7 Don't pre-filter. "This probably won't work because\u2026" is forbidden. Feasibility judgement happens in critique mode, not here.`,
18101
- " \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.",
18102
- ` \xB7 Don't preface with affirmation ("That's a good point, what if we\u2026") \u2014 just say the new idea.`,
18103
- " \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.",
18104
- ` \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.`,
18105
- ` \xB7 Avoid generic innovation language: "synergy", "leverage AI", "platform play", "democratise X", "AI-native", "unlock value". They're decoration, not ideas.`,
18106
- " \xB7 Avoid the same lens used by the immediately-prior speaker UNLESS you're explicitly yes-and'ing them.",
18279
+ "\u3010\u4E00\u4E2A\u66F4\u6027\u611F\u7684\u8868\u8FBE\u3011",
18280
+ "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",
18107
18281
  "",
18108
- "## Optional \xB7 one synthesis turn per round",
18109
- "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.",
18282
+ "\u3010\u4E00\u4E2A\u5177\u4F53\u505A\u6CD5\u3011",
18283
+ "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",
18110
18284
  "",
18111
- "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.",
18285
+ "\u3010\u6211\u8865\u5145\u7684\u65B0\u65B9\u5411\u3011",
18286
+ "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",
18112
18287
  "",
18113
- "DIVERGENCE DISCIPLINE (this is the anti-convergence rule, read it twice) \xB7",
18114
- " \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.",
18115
- " \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.",
18116
- ` \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").`,
18117
- ' \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.',
18118
- ` \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?").`,
18119
- ` \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.`
18288
+ "\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",
18289
+ "",
18290
+ "## English-language fallback",
18291
+ "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.",
18292
+ "",
18293
+ "## Light don'ts (carryovers worth keeping)",
18294
+ ' \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',
18295
+ " \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",
18296
+ ' \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',
18297
+ "",
18298
+ `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.`,
18299
+ "",
18300
+ '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.'
18120
18301
  ].join("\n"),
18121
18302
  constructive: [
18122
18303
  "CONSTRUCTIVE \xB7 sympathetic interrogator. You want the user to win, but only via an idea that can actually survive scrutiny.",
@@ -18221,6 +18402,23 @@ var TONE_GUIDANCE = {
18221
18402
  ].join("\n")
18222
18403
  };
18223
18404
  var CHAIR_MODE_PROTOCOL = {
18405
+ brainstorm: [
18406
+ `\u2500\u2500\u2500 CHAIR \xB7 BRAINSTORM-MODE PROTOCOL \u2500\u2500\u2500`,
18407
+ `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.`,
18408
+ ``,
18409
+ `**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).`,
18410
+ ``,
18411
+ `**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:`,
18412
+ ` \xB7 surface the 2\u20133 strongest unexpected VALUE angles the room opened (not the strongest objections)`,
18413
+ ` \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)`,
18414
+ ` \xB7 pick the most sexy / most concrete idea the room produced and re-frame it once for the user`,
18415
+ ` \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.`,
18416
+ ` \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.`,
18417
+ ``,
18418
+ `**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.`,
18419
+ ``,
18420
+ `**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.`
18421
+ ].join("\n"),
18224
18422
  research: [
18225
18423
  `\u2500\u2500\u2500 CHAIR \xB7 RESEARCH-MODE PROTOCOL \u2500\u2500\u2500`,
18226
18424
  `This room is in research mode. Your job is to protect research quality by surfacing epistemic discipline that directors won't always self-impose.`,
@@ -18244,7 +18442,7 @@ var CHAIR_MODE_PROTOCOL = {
18244
18442
  ].join("\n")
18245
18443
  };
18246
18444
  var HOUSE_ENGAGE_BY_TONE = {
18247
- 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",
18445
+ 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",
18248
18446
  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",
18249
18447
  debate: "steelman the target claim before attacking it, distinguish confidence from preference, and name what would change your mind",
18250
18448
  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",
@@ -18252,7 +18450,7 @@ var HOUSE_ENGAGE_BY_TONE = {
18252
18450
  };
18253
18451
  var HOUSE_ENGAGE_DEFAULT = HOUSE_ENGAGE_BY_TONE.debate;
18254
18452
  var TONE_OVERRIDE_BY_TONE = {
18255
- 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.",
18453
+ 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.`,
18256
18454
  constructive: "your default trained preference to be diplomatically vague. Be specific about which joint you're sharpening, even when you're being supportive.",
18257
18455
  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.",
18258
18456
  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.",
@@ -20663,11 +20861,14 @@ async function pumpQueue(roomId) {
20663
20861
  });
20664
20862
  if (reachedCap) {
20665
20863
  const room = getRoom(roomId);
20666
- if (room && room.status === "live" && !room.awaitingContinue && !room.awaitingClarify && // Manual vote-trigger · skip the auto round-prompt; the
20667
- // user fires it via the bottom-bar "End round & vote"
20668
- // button which posts to /api/rooms/:id/round-end (the
20669
- // same path the chat round-prompt button takes).
20670
- room.voteTrigger !== "manual") {
20864
+ if (room && room.status === "live" && !room.awaitingContinue && !room.awaitingClarify && room.voteTrigger === "manual") {
20865
+ const nextRound = nextUserRoundNum(roomId);
20866
+ rlog(roomId, "manual-auto-continue", {
20867
+ fromRound: state.roundNum,
20868
+ toRound: nextRound
20869
+ });
20870
+ tickRoom(roomId, { roundNum: nextRound, kind: "continue" });
20871
+ } else if (room && room.status === "live" && !room.awaitingContinue && !room.awaitingClarify) {
20671
20872
  const wrappedRound = state.roundNum;
20672
20873
  emitChairPending(roomId, "vote-summary");
20673
20874
  let recommendation;
@@ -20953,9 +21154,11 @@ async function streamSpeakerTurn(args) {
20953
21154
  }
20954
21155
  async function emitVoiceText(text) {
20955
21156
  if (!voiceMode || !text.trim()) return;
21157
+ const spoken = stripSpokenLabels(text);
21158
+ if (!spoken) return;
20956
21159
  const voiceProfile = currentVoiceProfile();
20957
21160
  if (!voiceProfile) return;
20958
- process.stderr.write(`[tts] emitVoiceText called: provider=${voiceProfile.provider} voiceId=${voiceProfile.voiceId} textLen=${text.length} text="${text.slice(0, 50)}"
21161
+ process.stderr.write(`[tts] emitVoiceText called: provider=${voiceProfile.provider} voiceId=${voiceProfile.voiceId} textLen=${spoken.length} text="${spoken.slice(0, 50)}"
20959
21162
  `);
20960
21163
  const MAX_ATTEMPTS = 2;
20961
21164
  const TIMEOUT_MS = 3e4;
@@ -20968,7 +21171,7 @@ async function streamSpeakerTurn(args) {
20968
21171
  let chunkCount = 0;
20969
21172
  let failure = null;
20970
21173
  try {
20971
- for await (const chunk of synthesizeSpeechStream(text, voiceProfile, timeoutCtrl.signal)) {
21174
+ for await (const chunk of synthesizeSpeechStream(spoken, voiceProfile, timeoutCtrl.signal)) {
20972
21175
  if (signal.aborted) break;
20973
21176
  chunkCount++;
20974
21177
  roomBus.emit(roomId, {
@@ -21198,6 +21401,13 @@ async function streamSpeakerTurn(args) {
21198
21401
  if (tail) await emitVoiceText(tail);
21199
21402
  roomBus.emit(roomId, { type: "voice-final", messageId: placeholder.id });
21200
21403
  }
21404
+ if (voiceMode && voiceSeq === 0) {
21405
+ process.stderr.write(
21406
+ `[tts] zero-chunks for msg=${placeholder.id.slice(0, 8)} agent=${speaker.name} \xB7 short-circuiting voice wait
21407
+ `
21408
+ );
21409
+ markVoicePlaybackDone(roomId, placeholder.id);
21410
+ }
21201
21411
  updateMessageBody(placeholder.id, buf, {
21202
21412
  ...placeholderMeta,
21203
21413
  speakerStatus: "final",
@@ -21253,6 +21463,9 @@ async function streamSpeakerTurn(args) {
21253
21463
  if (voiceChunker) {
21254
21464
  roomBus.emit(roomId, { type: "voice-final", messageId: placeholder.id });
21255
21465
  }
21466
+ if (voiceMode && voiceSeq === 0) {
21467
+ markVoicePlaybackDone(roomId, placeholder.id);
21468
+ }
21256
21469
  roomBus.emit(roomId, {
21257
21470
  type: "message-final",
21258
21471
  messageId: placeholder.id,
@@ -21923,8 +22136,9 @@ async function runChairClarify(roomId) {
21923
22136
  emitChairPending(roomId, "clarify-deciding");
21924
22137
  let decision = null;
21925
22138
  try {
22139
+ const roomForMode = getRoom(roomId);
21926
22140
  decision = await withTimeout(
21927
- pickChairClarifyDecision({ history }),
22141
+ pickChairClarifyDecision({ history, mode: roomForMode?.mode }),
21928
22142
  15e3,
21929
22143
  "chair-clarify-decision"
21930
22144
  );
@@ -24630,6 +24844,25 @@ function ttsCacheSet(key, val) {
24630
24844
  function voicesRouter() {
24631
24845
  const r = new Hono14();
24632
24846
  r.get("/", async (c) => {
24847
+ const url = new URL(c.req.url);
24848
+ const cursor = url.searchParams.get("cursor");
24849
+ const pageSizeRaw = url.searchParams.get("pageSize");
24850
+ if (cursor !== null || pageSizeRaw !== null) {
24851
+ const pageSize = pageSizeRaw ? Math.max(1, Number.parseInt(pageSizeRaw, 10) || 30) : 30;
24852
+ const page = await listVoicesPage(cursor, pageSize);
24853
+ return c.json({
24854
+ voices: page.voices,
24855
+ nextCursor: page.nextCursor,
24856
+ hasMore: page.hasMore,
24857
+ provider: page.provider,
24858
+ configured: page.configured,
24859
+ // Structured upstream error · the picker uses this to render
24860
+ // a clear "your API key is missing voices_read permission"
24861
+ // banner + a link to the ElevenLabs API-key settings page
24862
+ // instead of a silently empty dropdown.
24863
+ ...page.error ? { error: page.error } : {}
24864
+ });
24865
+ }
24633
24866
  const catalog = await listAvailableVoices();
24634
24867
  return c.json({
24635
24868
  voices: catalog.voices,
@@ -24776,7 +25009,7 @@ function voicesRouter() {
24776
25009
  init_paths();
24777
25010
 
24778
25011
  // src/version.ts
24779
- var VERSION = "0.1.32";
25012
+ var VERSION = "0.1.36";
24780
25013
 
24781
25014
  // src/utils/render-picker-catalog.ts
24782
25015
  function renderPickerCatalog() {