privateboard 0.1.29 → 0.1.32

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
@@ -856,6 +856,38 @@ CREATE INDEX idx_user_long_memory_active
856
856
  }
857
857
  });
858
858
 
859
+ // src/storage/migrations/049_voice_credentials.sql
860
+ var voice_credentials_default;
861
+ var init_voice_credentials = __esm({
862
+ "src/storage/migrations/049_voice_credentials.sql"() {
863
+ voice_credentials_default = "-- 049_voice_credentials.sql\n--\n-- Multi-instance VOICE TTS provider credentials \xB7 mirrors the LLM\n-- credential model that landed in 046. The same TTS provider can\n-- now be added more than once (e.g. two MiniMax accounts, or\n-- personal + team ElevenLabs) with distinct user-supplied labels.\n-- Legacy `provider_keys` was keyed by `provider` (one row per\n-- provider), which doesn't support this. The new table is keyed by\n-- an opaque `id`.\n--\n-- `prefs.active_voice_credential_id` is the single pointer that\n-- decides which TTS provider is \"active\" right now. Switching is\n-- a one-write change to this id; the credential rows themselves\n-- stay on file so the user can flip back without re-pasting.\n--\n-- Skill keys (brave, tavily) remain in `provider_keys` \u2014 they have\n-- no use case for multiple credentials per provider and their\n-- routes work fine as-is. After this migration, `provider_keys`\n-- holds ONLY skill rows.\n\nCREATE TABLE IF NOT EXISTS voice_credentials (\n id TEXT PRIMARY KEY,\n provider TEXT NOT NULL,\n label TEXT NOT NULL,\n key_blob BLOB NOT NULL,\n created_at INTEGER NOT NULL,\n updated_at INTEGER NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS voice_credentials_provider_idx\n ON voice_credentials(provider);\n\nALTER TABLE prefs ADD COLUMN active_voice_credential_id TEXT;\n\n-- One-shot migration \xB7 move every configured voice row from\n-- provider_keys into voice_credentials. Generated id uses a\n-- 16-char hex randomblob slice for collision resistance; labels\n-- default to the provider's display name (UI surfaces them under\n-- that label until the user renames). Empty key_blobs are skipped\n-- (legacy \"ever-configured\" rows that have since been emptied).\nINSERT INTO voice_credentials (id, provider, label, key_blob, created_at, updated_at)\nSELECT\n lower(hex(randomblob(8))) AS id,\n provider,\n CASE provider\n WHEN 'minimax' THEN 'MiniMax'\n WHEN 'elevenlabs' THEN 'ElevenLabs'\n ELSE provider\n END AS label,\n key_blob,\n created_at,\n updated_at\nFROM provider_keys\nWHERE provider IN ('minimax','elevenlabs')\n AND length(key_blob) > 0;\n\n-- Seed prefs.active_voice_credential_id with the highest-priority\n-- migrated credential. Priority mirrors the existing fallback chain\n-- in src/voice/tts.ts:165-179 (MiniMax > ElevenLabs).\nUPDATE prefs\n SET active_voice_credential_id = (\n SELECT id FROM voice_credentials\n ORDER BY CASE provider\n WHEN 'minimax' THEN 1\n WHEN 'elevenlabs' THEN 2\n END,\n created_at ASC\n LIMIT 1\n )\n WHERE id = 1\n AND active_voice_credential_id IS NULL;\n\n-- Finally \xB7 remove the migrated voice rows from provider_keys so\n-- that table is purely skill keys (brave + tavily) from here on.\n-- Cleared by name; an explicit IN() list matches what we inserted\n-- above. After this migration, `getKey(\"minimax\")` and\n-- `getKey(\"elevenlabs\")` always return null; callers must read\n-- the active voice credential instead.\nDELETE FROM provider_keys\n WHERE provider IN ('minimax','elevenlabs');\n";
864
+ }
865
+ });
866
+
867
+ // src/storage/migrations/050_agent_provider_buckets.sql
868
+ var agent_provider_buckets_default;
869
+ var init_agent_provider_buckets = __esm({
870
+ "src/storage/migrations/050_agent_provider_buckets.sql"() {
871
+ agent_provider_buckets_default = "-- 050_agent_provider_buckets.sql\n--\n-- SIM-swap memory for per-agent model + voice picks \xB7 keyed by\n-- provider. Today every `agents` row stores a single `model_v` and a\n-- single `voice_json`; switching the active LLM credential's provider\n-- runs `reconcileAgentModels({forcePrimary:true})` which overwrites\n-- both fields with random picks from the new carrier's pool \xB7 the\n-- user's manual per-director picks under the prior provider are lost.\n--\n-- This migration adds two TEXT columns that hold a JSON map keyed by\n-- provider (LLM carrier for `model_by_provider_json`, voice provider\n-- for `voice_by_provider_json`). The reconcile pass now:\n-- 1. Phase 1 \xB7 snapshots the current `model_v` / `voice_json` into\n-- `bucket[priorProvider]` BEFORE overwriting.\n-- 2. Phase 2 \xB7 reads `bucket[newProvider]` and restores from it if\n-- present (reachability-checked for models). Falls back to the\n-- existing random-fast-pool pick when the bucket is empty / stale.\n--\n-- Effect \xB7 switching provider feels like a SIM swap. Every per-agent\n-- config that existed on a provider is preserved and restored on the\n-- next visit to that provider.\n--\n-- Purely additive \xB7 both columns start NULL on every existing row.\n-- No data migration step; buckets seed organically as the user picks\n-- models / switches providers post-deploy. The migration runner's\n-- \"already-applied\" guard at db.ts handles ALTER retries idempotently\n-- if a rebase ever reorders this past a column it adds elsewhere.\n\nALTER TABLE agents ADD COLUMN model_by_provider_json TEXT;\nALTER TABLE agents ADD COLUMN voice_by_provider_json TEXT;\n";
872
+ }
873
+ });
874
+
875
+ // src/storage/migrations/051_search_credentials.sql
876
+ var search_credentials_default;
877
+ var init_search_credentials = __esm({
878
+ "src/storage/migrations/051_search_credentials.sql"() {
879
+ search_credentials_default = "-- 051_search_credentials.sql\n--\n-- Multi-instance SEARCH provider credentials \xB7 final mirror of the\n-- pattern that migrations 044-046 introduced for LLM and migration 049\n-- introduced for Voice TTS. The same web-search provider (Brave or\n-- Tavily) can now appear multiple times with distinct user-supplied\n-- labels (personal + team account, for example), with exactly one\n-- \"active\" credential pointed to by `prefs.active_search_credential_id`.\n--\n-- After this migration runs, `provider_keys` is EMPTY \xB7 every\n-- credential the user holds (LLM / voice / search) lives in its own\n-- typed table with `(id, provider, label, key_blob)` columns. The\n-- legacy `provider_keys` table itself stays in the schema for now \xB7\n-- it's structurally tiny and dropping it would require coordinated\n-- removal of the `getKey` / `setKey` helpers in `keys.ts`, which is\n-- noise unrelated to this feature.\n--\n-- Unlike the LLM and voice flips, switching active search provider\n-- has NO per-agent reshuffle \xB7 agents don't carry per-search-provider\n-- state, so the active swap is purely a routing decision that the\n-- next web-search call honours automatically. No `reconcile-*`\n-- helper is needed.\n\nCREATE TABLE IF NOT EXISTS search_credentials (\n id TEXT PRIMARY KEY,\n provider TEXT NOT NULL, -- 'brave' | 'tavily'\n label TEXT NOT NULL,\n key_blob BLOB NOT NULL,\n created_at INTEGER NOT NULL,\n updated_at INTEGER NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS search_credentials_provider_idx\n ON search_credentials(provider);\n\nALTER TABLE prefs ADD COLUMN active_search_credential_id TEXT;\n\n-- One-shot migration \xB7 move every configured brave/tavily row from\n-- provider_keys into search_credentials. Labels default to the\n-- provider's display name; the seed-active step below picks brave\n-- when both exist (matches the historical `webSearchProvider` default\n-- of \"brave\" set in migration 029).\nINSERT INTO search_credentials (id, provider, label, key_blob, created_at, updated_at)\nSELECT\n lower(hex(randomblob(8))) AS id,\n provider,\n CASE provider\n WHEN 'brave' THEN 'Brave Search'\n WHEN 'tavily' THEN 'Tavily Search'\n ELSE provider\n END AS label,\n key_blob,\n created_at,\n updated_at\nFROM provider_keys\nWHERE provider IN ('brave','tavily')\n AND length(key_blob) > 0;\n\n-- Seed prefs.active_search_credential_id with the highest-priority\n-- migrated credential. Priority mirrors the historical default in\n-- migration 029 (brave first, tavily as alternate). If the user had\n-- previously expressed a `web_search_provider = 'tavily'` preference,\n-- that's preserved here by sorting on that pref first.\nUPDATE prefs\n SET active_search_credential_id = (\n SELECT id FROM search_credentials\n ORDER BY CASE provider\n WHEN (SELECT COALESCE(web_search_provider, 'brave') FROM prefs WHERE id = 1) THEN 0\n WHEN 'brave' THEN 1\n WHEN 'tavily' THEN 2\n END,\n created_at ASC\n LIMIT 1\n )\n WHERE id = 1\n AND active_search_credential_id IS NULL;\n\n-- Finally \xB7 remove the migrated search rows from provider_keys so\n-- the legacy table is fully drained (all four credentialed providers\n-- \xB7 openai/anthropic/google/xai for LLM, minimax/elevenlabs for voice,\n-- brave/tavily for search \xB7 now live in their typed tables). After\n-- this migration, `getKey(\"brave\")` and `getKey(\"tavily\")` always\n-- return null \xB7 callers must read the active search credential via\n-- `getActiveSearchKeyPlaintext()` instead.\nDELETE FROM provider_keys\n WHERE provider IN ('brave','tavily');\n";
880
+ }
881
+ });
882
+
883
+ // src/storage/migrations/052_room_name_auto.sql
884
+ var room_name_auto_default;
885
+ var init_room_name_auto = __esm({
886
+ "src/storage/migrations/052_room_name_auto.sql"() {
887
+ room_name_auto_default = "-- 052_room_name_auto.sql\n-- Track whether a room's `name` was auto-derived from the opening\n-- question's first 60 chars (default \xB7 1) or explicitly set by the\n-- client at creation (0). The orchestrator's round-1-complete hook\n-- runs an LLM topic-phrase summariser and writes a short title back\n-- ONLY when name_auto = 1 (SQL-side guard via UPDATE WHERE), so any\n-- future rename UI can flip the flag to 0 without racing the auto\n-- pipeline.\n\nALTER TABLE rooms ADD COLUMN name_auto INTEGER NOT NULL DEFAULT 1;\n";
888
+ }
889
+ });
890
+
859
891
  // src/storage/db.ts
860
892
  var db_exports = {};
861
893
  __export(db_exports, {
@@ -978,6 +1010,10 @@ var init_db = __esm({
978
1010
  init_llm_credentials();
979
1011
  init_drop_topic_recs();
980
1012
  init_user_long_memory();
1013
+ init_voice_credentials();
1014
+ init_agent_provider_buckets();
1015
+ init_search_credentials();
1016
+ init_room_name_auto();
981
1017
  MIGRATIONS = [
982
1018
  { name: "001_init.sql", sql: init_default },
983
1019
  { name: "002_default_opus.sql", sql: default_opus_default },
@@ -1026,7 +1062,11 @@ var init_db = __esm({
1026
1062
  { name: "045_active_llm_provider_pref.sql", sql: active_llm_provider_pref_default },
1027
1063
  { name: "046_llm_credentials.sql", sql: llm_credentials_default },
1028
1064
  { name: "047_drop_topic_recs.sql", sql: drop_topic_recs_default },
1029
- { name: "048_user_long_memory.sql", sql: user_long_memory_default }
1065
+ { name: "048_user_long_memory.sql", sql: user_long_memory_default },
1066
+ { name: "049_voice_credentials.sql", sql: voice_credentials_default },
1067
+ { name: "050_agent_provider_buckets.sql", sql: agent_provider_buckets_default },
1068
+ { name: "051_search_credentials.sql", sql: search_credentials_default },
1069
+ { name: "052_room_name_auto.sql", sql: room_name_auto_default }
1030
1070
  ];
1031
1071
  _db = null;
1032
1072
  }
@@ -1177,7 +1217,9 @@ var VALID_CARRIER_PREFS = /* @__PURE__ */ new Set([
1177
1217
  "anthropic",
1178
1218
  "openai",
1179
1219
  "google",
1180
- "xai"
1220
+ "xai",
1221
+ "moonshot",
1222
+ "zhipu"
1181
1223
  ]);
1182
1224
  function parseCarrierPref(raw) {
1183
1225
  if (!raw) return null;
@@ -1400,6 +1442,54 @@ function parseBuildLog(raw) {
1400
1442
  ...totalTokens !== void 0 ? { totalTokens } : {}
1401
1443
  };
1402
1444
  }
1445
+ function parseModelByProvider(raw) {
1446
+ if (!raw) return {};
1447
+ try {
1448
+ const obj = JSON.parse(raw);
1449
+ if (!obj || typeof obj !== "object") return {};
1450
+ const out = {};
1451
+ for (const [k, v] of Object.entries(obj)) {
1452
+ if (VALID_CARRIER_PREFS.has(k) && typeof v === "string" && v.trim().length > 0) {
1453
+ out[k] = v.trim();
1454
+ }
1455
+ }
1456
+ return out;
1457
+ } catch {
1458
+ return {};
1459
+ }
1460
+ }
1461
+ function parseVoiceByProvider(raw) {
1462
+ if (!raw) return {};
1463
+ try {
1464
+ const obj = JSON.parse(raw);
1465
+ if (!obj || typeof obj !== "object") return {};
1466
+ const out = {};
1467
+ for (const [k, v] of Object.entries(obj)) {
1468
+ if (k !== "minimax" && k !== "elevenlabs") continue;
1469
+ const profile = parseVoice(JSON.stringify(v));
1470
+ if (profile) out[k] = profile;
1471
+ }
1472
+ return out;
1473
+ } catch {
1474
+ return {};
1475
+ }
1476
+ }
1477
+ function serializeModelByProvider(b) {
1478
+ if (!b || Object.keys(b).length === 0) return null;
1479
+ return JSON.stringify(b);
1480
+ }
1481
+ function serializeVoiceByProvider(b) {
1482
+ if (!b || Object.keys(b).length === 0) return null;
1483
+ const out = {};
1484
+ for (const [k, v] of Object.entries(b)) {
1485
+ if (!v) continue;
1486
+ const json = serializeVoice(v);
1487
+ if (!json) continue;
1488
+ out[k] = JSON.parse(json);
1489
+ }
1490
+ if (Object.keys(out).length === 0) return null;
1491
+ return JSON.stringify(out);
1492
+ }
1403
1493
  function serializeVoice(v) {
1404
1494
  if (!v) return null;
1405
1495
  const provider = VALID_VOICE_PROVIDERS.has(v.provider) ? v.provider : null;
@@ -1447,7 +1537,7 @@ function mapRow(row) {
1447
1537
  updatedAt: row.updated_at
1448
1538
  };
1449
1539
  }
1450
- var SELECT_COLS = "id, name, handle, role_tag, role_kind, bio, cover_quote, instruction, model_v, carrier_pref, avatar_path, ability_json, is_pinned, is_seed, web_search_enabled, voice_json, persona_spec_json, created_at, updated_at";
1540
+ var SELECT_COLS = "id, name, handle, role_tag, role_kind, bio, cover_quote, instruction, model_v, carrier_pref, avatar_path, ability_json, is_pinned, is_seed, web_search_enabled, voice_json, persona_spec_json, model_by_provider_json, voice_by_provider_json, created_at, updated_at";
1451
1541
  function listAgents() {
1452
1542
  const rows = getDb().prepare(
1453
1543
  `SELECT ${SELECT_COLS} FROM agents
@@ -1756,6 +1846,47 @@ function updateAgent(id, patch) {
1756
1846
  if (r.changes === 0) return null;
1757
1847
  return getAgent(id);
1758
1848
  }
1849
+ function getModelBucket(id) {
1850
+ const row = getDb().prepare("SELECT model_by_provider_json FROM agents WHERE id = ?").get(id);
1851
+ if (!row) return {};
1852
+ return parseModelByProvider(row.model_by_provider_json);
1853
+ }
1854
+ function getVoiceBucket(id) {
1855
+ const row = getDb().prepare("SELECT voice_by_provider_json FROM agents WHERE id = ?").get(id);
1856
+ if (!row) return {};
1857
+ return parseVoiceByProvider(row.voice_by_provider_json);
1858
+ }
1859
+ function writeModelBucketEntry(id, carrier, modelV) {
1860
+ if (!modelV) return;
1861
+ const bucket = getModelBucket(id);
1862
+ if (bucket[carrier] === modelV) return;
1863
+ bucket[carrier] = modelV;
1864
+ const serialized = serializeModelByProvider(bucket);
1865
+ getDb().prepare("UPDATE agents SET model_by_provider_json = ?, updated_at = ? WHERE id = ?").run(serialized, Date.now(), id);
1866
+ }
1867
+ function writeVoiceBucketEntry(id, provider, voice) {
1868
+ const normalized = parseVoice(serializeVoice(voice));
1869
+ if (!normalized || normalized.provider !== "minimax" && normalized.provider !== "elevenlabs") return;
1870
+ if (normalized.provider !== provider) return;
1871
+ const bucket = getVoiceBucket(id);
1872
+ bucket[provider] = normalized;
1873
+ const serialized = serializeVoiceByProvider(bucket);
1874
+ getDb().prepare("UPDATE agents SET voice_by_provider_json = ?, updated_at = ? WHERE id = ?").run(serialized, Date.now(), id);
1875
+ }
1876
+ function deleteModelBucketEntry(id, carrier) {
1877
+ const bucket = getModelBucket(id);
1878
+ if (!(carrier in bucket)) return;
1879
+ delete bucket[carrier];
1880
+ const serialized = serializeModelByProvider(bucket);
1881
+ getDb().prepare("UPDATE agents SET model_by_provider_json = ?, updated_at = ? WHERE id = ?").run(serialized, Date.now(), id);
1882
+ }
1883
+ function deleteVoiceBucketEntry(id, provider) {
1884
+ const bucket = getVoiceBucket(id);
1885
+ if (!(provider in bucket)) return;
1886
+ delete bucket[provider];
1887
+ const serialized = serializeVoiceByProvider(bucket);
1888
+ getDb().prepare("UPDATE agents SET voice_by_provider_json = ?, updated_at = ? WHERE id = ?").run(serialized, Date.now(), id);
1889
+ }
1759
1890
 
1760
1891
  // src/seed/run.ts
1761
1892
  init_db();
@@ -2474,7 +2605,7 @@ function runSeed() {
2474
2605
  // src/server.ts
2475
2606
  import { serve } from "@hono/node-server";
2476
2607
  import { serveStatic } from "@hono/node-server/serve-static";
2477
- import { Hono as Hono13 } from "hono";
2608
+ import { Hono as Hono15 } from "hono";
2478
2609
  import { existsSync as existsSync2 } from "fs";
2479
2610
 
2480
2611
  // src/routes/agents.ts
@@ -2649,10 +2780,11 @@ var MODELS = {
2649
2780
  deck: "V4 Flash \xB7 fast \xB7 1M ctx",
2650
2781
  viaUniversalOnly: true
2651
2782
  },
2652
- // ── Zhipu (Z.AI) · GLM family · OR + B.AI only ──
2653
- // OpenRouter catalog convention: `z-ai/glm-X.Y`. B.AI uses
2654
- // hyphenated lowercase: `glm-5-1`. No direct @ai-sdk client ·
2655
- // viaUniversalOnly skips the direct path.
2783
+ // ── Zhipu (Z.AI) · GLM family · direct + OR + B.AI ──
2784
+ // Direct route uses Zhipu's OpenAI-compatible chat-completions API
2785
+ // at https://open.bigmodel.cn/api/paas/v4/ (see adapter.ts
2786
+ // case "zhipu"). OpenRouter catalog convention: `z-ai/glm-X.Y`.
2787
+ // B.AI uses dotted lowercase: `glm-5.1`.
2656
2788
  "glm-5-1": {
2657
2789
  v: "glm-5-1",
2658
2790
  provider: "zhipu",
@@ -2661,16 +2793,16 @@ var MODELS = {
2661
2793
  baiId: "glm-5.1",
2662
2794
  displayName: "GLM 5.1",
2663
2795
  contextBudget: 2e5,
2664
- deck: "Zhipu flagship \xB7 200k ctx",
2665
- viaUniversalOnly: true
2796
+ deck: "Zhipu flagship \xB7 200k ctx"
2666
2797
  },
2667
- // ── Moonshot · Kimi family · OR + B.AI ──
2798
+ // ── Moonshot · Kimi family · direct + OR + B.AI ──
2799
+ // Direct route uses Moonshot's OpenAI-compatible chat-completions
2800
+ // API at https://api.moonshot.cn/v1 (see adapter.ts case "moonshot").
2668
2801
  // OpenRouter catalog convention: `moonshotai/kimi-k2.6` (the leading
2669
2802
  // `k` is part of the slug — `moonshotai/kimi-2.6` 404s). B.AI's
2670
2803
  // siliconflow distributor still ships the older `kimi-k2.5` channel
2671
2804
  // (per 2026-05-17 catalog snapshot), so the B.AI route serves K2.5
2672
- // until B.AI picks up the newer build. No direct @ai-sdk client ·
2673
- // viaUniversalOnly skips the direct path.
2805
+ // until B.AI picks up the newer build.
2674
2806
  "kimi-k2-6": {
2675
2807
  v: "kimi-k2-6",
2676
2808
  provider: "moonshot",
@@ -2679,8 +2811,7 @@ var MODELS = {
2679
2811
  baiId: "kimi-k2.5",
2680
2812
  displayName: "Kimi K2.6",
2681
2813
  contextBudget: 256e3,
2682
- deck: "Moonshot \xB7 long-context",
2683
- viaUniversalOnly: true
2814
+ deck: "Moonshot \xB7 long-context"
2684
2815
  },
2685
2816
  // ── MiniMax · M-series · OR + B.AI ──
2686
2817
  // No direct @ai-sdk client · viaUniversalOnly skips the direct path.
@@ -2777,7 +2908,11 @@ var PROFILE_SYSTEM = [
2777
2908
  "Constraints:",
2778
2909
  "\xB7 DO NOT use generic personality words. Every entry names a person / case / concept / position.",
2779
2910
  "\xB7 If the user description maps to a real domain (VC, product, security, biotech, monetary policy, etc.), prefer NAMED references from that domain.",
2780
- "\xB7 Avoid recreating the canonical six (Socrates, First Principles, Long Horizon, etc.) \u2014 pick a distinct angle."
2911
+ "\xB7 Avoid recreating the canonical six (Socrates, First Principles, Long Horizon, etc.) \u2014 pick a distinct angle.",
2912
+ "",
2913
+ "## CRITICAL \xB7 output format",
2914
+ "",
2915
+ "Your ENTIRE response is the JSON object inside ONE ```json fenced block. NO prose before. NO prose after. NO chain-of-thought, NO 'Here is...', NO 'I'll create...'. Use the EXACT field names from the schema above (`intellectualLineage`, `loadBearingConcepts`, `referentSet`, `failureModes`, `contrarianTakes` \u2014 camelCase, not snake_case, not abbreviated). If you cannot produce strong content for a field, emit an empty array `[]` for that field \u2014 DO NOT omit it, DO NOT substitute a different field name, DO NOT explain in prose why it's empty."
2781
2916
  ].join("\n");
2782
2917
  var HOUSE_STYLE = [
2783
2918
  "## Boardroom directors \xB7 house style",
@@ -2898,7 +3033,7 @@ function buildAgentProfileMessages(opts) {
2898
3033
  }
2899
3034
  userBody.push(
2900
3035
  "",
2901
- "Now produce the profile JSON object as specified."
3036
+ "Now produce the profile JSON object as specified \u2014 ```json fenced block, exact camelCase field names, no prose before or after."
2902
3037
  );
2903
3038
  return [
2904
3039
  { role: "system", content: PROFILE_SYSTEM },
@@ -3039,29 +3174,98 @@ function parseAgentSpec(raw) {
3039
3174
  ability
3040
3175
  };
3041
3176
  }
3177
+ function pickArrayField(parsed, aliases) {
3178
+ for (const k of aliases) {
3179
+ const v = parsed[k];
3180
+ if (Array.isArray(v)) return v;
3181
+ }
3182
+ return [];
3183
+ }
3184
+ function pickObjectField(parsed, aliases) {
3185
+ for (const k of aliases) {
3186
+ const v = parsed[k];
3187
+ if (v && typeof v === "object" && !Array.isArray(v)) return v;
3188
+ }
3189
+ return {};
3190
+ }
3191
+ function coerceConceptEntry(c) {
3192
+ if (typeof c === "string") {
3193
+ const s = c.trim();
3194
+ const m = /^(.+?)\s*[·:\-—]\s*(.+)$/.exec(s);
3195
+ if (m) return { name: clamp(m[1].trim(), 80), gloss: clamp(m[2].trim(), 200) };
3196
+ return { name: clamp(s, 80), gloss: "" };
3197
+ }
3198
+ if (!c || typeof c !== "object") return { name: "", gloss: "" };
3199
+ const o = c;
3200
+ const name = [o.name, o.concept, o.title, o.handle, o.term].find(
3201
+ (x) => typeof x === "string" && x.trim().length > 0
3202
+ );
3203
+ const gloss = [o.gloss, o.description, o.desc, o.detail, o.explanation, o.summary].find(
3204
+ (x) => typeof x === "string" && x.trim().length > 0
3205
+ );
3206
+ return {
3207
+ name: name ? clamp(name.trim(), 80) : "",
3208
+ gloss: gloss ? clamp(gloss.trim(), 200) : ""
3209
+ };
3210
+ }
3211
+ function coerceReferentEntry(r) {
3212
+ if (typeof r === "string") {
3213
+ const s = r.trim();
3214
+ const m = /^(.+?)\s*[·:\-—]\s*(.+)$/.exec(s);
3215
+ if (m) return { ref: clamp(m[1].trim(), 80), why: clamp(m[2].trim(), 200) };
3216
+ return { ref: clamp(s, 80), why: "" };
3217
+ }
3218
+ if (!r || typeof r !== "object") return { ref: "", why: "" };
3219
+ const o = r;
3220
+ const ref = [o.ref, o.name, o.reference, o.anchor, o.title, o.case].find(
3221
+ (x) => typeof x === "string" && x.trim().length > 0
3222
+ );
3223
+ const why = [o.why, o.reason, o.relevance, o.gloss, o.description, o.note].find(
3224
+ (x) => typeof x === "string" && x.trim().length > 0
3225
+ );
3226
+ return {
3227
+ ref: ref ? clamp(ref.trim(), 80) : "",
3228
+ why: why ? clamp(why.trim(), 200) : ""
3229
+ };
3230
+ }
3042
3231
  function parseAgentProfile(raw) {
3043
3232
  const parsed = extractJson(raw);
3044
3233
  if (!parsed) return null;
3045
- const lineage = parsed.intellectualLineage && typeof parsed.intellectualLineage === "object" ? parsed.intellectualLineage : {};
3046
- const influencedByRaw = Array.isArray(lineage.influencedBy) ? lineage.influencedBy : [];
3047
- const opposedToRaw = Array.isArray(lineage.opposedTo) ? lineage.opposedTo : [];
3234
+ const lineage = pickObjectField(parsed, ["intellectualLineage", "intellectual_lineage", "lineage"]);
3235
+ const influencedByRaw = pickArrayField(lineage, ["influencedBy", "influenced_by", "influences", "influenced"]);
3236
+ const opposedToRaw = pickArrayField(lineage, ["opposedTo", "opposed_to", "opposes", "against"]);
3048
3237
  const influencedBy = influencedByRaw.filter((s) => typeof s === "string" && s.trim().length > 0).map((s) => clamp(s.trim(), 200)).slice(0, 5);
3049
3238
  const opposedTo = opposedToRaw.filter((s) => typeof s === "string" && s.trim().length > 0).map((s) => clamp(s.trim(), 200)).slice(0, 4);
3050
- const conceptsRaw = Array.isArray(parsed.loadBearingConcepts) ? parsed.loadBearingConcepts : [];
3051
- const loadBearingConcepts = conceptsRaw.filter((c) => !!c && typeof c === "object").map((c) => ({
3052
- name: typeof c.name === "string" ? clamp(c.name.trim(), 80) : "",
3053
- gloss: typeof c.gloss === "string" ? clamp(c.gloss.trim(), 200) : ""
3054
- })).filter((c) => c.name.length > 0).slice(0, 6);
3055
- const referentsRaw = Array.isArray(parsed.referentSet) ? parsed.referentSet : [];
3056
- const referentSet = referentsRaw.filter((r) => !!r && typeof r === "object").map((r) => ({
3057
- ref: typeof r.ref === "string" ? clamp(r.ref.trim(), 80) : "",
3058
- why: typeof r.why === "string" ? clamp(r.why.trim(), 200) : ""
3059
- })).filter((r) => r.ref.length > 0).slice(0, 6);
3060
- const failureModesRaw = Array.isArray(parsed.failureModes) ? parsed.failureModes : [];
3239
+ const conceptsRaw = pickArrayField(parsed, [
3240
+ "loadBearingConcepts",
3241
+ "load_bearing_concepts",
3242
+ "concepts",
3243
+ "frames",
3244
+ "mentalTools",
3245
+ "mental_tools"
3246
+ ]);
3247
+ const loadBearingConcepts = conceptsRaw.map(coerceConceptEntry).filter((c) => c.name.length > 0).slice(0, 6);
3248
+ const referentsRaw = pickArrayField(parsed, [
3249
+ "referentSet",
3250
+ "referent_set",
3251
+ "referents",
3252
+ "anchors",
3253
+ "references",
3254
+ "citations"
3255
+ ]);
3256
+ const referentSet = referentsRaw.map(coerceReferentEntry).filter((r) => r.ref.length > 0).slice(0, 6);
3257
+ const failureModesRaw = pickArrayField(parsed, ["failureModes", "failure_modes", "blindSpots", "blind_spots"]);
3061
3258
  const failureModes = failureModesRaw.filter((s) => typeof s === "string" && s.trim().length > 0).map((s) => clamp(s.trim(), 220)).slice(0, 4);
3062
- const contrarianTakesRaw = Array.isArray(parsed.contrarianTakes) ? parsed.contrarianTakes : [];
3259
+ const contrarianTakesRaw = pickArrayField(parsed, [
3260
+ "contrarianTakes",
3261
+ "contrarian_takes",
3262
+ "contrarianViews",
3263
+ "contrarian_views",
3264
+ "takes"
3265
+ ]);
3063
3266
  const contrarianTakes = contrarianTakesRaw.filter((s) => typeof s === "string" && s.trim().length > 0).map((s) => clamp(s.trim(), 220)).slice(0, 4);
3064
- if (loadBearingConcepts.length === 0 && referentSet.length === 0) return null;
3267
+ const anyPopulated = loadBearingConcepts.length > 0 || referentSet.length > 0 || influencedBy.length > 0 || opposedTo.length > 0 || failureModes.length > 0 || contrarianTakes.length > 0;
3268
+ if (!anyPopulated) return null;
3065
3269
  return {
3066
3270
  intellectualLineage: { influencedBy, opposedTo },
3067
3271
  loadBearingConcepts,
@@ -3551,8 +3755,7 @@ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
3551
3755
  import { APICallError, streamText } from "ai";
3552
3756
 
3553
3757
  // src/storage/credentials.ts
3554
- import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "crypto";
3555
- import { userInfo } from "os";
3758
+ import { randomBytes as randomBytes2 } from "crypto";
3556
3759
 
3557
3760
  // src/ai/providers.ts
3558
3761
  var MULTI_MODEL_LLM_PROVIDERS = [
@@ -3563,7 +3766,9 @@ var SINGLE_MODEL_LLM_PROVIDERS = [
3563
3766
  "anthropic",
3564
3767
  "openai",
3565
3768
  "google",
3566
- "xai"
3769
+ "xai",
3770
+ "moonshot",
3771
+ "zhipu"
3567
3772
  ];
3568
3773
  var ALL_LLM_PROVIDERS = [
3569
3774
  ...MULTI_MODEL_LLM_PROVIDERS,
@@ -3575,7 +3780,9 @@ var LLM_PROVIDER_PRIORITY = [
3575
3780
  "anthropic",
3576
3781
  "openai",
3577
3782
  "google",
3578
- "xai"
3783
+ "xai",
3784
+ "moonshot",
3785
+ "zhipu"
3579
3786
  ];
3580
3787
  function isMultiModelProvider(p) {
3581
3788
  return p === "openrouter" || p === "bai";
@@ -3586,6 +3793,10 @@ function isLlmProvider(p) {
3586
3793
 
3587
3794
  // src/storage/credentials.ts
3588
3795
  init_db();
3796
+
3797
+ // src/storage/credential-crypto.ts
3798
+ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "crypto";
3799
+ import { userInfo } from "os";
3589
3800
  var SALT = "boardroom.v1.salt";
3590
3801
  var ALGO = "aes-256-gcm";
3591
3802
  var _key = null;
@@ -3595,14 +3806,14 @@ function deriveKey() {
3595
3806
  _key = scryptSync(username, SALT, 32);
3596
3807
  return _key;
3597
3808
  }
3598
- function encrypt(plain) {
3809
+ function encryptCredential(plain) {
3599
3810
  const iv = randomBytes(12);
3600
3811
  const cipher = createCipheriv(ALGO, deriveKey(), iv);
3601
3812
  const ct = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
3602
3813
  const tag = cipher.getAuthTag();
3603
3814
  return Buffer.concat([iv, tag, ct]);
3604
3815
  }
3605
- function decrypt(blob) {
3816
+ function decryptCredential(blob) {
3606
3817
  const iv = blob.subarray(0, 12);
3607
3818
  const tag = blob.subarray(12, 28);
3608
3819
  const ct = blob.subarray(28);
@@ -3610,7 +3821,7 @@ function decrypt(blob) {
3610
3821
  decipher.setAuthTag(tag);
3611
3822
  return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8");
3612
3823
  }
3613
- function maskKey(plain) {
3824
+ function maskCredential(plain) {
3614
3825
  const trimmed = plain.trim();
3615
3826
  if (!trimmed) return "";
3616
3827
  const n = trimmed.length;
@@ -3618,11 +3829,13 @@ function maskKey(plain) {
3618
3829
  if (n <= 12) return `${trimmed.slice(0, 2)}${"\u2022".repeat(n - 4)}${trimmed.slice(-2)}`;
3619
3830
  return `${trimmed.slice(0, 4)}${"\u2022".repeat(n - 8)}${trimmed.slice(-4)}`;
3620
3831
  }
3832
+
3833
+ // src/storage/credentials.ts
3621
3834
  function rowToMeta(row) {
3622
3835
  if (!isLlmProvider(row.provider)) return null;
3623
3836
  let preview = "";
3624
3837
  try {
3625
- preview = maskKey(decrypt(row.key_blob));
3838
+ preview = maskCredential(decryptCredential(row.key_blob));
3626
3839
  } catch {
3627
3840
  preview = "";
3628
3841
  }
@@ -3648,7 +3861,7 @@ function getLlmCredentialKey(id) {
3648
3861
  const row = getDb().prepare("SELECT key_blob FROM llm_credentials WHERE id = ?").get(id);
3649
3862
  if (!row) return null;
3650
3863
  try {
3651
- return decrypt(row.key_blob);
3864
+ return decryptCredential(row.key_blob);
3652
3865
  } catch {
3653
3866
  return null;
3654
3867
  }
@@ -3679,6 +3892,15 @@ function providerDisplayName(provider) {
3679
3892
  return "Gemini";
3680
3893
  case "xai":
3681
3894
  return "Grok";
3895
+ // Brand-name labels for the two newest direct providers · "Kimi"
3896
+ // (model brand) over "Moonshot" (company), "GLM" (model family)
3897
+ // over "Zhipu" (company) · same convention as "Claude"/anthropic
3898
+ // and "ChatGPT"/openai · the user-facing UI prefers the product
3899
+ // name over the corporate slug.
3900
+ case "moonshot":
3901
+ return "Kimi";
3902
+ case "zhipu":
3903
+ return "GLM";
3682
3904
  }
3683
3905
  }
3684
3906
  function createLlmCredential(provider, label, plain) {
@@ -3686,8 +3908,8 @@ function createLlmCredential(provider, label, plain) {
3686
3908
  if (!trimmed) return null;
3687
3909
  if (!ALL_LLM_PROVIDERS.includes(provider)) return null;
3688
3910
  const resolvedLabel = resolveFreeLabel(provider, label);
3689
- const id = randomBytes(8).toString("hex");
3690
- const blob = encrypt(trimmed);
3911
+ const id = randomBytes2(8).toString("hex");
3912
+ const blob = encryptCredential(trimmed);
3691
3913
  const now = Date.now();
3692
3914
  getDb().prepare(
3693
3915
  `INSERT INTO llm_credentials (id, provider, label, key_blob, created_at, updated_at)
@@ -3721,6 +3943,8 @@ function mapRow3(row) {
3721
3943
  minimaxRegion: normalizeMinimaxRegion(row.minimax_region),
3722
3944
  activeLlmProvider: raw && isLlmProvider(raw) ? raw : null,
3723
3945
  activeLlmCredentialId: row.active_llm_credential_id,
3946
+ activeVoiceCredentialId: row.active_voice_credential_id,
3947
+ activeSearchCredentialId: row.active_search_credential_id,
3724
3948
  createdAt: row.created_at,
3725
3949
  updatedAt: row.updated_at
3726
3950
  };
@@ -3732,6 +3956,8 @@ function getPrefs() {
3732
3956
  COALESCE(minimax_region, 'cn') AS minimax_region,
3733
3957
  active_llm_provider,
3734
3958
  active_llm_credential_id,
3959
+ active_voice_credential_id,
3960
+ active_search_credential_id,
3735
3961
  created_at, updated_at FROM prefs WHERE id = 1`
3736
3962
  ).get();
3737
3963
  if (!row) {
@@ -3774,6 +4000,14 @@ function updatePrefs(patch) {
3774
4000
  fields.push("active_llm_credential_id = ?");
3775
4001
  values.push(patch.activeLlmCredentialId);
3776
4002
  }
4003
+ if (patch.activeVoiceCredentialId !== void 0) {
4004
+ fields.push("active_voice_credential_id = ?");
4005
+ values.push(patch.activeVoiceCredentialId);
4006
+ }
4007
+ if (patch.activeSearchCredentialId !== void 0) {
4008
+ fields.push("active_search_credential_id = ?");
4009
+ values.push(patch.activeSearchCredentialId);
4010
+ }
3777
4011
  if (fields.length === 0) return getPrefs();
3778
4012
  fields.push("updated_at = ?");
3779
4013
  values.push(Date.now());
@@ -3983,6 +4217,30 @@ function directResolved(meta, apiKey) {
3983
4217
  carrier: "xai"
3984
4218
  };
3985
4219
  }
4220
+ case "moonshot": {
4221
+ const compat = createOpenAICompatible({
4222
+ name: "moonshot",
4223
+ apiKey,
4224
+ baseURL: "https://api.moonshot.cn/v1",
4225
+ fetch: makeLoggedFetch("moonshot")
4226
+ });
4227
+ return {
4228
+ model: compat.chatModel(meta.directApiId),
4229
+ carrier: "moonshot"
4230
+ };
4231
+ }
4232
+ case "zhipu": {
4233
+ const compat = createOpenAICompatible({
4234
+ name: "zhipu",
4235
+ apiKey,
4236
+ baseURL: "https://open.bigmodel.cn/api/paas/v4/",
4237
+ fetch: makeLoggedFetch("zhipu")
4238
+ });
4239
+ return {
4240
+ model: compat.chatModel(meta.directApiId),
4241
+ carrier: "zhipu"
4242
+ };
4243
+ }
3986
4244
  default:
3987
4245
  throw new NoKeyError(meta.provider);
3988
4246
  }
@@ -4185,6 +4443,7 @@ async function callLLM(req) {
4185
4443
  async function callLLMWithUsage(req) {
4186
4444
  let buf = "";
4187
4445
  let usage = null;
4446
+ let finishReason = null;
4188
4447
  for await (const chunk of callLLMStream(req)) {
4189
4448
  if (chunk.type === "text") buf += chunk.delta;
4190
4449
  else if (chunk.type === "error") throw new Error(chunk.message);
@@ -4194,15 +4453,118 @@ async function callLLMWithUsage(req) {
4194
4453
  completionTokens: chunk.completionTokens,
4195
4454
  totalTokens: chunk.totalTokens
4196
4455
  };
4456
+ } else if (chunk.type === "done") {
4457
+ if (typeof chunk.finishReason === "string" && chunk.finishReason.length > 0) {
4458
+ finishReason = chunk.finishReason;
4459
+ }
4197
4460
  }
4198
4461
  }
4199
- return { text: buf, usage };
4462
+ return { text: buf, usage, finishReason };
4200
4463
  }
4201
4464
 
4202
4465
  // src/storage/keys.ts
4203
4466
  init_db();
4204
- import { createCipheriv as createCipheriv2, createDecipheriv as createDecipheriv2, randomBytes as randomBytes2, scryptSync as scryptSync2 } from "crypto";
4467
+ import { createCipheriv as createCipheriv2, createDecipheriv as createDecipheriv2, randomBytes as randomBytes4, scryptSync as scryptSync2 } from "crypto";
4205
4468
  import { userInfo as userInfo2 } from "os";
4469
+
4470
+ // src/storage/search-credentials.ts
4471
+ import { randomBytes as randomBytes3 } from "crypto";
4472
+ init_db();
4473
+ var ALL_SEARCH_PROVIDERS = ["brave", "tavily"];
4474
+ function isSearchProvider(p) {
4475
+ return ALL_SEARCH_PROVIDERS.includes(p);
4476
+ }
4477
+ var SEARCH_PROVIDER_PRIORITY = ["brave", "tavily"];
4478
+ function rowToMeta2(row) {
4479
+ if (!isSearchProvider(row.provider)) return null;
4480
+ let preview = "";
4481
+ try {
4482
+ preview = maskCredential(decryptCredential(row.key_blob));
4483
+ } catch {
4484
+ preview = "";
4485
+ }
4486
+ return {
4487
+ id: row.id,
4488
+ provider: row.provider,
4489
+ label: row.label,
4490
+ preview,
4491
+ createdAt: row.created_at,
4492
+ updatedAt: row.updated_at
4493
+ };
4494
+ }
4495
+ function listSearchCredentials() {
4496
+ const rows = getDb().prepare("SELECT id, provider, label, key_blob, created_at, updated_at FROM search_credentials ORDER BY created_at ASC").all();
4497
+ return rows.map(rowToMeta2).filter((m) => m !== null);
4498
+ }
4499
+ function getSearchCredentialMeta(id) {
4500
+ const row = getDb().prepare("SELECT id, provider, label, key_blob, created_at, updated_at FROM search_credentials WHERE id = ?").get(id);
4501
+ if (!row) return null;
4502
+ return rowToMeta2(row);
4503
+ }
4504
+ function getSearchCredentialKey(id) {
4505
+ const row = getDb().prepare("SELECT key_blob FROM search_credentials WHERE id = ?").get(id);
4506
+ if (!row) return null;
4507
+ try {
4508
+ return decryptCredential(row.key_blob);
4509
+ } catch {
4510
+ return null;
4511
+ }
4512
+ }
4513
+ function providerDisplayName2(provider) {
4514
+ switch (provider) {
4515
+ case "brave":
4516
+ return "Brave Search";
4517
+ case "tavily":
4518
+ return "Tavily Search";
4519
+ }
4520
+ }
4521
+ function resolveFreeLabel2(provider, suggested) {
4522
+ const base = suggested && suggested.trim() || providerDisplayName2(provider);
4523
+ const existing = new Set(
4524
+ getDb().prepare("SELECT label FROM search_credentials").all().map((r) => r.label)
4525
+ );
4526
+ if (!existing.has(base)) return base;
4527
+ for (let n = 2; n < 1e3; n++) {
4528
+ const candidate = `${base} ${n}`;
4529
+ if (!existing.has(candidate)) return candidate;
4530
+ }
4531
+ return `${base} ${Date.now()}`;
4532
+ }
4533
+ function createSearchCredential(provider, label, plain) {
4534
+ const trimmed = plain.trim();
4535
+ if (!trimmed) return null;
4536
+ if (!isSearchProvider(provider)) return null;
4537
+ const resolvedLabel = resolveFreeLabel2(provider, label);
4538
+ const id = randomBytes3(8).toString("hex");
4539
+ const blob = encryptCredential(trimmed);
4540
+ const now = Date.now();
4541
+ getDb().prepare(
4542
+ `INSERT INTO search_credentials (id, provider, label, key_blob, created_at, updated_at)
4543
+ VALUES (?, ?, ?, ?, ?, ?)`
4544
+ ).run(id, provider, resolvedLabel, blob, now, now);
4545
+ return getSearchCredentialMeta(id);
4546
+ }
4547
+ function deleteSearchCredential(id) {
4548
+ const meta = getSearchCredentialMeta(id);
4549
+ if (!meta) return null;
4550
+ getDb().prepare("DELETE FROM search_credentials WHERE id = ?").run(id);
4551
+ return meta.provider;
4552
+ }
4553
+ function resolveActiveSearchCredential() {
4554
+ const prefs = getPrefs();
4555
+ if (!prefs.activeSearchCredentialId) return null;
4556
+ return getSearchCredentialMeta(prefs.activeSearchCredentialId);
4557
+ }
4558
+ function getActiveSearchProvider() {
4559
+ return resolveActiveSearchCredential()?.provider ?? null;
4560
+ }
4561
+ function getActiveSearchKeyPlaintext() {
4562
+ const active = resolveActiveSearchCredential();
4563
+ if (!active) return null;
4564
+ return getSearchCredentialKey(active.id);
4565
+ }
4566
+
4567
+ // src/storage/keys.ts
4206
4568
  var SALT2 = "boardroom.v1.salt";
4207
4569
  var ALGO2 = "aes-256-gcm";
4208
4570
  var _key2 = null;
@@ -4212,14 +4574,14 @@ function deriveKey2() {
4212
4574
  _key2 = scryptSync2(username, SALT2, 32);
4213
4575
  return _key2;
4214
4576
  }
4215
- function encrypt2(plain) {
4216
- const iv = randomBytes2(12);
4577
+ function encrypt(plain) {
4578
+ const iv = randomBytes4(12);
4217
4579
  const cipher = createCipheriv2(ALGO2, deriveKey2(), iv);
4218
4580
  const ct = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
4219
4581
  const tag = cipher.getAuthTag();
4220
4582
  return Buffer.concat([iv, tag, ct]);
4221
4583
  }
4222
- function decrypt2(blob) {
4584
+ function decrypt(blob) {
4223
4585
  const iv = blob.subarray(0, 12);
4224
4586
  const tag = blob.subarray(12, 28);
4225
4587
  const ct = blob.subarray(28);
@@ -4227,36 +4589,16 @@ function decrypt2(blob) {
4227
4589
  decipher.setAuthTag(tag);
4228
4590
  return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8");
4229
4591
  }
4230
- function hasBraveKey() {
4231
- const k = getKey("brave");
4232
- return typeof k === "string" && k.length > 0;
4233
- }
4234
- function hasTavilyKey() {
4235
- const k = getKey("tavily");
4236
- return typeof k === "string" && k.length > 0;
4237
- }
4238
4592
  function hasWebSearchKey() {
4239
- return hasBraveKey() || hasTavilyKey();
4240
- }
4241
- function resolveWebSearchBackend(preference) {
4242
- const b = hasBraveKey();
4243
- const t = hasTavilyKey();
4244
- if (!b && !t) return null;
4245
- if (b && !t) return "brave";
4246
- if (!b && t) return "tavily";
4247
- if (preference === "tavily" && t) return "tavily";
4248
- if (preference === "brave" && b) return "brave";
4249
- return b ? "brave" : "tavily";
4593
+ return getActiveSearchProvider() !== null;
4250
4594
  }
4251
4595
  function getActiveWebSearchCredentials() {
4252
- const prefRaw = getPrefs().webSearchProvider;
4253
- const preference = prefRaw === "tavily" ? "tavily" : "brave";
4254
- const backend = resolveWebSearchBackend(preference);
4596
+ const backend = getActiveSearchProvider();
4255
4597
  if (!backend) return null;
4256
- const apiKey = getKey(backend);
4598
+ const apiKey = getActiveSearchKeyPlaintext();
4257
4599
  return apiKey ? { backend, apiKey } : null;
4258
4600
  }
4259
- function maskKey2(plain) {
4601
+ function maskKey(plain) {
4260
4602
  const trimmed = plain.trim();
4261
4603
  if (!trimmed) return "";
4262
4604
  const n = trimmed.length;
@@ -4273,7 +4615,7 @@ function listKeyMeta() {
4273
4615
  let preview = null;
4274
4616
  if (r.key_blob.length > 0) {
4275
4617
  try {
4276
- preview = maskKey2(decrypt2(r.key_blob));
4618
+ preview = maskKey(decrypt(r.key_blob));
4277
4619
  } catch {
4278
4620
  preview = null;
4279
4621
  }
@@ -4291,7 +4633,7 @@ function getKey(provider) {
4291
4633
  const row = getDb().prepare("SELECT key_blob FROM provider_keys WHERE provider = ?").get(provider);
4292
4634
  if (!row) return null;
4293
4635
  try {
4294
- return decrypt2(row.key_blob);
4636
+ return decrypt(row.key_blob);
4295
4637
  } catch {
4296
4638
  return null;
4297
4639
  }
@@ -4302,7 +4644,7 @@ function setKey(provider, plain) {
4302
4644
  deleteKey(provider);
4303
4645
  return;
4304
4646
  }
4305
- const blob = encrypt2(trimmed);
4647
+ const blob = encrypt(trimmed);
4306
4648
  const now = Date.now();
4307
4649
  getDb().prepare(
4308
4650
  `INSERT INTO provider_keys (provider, key_blob, created_at, updated_at)
@@ -4320,8 +4662,10 @@ var PRIMARY_BY_CARRIER = {
4320
4662
  bai: "haiku-4-5",
4321
4663
  anthropic: "haiku-4-5",
4322
4664
  openai: "gpt-5-4-mini",
4323
- google: "gemini-3-1-flash"
4665
+ google: "gemini-3-1-flash",
4324
4666
  // xai · no primary (no LLM modelV in registry as of 2026-05-17).
4667
+ moonshot: "kimi-k2-6",
4668
+ zhipu: "glm-5-1"
4325
4669
  };
4326
4670
  function reachableModelVs() {
4327
4671
  const out = /* @__PURE__ */ new Set();
@@ -4346,6 +4690,7 @@ function reconcileAgentModels(opts = {}) {
4346
4690
  const carrier = activeCarrier();
4347
4691
  const primary = carrier ? PRIMARY_BY_CARRIER[carrier] ?? null : null;
4348
4692
  const forcePrimary = opts.forcePrimary === true;
4693
+ const priorCarrier = opts.priorCarrier ?? null;
4349
4694
  const switched = [];
4350
4695
  const cleared = [];
4351
4696
  for (const agent of listAllAgents()) {
@@ -4353,12 +4698,29 @@ function reconcileAgentModels(opts = {}) {
4353
4698
  if (agent.carrierPref) {
4354
4699
  updateAgent(agent.id, { carrierPref: null });
4355
4700
  }
4701
+ if (priorCarrier && v && priorCarrier !== carrier) {
4702
+ writeModelBucketEntry(agent.id, priorCarrier, v);
4703
+ }
4356
4704
  if (!forcePrimary && v && reachable.has(v)) continue;
4357
4705
  if (primary && carrier) {
4706
+ const bucket = getModelBucket(agent.id);
4707
+ const memorised = bucket[carrier];
4358
4708
  const isChair = agent.roleKind === "moderator";
4359
- const target = isChair ? primary : pickRandomFastModel(carrier) ?? primary;
4360
- if (v === target) continue;
4709
+ let target;
4710
+ if (memorised && reachable.has(memorised)) {
4711
+ target = memorised;
4712
+ } else {
4713
+ if (memorised) deleteModelBucketEntry(agent.id, carrier);
4714
+ target = isChair ? primary : pickRandomFastModel(carrier) ?? primary;
4715
+ }
4716
+ if (v === target) {
4717
+ if (bucket[carrier] !== target) {
4718
+ writeModelBucketEntry(agent.id, carrier, target);
4719
+ }
4720
+ continue;
4721
+ }
4361
4722
  updateAgent(agent.id, { modelV: target });
4723
+ writeModelBucketEntry(agent.id, carrier, target);
4362
4724
  switched.push(agent.id);
4363
4725
  } else {
4364
4726
  if (v === "") continue;
@@ -4489,8 +4851,15 @@ var FAST_POOL_BY_CARRIER = {
4489
4851
  ],
4490
4852
  anthropic: ["opus-4-6-fast", "haiku-4-5"],
4491
4853
  openai: ["gpt-5-4-mini"],
4492
- google: ["gemini-3-flash", "gemini-3-1-flash"]
4854
+ google: ["gemini-3-flash", "gemini-3-1-flash"],
4493
4855
  // xai · no fast pool (no LLM modelV in registry).
4856
+ // Moonshot / Zhipu · single-entry pools because the registry only
4857
+ // carries one LLM modelV per provider today. Every director on this
4858
+ // carrier ends up on the same model (no brand variety), which is fine
4859
+ // for these single-model providers · adding more Kimi / GLM rows
4860
+ // to the registry would naturally extend the pool.
4861
+ moonshot: ["kimi-k2-6"],
4862
+ zhipu: ["glm-5-1"]
4494
4863
  };
4495
4864
  function pickRandomFastModel(carrier) {
4496
4865
  if (!carrier) return null;
@@ -5152,6 +5521,10 @@ function flagshipCandidates() {
5152
5521
  return out;
5153
5522
  }
5154
5523
  async function callPhaseLLM(state, modelV, messages, opts) {
5524
+ const r = await callPhaseLLMVerbose(state, modelV, messages, opts);
5525
+ return r ? r.text : null;
5526
+ }
5527
+ async function callPhaseLLMVerbose(state, modelV, messages, opts) {
5155
5528
  if (!isModelV(modelV)) return null;
5156
5529
  const t = signalWithTimeout(state.controller.signal, LLM_CALL_TIMEOUT_MS);
5157
5530
  try {
@@ -5169,7 +5542,7 @@ async function callPhaseLLM(state, modelV, messages, opts) {
5169
5542
  return null;
5170
5543
  }
5171
5544
  }
5172
- return r.text;
5545
+ return { text: r.text, finishReason: r.finishReason };
5173
5546
  } catch (e) {
5174
5547
  process.stderr.write(`[persona-builder] ${modelV} failed: ${e instanceof Error ? e.message : String(e)}
5175
5548
  `);
@@ -5347,7 +5720,9 @@ async function runPipeline(state) {
5347
5720
  if (!profileV1) {
5348
5721
  const status = checkAbortOrCap();
5349
5722
  if (status === "aborted") return finalizeAbort(state);
5350
- return fail("Phase 1 (persona spec) failed \xB7 no flagship model produced a parseable profile.");
5723
+ const candidatesTried = flagshipCandidates();
5724
+ const hint = candidatesTried.length === 0 ? "no flagship model is reachable with your current API key \xB7 open Preferences \u25B8 API Key and add a provider that exposes Claude / GPT / Gemini / Kimi / GLM" : `${candidatesTried.length === 1 ? "the only reachable model" : "all reachable flagship models"} (${candidatesTried.join(", ")}) returned an unparseable profile \u2014 try again, or switch to a stronger model via Preferences \u25B8 API Key`;
5725
+ return fail(`Phase 1 (persona spec) failed \xB7 ${hint}.`);
5351
5726
  }
5352
5727
  partial.profileV1 = profileV1;
5353
5728
  partial.spec = toCore(profileV1);
@@ -5499,17 +5874,58 @@ function finalizeFromCheck(state, status) {
5499
5874
  }
5500
5875
  async function runProfilePass(state, label, webContext) {
5501
5876
  const messages = buildAgentProfileMessages({ description: state.description, webContext: webContext ?? null });
5502
- for (const modelV of flagshipCandidates()) {
5877
+ const candidates = flagshipCandidates();
5878
+ if (candidates.length === 0) {
5879
+ process.stderr.write(`[persona-builder/${label}] no reachable models for the active credential \xB7 cannot build profile
5880
+ `);
5881
+ return null;
5882
+ }
5883
+ const PROFILE_ATTEMPTS_PER_MODEL = 2;
5884
+ const PROFILE_MAX_TOKENS_BASE = 4096;
5885
+ const PROFILE_MAX_TOKENS_ESCALATED = 6500;
5886
+ for (const modelV of candidates) {
5503
5887
  if (state.controller.signal.aborted) return null;
5504
- const raw = await callPhaseLLM(state, modelV, messages, { temperature: 0.6, maxTokens: 2400 });
5505
- if (!raw) continue;
5506
- const parsed = parseAgentProfile(raw);
5507
- if (parsed) return parsed;
5508
- process.stderr.write(`[persona-builder/${label}] ${modelV} returned unparseable profile
5888
+ let truncatedLastAttempt = false;
5889
+ for (let attempt = 1; attempt <= PROFILE_ATTEMPTS_PER_MODEL; attempt++) {
5890
+ if (state.controller.signal.aborted) return null;
5891
+ const temperature = attempt === 1 ? 0.6 : 0.8;
5892
+ const maxTokens = truncatedLastAttempt ? PROFILE_MAX_TOKENS_ESCALATED : PROFILE_MAX_TOKENS_BASE;
5893
+ const result = await callPhaseLLMVerbose(state, modelV, messages, { temperature, maxTokens });
5894
+ if (!result || !result.text) {
5895
+ process.stderr.write(`[persona-builder/${label}] ${modelV} attempt ${attempt}/${PROFILE_ATTEMPTS_PER_MODEL} returned no text
5509
5896
  `);
5897
+ truncatedLastAttempt = false;
5898
+ continue;
5899
+ }
5900
+ const raw = result.text;
5901
+ const parsed = parseAgentProfile(raw);
5902
+ if (parsed) return parsed;
5903
+ const truncated = result.finishReason === "length" || looksTruncated(raw);
5904
+ truncatedLastAttempt = truncated;
5905
+ const head = raw.slice(0, 200).replace(/\s+/g, " ");
5906
+ const tail = raw.length > 200 ? raw.slice(-160).replace(/\s+/g, " ") : "";
5907
+ process.stderr.write(
5908
+ `[persona-builder/${label}] ${modelV} attempt ${attempt}/${PROFILE_ATTEMPTS_PER_MODEL} returned unparseable profile \xB7 len=${raw.length} finish=${result.finishReason ?? "n/a"} maxTokens=${maxTokens}${truncated ? " TRUNCATED" : ""} \xB7 head: ${head}${tail ? ` \u2026 tail: ${tail}` : ""}
5909
+ `
5910
+ );
5911
+ }
5510
5912
  }
5511
5913
  return null;
5512
5914
  }
5915
+ function looksTruncated(raw) {
5916
+ if (!raw) return false;
5917
+ const fenceMatch = /```(?:json)?\s*([\s\S]*)$/i.exec(raw);
5918
+ const body = fenceMatch ? fenceMatch[1] : raw;
5919
+ if (!body) return false;
5920
+ const start = body.indexOf("{");
5921
+ if (start === -1) return false;
5922
+ let depth = 0;
5923
+ for (let i = start; i < body.length; i++) {
5924
+ if (body[i] === "{") depth++;
5925
+ else if (body[i] === "}") depth--;
5926
+ }
5927
+ return depth > 0;
5928
+ }
5513
5929
  async function runReActLoop(state, profileV1, reportProgress) {
5514
5930
  const creds = getActiveWebSearchCredentials();
5515
5931
  if (!creds) {
@@ -6155,12 +6571,12 @@ async function generateCelebritySeed(opts) {
6155
6571
  init_db();
6156
6572
 
6157
6573
  // src/utils/id.ts
6158
- import { randomBytes as randomBytes3 } from "crypto";
6574
+ import { randomBytes as randomBytes5 } from "crypto";
6159
6575
  var ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
6160
6576
  var ALPHABET_LEN = ALPHABET.length;
6161
6577
  var MASK = (1 << 5) - 1;
6162
6578
  function newId(len = 12) {
6163
- const bytes = randomBytes3(len);
6579
+ const bytes = randomBytes5(len);
6164
6580
  let out = "";
6165
6581
  for (let i = 0; i < len; i++) {
6166
6582
  out += ALPHABET[bytes[i] & MASK];
@@ -8109,7 +8525,14 @@ function agentsRouter() {
8109
8525
  modelV,
8110
8526
  messages: profileMessages,
8111
8527
  temperature: 0.6,
8112
- maxTokens: 1600,
8528
+ // Bumped from 1600 → 4096 · the profile JSON plus any
8529
+ // chain-of-thought / reasoning preamble on Kimi / GLM /
8530
+ // DeepSeek-R lines easily overran the smaller ceiling on
8531
+ // OpenRouter routes, which surface truncation as
8532
+ // "unparseable JSON" (the closing `}` is missing). Quick
8533
+ // mode is single-shot — no retry — so the ceiling has to
8534
+ // be generous up front.
8535
+ maxTokens: 4096,
8113
8536
  signal
8114
8537
  });
8115
8538
  const parsed = parseAgentProfile(raw);
@@ -8117,6 +8540,8 @@ function agentsRouter() {
8117
8540
  profile = parsed;
8118
8541
  break;
8119
8542
  }
8543
+ process.stderr.write(`[agent-spec/profile] ${modelV} returned unparseable profile \xB7 len=${raw.length} head: ${raw.slice(0, 200).replace(/\s+/g, " ")}
8544
+ `);
8120
8545
  } catch (e) {
8121
8546
  process.stderr.write(`[agent-spec/profile] ${modelV} failed: ${e instanceof Error ? e.message : String(e)}
8122
8547
  `);
@@ -8490,7 +8915,7 @@ function agentsRouter() {
8490
8915
  patch.carrierPref = null;
8491
8916
  } else if (typeof b.carrierPref === "string") {
8492
8917
  const v = b.carrierPref.trim();
8493
- const allowed = /* @__PURE__ */ new Set(["openrouter", "bai", "anthropic", "openai", "google", "xai"]);
8918
+ const allowed = /* @__PURE__ */ new Set(["openrouter", "bai", "anthropic", "openai", "google", "xai", "moonshot", "zhipu"]);
8494
8919
  if (!allowed.has(v)) {
8495
8920
  return c.json({ error: `unknown carrier: ${v}` }, 400);
8496
8921
  }
@@ -8542,6 +8967,42 @@ function agentsRouter() {
8542
8967
  patch.isPinned = b.isPinned;
8543
8968
  }
8544
8969
  const updated = updateAgent(id, patch);
8970
+ if (updated) {
8971
+ if (patch.modelV !== void 0) {
8972
+ const carrier = activeCarrier();
8973
+ if (carrier) {
8974
+ try {
8975
+ writeModelBucketEntry(id, carrier, patch.modelV);
8976
+ } catch (e) {
8977
+ process.stderr.write(
8978
+ `[agents.patch] model bucket write failed for ${id}: ${e instanceof Error ? e.message : String(e)}
8979
+ `
8980
+ );
8981
+ }
8982
+ }
8983
+ }
8984
+ if ("voice" in patch) {
8985
+ if (patch.voice && updated.voice && (updated.voice.provider === "minimax" || updated.voice.provider === "elevenlabs")) {
8986
+ try {
8987
+ writeVoiceBucketEntry(id, updated.voice.provider, updated.voice);
8988
+ } catch (e) {
8989
+ process.stderr.write(
8990
+ `[agents.patch] voice bucket write failed for ${id}: ${e instanceof Error ? e.message : String(e)}
8991
+ `
8992
+ );
8993
+ }
8994
+ } else if (!patch.voice && existing.voice && (existing.voice.provider === "minimax" || existing.voice.provider === "elevenlabs")) {
8995
+ try {
8996
+ deleteVoiceBucketEntry(id, existing.voice.provider);
8997
+ } catch (e) {
8998
+ process.stderr.write(
8999
+ `[agents.patch] voice bucket delete failed for ${id}: ${e instanceof Error ? e.message : String(e)}
9000
+ `
9001
+ );
9002
+ }
9003
+ }
9004
+ }
9005
+ }
8545
9006
  return c.json(updated);
8546
9007
  });
8547
9008
  r.delete("/:id", (c) => {
@@ -13700,7 +14161,7 @@ function cleanupOrphanedStreams(opts = {}) {
13700
14161
 
13701
14162
  // src/storage/rooms.ts
13702
14163
  init_db();
13703
- var ROOM_COLS = "id, number, name, subject, mode, intensity, delivery_mode, vote_trigger, status, brief_style, awaiting_continue, awaiting_clarify, created_at, paused_at, adjourned_at, incognito, parent_room_id, parent_brief_id";
14164
+ var ROOM_COLS = "id, number, name, subject, mode, intensity, delivery_mode, vote_trigger, status, brief_style, awaiting_continue, awaiting_clarify, created_at, paused_at, adjourned_at, incognito, parent_room_id, parent_brief_id, name_auto";
13704
14165
  function mapRow8(row) {
13705
14166
  return {
13706
14167
  id: row.id,
@@ -13720,7 +14181,8 @@ function mapRow8(row) {
13720
14181
  adjournedAt: row.adjourned_at,
13721
14182
  incognito: row.incognito === 1,
13722
14183
  parentRoomId: row.parent_room_id,
13723
- parentBriefId: row.parent_brief_id
14184
+ parentBriefId: row.parent_brief_id,
14185
+ nameAuto: row.name_auto === 1
13724
14186
  };
13725
14187
  }
13726
14188
  function mapMember(row) {
@@ -13784,7 +14246,7 @@ function createRoom(input) {
13784
14246
  const briefStyle = input.briefStyle ?? "auto";
13785
14247
  const deliveryMode = input.deliveryMode === "voice" ? "voice" : "text";
13786
14248
  const insertRoom = db.prepare(
13787
- "INSERT INTO rooms (id, number, name, subject, mode, intensity, delivery_mode, brief_style, status, created_at, parent_room_id, parent_brief_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'live', ?, ?, ?)"
14249
+ "INSERT INTO rooms (id, number, name, subject, mode, intensity, delivery_mode, brief_style, status, created_at, parent_room_id, parent_brief_id, name_auto) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'live', ?, ?, ?, ?)"
13788
14250
  );
13789
14251
  const insertMember = db.prepare(
13790
14252
  "INSERT INTO room_members (room_id, agent_id, position, joined_at) VALUES (?, ?, ?, ?)"
@@ -13792,8 +14254,9 @@ function createRoom(input) {
13792
14254
  const chair = getChairAgent();
13793
14255
  const parentRoomId = input.parentRoomId && input.parentRoomId.trim() ? input.parentRoomId.trim() : null;
13794
14256
  const parentBriefId = input.parentBriefId && input.parentBriefId.trim() ? input.parentBriefId.trim() : null;
14257
+ const nameAuto = input.nameAuto === false ? 0 : 1;
13795
14258
  const tx = db.transaction(() => {
13796
- insertRoom.run(id, number, input.name, input.subject, mode, intensity, deliveryMode, briefStyle, now, parentRoomId, parentBriefId);
14259
+ insertRoom.run(id, number, input.name, input.subject, mode, intensity, deliveryMode, briefStyle, now, parentRoomId, parentBriefId, nameAuto);
13797
14260
  if (chair) insertMember.run(id, chair.id, -1, now);
13798
14261
  input.agentIds.forEach((agentId, idx) => {
13799
14262
  if (chair && agentId === chair.id) return;
@@ -13820,6 +14283,12 @@ function setRoomStatus(roomId, status, ts = {}) {
13820
14283
  vals.push(roomId);
13821
14284
  getDb().prepare(`UPDATE rooms SET ${sets.join(", ")} WHERE id = ?`).run(...vals);
13822
14285
  }
14286
+ function setRoomNameFromAuto(roomId, name) {
14287
+ const trimmed = name.trim();
14288
+ if (!trimmed) return false;
14289
+ const r = getDb().prepare("UPDATE rooms SET name = ? WHERE id = ? AND name_auto = 1").run(trimmed, roomId);
14290
+ return r.changes > 0;
14291
+ }
13823
14292
  function addRoomMember(roomId, agentId) {
13824
14293
  const db = getDb();
13825
14294
  const existing = db.prepare("SELECT agent_id, position, joined_at, removed_at FROM room_members WHERE room_id = ? AND agent_id = ?").get(roomId, agentId);
@@ -14339,6 +14808,103 @@ import { Buffer as Buffer2 } from "buffer";
14339
14808
  init_db();
14340
14809
  import { createHash } from "crypto";
14341
14810
 
14811
+ // src/storage/voice-credentials.ts
14812
+ import { randomBytes as randomBytes6 } from "crypto";
14813
+ init_db();
14814
+ var ALL_VOICE_PROVIDERS = ["minimax", "elevenlabs"];
14815
+ function isVoiceProvider(p) {
14816
+ return ALL_VOICE_PROVIDERS.includes(p);
14817
+ }
14818
+ var VOICE_PROVIDER_PRIORITY = ["minimax", "elevenlabs"];
14819
+ function rowToMeta3(row) {
14820
+ if (!isVoiceProvider(row.provider)) return null;
14821
+ let preview = "";
14822
+ try {
14823
+ preview = maskCredential(decryptCredential(row.key_blob));
14824
+ } catch {
14825
+ preview = "";
14826
+ }
14827
+ return {
14828
+ id: row.id,
14829
+ provider: row.provider,
14830
+ label: row.label,
14831
+ preview,
14832
+ createdAt: row.created_at,
14833
+ updatedAt: row.updated_at
14834
+ };
14835
+ }
14836
+ function listVoiceCredentials() {
14837
+ const rows = getDb().prepare("SELECT id, provider, label, key_blob, created_at, updated_at FROM voice_credentials ORDER BY created_at ASC").all();
14838
+ return rows.map(rowToMeta3).filter((m) => m !== null);
14839
+ }
14840
+ function getVoiceCredentialMeta(id) {
14841
+ const row = getDb().prepare("SELECT id, provider, label, key_blob, created_at, updated_at FROM voice_credentials WHERE id = ?").get(id);
14842
+ if (!row) return null;
14843
+ return rowToMeta3(row);
14844
+ }
14845
+ function getVoiceCredentialKey(id) {
14846
+ const row = getDb().prepare("SELECT key_blob FROM voice_credentials WHERE id = ?").get(id);
14847
+ if (!row) return null;
14848
+ try {
14849
+ return decryptCredential(row.key_blob);
14850
+ } catch {
14851
+ return null;
14852
+ }
14853
+ }
14854
+ function providerDisplayName3(provider) {
14855
+ switch (provider) {
14856
+ case "minimax":
14857
+ return "MiniMax";
14858
+ case "elevenlabs":
14859
+ return "ElevenLabs";
14860
+ }
14861
+ }
14862
+ function resolveFreeLabel3(provider, suggested) {
14863
+ const base = suggested && suggested.trim() || providerDisplayName3(provider);
14864
+ const existing = new Set(
14865
+ getDb().prepare("SELECT label FROM voice_credentials").all().map((r) => r.label)
14866
+ );
14867
+ if (!existing.has(base)) return base;
14868
+ for (let n = 2; n < 1e3; n++) {
14869
+ const candidate = `${base} ${n}`;
14870
+ if (!existing.has(candidate)) return candidate;
14871
+ }
14872
+ return `${base} ${Date.now()}`;
14873
+ }
14874
+ function createVoiceCredential(provider, label, plain) {
14875
+ const trimmed = plain.trim();
14876
+ if (!trimmed) return null;
14877
+ if (!isVoiceProvider(provider)) return null;
14878
+ const resolvedLabel = resolveFreeLabel3(provider, label);
14879
+ const id = randomBytes6(8).toString("hex");
14880
+ const blob = encryptCredential(trimmed);
14881
+ const now = Date.now();
14882
+ getDb().prepare(
14883
+ `INSERT INTO voice_credentials (id, provider, label, key_blob, created_at, updated_at)
14884
+ VALUES (?, ?, ?, ?, ?, ?)`
14885
+ ).run(id, provider, resolvedLabel, blob, now, now);
14886
+ return getVoiceCredentialMeta(id);
14887
+ }
14888
+ function deleteVoiceCredential(id) {
14889
+ const meta = getVoiceCredentialMeta(id);
14890
+ if (!meta) return null;
14891
+ getDb().prepare("DELETE FROM voice_credentials WHERE id = ?").run(id);
14892
+ return meta.provider;
14893
+ }
14894
+ function resolveActiveVoiceCredential() {
14895
+ const prefs = getPrefs();
14896
+ if (!prefs.activeVoiceCredentialId) return null;
14897
+ return getVoiceCredentialMeta(prefs.activeVoiceCredentialId);
14898
+ }
14899
+ function getActiveVoiceProvider() {
14900
+ return resolveActiveVoiceCredential()?.provider ?? null;
14901
+ }
14902
+ function getActiveVoiceKeyPlaintext() {
14903
+ const active = resolveActiveVoiceCredential();
14904
+ if (!active) return null;
14905
+ return getVoiceCredentialKey(active.id);
14906
+ }
14907
+
14342
14908
  // src/voice/registry.ts
14343
14909
  function minimaxBaseUrl() {
14344
14910
  const region = getPrefs().minimaxRegion;
@@ -14383,10 +14949,12 @@ function listConfiguredVoices() {
14383
14949
  const out = [];
14384
14950
  const openaiReady = !!getKey("openai");
14385
14951
  if (openaiReady) out.push(...OPENAI_VOICES.map((v) => ({ ...v, configured: true })));
14386
- const minimaxReady = !!getKey("minimax");
14387
- if (minimaxReady) out.push(...MINIMAX_SYSTEM_VOICES.map((v) => ({ ...v, configured: true })));
14388
- const elevenReady = !!getKey("elevenlabs");
14389
- if (elevenReady) out.push(...ELEVENLABS_DEFAULT_VOICES.map((v) => ({ ...v, configured: true })));
14952
+ const activeProvider = getActiveVoiceProvider();
14953
+ if (activeProvider === "minimax") {
14954
+ out.push(...MINIMAX_SYSTEM_VOICES.map((v) => ({ ...v, configured: true })));
14955
+ } else if (activeProvider === "elevenlabs") {
14956
+ out.push(...ELEVENLABS_DEFAULT_VOICES.map((v) => ({ ...v, configured: true })));
14957
+ }
14390
14958
  out.push({
14391
14959
  provider: "browser",
14392
14960
  model: "speechSynthesis",
@@ -14397,14 +14965,21 @@ function listConfiguredVoices() {
14397
14965
  return out;
14398
14966
  }
14399
14967
  async function listAvailableVoices() {
14968
+ const activeProvider = getActiveVoiceProvider();
14400
14969
  let voices = listConfiguredVoices();
14401
- const mmKey = getKey("minimax");
14402
- if (mmKey) {
14403
- try {
14970
+ if (!activeProvider) {
14971
+ return { voices, provider: null, configured: false };
14972
+ }
14973
+ const activeKey = getActiveVoiceKeyPlaintext();
14974
+ if (!activeKey) {
14975
+ return { voices, provider: activeProvider, configured: false };
14976
+ }
14977
+ if (activeProvider === "minimax") {
14978
+ try {
14404
14979
  const res = await fetch(`${minimaxBaseUrl()}/v1/get_voice`, {
14405
14980
  method: "POST",
14406
14981
  headers: {
14407
- "authorization": `Bearer ${mmKey}`,
14982
+ "authorization": `Bearer ${activeKey}`,
14408
14983
  "content-type": "application/json"
14409
14984
  },
14410
14985
  body: JSON.stringify({ voice_type: "all" })
@@ -14433,9 +15008,9 @@ async function listAvailableVoices() {
14433
15008
  }
14434
15009
  } catch {
14435
15010
  }
15011
+ return { voices, provider: "minimax", configured: true };
14436
15012
  }
14437
- const elKey = getKey("elevenlabs");
14438
- if (elKey) {
15013
+ if (activeProvider === "elevenlabs") {
14439
15014
  const personal = [];
14440
15015
  const shared = [];
14441
15016
  await Promise.all([
@@ -14443,7 +15018,7 @@ async function listAvailableVoices() {
14443
15018
  try {
14444
15019
  const res = await fetch(
14445
15020
  "https://api.elevenlabs.io/v1/voices?show_legacy=true&include_total_count=true",
14446
- { headers: { "xi-api-key": elKey } }
15021
+ { headers: { "xi-api-key": activeKey } }
14447
15022
  );
14448
15023
  if (!res.ok) {
14449
15024
  const errText = await res.text();
@@ -14471,7 +15046,7 @@ async function listAvailableVoices() {
14471
15046
  try {
14472
15047
  const res = await fetch(
14473
15048
  "https://api.elevenlabs.io/v1/shared-voices?page_size=100",
14474
- { headers: { "xi-api-key": elKey } }
15049
+ { headers: { "xi-api-key": activeKey } }
14475
15050
  );
14476
15051
  if (!res.ok) {
14477
15052
  const errText = await res.text();
@@ -14524,8 +15099,9 @@ async function listAvailableVoices() {
14524
15099
  }));
14525
15100
  voices = [...nonEl, ...personalMapped, ...sharedMapped];
14526
15101
  }
15102
+ return { voices, provider: "elevenlabs", configured: true };
14527
15103
  }
14528
- return voices;
15104
+ return { voices, provider: activeProvider, configured: true };
14529
15105
  }
14530
15106
  function elevenLabsSharedVoiceRows(raw) {
14531
15107
  if (!Array.isArray(raw)) return [];
@@ -14574,6 +15150,10 @@ function defaultVoiceForProvider(provider) {
14574
15150
  }
14575
15151
 
14576
15152
  // src/voice/tts.ts
15153
+ function activeVoiceKeyFor(wanted) {
15154
+ if (getActiveVoiceProvider() !== wanted) return null;
15155
+ return getActiveVoiceKeyPlaintext();
15156
+ }
14577
15157
  function minimaxBaseUrl2() {
14578
15158
  const region = getPrefs().minimaxRegion;
14579
15159
  return region === "intl" ? "https://api.minimax.io" : "https://api.minimaxi.com";
@@ -14638,9 +15218,26 @@ function cleanForSpeech(md) {
14638
15218
  return out.trim();
14639
15219
  }
14640
15220
  function voiceProfileForAgent(agent) {
14641
- if (agent.voice) return agent.voice;
15221
+ const activeProvider = getActiveVoiceProvider();
15222
+ if (agent.voice && agent.voice.provider === activeProvider) {
15223
+ return agent.voice;
15224
+ }
15225
+ if (agent.voice && activeProvider) {
15226
+ const fresh = defaultVoiceForProvider(activeProvider);
15227
+ if (fresh) {
15228
+ return {
15229
+ provider: fresh.provider,
15230
+ model: fresh.model,
15231
+ voiceId: fresh.voiceId,
15232
+ speed: agent.voice.speed ?? 1,
15233
+ pitch: agent.voice.pitch ?? 0,
15234
+ volume: agent.voice.volume ?? 1,
15235
+ ...agent.voice.emotion ? { emotion: agent.voice.emotion } : {}
15236
+ };
15237
+ }
15238
+ }
14642
15239
  const fallback = defaultVoiceForProvider(
14643
- getKey("minimax") ? "minimax" : getKey("elevenlabs") ? "elevenlabs" : getKey("openai") ? "openai" : "browser"
15240
+ activeProvider ?? (getKey("openai") ? "openai" : "browser")
14644
15241
  );
14645
15242
  return {
14646
15243
  provider: fallback?.provider ?? "browser",
@@ -14663,15 +15260,16 @@ async function synthesizeSpeech(text, profile, signal) {
14663
15260
  };
14664
15261
  }
14665
15262
  async function* synthesizeSpeechStream(text, profile, signal) {
14666
- if (profile.provider === "elevenlabs" && getKey("elevenlabs")) {
15263
+ if (profile.provider === "elevenlabs" && activeVoiceKeyFor("elevenlabs")) {
14667
15264
  yield* synthesizeElevenLabsStream(text, profile, signal);
14668
15265
  return;
14669
15266
  }
14670
- if (profile.provider !== "minimax" || !getKey("minimax")) {
15267
+ const minimaxKey = activeVoiceKeyFor("minimax");
15268
+ if (profile.provider !== "minimax" || !minimaxKey) {
14671
15269
  yield await synthesizeSpeech(text, profile, signal);
14672
15270
  return;
14673
15271
  }
14674
- const key = getKey("minimax");
15272
+ const key = minimaxKey;
14675
15273
  const model = profile.model || "speech-2.8-hd";
14676
15274
  const res = await fetch(`${minimaxBaseUrl2()}/v1/t2a_v2`, {
14677
15275
  method: "POST",
@@ -14787,7 +15385,7 @@ async function* synthesizeSpeechStream(text, profile, signal) {
14787
15385
  }
14788
15386
  }
14789
15387
  async function synthesizeMiniMax(text, profile, signal) {
14790
- const key = getKey("minimax");
15388
+ const key = activeVoiceKeyFor("minimax");
14791
15389
  if (!key) {
14792
15390
  return { provider: "browser", model: "speechSynthesis", voiceId: "system-default", text };
14793
15391
  }
@@ -14897,7 +15495,7 @@ async function synthesizeOpenAI(text, profile, signal) {
14897
15495
  };
14898
15496
  }
14899
15497
  async function synthesizeElevenLabs(text, profile, signal) {
14900
- const key = getKey("elevenlabs");
15498
+ const key = activeVoiceKeyFor("elevenlabs");
14901
15499
  if (!key) {
14902
15500
  return { provider: "browser", model: "speechSynthesis", voiceId: "system-default", text };
14903
15501
  }
@@ -14942,7 +15540,7 @@ async function synthesizeElevenLabs(text, profile, signal) {
14942
15540
  };
14943
15541
  }
14944
15542
  async function* synthesizeElevenLabsStream(text, profile, signal) {
14945
- const key = getKey("elevenlabs");
15543
+ const key = activeVoiceKeyFor("elevenlabs");
14946
15544
  if (!key) {
14947
15545
  yield await synthesizeSpeech(text, profile, signal);
14948
15546
  return;
@@ -16906,6 +17504,7 @@ function credentialsRouter() {
16906
17504
  } else {
16907
17505
  return c.json({ error: "id must be a string or null" }, 400);
16908
17506
  }
17507
+ const priorCarrier = activeCarrier();
16909
17508
  if (nextId) {
16910
17509
  const meta = getLlmCredentialMeta(nextId);
16911
17510
  if (!meta) return c.json({ error: "credential not found" }, 404);
@@ -16916,7 +17515,7 @@ function credentialsRouter() {
16916
17515
  updatePrefs({ activeLlmCredentialId: null });
16917
17516
  }
16918
17517
  try {
16919
- reconcileAgentModels({ forcePrimary: true });
17518
+ reconcileAgentModels({ forcePrimary: true, priorCarrier });
16920
17519
  } catch (e) {
16921
17520
  process.stderr.write(`[credentials.active] reconcile failed: ${e instanceof Error ? e.message : String(e)}
16922
17521
  `);
@@ -16948,7 +17547,7 @@ function credentialsRouter() {
16948
17547
  const flagship = PRIMARY_BY_CARRIER[provider];
16949
17548
  if (flagship) updatePrefs({ defaultModelV: flagship });
16950
17549
  try {
16951
- reconcileAgentModels({ forcePrimary: true });
17550
+ reconcileAgentModels({ forcePrimary: true, priorCarrier: null });
16952
17551
  } catch (e) {
16953
17552
  process.stderr.write(`[credentials.post] reconcile failed: ${e instanceof Error ? e.message : String(e)}
16954
17553
  `);
@@ -16963,6 +17562,7 @@ function credentialsRouter() {
16963
17562
  if (!meta) return c.json({ error: "credential not found" }, 404);
16964
17563
  const prefs = getPrefs();
16965
17564
  const wasActive = prefs.activeLlmCredentialId === id;
17565
+ const priorCarrier = wasActive ? activeCarrier() : null;
16966
17566
  const removedProvider = deleteLlmCredential(id);
16967
17567
  if (wasActive) {
16968
17568
  const nextId = pickNextActiveId(removedProvider);
@@ -16978,7 +17578,7 @@ function credentialsRouter() {
16978
17578
  }
16979
17579
  }
16980
17580
  try {
16981
- reconcileAgentModels(wasActive ? { forcePrimary: true } : void 0);
17581
+ reconcileAgentModels(wasActive ? { forcePrimary: true, priorCarrier } : void 0);
16982
17582
  } catch (e) {
16983
17583
  process.stderr.write(`[credentials.delete] reconcile failed: ${e instanceof Error ? e.message : String(e)}
16984
17584
  `);
@@ -17004,39 +17604,6 @@ var PROVIDERS = /* @__PURE__ */ new Set([
17004
17604
  function isProvider(s) {
17005
17605
  return PROVIDERS.has(s);
17006
17606
  }
17007
- function autoAssignVoicesOnFirstKey(provider) {
17008
- if (provider !== "minimax" && provider !== "elevenlabs") return;
17009
- const pool = listConfiguredVoices().filter((v) => v.provider === provider);
17010
- if (pool.length === 0) return;
17011
- const agents = listAllAgents();
17012
- if (agents.length === 0) return;
17013
- const shuffled = [...pool];
17014
- for (let i = shuffled.length - 1; i > 0; i--) {
17015
- const j = Math.floor(Math.random() * (i + 1));
17016
- [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
17017
- }
17018
- for (let i = 0; i < agents.length; i++) {
17019
- const v = shuffled[i % shuffled.length];
17020
- const prev = agents[i].voice;
17021
- const profile = {
17022
- provider: v.provider,
17023
- model: v.model,
17024
- voiceId: v.voiceId,
17025
- ...prev?.speed !== void 0 ? { speed: prev.speed } : {},
17026
- ...prev?.pitch !== void 0 ? { pitch: prev.pitch } : {},
17027
- ...prev?.volume !== void 0 ? { volume: prev.volume } : {},
17028
- ...prev?.emotion !== void 0 ? { emotion: prev.emotion } : {}
17029
- };
17030
- try {
17031
- updateAgent(agents[i].id, { voice: profile });
17032
- } catch (e) {
17033
- process.stderr.write(
17034
- `[keys.put] auto-assign voice failed for ${agents[i].id}: ${e instanceof Error ? e.message : String(e)}
17035
- `
17036
- );
17037
- }
17038
- }
17039
- }
17040
17607
  function keysRouter() {
17041
17608
  const r = new Hono5();
17042
17609
  r.get("/", (c) => {
@@ -17062,6 +17629,12 @@ function keysRouter() {
17062
17629
  if (isLlmProvider(provider)) {
17063
17630
  return c.json({ error: "LLM providers use POST /api/credentials" }, 410);
17064
17631
  }
17632
+ if (provider === "minimax" || provider === "elevenlabs") {
17633
+ return c.json({ error: "voice providers use POST /api/voice-credentials" }, 410);
17634
+ }
17635
+ if (provider === "brave" || provider === "tavily") {
17636
+ return c.json({ error: "search providers use POST /api/search-credentials" }, 410);
17637
+ }
17065
17638
  let body;
17066
17639
  try {
17067
17640
  body = await c.req.json();
@@ -17070,16 +17643,7 @@ function keysRouter() {
17070
17643
  }
17071
17644
  const key = body?.key;
17072
17645
  if (typeof key !== "string") return c.json({ error: "body must contain { key: string }" }, 400);
17073
- const hadAnyVoiceKeyBefore = !!getKey("minimax") || !!getKey("elevenlabs");
17074
17646
  setKey(provider, key);
17075
- if ((provider === "minimax" || provider === "elevenlabs") && key.trim().length > 0 && !hadAnyVoiceKeyBefore) {
17076
- try {
17077
- autoAssignVoicesOnFirstKey(provider);
17078
- } catch (e) {
17079
- process.stderr.write(`[keys.put] voice auto-assign failed: ${e instanceof Error ? e.message : String(e)}
17080
- `);
17081
- }
17082
- }
17083
17647
  const fresh = listKeyMeta().find((m) => m.provider === provider);
17084
17648
  return c.json(
17085
17649
  fresh ?? { provider, configured: key.trim().length > 0, updatedAt: Date.now(), preview: null }
@@ -17091,6 +17655,12 @@ function keysRouter() {
17091
17655
  if (isLlmProvider(provider)) {
17092
17656
  return c.json({ error: "LLM providers use DELETE /api/credentials/:id" }, 410);
17093
17657
  }
17658
+ if (provider === "minimax" || provider === "elevenlabs") {
17659
+ return c.json({ error: "voice providers use DELETE /api/voice-credentials/:id" }, 410);
17660
+ }
17661
+ if (provider === "brave" || provider === "tavily") {
17662
+ return c.json({ error: "search providers use DELETE /api/search-credentials/:id" }, 410);
17663
+ }
17094
17664
  deleteKey(provider);
17095
17665
  return c.json({ provider, configured: false, updatedAt: null, preview: null });
17096
17666
  });
@@ -21895,6 +22465,7 @@ async function streamChairMessage(args) {
21895
22465
  const metaKind = meta?.kind;
21896
22466
  const isRoundEndVoice = voiceMode && metaKind === "round-end";
21897
22467
  let pingDone = false;
22468
+ let tailDone = false;
21898
22469
  let voiceBuf = "";
21899
22470
  const voiceProfile = voiceMode ? voiceProfileForAgent(chair) : null;
21900
22471
  let voiceSeq = 0;
@@ -21956,20 +22527,31 @@ async function streamChairMessage(args) {
21956
22527
  });
21957
22528
  if (voiceChunker) {
21958
22529
  if (isRoundEndVoice) {
21959
- voiceBuf += chunk.delta;
21960
- if (!pingDone) {
21961
- const idx = voiceBuf.search(/POINTS\s*:/i);
21962
- if (idx >= 0) {
21963
- pingDone = true;
21964
- const after = voiceBuf.slice(voiceBuf.search(/POINTS\s*:/i));
21965
- voiceBuf = after.replace(/POINTS\s*:/i, "");
22530
+ if (tailDone) {
22531
+ } else {
22532
+ voiceBuf += chunk.delta;
22533
+ if (!pingDone) {
22534
+ const idx = voiceBuf.search(/POINTS\s*:/i);
22535
+ if (idx >= 0) {
22536
+ pingDone = true;
22537
+ const after = voiceBuf.slice(voiceBuf.search(/POINTS\s*:/i));
22538
+ voiceBuf = after.replace(/POINTS\s*:/i, "");
22539
+ }
21966
22540
  }
21967
- }
21968
- if (pingDone && voiceBuf) {
21969
- const cleaned = voiceBuf.replace(/(^|\n)\s*[-*]\s*/g, ". ");
21970
- voiceBuf = "";
21971
- for (const spoken of voiceChunker.push(cleaned)) {
21972
- await emitChairVoice(spoken);
22541
+ if (pingDone && voiceBuf) {
22542
+ const tailIdx = voiceBuf.search(/MODE[-\s]?SHIFT\s*:/i);
22543
+ if (tailIdx >= 0) {
22544
+ tailDone = true;
22545
+ const beforeTail = voiceBuf.slice(0, tailIdx);
22546
+ voiceBuf = beforeTail;
22547
+ }
22548
+ if (voiceBuf) {
22549
+ const cleaned = voiceBuf.replace(/(^|\n)\s*[-*]\s*/g, ". ").replace(/\bBECAUSE\s*:\s*/gi, "");
22550
+ voiceBuf = "";
22551
+ for (const spoken of voiceChunker.push(cleaned)) {
22552
+ await emitChairVoice(spoken);
22553
+ }
22554
+ }
21973
22555
  }
21974
22556
  }
21975
22557
  } else {
@@ -23110,6 +23692,129 @@ async function pickDirectors(opts) {
23110
23692
  };
23111
23693
  }
23112
23694
 
23695
+ // src/orchestrator/roomTitle.ts
23696
+ var MAX_TITLE_CHARS = 32;
23697
+ var REJECT_PHRASES = /* @__PURE__ */ new Set([
23698
+ "untitled",
23699
+ "untitled room",
23700
+ "discussion",
23701
+ "chat",
23702
+ "conversation",
23703
+ "topic",
23704
+ "summary",
23705
+ "\u672A\u547D\u540D",
23706
+ "\u8BA8\u8BBA",
23707
+ "\u5BF9\u8BDD",
23708
+ "\u804A\u5929"
23709
+ ]);
23710
+ async function generateRoomTitle(roomId) {
23711
+ const room = getRoom(roomId);
23712
+ if (!room) return { kind: "skipped", reason: "no-room" };
23713
+ if (!room.nameAuto) return { kind: "skipped", reason: "user-named" };
23714
+ const subject = room.subject.trim();
23715
+ if (!subject) return { kind: "skipped", reason: "no-subject" };
23716
+ const fallbackName = room.subject.slice(0, 60);
23717
+ if (room.name !== fallbackName) {
23718
+ return { kind: "skipped", reason: "already-renamed", detail: room.name.slice(0, 60) };
23719
+ }
23720
+ const modelV = utilityModelFor();
23721
+ if (!modelV) return { kind: "skipped", reason: "no-model" };
23722
+ const prompt = `You are titling a conversation for a sidebar entry, the way ChatGPT does it. Read the user's opening question and write the title that another reader would expect to see for THIS conversation \u2014 specific enough to distinguish it from any neighbouring entry in the same domain.
23723
+
23724
+ How to write a representative title:
23725
+ 1. Identify the CORE SUBJECT or TASK (the noun, the deliverable, the decision being made).
23726
+ 2. Strip throat-clearing, polite framing, self-introduction, and product names that are not the subject itself.
23727
+ 3. Keep one distinguishing modifier when the bare noun would be ambiguous ("\u4EA7\u54C1\u5BA3\u4F20\u89C6\u9891\u811A\u672C" beats "\u89C6\u9891\u811A\u672C"; "LoRA vs \u5168\u91CF\u5FAE\u8C03" beats "LoRA").
23728
+ 4. Use the SAME language as the opening question.
23729
+
23730
+ Length:
23731
+ - Chinese / Japanese: 5-10 characters.
23732
+ - English / Spanish / other Latin scripts: 3-6 words.
23733
+ - \u226424 characters total.
23734
+
23735
+ Format:
23736
+ - Output ONLY the title \u2014 no quotes, no brackets, no trailing punctuation, no labels like "Topic:" / "\u4E3B\u9898\uFF1A", no explanation.
23737
+ - Never output fillers like "Untitled", "Discussion", "Chat", "Conversation", "\u8BA8\u8BBA", "\u5BF9\u8BDD", "\u804A\u5929".
23738
+
23739
+ Examples:
23740
+
23741
+ Input: privateboard.ai \u662F\u6211\u7684\u521B\u4E1A\u4EA7\u54C1\uFF0C\u6211\u73B0\u5728\u60F3\u526A\u8F91\u4E00\u4E2A\u89C6\u9891\u653E\u5230\u5B98\u7F51\u548C x.com \u4E0A\u9762\u5BA3\u4F20\u4ECB\u7ECD\u4EA7\u54C1\u3002\u4F60\u5E2E\u6211\u5199\u4E00\u4E2A\u811A\u672C\u3002
23742
+ Output: \u4EA7\u54C1\u5BA3\u4F20\u89C6\u9891\u811A\u672C
23743
+
23744
+ Input: \u6211\u4EEC\u516C\u53F8\u5728\u8003\u8651\u8981\u4E0D\u8981\u4ECE Postgres \u8FC1\u79FB\u5230 ClickHouse \u5904\u7406\u5206\u6790\u67E5\u8BE2\uFF0C\u80FD\u5E2E\u6211\u5217\u51FA\u6743\u8861\u4E48
23745
+ Output: Postgres \u8F6C ClickHouse \u6743\u8861
23746
+
23747
+ Input: \u6211\u60F3\u8BA8\u8BBA\u4E00\u4E0B LoRA \u5FAE\u8C03\u76F8\u6BD4\u5168\u91CF\u5FAE\u8C03\u6709\u4EC0\u4E48\u4F18\u7F3A\u70B9\uFF0C\u7279\u522B\u662F\u5728\u5C0F\u6A21\u578B\u4E0A
23748
+ Output: LoRA vs \u5168\u91CF\u5FAE\u8C03
23749
+
23750
+ Input: Can you help me debug this Python regex that's failing on Unicode strings with combining marks?
23751
+ Output: Python regex Unicode bug
23752
+
23753
+ Input: I want to redesign our onboarding email sequence \u2014 currently 5 emails over 2 weeks, low click-through.
23754
+ Output: Onboarding email redesign
23755
+
23756
+ --- User's opening question ---
23757
+ ${subject}
23758
+
23759
+ --- Title ---
23760
+ `;
23761
+ let raw = "";
23762
+ try {
23763
+ raw = await callLLM({
23764
+ modelV,
23765
+ carrier: null,
23766
+ messages: [{ role: "user", content: prompt }],
23767
+ // Low but not zero · 0.2 was deterministic-ish but kept locking
23768
+ // onto a generic first-noun pick. 0.4 lets the model trade off
23769
+ // alternatives without wandering into creative territory.
23770
+ temperature: 0.4,
23771
+ // 40 was tight enough that a model thinking briefly before
23772
+ // answering would get cut off mid-title; 80 fits the title plus
23773
+ // a small margin without inviting paragraphs.
23774
+ maxTokens: 80
23775
+ });
23776
+ } catch (e) {
23777
+ const detail = e instanceof Error ? e.message : String(e);
23778
+ process.stderr.write(`[room-title] LLM call failed for ${roomId}: ${detail}
23779
+ `);
23780
+ return { kind: "skipped", reason: "llm-error", detail };
23781
+ }
23782
+ if (!raw.trim()) {
23783
+ return { kind: "skipped", reason: "empty-output", detail: `model=${modelV}` };
23784
+ }
23785
+ const phrase = sanitiseTitle(raw);
23786
+ if (!phrase) {
23787
+ return { kind: "skipped", reason: "rejected-generic", detail: raw.trim().slice(0, 80) };
23788
+ }
23789
+ const updated = setRoomNameFromAuto(roomId, phrase);
23790
+ if (!updated) return { kind: "skipped", reason: "race-after-rename" };
23791
+ roomBus.emit(roomId, {
23792
+ type: "config-event",
23793
+ kind: "settings-changed",
23794
+ payload: { changes: { name: { from: room.name, to: phrase } } },
23795
+ createdAt: Date.now()
23796
+ });
23797
+ return { kind: "ok", before: room.name, after: phrase };
23798
+ }
23799
+ function sanitiseTitle(raw) {
23800
+ let s = raw.trim();
23801
+ if (!s) return null;
23802
+ const firstLine = s.split(/\r?\n/).map((l) => l.trim()).find((l) => l.length > 0);
23803
+ if (firstLine) s = firstLine;
23804
+ s = s.replace(/^\s*output\s*[::]\s*/i, "");
23805
+ s = s.replace(/^\s*(topic|title|主题|主題|标题|標題|タイトル)\s*[::]\s*/i, "");
23806
+ s = s.replace(/^[\s"'`「『《【〈“‘]+/, "").replace(/[\s"'`」』》】〉”’]+$/, "");
23807
+ s = s.replace(/[\s.。!!??,,;;::]+$/, "");
23808
+ s = s.replace(/\s+/g, " ").trim();
23809
+ if (!s) return null;
23810
+ if (REJECT_PHRASES.has(s.toLowerCase())) return null;
23811
+ const cps = Array.from(s);
23812
+ if (cps.length > MAX_TITLE_CHARS) {
23813
+ s = cps.slice(0, MAX_TITLE_CHARS).join("");
23814
+ }
23815
+ return s;
23816
+ }
23817
+
23113
23818
  // src/routes/rooms.ts
23114
23819
  async function runAutoPickAndSeat(roomId, subject) {
23115
23820
  const candidates = listAgents().filter((a) => a.roleKind === "director");
@@ -23229,7 +23934,10 @@ function roomsRouter() {
23229
23934
  );
23230
23935
  }
23231
23936
  }
23232
- const name = typeof b.name === "string" && b.name.trim() ? b.name.trim().slice(0, 80) : subject.slice(0, 60);
23937
+ const rawName = typeof b.name === "string" ? b.name.trim() : "";
23938
+ const hasExplicitName = rawName.length > 0;
23939
+ const name = hasExplicitName ? rawName.slice(0, 80) : subject.slice(0, 60);
23940
+ const nameAuto = !hasExplicitName;
23233
23941
  const ALLOWED_MODES = /* @__PURE__ */ new Set(["brainstorm", "constructive", "research", "debate", "critique"]);
23234
23942
  const ALLOWED_INTENSITY = /* @__PURE__ */ new Set(["calm", "sharp", "terse", "brutal"]);
23235
23943
  const normalizeIntensityValue = (v) => v === "brutal" ? "terse" : v;
@@ -23252,7 +23960,8 @@ function roomsRouter() {
23252
23960
  deliveryMode,
23253
23961
  agentIds,
23254
23962
  parentRoomId,
23255
- parentBriefId
23963
+ parentBriefId,
23964
+ nameAuto
23256
23965
  });
23257
23966
  insertConfigEvent({
23258
23967
  roomId: room.id,
@@ -23278,6 +23987,25 @@ function roomsRouter() {
23278
23987
  createdAt: opening.createdAt
23279
23988
  });
23280
23989
  roomBus.emit(room.id, { type: "message-final", messageId: opening.id });
23990
+ generateRoomTitle(room.id).then((result) => {
23991
+ if (result.kind === "ok") {
23992
+ process.stderr.write(
23993
+ `[room-title] room=${room.id} renamed "${result.before.slice(0, 40)}" \u2192 "${result.after}"
23994
+ `
23995
+ );
23996
+ } else {
23997
+ const tail = result.detail ? ` \xB7 detail=${result.detail.slice(0, 100)}` : "";
23998
+ process.stderr.write(
23999
+ `[room-title] room=${room.id} skipped \xB7 reason=${result.reason}${tail}
24000
+ `
24001
+ );
24002
+ }
24003
+ }).catch((e) => {
24004
+ process.stderr.write(
24005
+ `[room-title] room=${room.id} threw: ${e instanceof Error ? e.message : String(e)}
24006
+ `
24007
+ );
24008
+ });
23281
24009
  setAwaitingClarify(room.id, true);
23282
24010
  if (mode === "research" && !hasWebSearchKey()) {
23283
24011
  const langGuess = /[一-鿿]/.test(subject) ? "zh" : "en";
@@ -24120,10 +24848,122 @@ function buildRoomExportMarkdown(opts) {
24120
24848
  return [...headerLines, ...transcriptLines, ...briefsLines].join("\n") + "\n";
24121
24849
  }
24122
24850
 
24123
- // src/routes/search.ts
24851
+ // src/routes/search-credentials.ts
24124
24852
  import { Hono as Hono10 } from "hono";
24125
- function searchRouter() {
24853
+ function payloadFor2(meta, activeId) {
24854
+ return {
24855
+ id: meta.id,
24856
+ provider: meta.provider,
24857
+ label: meta.label,
24858
+ preview: meta.preview,
24859
+ createdAt: meta.createdAt,
24860
+ updatedAt: meta.updatedAt,
24861
+ isActive: meta.id === activeId
24862
+ };
24863
+ }
24864
+ function pickNextActiveSearchId(removedProvider) {
24865
+ const all = listSearchCredentials();
24866
+ if (all.length === 0) return null;
24867
+ if (removedProvider) {
24868
+ const sameProvider = all.filter((c) => c.provider === removedProvider);
24869
+ if (sameProvider.length > 0) {
24870
+ sameProvider.sort((a, b) => a.createdAt - b.createdAt);
24871
+ return sameProvider[0].id;
24872
+ }
24873
+ }
24874
+ const sorted = all.slice().sort((a, b) => {
24875
+ const ai = SEARCH_PROVIDER_PRIORITY.indexOf(a.provider);
24876
+ const bi = SEARCH_PROVIDER_PRIORITY.indexOf(b.provider);
24877
+ if (ai !== bi) return ai - bi;
24878
+ return a.createdAt - b.createdAt;
24879
+ });
24880
+ return sorted[0]?.id ?? null;
24881
+ }
24882
+ function searchCredentialsRouter() {
24126
24883
  const r = new Hono10();
24884
+ r.get("/", (c) => {
24885
+ const activeId = getPrefs().activeSearchCredentialId;
24886
+ const items = listSearchCredentials().map((m) => payloadFor2(m, activeId));
24887
+ return c.json({
24888
+ credentials: items,
24889
+ activeId
24890
+ });
24891
+ });
24892
+ r.put("/active", async (c) => {
24893
+ let body;
24894
+ try {
24895
+ body = await c.req.json();
24896
+ } catch {
24897
+ return c.json({ error: "invalid JSON body" }, 400);
24898
+ }
24899
+ const rawId = body?.id;
24900
+ let nextId;
24901
+ if (rawId === null || rawId === void 0) {
24902
+ nextId = null;
24903
+ } else if (typeof rawId === "string") {
24904
+ nextId = rawId;
24905
+ } else {
24906
+ return c.json({ error: "id must be a string or null" }, 400);
24907
+ }
24908
+ if (nextId) {
24909
+ const meta = getSearchCredentialMeta(nextId);
24910
+ if (!meta) return c.json({ error: "credential not found" }, 404);
24911
+ updatePrefs({ activeSearchCredentialId: nextId });
24912
+ } else {
24913
+ updatePrefs({ activeSearchCredentialId: null });
24914
+ }
24915
+ return c.json({ activeId: nextId });
24916
+ });
24917
+ r.post("/", async (c) => {
24918
+ let body;
24919
+ try {
24920
+ body = await c.req.json();
24921
+ } catch {
24922
+ return c.json({ error: "invalid JSON body" }, 400);
24923
+ }
24924
+ const provider = body?.provider;
24925
+ const labelRaw = body?.label;
24926
+ const key = body?.key;
24927
+ if (typeof provider !== "string" || !isSearchProvider(provider)) {
24928
+ return c.json({ error: "provider must be 'brave' or 'tavily'" }, 400);
24929
+ }
24930
+ if (typeof key !== "string" || key.trim().length === 0) {
24931
+ return c.json({ error: "key must be a non-empty string" }, 400);
24932
+ }
24933
+ const label = typeof labelRaw === "string" ? labelRaw : null;
24934
+ const meta = createSearchCredential(provider, label, key);
24935
+ if (!meta) return c.json({ error: "failed to create credential" }, 500);
24936
+ const hadActive = !!getPrefs().activeSearchCredentialId;
24937
+ if (!hadActive) {
24938
+ updatePrefs({ activeSearchCredentialId: meta.id });
24939
+ }
24940
+ const activeId = getPrefs().activeSearchCredentialId;
24941
+ return c.json(payloadFor2(meta, activeId), 201);
24942
+ });
24943
+ r.delete("/:id", (c) => {
24944
+ const id = c.req.param("id");
24945
+ const meta = getSearchCredentialMeta(id);
24946
+ if (!meta) return c.json({ error: "credential not found" }, 404);
24947
+ const prefs = getPrefs();
24948
+ const wasActive = prefs.activeSearchCredentialId === id;
24949
+ const removedProvider = deleteSearchCredential(id);
24950
+ if (wasActive) {
24951
+ const nextId = pickNextActiveSearchId(removedProvider);
24952
+ updatePrefs({ activeSearchCredentialId: nextId });
24953
+ }
24954
+ return c.json({
24955
+ id,
24956
+ deleted: true,
24957
+ activeId: getPrefs().activeSearchCredentialId
24958
+ });
24959
+ });
24960
+ return r;
24961
+ }
24962
+
24963
+ // src/routes/search.ts
24964
+ import { Hono as Hono11 } from "hono";
24965
+ function searchRouter() {
24966
+ const r = new Hono11();
24127
24967
  r.get("/", (c) => {
24128
24968
  const q = (c.req.query("q") || "").trim();
24129
24969
  if (q.length < 1) {
@@ -24162,7 +25002,7 @@ function searchRouter() {
24162
25002
  }
24163
25003
 
24164
25004
  // src/routes/usage.ts
24165
- import { Hono as Hono11 } from "hono";
25005
+ import { Hono as Hono12 } from "hono";
24166
25006
  function modelDisplay(modelV) {
24167
25007
  if (isModelV(modelV)) {
24168
25008
  const m = MODELS[modelV];
@@ -24171,7 +25011,7 @@ function modelDisplay(modelV) {
24171
25011
  return { displayName: modelV, provider: "unknown" };
24172
25012
  }
24173
25013
  function usageRouter() {
24174
- const r = new Hono11();
25014
+ const r = new Hono12();
24175
25015
  r.get("/summary", (c) => {
24176
25016
  const s = getUsageSummary();
24177
25017
  return c.json({
@@ -24220,8 +25060,293 @@ function usageRouter() {
24220
25060
  return r;
24221
25061
  }
24222
25062
 
25063
+ // src/routes/voice-credentials.ts
25064
+ import { Hono as Hono13 } from "hono";
25065
+
25066
+ // src/storage/reconcile-voices.ts
25067
+ var MINIMAX_SEED_VOICES = [
25068
+ { provider: "minimax", model: "speech-2.8-hd", voiceId: "male-qn-qingse" },
25069
+ { provider: "minimax", model: "speech-2.8-hd", voiceId: "female-shaonv" },
25070
+ { provider: "minimax", model: "speech-2.8-hd", voiceId: "female-yujie" },
25071
+ { provider: "minimax", model: "speech-2.8-hd", voiceId: "male-qn-jingying" },
25072
+ { provider: "minimax", model: "speech-2.8-hd", voiceId: "female-chengshu" },
25073
+ { provider: "minimax", model: "speech-2.8-hd", voiceId: "female-tianmei" }
25074
+ ];
25075
+ var ELEVENLABS_SEED_VOICES = [
25076
+ { provider: "elevenlabs", model: "eleven_multilingual_v2", voiceId: "21m00Tcm4TlvDq8ikWAM" },
25077
+ { provider: "elevenlabs", model: "eleven_multilingual_v2", voiceId: "JBFqnCBsd6RMkjVDRZzb" }
25078
+ ];
25079
+ function shuffle(arr) {
25080
+ for (let i = arr.length - 1; i > 0; i--) {
25081
+ const j = Math.floor(Math.random() * (i + 1));
25082
+ [arr[i], arr[j]] = [arr[j], arr[i]];
25083
+ }
25084
+ return arr;
25085
+ }
25086
+ function snapshotPrior(agent, priorProvider, targetProvider) {
25087
+ if (!priorProvider) return;
25088
+ if (priorProvider === targetProvider) return;
25089
+ if (!agent.voice) return;
25090
+ if (agent.voice.provider !== priorProvider) return;
25091
+ try {
25092
+ writeVoiceBucketEntry(agent.id, priorProvider, agent.voice);
25093
+ } catch (e) {
25094
+ process.stderr.write(
25095
+ `[reconcile-voices] snapshot failed for ${agent.id}: ${e instanceof Error ? e.message : String(e)}
25096
+ `
25097
+ );
25098
+ }
25099
+ }
25100
+ function reconcileAgentVoices(opts) {
25101
+ const targetProvider = getActiveVoiceProvider();
25102
+ const priorProvider = opts.priorProvider ?? null;
25103
+ const agents = listAllAgents();
25104
+ if (agents.length === 0) {
25105
+ return { changed: 0, cleared: 0, reason: opts.reason, toProvider: targetProvider };
25106
+ }
25107
+ if (!targetProvider) {
25108
+ let cleared = 0;
25109
+ for (const a of agents) {
25110
+ snapshotPrior(a, priorProvider, null);
25111
+ if (a.voice) {
25112
+ try {
25113
+ updateAgent(a.id, { voice: null });
25114
+ cleared++;
25115
+ } catch (e) {
25116
+ process.stderr.write(
25117
+ `[reconcile-voices] clear failed for ${a.id}: ${e instanceof Error ? e.message : String(e)}
25118
+ `
25119
+ );
25120
+ }
25121
+ }
25122
+ }
25123
+ process.stderr.write(`[reconcile-voices] reason=${opts.reason} toProvider=null cleared=${cleared}
25124
+ `);
25125
+ return { changed: 0, cleared, reason: opts.reason, toProvider: null };
25126
+ }
25127
+ const pool = targetProvider === "minimax" ? MINIMAX_SEED_VOICES : targetProvider === "elevenlabs" ? ELEVENLABS_SEED_VOICES : [];
25128
+ if (pool.length === 0) {
25129
+ process.stderr.write(`[reconcile-voices] reason=${opts.reason} toProvider=${targetProvider} no-pool \xB7 skipped
25130
+ `);
25131
+ return { changed: 0, cleared: 0, reason: opts.reason, toProvider: targetProvider };
25132
+ }
25133
+ const shuffled = shuffle([...pool]);
25134
+ let changed = 0;
25135
+ const targetVp = targetProvider;
25136
+ for (let i = 0; i < agents.length; i++) {
25137
+ const a = agents[i];
25138
+ snapshotPrior(a, priorProvider, targetVp);
25139
+ const bucket = getVoiceBucket(a.id);
25140
+ const memorised = bucket[targetVp];
25141
+ const prev = a.voice;
25142
+ let profile;
25143
+ if (memorised) {
25144
+ profile = {
25145
+ provider: memorised.provider,
25146
+ model: memorised.model,
25147
+ voiceId: memorised.voiceId,
25148
+ ...prev?.speed !== void 0 ? { speed: prev.speed } : memorised.speed !== void 0 ? { speed: memorised.speed } : {},
25149
+ ...prev?.pitch !== void 0 ? { pitch: prev.pitch } : memorised.pitch !== void 0 ? { pitch: memorised.pitch } : {},
25150
+ ...prev?.volume !== void 0 ? { volume: prev.volume } : memorised.volume !== void 0 ? { volume: memorised.volume } : {},
25151
+ ...prev?.emotion !== void 0 ? { emotion: prev.emotion } : memorised.emotion !== void 0 ? { emotion: memorised.emotion } : {},
25152
+ ...memorised.modifyPitch !== void 0 ? { modifyPitch: memorised.modifyPitch } : {},
25153
+ ...memorised.modifyIntensity !== void 0 ? { modifyIntensity: memorised.modifyIntensity } : {},
25154
+ ...memorised.modifyTimbre !== void 0 ? { modifyTimbre: memorised.modifyTimbre } : {},
25155
+ ...memorised.instructions !== void 0 ? { instructions: memorised.instructions } : {}
25156
+ };
25157
+ } else {
25158
+ if (opts.reason === "first-key" && a.voice && a.voice.provider === targetVp) {
25159
+ if (!bucket[targetVp]) {
25160
+ try {
25161
+ writeVoiceBucketEntry(a.id, targetVp, a.voice);
25162
+ } catch (e) {
25163
+ process.stderr.write(
25164
+ `[reconcile-voices] seed failed for ${a.id}: ${e instanceof Error ? e.message : String(e)}
25165
+ `
25166
+ );
25167
+ }
25168
+ }
25169
+ continue;
25170
+ }
25171
+ const pick = shuffled[i % shuffled.length];
25172
+ profile = {
25173
+ provider: pick.provider,
25174
+ model: pick.model,
25175
+ voiceId: pick.voiceId,
25176
+ ...prev?.speed !== void 0 ? { speed: prev.speed } : {},
25177
+ ...prev?.pitch !== void 0 ? { pitch: prev.pitch } : {},
25178
+ ...prev?.volume !== void 0 ? { volume: prev.volume } : {},
25179
+ ...prev?.emotion !== void 0 ? { emotion: prev.emotion } : {}
25180
+ };
25181
+ }
25182
+ try {
25183
+ updateAgent(a.id, { voice: profile });
25184
+ writeVoiceBucketEntry(a.id, targetVp, profile);
25185
+ changed++;
25186
+ } catch (e) {
25187
+ process.stderr.write(
25188
+ `[reconcile-voices] update failed for ${a.id}: ${e instanceof Error ? e.message : String(e)}
25189
+ `
25190
+ );
25191
+ }
25192
+ }
25193
+ process.stderr.write(
25194
+ `[reconcile-voices] reason=${opts.reason} toProvider=${targetProvider} changed=${changed}
25195
+ `
25196
+ );
25197
+ return { changed, cleared: 0, reason: opts.reason, toProvider: targetProvider };
25198
+ }
25199
+
25200
+ // src/routes/voice-credentials.ts
25201
+ function payloadFor3(meta, activeId) {
25202
+ return {
25203
+ id: meta.id,
25204
+ provider: meta.provider,
25205
+ label: meta.label,
25206
+ preview: meta.preview,
25207
+ createdAt: meta.createdAt,
25208
+ updatedAt: meta.updatedAt,
25209
+ isActive: meta.id === activeId
25210
+ };
25211
+ }
25212
+ function pickNextActiveVoiceId(removedProvider) {
25213
+ const all = listVoiceCredentials();
25214
+ if (all.length === 0) return null;
25215
+ if (removedProvider) {
25216
+ const sameProvider = all.filter((c) => c.provider === removedProvider);
25217
+ if (sameProvider.length > 0) {
25218
+ sameProvider.sort((a, b) => a.createdAt - b.createdAt);
25219
+ return sameProvider[0].id;
25220
+ }
25221
+ }
25222
+ const sorted = all.slice().sort((a, b) => {
25223
+ const ai = VOICE_PROVIDER_PRIORITY.indexOf(a.provider);
25224
+ const bi = VOICE_PROVIDER_PRIORITY.indexOf(b.provider);
25225
+ if (ai !== bi) return ai - bi;
25226
+ return a.createdAt - b.createdAt;
25227
+ });
25228
+ return sorted[0]?.id ?? null;
25229
+ }
25230
+ function voiceCredentialsRouter() {
25231
+ const r = new Hono13();
25232
+ r.get("/", (c) => {
25233
+ const activeId = getPrefs().activeVoiceCredentialId;
25234
+ const items = listVoiceCredentials().map((m) => payloadFor3(m, activeId));
25235
+ return c.json({
25236
+ credentials: items,
25237
+ activeId
25238
+ });
25239
+ });
25240
+ r.put("/active", async (c) => {
25241
+ let body;
25242
+ try {
25243
+ body = await c.req.json();
25244
+ } catch {
25245
+ return c.json({ error: "invalid JSON body" }, 400);
25246
+ }
25247
+ const rawId = body?.id;
25248
+ let nextId;
25249
+ if (rawId === null || rawId === void 0) {
25250
+ nextId = null;
25251
+ } else if (typeof rawId === "string") {
25252
+ nextId = rawId;
25253
+ } else {
25254
+ return c.json({ error: "id must be a string or null" }, 400);
25255
+ }
25256
+ const prefs = getPrefs();
25257
+ const priorActiveId = prefs.activeVoiceCredentialId;
25258
+ const priorProvider = priorActiveId ? getVoiceCredentialMeta(priorActiveId)?.provider ?? null : null;
25259
+ let nextProvider = null;
25260
+ if (nextId) {
25261
+ const meta = getVoiceCredentialMeta(nextId);
25262
+ if (!meta) return c.json({ error: "credential not found" }, 404);
25263
+ nextProvider = meta.provider;
25264
+ updatePrefs({ activeVoiceCredentialId: nextId });
25265
+ } else {
25266
+ updatePrefs({ activeVoiceCredentialId: null });
25267
+ }
25268
+ if (priorProvider !== nextProvider) {
25269
+ try {
25270
+ reconcileAgentVoices({ reason: "provider-switch", priorProvider });
25271
+ } catch (e) {
25272
+ process.stderr.write(
25273
+ `[voice-credentials.active] reconcile failed: ${e instanceof Error ? e.message : String(e)}
25274
+ `
25275
+ );
25276
+ }
25277
+ }
25278
+ return c.json({ activeId: nextId });
25279
+ });
25280
+ r.post("/", async (c) => {
25281
+ let body;
25282
+ try {
25283
+ body = await c.req.json();
25284
+ } catch {
25285
+ return c.json({ error: "invalid JSON body" }, 400);
25286
+ }
25287
+ const provider = body?.provider;
25288
+ const labelRaw = body?.label;
25289
+ const key = body?.key;
25290
+ if (typeof provider !== "string" || !isVoiceProvider(provider)) {
25291
+ return c.json({ error: "provider must be 'minimax' or 'elevenlabs'" }, 400);
25292
+ }
25293
+ if (typeof key !== "string" || key.trim().length === 0) {
25294
+ return c.json({ error: "key must be a non-empty string" }, 400);
25295
+ }
25296
+ const label = typeof labelRaw === "string" ? labelRaw : null;
25297
+ const meta = createVoiceCredential(provider, label, key);
25298
+ if (!meta) return c.json({ error: "failed to create credential" }, 500);
25299
+ const hadActive = !!getPrefs().activeVoiceCredentialId;
25300
+ if (!hadActive) {
25301
+ updatePrefs({ activeVoiceCredentialId: meta.id });
25302
+ try {
25303
+ reconcileAgentVoices({ reason: "first-key", priorProvider: null });
25304
+ } catch (e) {
25305
+ process.stderr.write(
25306
+ `[voice-credentials.post] reconcile failed: ${e instanceof Error ? e.message : String(e)}
25307
+ `
25308
+ );
25309
+ }
25310
+ }
25311
+ const activeId = getPrefs().activeVoiceCredentialId;
25312
+ return c.json(payloadFor3(meta, activeId), 201);
25313
+ });
25314
+ r.delete("/:id", (c) => {
25315
+ const id = c.req.param("id");
25316
+ const meta = getVoiceCredentialMeta(id);
25317
+ if (!meta) return c.json({ error: "credential not found" }, 404);
25318
+ const prefs = getPrefs();
25319
+ const wasActive = prefs.activeVoiceCredentialId === id;
25320
+ const removedProvider = deleteVoiceCredential(id);
25321
+ let reshuffled = false;
25322
+ if (wasActive) {
25323
+ const nextId = pickNextActiveVoiceId(removedProvider);
25324
+ updatePrefs({ activeVoiceCredentialId: nextId });
25325
+ const nextProvider = nextId ? getVoiceCredentialMeta(nextId)?.provider ?? null : null;
25326
+ if (nextProvider !== removedProvider) {
25327
+ try {
25328
+ reconcileAgentVoices({ reason: "provider-switch", priorProvider: removedProvider });
25329
+ reshuffled = true;
25330
+ } catch (e) {
25331
+ process.stderr.write(
25332
+ `[voice-credentials.delete] reconcile failed: ${e instanceof Error ? e.message : String(e)}
25333
+ `
25334
+ );
25335
+ }
25336
+ }
25337
+ }
25338
+ return c.json({
25339
+ id,
25340
+ deleted: true,
25341
+ activeId: getPrefs().activeVoiceCredentialId,
25342
+ reshuffled
25343
+ });
25344
+ });
25345
+ return r;
25346
+ }
25347
+
24223
25348
  // src/routes/voices.ts
24224
- import { Hono as Hono12 } from "hono";
25349
+ import { Hono as Hono14 } from "hono";
24225
25350
  function ttsErrorMessage(e, providerLabel) {
24226
25351
  if (!(e instanceof Error)) return String(e);
24227
25352
  const cause = e.cause;
@@ -24266,8 +25391,15 @@ function ttsCacheSet(key, val) {
24266
25391
  }
24267
25392
  }
24268
25393
  function voicesRouter() {
24269
- const r = new Hono12();
24270
- r.get("/", async (c) => c.json({ voices: await listAvailableVoices() }));
25394
+ const r = new Hono14();
25395
+ r.get("/", async (c) => {
25396
+ const catalog = await listAvailableVoices();
25397
+ return c.json({
25398
+ voices: catalog.voices,
25399
+ provider: catalog.provider,
25400
+ configured: catalog.configured
25401
+ });
25402
+ });
24271
25403
  r.get("/message/:id/audio", (c) => {
24272
25404
  const messageId = c.req.param("id");
24273
25405
  const row = getUsableMessageVoice(messageId);
@@ -24407,7 +25539,7 @@ function voicesRouter() {
24407
25539
  init_paths();
24408
25540
 
24409
25541
  // src/version.ts
24410
- var VERSION = "0.1.29";
25542
+ var VERSION = "0.1.32";
24411
25543
 
24412
25544
  // src/utils/render-picker-catalog.ts
24413
25545
  function renderPickerCatalog() {
@@ -24419,7 +25551,7 @@ function renderPickerCatalog() {
24419
25551
 
24420
25552
  // src/server.ts
24421
25553
  function createApp() {
24422
- const app = new Hono13();
25554
+ const app = new Hono15();
24423
25555
  const dir = publicDir();
24424
25556
  if (!existsSync2(dir)) {
24425
25557
  throw new Error(
@@ -24467,7 +25599,9 @@ Build the package or check that public/ is bundled alongside dist/.`
24467
25599
  app.route("/api/avatar", avatarRouter());
24468
25600
  app.route("/api/usage", usageRouter());
24469
25601
  app.route("/api/voices", voicesRouter());
25602
+ app.route("/api/voice-credentials", voiceCredentialsRouter());
24470
25603
  app.route("/api/search", searchRouter());
25604
+ app.route("/api/search-credentials", searchCredentialsRouter());
24471
25605
  app.use(
24472
25606
  "/*",
24473
25607
  serveStatic({
@@ -24488,6 +25622,10 @@ async function startServer(opts) {
24488
25622
  url: `http://${host}:${opts.port}`,
24489
25623
  close: () => new Promise((resolve2, reject) => {
24490
25624
  server.close((err2) => err2 ? reject(err2) : resolve2());
25625
+ const maybeForceClose = server.closeAllConnections;
25626
+ if (typeof maybeForceClose === "function") {
25627
+ maybeForceClose.call(server);
25628
+ }
24491
25629
  })
24492
25630
  };
24493
25631
  }