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/cli.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
  }
@@ -1181,7 +1221,9 @@ var VALID_CARRIER_PREFS = /* @__PURE__ */ new Set([
1181
1221
  "anthropic",
1182
1222
  "openai",
1183
1223
  "google",
1184
- "xai"
1224
+ "xai",
1225
+ "moonshot",
1226
+ "zhipu"
1185
1227
  ]);
1186
1228
  function parseCarrierPref(raw) {
1187
1229
  if (!raw) return null;
@@ -1404,6 +1446,54 @@ function parseBuildLog(raw) {
1404
1446
  ...totalTokens !== void 0 ? { totalTokens } : {}
1405
1447
  };
1406
1448
  }
1449
+ function parseModelByProvider(raw) {
1450
+ if (!raw) return {};
1451
+ try {
1452
+ const obj = JSON.parse(raw);
1453
+ if (!obj || typeof obj !== "object") return {};
1454
+ const out = {};
1455
+ for (const [k, v] of Object.entries(obj)) {
1456
+ if (VALID_CARRIER_PREFS.has(k) && typeof v === "string" && v.trim().length > 0) {
1457
+ out[k] = v.trim();
1458
+ }
1459
+ }
1460
+ return out;
1461
+ } catch {
1462
+ return {};
1463
+ }
1464
+ }
1465
+ function parseVoiceByProvider(raw) {
1466
+ if (!raw) return {};
1467
+ try {
1468
+ const obj = JSON.parse(raw);
1469
+ if (!obj || typeof obj !== "object") return {};
1470
+ const out = {};
1471
+ for (const [k, v] of Object.entries(obj)) {
1472
+ if (k !== "minimax" && k !== "elevenlabs") continue;
1473
+ const profile = parseVoice(JSON.stringify(v));
1474
+ if (profile) out[k] = profile;
1475
+ }
1476
+ return out;
1477
+ } catch {
1478
+ return {};
1479
+ }
1480
+ }
1481
+ function serializeModelByProvider(b) {
1482
+ if (!b || Object.keys(b).length === 0) return null;
1483
+ return JSON.stringify(b);
1484
+ }
1485
+ function serializeVoiceByProvider(b) {
1486
+ if (!b || Object.keys(b).length === 0) return null;
1487
+ const out = {};
1488
+ for (const [k, v] of Object.entries(b)) {
1489
+ if (!v) continue;
1490
+ const json = serializeVoice(v);
1491
+ if (!json) continue;
1492
+ out[k] = JSON.parse(json);
1493
+ }
1494
+ if (Object.keys(out).length === 0) return null;
1495
+ return JSON.stringify(out);
1496
+ }
1407
1497
  function serializeVoice(v) {
1408
1498
  if (!v) return null;
1409
1499
  const provider = VALID_VOICE_PROVIDERS.has(v.provider) ? v.provider : null;
@@ -1451,7 +1541,7 @@ function mapRow(row) {
1451
1541
  updatedAt: row.updated_at
1452
1542
  };
1453
1543
  }
1454
- 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";
1544
+ 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";
1455
1545
  function listAgents() {
1456
1546
  const rows = getDb().prepare(
1457
1547
  `SELECT ${SELECT_COLS} FROM agents
@@ -1760,6 +1850,47 @@ function updateAgent(id, patch) {
1760
1850
  if (r.changes === 0) return null;
1761
1851
  return getAgent(id);
1762
1852
  }
1853
+ function getModelBucket(id) {
1854
+ const row = getDb().prepare("SELECT model_by_provider_json FROM agents WHERE id = ?").get(id);
1855
+ if (!row) return {};
1856
+ return parseModelByProvider(row.model_by_provider_json);
1857
+ }
1858
+ function getVoiceBucket(id) {
1859
+ const row = getDb().prepare("SELECT voice_by_provider_json FROM agents WHERE id = ?").get(id);
1860
+ if (!row) return {};
1861
+ return parseVoiceByProvider(row.voice_by_provider_json);
1862
+ }
1863
+ function writeModelBucketEntry(id, carrier, modelV) {
1864
+ if (!modelV) return;
1865
+ const bucket = getModelBucket(id);
1866
+ if (bucket[carrier] === modelV) return;
1867
+ bucket[carrier] = modelV;
1868
+ const serialized = serializeModelByProvider(bucket);
1869
+ getDb().prepare("UPDATE agents SET model_by_provider_json = ?, updated_at = ? WHERE id = ?").run(serialized, Date.now(), id);
1870
+ }
1871
+ function writeVoiceBucketEntry(id, provider, voice) {
1872
+ const normalized = parseVoice(serializeVoice(voice));
1873
+ if (!normalized || normalized.provider !== "minimax" && normalized.provider !== "elevenlabs") return;
1874
+ if (normalized.provider !== provider) return;
1875
+ const bucket = getVoiceBucket(id);
1876
+ bucket[provider] = normalized;
1877
+ const serialized = serializeVoiceByProvider(bucket);
1878
+ getDb().prepare("UPDATE agents SET voice_by_provider_json = ?, updated_at = ? WHERE id = ?").run(serialized, Date.now(), id);
1879
+ }
1880
+ function deleteModelBucketEntry(id, carrier) {
1881
+ const bucket = getModelBucket(id);
1882
+ if (!(carrier in bucket)) return;
1883
+ delete bucket[carrier];
1884
+ const serialized = serializeModelByProvider(bucket);
1885
+ getDb().prepare("UPDATE agents SET model_by_provider_json = ?, updated_at = ? WHERE id = ?").run(serialized, Date.now(), id);
1886
+ }
1887
+ function deleteVoiceBucketEntry(id, provider) {
1888
+ const bucket = getVoiceBucket(id);
1889
+ if (!(provider in bucket)) return;
1890
+ delete bucket[provider];
1891
+ const serialized = serializeVoiceByProvider(bucket);
1892
+ getDb().prepare("UPDATE agents SET voice_by_provider_json = ?, updated_at = ? WHERE id = ?").run(serialized, Date.now(), id);
1893
+ }
1763
1894
 
1764
1895
  // src/seed/run.ts
1765
1896
  init_db();
@@ -2478,7 +2609,7 @@ function runSeed() {
2478
2609
  // src/server.ts
2479
2610
  import { serve } from "@hono/node-server";
2480
2611
  import { serveStatic } from "@hono/node-server/serve-static";
2481
- import { Hono as Hono13 } from "hono";
2612
+ import { Hono as Hono15 } from "hono";
2482
2613
  import { existsSync as existsSync2 } from "fs";
2483
2614
 
2484
2615
  // src/routes/agents.ts
@@ -2653,10 +2784,11 @@ var MODELS = {
2653
2784
  deck: "V4 Flash \xB7 fast \xB7 1M ctx",
2654
2785
  viaUniversalOnly: true
2655
2786
  },
2656
- // ── Zhipu (Z.AI) · GLM family · OR + B.AI only ──
2657
- // OpenRouter catalog convention: `z-ai/glm-X.Y`. B.AI uses
2658
- // hyphenated lowercase: `glm-5-1`. No direct @ai-sdk client ·
2659
- // viaUniversalOnly skips the direct path.
2787
+ // ── Zhipu (Z.AI) · GLM family · direct + OR + B.AI ──
2788
+ // Direct route uses Zhipu's OpenAI-compatible chat-completions API
2789
+ // at https://open.bigmodel.cn/api/paas/v4/ (see adapter.ts
2790
+ // case "zhipu"). OpenRouter catalog convention: `z-ai/glm-X.Y`.
2791
+ // B.AI uses dotted lowercase: `glm-5.1`.
2660
2792
  "glm-5-1": {
2661
2793
  v: "glm-5-1",
2662
2794
  provider: "zhipu",
@@ -2665,16 +2797,16 @@ var MODELS = {
2665
2797
  baiId: "glm-5.1",
2666
2798
  displayName: "GLM 5.1",
2667
2799
  contextBudget: 2e5,
2668
- deck: "Zhipu flagship \xB7 200k ctx",
2669
- viaUniversalOnly: true
2800
+ deck: "Zhipu flagship \xB7 200k ctx"
2670
2801
  },
2671
- // ── Moonshot · Kimi family · OR + B.AI ──
2802
+ // ── Moonshot · Kimi family · direct + OR + B.AI ──
2803
+ // Direct route uses Moonshot's OpenAI-compatible chat-completions
2804
+ // API at https://api.moonshot.cn/v1 (see adapter.ts case "moonshot").
2672
2805
  // OpenRouter catalog convention: `moonshotai/kimi-k2.6` (the leading
2673
2806
  // `k` is part of the slug — `moonshotai/kimi-2.6` 404s). B.AI's
2674
2807
  // siliconflow distributor still ships the older `kimi-k2.5` channel
2675
2808
  // (per 2026-05-17 catalog snapshot), so the B.AI route serves K2.5
2676
- // until B.AI picks up the newer build. No direct @ai-sdk client ·
2677
- // viaUniversalOnly skips the direct path.
2809
+ // until B.AI picks up the newer build.
2678
2810
  "kimi-k2-6": {
2679
2811
  v: "kimi-k2-6",
2680
2812
  provider: "moonshot",
@@ -2683,8 +2815,7 @@ var MODELS = {
2683
2815
  baiId: "kimi-k2.5",
2684
2816
  displayName: "Kimi K2.6",
2685
2817
  contextBudget: 256e3,
2686
- deck: "Moonshot \xB7 long-context",
2687
- viaUniversalOnly: true
2818
+ deck: "Moonshot \xB7 long-context"
2688
2819
  },
2689
2820
  // ── MiniMax · M-series · OR + B.AI ──
2690
2821
  // No direct @ai-sdk client · viaUniversalOnly skips the direct path.
@@ -2781,7 +2912,11 @@ var PROFILE_SYSTEM = [
2781
2912
  "Constraints:",
2782
2913
  "\xB7 DO NOT use generic personality words. Every entry names a person / case / concept / position.",
2783
2914
  "\xB7 If the user description maps to a real domain (VC, product, security, biotech, monetary policy, etc.), prefer NAMED references from that domain.",
2784
- "\xB7 Avoid recreating the canonical six (Socrates, First Principles, Long Horizon, etc.) \u2014 pick a distinct angle."
2915
+ "\xB7 Avoid recreating the canonical six (Socrates, First Principles, Long Horizon, etc.) \u2014 pick a distinct angle.",
2916
+ "",
2917
+ "## CRITICAL \xB7 output format",
2918
+ "",
2919
+ "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."
2785
2920
  ].join("\n");
2786
2921
  var HOUSE_STYLE = [
2787
2922
  "## Boardroom directors \xB7 house style",
@@ -2902,7 +3037,7 @@ function buildAgentProfileMessages(opts) {
2902
3037
  }
2903
3038
  userBody.push(
2904
3039
  "",
2905
- "Now produce the profile JSON object as specified."
3040
+ "Now produce the profile JSON object as specified \u2014 ```json fenced block, exact camelCase field names, no prose before or after."
2906
3041
  );
2907
3042
  return [
2908
3043
  { role: "system", content: PROFILE_SYSTEM },
@@ -3043,29 +3178,98 @@ function parseAgentSpec(raw) {
3043
3178
  ability
3044
3179
  };
3045
3180
  }
3181
+ function pickArrayField(parsed, aliases) {
3182
+ for (const k of aliases) {
3183
+ const v = parsed[k];
3184
+ if (Array.isArray(v)) return v;
3185
+ }
3186
+ return [];
3187
+ }
3188
+ function pickObjectField(parsed, aliases) {
3189
+ for (const k of aliases) {
3190
+ const v = parsed[k];
3191
+ if (v && typeof v === "object" && !Array.isArray(v)) return v;
3192
+ }
3193
+ return {};
3194
+ }
3195
+ function coerceConceptEntry(c) {
3196
+ if (typeof c === "string") {
3197
+ const s = c.trim();
3198
+ const m = /^(.+?)\s*[·:\-—]\s*(.+)$/.exec(s);
3199
+ if (m) return { name: clamp(m[1].trim(), 80), gloss: clamp(m[2].trim(), 200) };
3200
+ return { name: clamp(s, 80), gloss: "" };
3201
+ }
3202
+ if (!c || typeof c !== "object") return { name: "", gloss: "" };
3203
+ const o = c;
3204
+ const name = [o.name, o.concept, o.title, o.handle, o.term].find(
3205
+ (x) => typeof x === "string" && x.trim().length > 0
3206
+ );
3207
+ const gloss = [o.gloss, o.description, o.desc, o.detail, o.explanation, o.summary].find(
3208
+ (x) => typeof x === "string" && x.trim().length > 0
3209
+ );
3210
+ return {
3211
+ name: name ? clamp(name.trim(), 80) : "",
3212
+ gloss: gloss ? clamp(gloss.trim(), 200) : ""
3213
+ };
3214
+ }
3215
+ function coerceReferentEntry(r) {
3216
+ if (typeof r === "string") {
3217
+ const s = r.trim();
3218
+ const m = /^(.+?)\s*[·:\-—]\s*(.+)$/.exec(s);
3219
+ if (m) return { ref: clamp(m[1].trim(), 80), why: clamp(m[2].trim(), 200) };
3220
+ return { ref: clamp(s, 80), why: "" };
3221
+ }
3222
+ if (!r || typeof r !== "object") return { ref: "", why: "" };
3223
+ const o = r;
3224
+ const ref = [o.ref, o.name, o.reference, o.anchor, o.title, o.case].find(
3225
+ (x) => typeof x === "string" && x.trim().length > 0
3226
+ );
3227
+ const why = [o.why, o.reason, o.relevance, o.gloss, o.description, o.note].find(
3228
+ (x) => typeof x === "string" && x.trim().length > 0
3229
+ );
3230
+ return {
3231
+ ref: ref ? clamp(ref.trim(), 80) : "",
3232
+ why: why ? clamp(why.trim(), 200) : ""
3233
+ };
3234
+ }
3046
3235
  function parseAgentProfile(raw) {
3047
3236
  const parsed = extractJson(raw);
3048
3237
  if (!parsed) return null;
3049
- const lineage = parsed.intellectualLineage && typeof parsed.intellectualLineage === "object" ? parsed.intellectualLineage : {};
3050
- const influencedByRaw = Array.isArray(lineage.influencedBy) ? lineage.influencedBy : [];
3051
- const opposedToRaw = Array.isArray(lineage.opposedTo) ? lineage.opposedTo : [];
3238
+ const lineage = pickObjectField(parsed, ["intellectualLineage", "intellectual_lineage", "lineage"]);
3239
+ const influencedByRaw = pickArrayField(lineage, ["influencedBy", "influenced_by", "influences", "influenced"]);
3240
+ const opposedToRaw = pickArrayField(lineage, ["opposedTo", "opposed_to", "opposes", "against"]);
3052
3241
  const influencedBy = influencedByRaw.filter((s) => typeof s === "string" && s.trim().length > 0).map((s) => clamp(s.trim(), 200)).slice(0, 5);
3053
3242
  const opposedTo = opposedToRaw.filter((s) => typeof s === "string" && s.trim().length > 0).map((s) => clamp(s.trim(), 200)).slice(0, 4);
3054
- const conceptsRaw = Array.isArray(parsed.loadBearingConcepts) ? parsed.loadBearingConcepts : [];
3055
- const loadBearingConcepts = conceptsRaw.filter((c) => !!c && typeof c === "object").map((c) => ({
3056
- name: typeof c.name === "string" ? clamp(c.name.trim(), 80) : "",
3057
- gloss: typeof c.gloss === "string" ? clamp(c.gloss.trim(), 200) : ""
3058
- })).filter((c) => c.name.length > 0).slice(0, 6);
3059
- const referentsRaw = Array.isArray(parsed.referentSet) ? parsed.referentSet : [];
3060
- const referentSet = referentsRaw.filter((r) => !!r && typeof r === "object").map((r) => ({
3061
- ref: typeof r.ref === "string" ? clamp(r.ref.trim(), 80) : "",
3062
- why: typeof r.why === "string" ? clamp(r.why.trim(), 200) : ""
3063
- })).filter((r) => r.ref.length > 0).slice(0, 6);
3064
- const failureModesRaw = Array.isArray(parsed.failureModes) ? parsed.failureModes : [];
3243
+ const conceptsRaw = pickArrayField(parsed, [
3244
+ "loadBearingConcepts",
3245
+ "load_bearing_concepts",
3246
+ "concepts",
3247
+ "frames",
3248
+ "mentalTools",
3249
+ "mental_tools"
3250
+ ]);
3251
+ const loadBearingConcepts = conceptsRaw.map(coerceConceptEntry).filter((c) => c.name.length > 0).slice(0, 6);
3252
+ const referentsRaw = pickArrayField(parsed, [
3253
+ "referentSet",
3254
+ "referent_set",
3255
+ "referents",
3256
+ "anchors",
3257
+ "references",
3258
+ "citations"
3259
+ ]);
3260
+ const referentSet = referentsRaw.map(coerceReferentEntry).filter((r) => r.ref.length > 0).slice(0, 6);
3261
+ const failureModesRaw = pickArrayField(parsed, ["failureModes", "failure_modes", "blindSpots", "blind_spots"]);
3065
3262
  const failureModes = failureModesRaw.filter((s) => typeof s === "string" && s.trim().length > 0).map((s) => clamp(s.trim(), 220)).slice(0, 4);
3066
- const contrarianTakesRaw = Array.isArray(parsed.contrarianTakes) ? parsed.contrarianTakes : [];
3263
+ const contrarianTakesRaw = pickArrayField(parsed, [
3264
+ "contrarianTakes",
3265
+ "contrarian_takes",
3266
+ "contrarianViews",
3267
+ "contrarian_views",
3268
+ "takes"
3269
+ ]);
3067
3270
  const contrarianTakes = contrarianTakesRaw.filter((s) => typeof s === "string" && s.trim().length > 0).map((s) => clamp(s.trim(), 220)).slice(0, 4);
3068
- if (loadBearingConcepts.length === 0 && referentSet.length === 0) return null;
3271
+ const anyPopulated = loadBearingConcepts.length > 0 || referentSet.length > 0 || influencedBy.length > 0 || opposedTo.length > 0 || failureModes.length > 0 || contrarianTakes.length > 0;
3272
+ if (!anyPopulated) return null;
3069
3273
  return {
3070
3274
  intellectualLineage: { influencedBy, opposedTo },
3071
3275
  loadBearingConcepts,
@@ -3555,8 +3759,7 @@ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
3555
3759
  import { APICallError, streamText } from "ai";
3556
3760
 
3557
3761
  // src/storage/credentials.ts
3558
- import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "crypto";
3559
- import { userInfo } from "os";
3762
+ import { randomBytes as randomBytes2 } from "crypto";
3560
3763
 
3561
3764
  // src/ai/providers.ts
3562
3765
  var MULTI_MODEL_LLM_PROVIDERS = [
@@ -3567,7 +3770,9 @@ var SINGLE_MODEL_LLM_PROVIDERS = [
3567
3770
  "anthropic",
3568
3771
  "openai",
3569
3772
  "google",
3570
- "xai"
3773
+ "xai",
3774
+ "moonshot",
3775
+ "zhipu"
3571
3776
  ];
3572
3777
  var ALL_LLM_PROVIDERS = [
3573
3778
  ...MULTI_MODEL_LLM_PROVIDERS,
@@ -3579,7 +3784,9 @@ var LLM_PROVIDER_PRIORITY = [
3579
3784
  "anthropic",
3580
3785
  "openai",
3581
3786
  "google",
3582
- "xai"
3787
+ "xai",
3788
+ "moonshot",
3789
+ "zhipu"
3583
3790
  ];
3584
3791
  function isMultiModelProvider(p) {
3585
3792
  return p === "openrouter" || p === "bai";
@@ -3590,6 +3797,10 @@ function isLlmProvider(p) {
3590
3797
 
3591
3798
  // src/storage/credentials.ts
3592
3799
  init_db();
3800
+
3801
+ // src/storage/credential-crypto.ts
3802
+ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "crypto";
3803
+ import { userInfo } from "os";
3593
3804
  var SALT = "boardroom.v1.salt";
3594
3805
  var ALGO = "aes-256-gcm";
3595
3806
  var _key = null;
@@ -3599,14 +3810,14 @@ function deriveKey() {
3599
3810
  _key = scryptSync(username, SALT, 32);
3600
3811
  return _key;
3601
3812
  }
3602
- function encrypt(plain) {
3813
+ function encryptCredential(plain) {
3603
3814
  const iv = randomBytes(12);
3604
3815
  const cipher = createCipheriv(ALGO, deriveKey(), iv);
3605
3816
  const ct = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
3606
3817
  const tag = cipher.getAuthTag();
3607
3818
  return Buffer.concat([iv, tag, ct]);
3608
3819
  }
3609
- function decrypt(blob) {
3820
+ function decryptCredential(blob) {
3610
3821
  const iv = blob.subarray(0, 12);
3611
3822
  const tag = blob.subarray(12, 28);
3612
3823
  const ct = blob.subarray(28);
@@ -3614,7 +3825,7 @@ function decrypt(blob) {
3614
3825
  decipher.setAuthTag(tag);
3615
3826
  return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8");
3616
3827
  }
3617
- function maskKey(plain) {
3828
+ function maskCredential(plain) {
3618
3829
  const trimmed = plain.trim();
3619
3830
  if (!trimmed) return "";
3620
3831
  const n = trimmed.length;
@@ -3622,11 +3833,13 @@ function maskKey(plain) {
3622
3833
  if (n <= 12) return `${trimmed.slice(0, 2)}${"\u2022".repeat(n - 4)}${trimmed.slice(-2)}`;
3623
3834
  return `${trimmed.slice(0, 4)}${"\u2022".repeat(n - 8)}${trimmed.slice(-4)}`;
3624
3835
  }
3836
+
3837
+ // src/storage/credentials.ts
3625
3838
  function rowToMeta(row) {
3626
3839
  if (!isLlmProvider(row.provider)) return null;
3627
3840
  let preview = "";
3628
3841
  try {
3629
- preview = maskKey(decrypt(row.key_blob));
3842
+ preview = maskCredential(decryptCredential(row.key_blob));
3630
3843
  } catch {
3631
3844
  preview = "";
3632
3845
  }
@@ -3652,7 +3865,7 @@ function getLlmCredentialKey(id) {
3652
3865
  const row = getDb().prepare("SELECT key_blob FROM llm_credentials WHERE id = ?").get(id);
3653
3866
  if (!row) return null;
3654
3867
  try {
3655
- return decrypt(row.key_blob);
3868
+ return decryptCredential(row.key_blob);
3656
3869
  } catch {
3657
3870
  return null;
3658
3871
  }
@@ -3683,6 +3896,15 @@ function providerDisplayName(provider) {
3683
3896
  return "Gemini";
3684
3897
  case "xai":
3685
3898
  return "Grok";
3899
+ // Brand-name labels for the two newest direct providers · "Kimi"
3900
+ // (model brand) over "Moonshot" (company), "GLM" (model family)
3901
+ // over "Zhipu" (company) · same convention as "Claude"/anthropic
3902
+ // and "ChatGPT"/openai · the user-facing UI prefers the product
3903
+ // name over the corporate slug.
3904
+ case "moonshot":
3905
+ return "Kimi";
3906
+ case "zhipu":
3907
+ return "GLM";
3686
3908
  }
3687
3909
  }
3688
3910
  function createLlmCredential(provider, label, plain) {
@@ -3690,8 +3912,8 @@ function createLlmCredential(provider, label, plain) {
3690
3912
  if (!trimmed) return null;
3691
3913
  if (!ALL_LLM_PROVIDERS.includes(provider)) return null;
3692
3914
  const resolvedLabel = resolveFreeLabel(provider, label);
3693
- const id = randomBytes(8).toString("hex");
3694
- const blob = encrypt(trimmed);
3915
+ const id = randomBytes2(8).toString("hex");
3916
+ const blob = encryptCredential(trimmed);
3695
3917
  const now = Date.now();
3696
3918
  getDb().prepare(
3697
3919
  `INSERT INTO llm_credentials (id, provider, label, key_blob, created_at, updated_at)
@@ -3725,6 +3947,8 @@ function mapRow3(row) {
3725
3947
  minimaxRegion: normalizeMinimaxRegion(row.minimax_region),
3726
3948
  activeLlmProvider: raw && isLlmProvider(raw) ? raw : null,
3727
3949
  activeLlmCredentialId: row.active_llm_credential_id,
3950
+ activeVoiceCredentialId: row.active_voice_credential_id,
3951
+ activeSearchCredentialId: row.active_search_credential_id,
3728
3952
  createdAt: row.created_at,
3729
3953
  updatedAt: row.updated_at
3730
3954
  };
@@ -3736,6 +3960,8 @@ function getPrefs() {
3736
3960
  COALESCE(minimax_region, 'cn') AS minimax_region,
3737
3961
  active_llm_provider,
3738
3962
  active_llm_credential_id,
3963
+ active_voice_credential_id,
3964
+ active_search_credential_id,
3739
3965
  created_at, updated_at FROM prefs WHERE id = 1`
3740
3966
  ).get();
3741
3967
  if (!row) {
@@ -3778,6 +4004,14 @@ function updatePrefs(patch) {
3778
4004
  fields.push("active_llm_credential_id = ?");
3779
4005
  values.push(patch.activeLlmCredentialId);
3780
4006
  }
4007
+ if (patch.activeVoiceCredentialId !== void 0) {
4008
+ fields.push("active_voice_credential_id = ?");
4009
+ values.push(patch.activeVoiceCredentialId);
4010
+ }
4011
+ if (patch.activeSearchCredentialId !== void 0) {
4012
+ fields.push("active_search_credential_id = ?");
4013
+ values.push(patch.activeSearchCredentialId);
4014
+ }
3781
4015
  if (fields.length === 0) return getPrefs();
3782
4016
  fields.push("updated_at = ?");
3783
4017
  values.push(Date.now());
@@ -3987,6 +4221,30 @@ function directResolved(meta, apiKey) {
3987
4221
  carrier: "xai"
3988
4222
  };
3989
4223
  }
4224
+ case "moonshot": {
4225
+ const compat = createOpenAICompatible({
4226
+ name: "moonshot",
4227
+ apiKey,
4228
+ baseURL: "https://api.moonshot.cn/v1",
4229
+ fetch: makeLoggedFetch("moonshot")
4230
+ });
4231
+ return {
4232
+ model: compat.chatModel(meta.directApiId),
4233
+ carrier: "moonshot"
4234
+ };
4235
+ }
4236
+ case "zhipu": {
4237
+ const compat = createOpenAICompatible({
4238
+ name: "zhipu",
4239
+ apiKey,
4240
+ baseURL: "https://open.bigmodel.cn/api/paas/v4/",
4241
+ fetch: makeLoggedFetch("zhipu")
4242
+ });
4243
+ return {
4244
+ model: compat.chatModel(meta.directApiId),
4245
+ carrier: "zhipu"
4246
+ };
4247
+ }
3990
4248
  default:
3991
4249
  throw new NoKeyError(meta.provider);
3992
4250
  }
@@ -4189,6 +4447,7 @@ async function callLLM(req) {
4189
4447
  async function callLLMWithUsage(req) {
4190
4448
  let buf = "";
4191
4449
  let usage = null;
4450
+ let finishReason = null;
4192
4451
  for await (const chunk of callLLMStream(req)) {
4193
4452
  if (chunk.type === "text") buf += chunk.delta;
4194
4453
  else if (chunk.type === "error") throw new Error(chunk.message);
@@ -4198,15 +4457,118 @@ async function callLLMWithUsage(req) {
4198
4457
  completionTokens: chunk.completionTokens,
4199
4458
  totalTokens: chunk.totalTokens
4200
4459
  };
4460
+ } else if (chunk.type === "done") {
4461
+ if (typeof chunk.finishReason === "string" && chunk.finishReason.length > 0) {
4462
+ finishReason = chunk.finishReason;
4463
+ }
4201
4464
  }
4202
4465
  }
4203
- return { text: buf, usage };
4466
+ return { text: buf, usage, finishReason };
4204
4467
  }
4205
4468
 
4206
4469
  // src/storage/keys.ts
4207
4470
  init_db();
4208
- import { createCipheriv as createCipheriv2, createDecipheriv as createDecipheriv2, randomBytes as randomBytes2, scryptSync as scryptSync2 } from "crypto";
4471
+ import { createCipheriv as createCipheriv2, createDecipheriv as createDecipheriv2, randomBytes as randomBytes4, scryptSync as scryptSync2 } from "crypto";
4209
4472
  import { userInfo as userInfo2 } from "os";
4473
+
4474
+ // src/storage/search-credentials.ts
4475
+ import { randomBytes as randomBytes3 } from "crypto";
4476
+ init_db();
4477
+ var ALL_SEARCH_PROVIDERS = ["brave", "tavily"];
4478
+ function isSearchProvider(p) {
4479
+ return ALL_SEARCH_PROVIDERS.includes(p);
4480
+ }
4481
+ var SEARCH_PROVIDER_PRIORITY = ["brave", "tavily"];
4482
+ function rowToMeta2(row) {
4483
+ if (!isSearchProvider(row.provider)) return null;
4484
+ let preview = "";
4485
+ try {
4486
+ preview = maskCredential(decryptCredential(row.key_blob));
4487
+ } catch {
4488
+ preview = "";
4489
+ }
4490
+ return {
4491
+ id: row.id,
4492
+ provider: row.provider,
4493
+ label: row.label,
4494
+ preview,
4495
+ createdAt: row.created_at,
4496
+ updatedAt: row.updated_at
4497
+ };
4498
+ }
4499
+ function listSearchCredentials() {
4500
+ const rows = getDb().prepare("SELECT id, provider, label, key_blob, created_at, updated_at FROM search_credentials ORDER BY created_at ASC").all();
4501
+ return rows.map(rowToMeta2).filter((m) => m !== null);
4502
+ }
4503
+ function getSearchCredentialMeta(id) {
4504
+ const row = getDb().prepare("SELECT id, provider, label, key_blob, created_at, updated_at FROM search_credentials WHERE id = ?").get(id);
4505
+ if (!row) return null;
4506
+ return rowToMeta2(row);
4507
+ }
4508
+ function getSearchCredentialKey(id) {
4509
+ const row = getDb().prepare("SELECT key_blob FROM search_credentials WHERE id = ?").get(id);
4510
+ if (!row) return null;
4511
+ try {
4512
+ return decryptCredential(row.key_blob);
4513
+ } catch {
4514
+ return null;
4515
+ }
4516
+ }
4517
+ function providerDisplayName2(provider) {
4518
+ switch (provider) {
4519
+ case "brave":
4520
+ return "Brave Search";
4521
+ case "tavily":
4522
+ return "Tavily Search";
4523
+ }
4524
+ }
4525
+ function resolveFreeLabel2(provider, suggested) {
4526
+ const base = suggested && suggested.trim() || providerDisplayName2(provider);
4527
+ const existing = new Set(
4528
+ getDb().prepare("SELECT label FROM search_credentials").all().map((r) => r.label)
4529
+ );
4530
+ if (!existing.has(base)) return base;
4531
+ for (let n = 2; n < 1e3; n++) {
4532
+ const candidate = `${base} ${n}`;
4533
+ if (!existing.has(candidate)) return candidate;
4534
+ }
4535
+ return `${base} ${Date.now()}`;
4536
+ }
4537
+ function createSearchCredential(provider, label, plain) {
4538
+ const trimmed = plain.trim();
4539
+ if (!trimmed) return null;
4540
+ if (!isSearchProvider(provider)) return null;
4541
+ const resolvedLabel = resolveFreeLabel2(provider, label);
4542
+ const id = randomBytes3(8).toString("hex");
4543
+ const blob = encryptCredential(trimmed);
4544
+ const now = Date.now();
4545
+ getDb().prepare(
4546
+ `INSERT INTO search_credentials (id, provider, label, key_blob, created_at, updated_at)
4547
+ VALUES (?, ?, ?, ?, ?, ?)`
4548
+ ).run(id, provider, resolvedLabel, blob, now, now);
4549
+ return getSearchCredentialMeta(id);
4550
+ }
4551
+ function deleteSearchCredential(id) {
4552
+ const meta = getSearchCredentialMeta(id);
4553
+ if (!meta) return null;
4554
+ getDb().prepare("DELETE FROM search_credentials WHERE id = ?").run(id);
4555
+ return meta.provider;
4556
+ }
4557
+ function resolveActiveSearchCredential() {
4558
+ const prefs = getPrefs();
4559
+ if (!prefs.activeSearchCredentialId) return null;
4560
+ return getSearchCredentialMeta(prefs.activeSearchCredentialId);
4561
+ }
4562
+ function getActiveSearchProvider() {
4563
+ return resolveActiveSearchCredential()?.provider ?? null;
4564
+ }
4565
+ function getActiveSearchKeyPlaintext() {
4566
+ const active = resolveActiveSearchCredential();
4567
+ if (!active) return null;
4568
+ return getSearchCredentialKey(active.id);
4569
+ }
4570
+
4571
+ // src/storage/keys.ts
4210
4572
  var SALT2 = "boardroom.v1.salt";
4211
4573
  var ALGO2 = "aes-256-gcm";
4212
4574
  var _key2 = null;
@@ -4216,14 +4578,14 @@ function deriveKey2() {
4216
4578
  _key2 = scryptSync2(username, SALT2, 32);
4217
4579
  return _key2;
4218
4580
  }
4219
- function encrypt2(plain) {
4220
- const iv = randomBytes2(12);
4581
+ function encrypt(plain) {
4582
+ const iv = randomBytes4(12);
4221
4583
  const cipher = createCipheriv2(ALGO2, deriveKey2(), iv);
4222
4584
  const ct = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
4223
4585
  const tag = cipher.getAuthTag();
4224
4586
  return Buffer.concat([iv, tag, ct]);
4225
4587
  }
4226
- function decrypt2(blob) {
4588
+ function decrypt(blob) {
4227
4589
  const iv = blob.subarray(0, 12);
4228
4590
  const tag = blob.subarray(12, 28);
4229
4591
  const ct = blob.subarray(28);
@@ -4231,36 +4593,16 @@ function decrypt2(blob) {
4231
4593
  decipher.setAuthTag(tag);
4232
4594
  return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8");
4233
4595
  }
4234
- function hasBraveKey() {
4235
- const k = getKey("brave");
4236
- return typeof k === "string" && k.length > 0;
4237
- }
4238
- function hasTavilyKey() {
4239
- const k = getKey("tavily");
4240
- return typeof k === "string" && k.length > 0;
4241
- }
4242
4596
  function hasWebSearchKey() {
4243
- return hasBraveKey() || hasTavilyKey();
4244
- }
4245
- function resolveWebSearchBackend(preference) {
4246
- const b = hasBraveKey();
4247
- const t = hasTavilyKey();
4248
- if (!b && !t) return null;
4249
- if (b && !t) return "brave";
4250
- if (!b && t) return "tavily";
4251
- if (preference === "tavily" && t) return "tavily";
4252
- if (preference === "brave" && b) return "brave";
4253
- return b ? "brave" : "tavily";
4597
+ return getActiveSearchProvider() !== null;
4254
4598
  }
4255
4599
  function getActiveWebSearchCredentials() {
4256
- const prefRaw = getPrefs().webSearchProvider;
4257
- const preference = prefRaw === "tavily" ? "tavily" : "brave";
4258
- const backend = resolveWebSearchBackend(preference);
4600
+ const backend = getActiveSearchProvider();
4259
4601
  if (!backend) return null;
4260
- const apiKey = getKey(backend);
4602
+ const apiKey = getActiveSearchKeyPlaintext();
4261
4603
  return apiKey ? { backend, apiKey } : null;
4262
4604
  }
4263
- function maskKey2(plain) {
4605
+ function maskKey(plain) {
4264
4606
  const trimmed = plain.trim();
4265
4607
  if (!trimmed) return "";
4266
4608
  const n = trimmed.length;
@@ -4277,7 +4619,7 @@ function listKeyMeta() {
4277
4619
  let preview = null;
4278
4620
  if (r.key_blob.length > 0) {
4279
4621
  try {
4280
- preview = maskKey2(decrypt2(r.key_blob));
4622
+ preview = maskKey(decrypt(r.key_blob));
4281
4623
  } catch {
4282
4624
  preview = null;
4283
4625
  }
@@ -4295,7 +4637,7 @@ function getKey(provider) {
4295
4637
  const row = getDb().prepare("SELECT key_blob FROM provider_keys WHERE provider = ?").get(provider);
4296
4638
  if (!row) return null;
4297
4639
  try {
4298
- return decrypt2(row.key_blob);
4640
+ return decrypt(row.key_blob);
4299
4641
  } catch {
4300
4642
  return null;
4301
4643
  }
@@ -4306,7 +4648,7 @@ function setKey(provider, plain) {
4306
4648
  deleteKey(provider);
4307
4649
  return;
4308
4650
  }
4309
- const blob = encrypt2(trimmed);
4651
+ const blob = encrypt(trimmed);
4310
4652
  const now = Date.now();
4311
4653
  getDb().prepare(
4312
4654
  `INSERT INTO provider_keys (provider, key_blob, created_at, updated_at)
@@ -4324,8 +4666,10 @@ var PRIMARY_BY_CARRIER = {
4324
4666
  bai: "haiku-4-5",
4325
4667
  anthropic: "haiku-4-5",
4326
4668
  openai: "gpt-5-4-mini",
4327
- google: "gemini-3-1-flash"
4669
+ google: "gemini-3-1-flash",
4328
4670
  // xai · no primary (no LLM modelV in registry as of 2026-05-17).
4671
+ moonshot: "kimi-k2-6",
4672
+ zhipu: "glm-5-1"
4329
4673
  };
4330
4674
  function reachableModelVs() {
4331
4675
  const out = /* @__PURE__ */ new Set();
@@ -4350,6 +4694,7 @@ function reconcileAgentModels(opts = {}) {
4350
4694
  const carrier = activeCarrier();
4351
4695
  const primary = carrier ? PRIMARY_BY_CARRIER[carrier] ?? null : null;
4352
4696
  const forcePrimary = opts.forcePrimary === true;
4697
+ const priorCarrier = opts.priorCarrier ?? null;
4353
4698
  const switched = [];
4354
4699
  const cleared = [];
4355
4700
  for (const agent of listAllAgents()) {
@@ -4357,12 +4702,29 @@ function reconcileAgentModels(opts = {}) {
4357
4702
  if (agent.carrierPref) {
4358
4703
  updateAgent(agent.id, { carrierPref: null });
4359
4704
  }
4705
+ if (priorCarrier && v && priorCarrier !== carrier) {
4706
+ writeModelBucketEntry(agent.id, priorCarrier, v);
4707
+ }
4360
4708
  if (!forcePrimary && v && reachable.has(v)) continue;
4361
4709
  if (primary && carrier) {
4710
+ const bucket = getModelBucket(agent.id);
4711
+ const memorised = bucket[carrier];
4362
4712
  const isChair = agent.roleKind === "moderator";
4363
- const target = isChair ? primary : pickRandomFastModel(carrier) ?? primary;
4364
- if (v === target) continue;
4713
+ let target;
4714
+ if (memorised && reachable.has(memorised)) {
4715
+ target = memorised;
4716
+ } else {
4717
+ if (memorised) deleteModelBucketEntry(agent.id, carrier);
4718
+ target = isChair ? primary : pickRandomFastModel(carrier) ?? primary;
4719
+ }
4720
+ if (v === target) {
4721
+ if (bucket[carrier] !== target) {
4722
+ writeModelBucketEntry(agent.id, carrier, target);
4723
+ }
4724
+ continue;
4725
+ }
4365
4726
  updateAgent(agent.id, { modelV: target });
4727
+ writeModelBucketEntry(agent.id, carrier, target);
4366
4728
  switched.push(agent.id);
4367
4729
  } else {
4368
4730
  if (v === "") continue;
@@ -4493,8 +4855,15 @@ var FAST_POOL_BY_CARRIER = {
4493
4855
  ],
4494
4856
  anthropic: ["opus-4-6-fast", "haiku-4-5"],
4495
4857
  openai: ["gpt-5-4-mini"],
4496
- google: ["gemini-3-flash", "gemini-3-1-flash"]
4858
+ google: ["gemini-3-flash", "gemini-3-1-flash"],
4497
4859
  // xai · no fast pool (no LLM modelV in registry).
4860
+ // Moonshot / Zhipu · single-entry pools because the registry only
4861
+ // carries one LLM modelV per provider today. Every director on this
4862
+ // carrier ends up on the same model (no brand variety), which is fine
4863
+ // for these single-model providers · adding more Kimi / GLM rows
4864
+ // to the registry would naturally extend the pool.
4865
+ moonshot: ["kimi-k2-6"],
4866
+ zhipu: ["glm-5-1"]
4498
4867
  };
4499
4868
  function pickRandomFastModel(carrier) {
4500
4869
  if (!carrier) return null;
@@ -5156,6 +5525,10 @@ function flagshipCandidates() {
5156
5525
  return out;
5157
5526
  }
5158
5527
  async function callPhaseLLM(state, modelV, messages, opts) {
5528
+ const r = await callPhaseLLMVerbose(state, modelV, messages, opts);
5529
+ return r ? r.text : null;
5530
+ }
5531
+ async function callPhaseLLMVerbose(state, modelV, messages, opts) {
5159
5532
  if (!isModelV(modelV)) return null;
5160
5533
  const t = signalWithTimeout(state.controller.signal, LLM_CALL_TIMEOUT_MS);
5161
5534
  try {
@@ -5173,7 +5546,7 @@ async function callPhaseLLM(state, modelV, messages, opts) {
5173
5546
  return null;
5174
5547
  }
5175
5548
  }
5176
- return r.text;
5549
+ return { text: r.text, finishReason: r.finishReason };
5177
5550
  } catch (e) {
5178
5551
  process.stderr.write(`[persona-builder] ${modelV} failed: ${e instanceof Error ? e.message : String(e)}
5179
5552
  `);
@@ -5351,7 +5724,9 @@ async function runPipeline(state) {
5351
5724
  if (!profileV1) {
5352
5725
  const status = checkAbortOrCap();
5353
5726
  if (status === "aborted") return finalizeAbort(state);
5354
- return fail("Phase 1 (persona spec) failed \xB7 no flagship model produced a parseable profile.");
5727
+ const candidatesTried = flagshipCandidates();
5728
+ 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`;
5729
+ return fail(`Phase 1 (persona spec) failed \xB7 ${hint}.`);
5355
5730
  }
5356
5731
  partial.profileV1 = profileV1;
5357
5732
  partial.spec = toCore(profileV1);
@@ -5503,17 +5878,58 @@ function finalizeFromCheck(state, status) {
5503
5878
  }
5504
5879
  async function runProfilePass(state, label, webContext) {
5505
5880
  const messages = buildAgentProfileMessages({ description: state.description, webContext: webContext ?? null });
5506
- for (const modelV of flagshipCandidates()) {
5881
+ const candidates = flagshipCandidates();
5882
+ if (candidates.length === 0) {
5883
+ process.stderr.write(`[persona-builder/${label}] no reachable models for the active credential \xB7 cannot build profile
5884
+ `);
5885
+ return null;
5886
+ }
5887
+ const PROFILE_ATTEMPTS_PER_MODEL = 2;
5888
+ const PROFILE_MAX_TOKENS_BASE = 4096;
5889
+ const PROFILE_MAX_TOKENS_ESCALATED = 6500;
5890
+ for (const modelV of candidates) {
5507
5891
  if (state.controller.signal.aborted) return null;
5508
- const raw = await callPhaseLLM(state, modelV, messages, { temperature: 0.6, maxTokens: 2400 });
5509
- if (!raw) continue;
5510
- const parsed = parseAgentProfile(raw);
5511
- if (parsed) return parsed;
5512
- process.stderr.write(`[persona-builder/${label}] ${modelV} returned unparseable profile
5892
+ let truncatedLastAttempt = false;
5893
+ for (let attempt = 1; attempt <= PROFILE_ATTEMPTS_PER_MODEL; attempt++) {
5894
+ if (state.controller.signal.aborted) return null;
5895
+ const temperature = attempt === 1 ? 0.6 : 0.8;
5896
+ const maxTokens = truncatedLastAttempt ? PROFILE_MAX_TOKENS_ESCALATED : PROFILE_MAX_TOKENS_BASE;
5897
+ const result = await callPhaseLLMVerbose(state, modelV, messages, { temperature, maxTokens });
5898
+ if (!result || !result.text) {
5899
+ process.stderr.write(`[persona-builder/${label}] ${modelV} attempt ${attempt}/${PROFILE_ATTEMPTS_PER_MODEL} returned no text
5513
5900
  `);
5901
+ truncatedLastAttempt = false;
5902
+ continue;
5903
+ }
5904
+ const raw = result.text;
5905
+ const parsed = parseAgentProfile(raw);
5906
+ if (parsed) return parsed;
5907
+ const truncated = result.finishReason === "length" || looksTruncated(raw);
5908
+ truncatedLastAttempt = truncated;
5909
+ const head = raw.slice(0, 200).replace(/\s+/g, " ");
5910
+ const tail = raw.length > 200 ? raw.slice(-160).replace(/\s+/g, " ") : "";
5911
+ process.stderr.write(
5912
+ `[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}` : ""}
5913
+ `
5914
+ );
5915
+ }
5514
5916
  }
5515
5917
  return null;
5516
5918
  }
5919
+ function looksTruncated(raw) {
5920
+ if (!raw) return false;
5921
+ const fenceMatch = /```(?:json)?\s*([\s\S]*)$/i.exec(raw);
5922
+ const body = fenceMatch ? fenceMatch[1] : raw;
5923
+ if (!body) return false;
5924
+ const start = body.indexOf("{");
5925
+ if (start === -1) return false;
5926
+ let depth = 0;
5927
+ for (let i = start; i < body.length; i++) {
5928
+ if (body[i] === "{") depth++;
5929
+ else if (body[i] === "}") depth--;
5930
+ }
5931
+ return depth > 0;
5932
+ }
5517
5933
  async function runReActLoop(state, profileV1, reportProgress) {
5518
5934
  const creds = getActiveWebSearchCredentials();
5519
5935
  if (!creds) {
@@ -6159,12 +6575,12 @@ async function generateCelebritySeed(opts) {
6159
6575
  init_db();
6160
6576
 
6161
6577
  // src/utils/id.ts
6162
- import { randomBytes as randomBytes3 } from "crypto";
6578
+ import { randomBytes as randomBytes5 } from "crypto";
6163
6579
  var ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
6164
6580
  var ALPHABET_LEN = ALPHABET.length;
6165
6581
  var MASK = (1 << 5) - 1;
6166
6582
  function newId(len = 12) {
6167
- const bytes = randomBytes3(len);
6583
+ const bytes = randomBytes5(len);
6168
6584
  let out = "";
6169
6585
  for (let i = 0; i < len; i++) {
6170
6586
  out += ALPHABET[bytes[i] & MASK];
@@ -8113,7 +8529,14 @@ function agentsRouter() {
8113
8529
  modelV,
8114
8530
  messages: profileMessages,
8115
8531
  temperature: 0.6,
8116
- maxTokens: 1600,
8532
+ // Bumped from 1600 → 4096 · the profile JSON plus any
8533
+ // chain-of-thought / reasoning preamble on Kimi / GLM /
8534
+ // DeepSeek-R lines easily overran the smaller ceiling on
8535
+ // OpenRouter routes, which surface truncation as
8536
+ // "unparseable JSON" (the closing `}` is missing). Quick
8537
+ // mode is single-shot — no retry — so the ceiling has to
8538
+ // be generous up front.
8539
+ maxTokens: 4096,
8117
8540
  signal
8118
8541
  });
8119
8542
  const parsed = parseAgentProfile(raw);
@@ -8121,6 +8544,8 @@ function agentsRouter() {
8121
8544
  profile = parsed;
8122
8545
  break;
8123
8546
  }
8547
+ process.stderr.write(`[agent-spec/profile] ${modelV} returned unparseable profile \xB7 len=${raw.length} head: ${raw.slice(0, 200).replace(/\s+/g, " ")}
8548
+ `);
8124
8549
  } catch (e) {
8125
8550
  process.stderr.write(`[agent-spec/profile] ${modelV} failed: ${e instanceof Error ? e.message : String(e)}
8126
8551
  `);
@@ -8494,7 +8919,7 @@ function agentsRouter() {
8494
8919
  patch.carrierPref = null;
8495
8920
  } else if (typeof b.carrierPref === "string") {
8496
8921
  const v = b.carrierPref.trim();
8497
- const allowed = /* @__PURE__ */ new Set(["openrouter", "bai", "anthropic", "openai", "google", "xai"]);
8922
+ const allowed = /* @__PURE__ */ new Set(["openrouter", "bai", "anthropic", "openai", "google", "xai", "moonshot", "zhipu"]);
8498
8923
  if (!allowed.has(v)) {
8499
8924
  return c.json({ error: `unknown carrier: ${v}` }, 400);
8500
8925
  }
@@ -8546,6 +8971,42 @@ function agentsRouter() {
8546
8971
  patch.isPinned = b.isPinned;
8547
8972
  }
8548
8973
  const updated = updateAgent(id, patch);
8974
+ if (updated) {
8975
+ if (patch.modelV !== void 0) {
8976
+ const carrier = activeCarrier();
8977
+ if (carrier) {
8978
+ try {
8979
+ writeModelBucketEntry(id, carrier, patch.modelV);
8980
+ } catch (e) {
8981
+ process.stderr.write(
8982
+ `[agents.patch] model bucket write failed for ${id}: ${e instanceof Error ? e.message : String(e)}
8983
+ `
8984
+ );
8985
+ }
8986
+ }
8987
+ }
8988
+ if ("voice" in patch) {
8989
+ if (patch.voice && updated.voice && (updated.voice.provider === "minimax" || updated.voice.provider === "elevenlabs")) {
8990
+ try {
8991
+ writeVoiceBucketEntry(id, updated.voice.provider, updated.voice);
8992
+ } catch (e) {
8993
+ process.stderr.write(
8994
+ `[agents.patch] voice bucket write failed for ${id}: ${e instanceof Error ? e.message : String(e)}
8995
+ `
8996
+ );
8997
+ }
8998
+ } else if (!patch.voice && existing.voice && (existing.voice.provider === "minimax" || existing.voice.provider === "elevenlabs")) {
8999
+ try {
9000
+ deleteVoiceBucketEntry(id, existing.voice.provider);
9001
+ } catch (e) {
9002
+ process.stderr.write(
9003
+ `[agents.patch] voice bucket delete failed for ${id}: ${e instanceof Error ? e.message : String(e)}
9004
+ `
9005
+ );
9006
+ }
9007
+ }
9008
+ }
9009
+ }
8549
9010
  return c.json(updated);
8550
9011
  });
8551
9012
  r.delete("/:id", (c) => {
@@ -13704,7 +14165,7 @@ function cleanupOrphanedStreams(opts = {}) {
13704
14165
 
13705
14166
  // src/storage/rooms.ts
13706
14167
  init_db();
13707
- 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";
14168
+ 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";
13708
14169
  function mapRow8(row) {
13709
14170
  return {
13710
14171
  id: row.id,
@@ -13724,7 +14185,8 @@ function mapRow8(row) {
13724
14185
  adjournedAt: row.adjourned_at,
13725
14186
  incognito: row.incognito === 1,
13726
14187
  parentRoomId: row.parent_room_id,
13727
- parentBriefId: row.parent_brief_id
14188
+ parentBriefId: row.parent_brief_id,
14189
+ nameAuto: row.name_auto === 1
13728
14190
  };
13729
14191
  }
13730
14192
  function mapMember(row) {
@@ -13788,7 +14250,7 @@ function createRoom(input) {
13788
14250
  const briefStyle = input.briefStyle ?? "auto";
13789
14251
  const deliveryMode = input.deliveryMode === "voice" ? "voice" : "text";
13790
14252
  const insertRoom = db.prepare(
13791
- "INSERT INTO rooms (id, number, name, subject, mode, intensity, delivery_mode, brief_style, status, created_at, parent_room_id, parent_brief_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'live', ?, ?, ?)"
14253
+ "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', ?, ?, ?, ?)"
13792
14254
  );
13793
14255
  const insertMember = db.prepare(
13794
14256
  "INSERT INTO room_members (room_id, agent_id, position, joined_at) VALUES (?, ?, ?, ?)"
@@ -13796,8 +14258,9 @@ function createRoom(input) {
13796
14258
  const chair = getChairAgent();
13797
14259
  const parentRoomId = input.parentRoomId && input.parentRoomId.trim() ? input.parentRoomId.trim() : null;
13798
14260
  const parentBriefId = input.parentBriefId && input.parentBriefId.trim() ? input.parentBriefId.trim() : null;
14261
+ const nameAuto = input.nameAuto === false ? 0 : 1;
13799
14262
  const tx = db.transaction(() => {
13800
- insertRoom.run(id, number, input.name, input.subject, mode, intensity, deliveryMode, briefStyle, now, parentRoomId, parentBriefId);
14263
+ insertRoom.run(id, number, input.name, input.subject, mode, intensity, deliveryMode, briefStyle, now, parentRoomId, parentBriefId, nameAuto);
13801
14264
  if (chair) insertMember.run(id, chair.id, -1, now);
13802
14265
  input.agentIds.forEach((agentId, idx) => {
13803
14266
  if (chair && agentId === chair.id) return;
@@ -13824,6 +14287,12 @@ function setRoomStatus(roomId, status, ts = {}) {
13824
14287
  vals.push(roomId);
13825
14288
  getDb().prepare(`UPDATE rooms SET ${sets.join(", ")} WHERE id = ?`).run(...vals);
13826
14289
  }
14290
+ function setRoomNameFromAuto(roomId, name) {
14291
+ const trimmed = name.trim();
14292
+ if (!trimmed) return false;
14293
+ const r = getDb().prepare("UPDATE rooms SET name = ? WHERE id = ? AND name_auto = 1").run(trimmed, roomId);
14294
+ return r.changes > 0;
14295
+ }
13827
14296
  function addRoomMember(roomId, agentId) {
13828
14297
  const db = getDb();
13829
14298
  const existing = db.prepare("SELECT agent_id, position, joined_at, removed_at FROM room_members WHERE room_id = ? AND agent_id = ?").get(roomId, agentId);
@@ -14343,6 +14812,103 @@ import { Buffer as Buffer2 } from "buffer";
14343
14812
  init_db();
14344
14813
  import { createHash } from "crypto";
14345
14814
 
14815
+ // src/storage/voice-credentials.ts
14816
+ import { randomBytes as randomBytes6 } from "crypto";
14817
+ init_db();
14818
+ var ALL_VOICE_PROVIDERS = ["minimax", "elevenlabs"];
14819
+ function isVoiceProvider(p) {
14820
+ return ALL_VOICE_PROVIDERS.includes(p);
14821
+ }
14822
+ var VOICE_PROVIDER_PRIORITY = ["minimax", "elevenlabs"];
14823
+ function rowToMeta3(row) {
14824
+ if (!isVoiceProvider(row.provider)) return null;
14825
+ let preview = "";
14826
+ try {
14827
+ preview = maskCredential(decryptCredential(row.key_blob));
14828
+ } catch {
14829
+ preview = "";
14830
+ }
14831
+ return {
14832
+ id: row.id,
14833
+ provider: row.provider,
14834
+ label: row.label,
14835
+ preview,
14836
+ createdAt: row.created_at,
14837
+ updatedAt: row.updated_at
14838
+ };
14839
+ }
14840
+ function listVoiceCredentials() {
14841
+ const rows = getDb().prepare("SELECT id, provider, label, key_blob, created_at, updated_at FROM voice_credentials ORDER BY created_at ASC").all();
14842
+ return rows.map(rowToMeta3).filter((m) => m !== null);
14843
+ }
14844
+ function getVoiceCredentialMeta(id) {
14845
+ const row = getDb().prepare("SELECT id, provider, label, key_blob, created_at, updated_at FROM voice_credentials WHERE id = ?").get(id);
14846
+ if (!row) return null;
14847
+ return rowToMeta3(row);
14848
+ }
14849
+ function getVoiceCredentialKey(id) {
14850
+ const row = getDb().prepare("SELECT key_blob FROM voice_credentials WHERE id = ?").get(id);
14851
+ if (!row) return null;
14852
+ try {
14853
+ return decryptCredential(row.key_blob);
14854
+ } catch {
14855
+ return null;
14856
+ }
14857
+ }
14858
+ function providerDisplayName3(provider) {
14859
+ switch (provider) {
14860
+ case "minimax":
14861
+ return "MiniMax";
14862
+ case "elevenlabs":
14863
+ return "ElevenLabs";
14864
+ }
14865
+ }
14866
+ function resolveFreeLabel3(provider, suggested) {
14867
+ const base = suggested && suggested.trim() || providerDisplayName3(provider);
14868
+ const existing = new Set(
14869
+ getDb().prepare("SELECT label FROM voice_credentials").all().map((r) => r.label)
14870
+ );
14871
+ if (!existing.has(base)) return base;
14872
+ for (let n = 2; n < 1e3; n++) {
14873
+ const candidate = `${base} ${n}`;
14874
+ if (!existing.has(candidate)) return candidate;
14875
+ }
14876
+ return `${base} ${Date.now()}`;
14877
+ }
14878
+ function createVoiceCredential(provider, label, plain) {
14879
+ const trimmed = plain.trim();
14880
+ if (!trimmed) return null;
14881
+ if (!isVoiceProvider(provider)) return null;
14882
+ const resolvedLabel = resolveFreeLabel3(provider, label);
14883
+ const id = randomBytes6(8).toString("hex");
14884
+ const blob = encryptCredential(trimmed);
14885
+ const now = Date.now();
14886
+ getDb().prepare(
14887
+ `INSERT INTO voice_credentials (id, provider, label, key_blob, created_at, updated_at)
14888
+ VALUES (?, ?, ?, ?, ?, ?)`
14889
+ ).run(id, provider, resolvedLabel, blob, now, now);
14890
+ return getVoiceCredentialMeta(id);
14891
+ }
14892
+ function deleteVoiceCredential(id) {
14893
+ const meta = getVoiceCredentialMeta(id);
14894
+ if (!meta) return null;
14895
+ getDb().prepare("DELETE FROM voice_credentials WHERE id = ?").run(id);
14896
+ return meta.provider;
14897
+ }
14898
+ function resolveActiveVoiceCredential() {
14899
+ const prefs = getPrefs();
14900
+ if (!prefs.activeVoiceCredentialId) return null;
14901
+ return getVoiceCredentialMeta(prefs.activeVoiceCredentialId);
14902
+ }
14903
+ function getActiveVoiceProvider() {
14904
+ return resolveActiveVoiceCredential()?.provider ?? null;
14905
+ }
14906
+ function getActiveVoiceKeyPlaintext() {
14907
+ const active = resolveActiveVoiceCredential();
14908
+ if (!active) return null;
14909
+ return getVoiceCredentialKey(active.id);
14910
+ }
14911
+
14346
14912
  // src/voice/registry.ts
14347
14913
  function minimaxBaseUrl() {
14348
14914
  const region = getPrefs().minimaxRegion;
@@ -14387,10 +14953,12 @@ function listConfiguredVoices() {
14387
14953
  const out = [];
14388
14954
  const openaiReady = !!getKey("openai");
14389
14955
  if (openaiReady) out.push(...OPENAI_VOICES.map((v) => ({ ...v, configured: true })));
14390
- const minimaxReady = !!getKey("minimax");
14391
- if (minimaxReady) out.push(...MINIMAX_SYSTEM_VOICES.map((v) => ({ ...v, configured: true })));
14392
- const elevenReady = !!getKey("elevenlabs");
14393
- if (elevenReady) out.push(...ELEVENLABS_DEFAULT_VOICES.map((v) => ({ ...v, configured: true })));
14956
+ const activeProvider = getActiveVoiceProvider();
14957
+ if (activeProvider === "minimax") {
14958
+ out.push(...MINIMAX_SYSTEM_VOICES.map((v) => ({ ...v, configured: true })));
14959
+ } else if (activeProvider === "elevenlabs") {
14960
+ out.push(...ELEVENLABS_DEFAULT_VOICES.map((v) => ({ ...v, configured: true })));
14961
+ }
14394
14962
  out.push({
14395
14963
  provider: "browser",
14396
14964
  model: "speechSynthesis",
@@ -14401,14 +14969,21 @@ function listConfiguredVoices() {
14401
14969
  return out;
14402
14970
  }
14403
14971
  async function listAvailableVoices() {
14972
+ const activeProvider = getActiveVoiceProvider();
14404
14973
  let voices = listConfiguredVoices();
14405
- const mmKey = getKey("minimax");
14406
- if (mmKey) {
14407
- try {
14974
+ if (!activeProvider) {
14975
+ return { voices, provider: null, configured: false };
14976
+ }
14977
+ const activeKey = getActiveVoiceKeyPlaintext();
14978
+ if (!activeKey) {
14979
+ return { voices, provider: activeProvider, configured: false };
14980
+ }
14981
+ if (activeProvider === "minimax") {
14982
+ try {
14408
14983
  const res = await fetch(`${minimaxBaseUrl()}/v1/get_voice`, {
14409
14984
  method: "POST",
14410
14985
  headers: {
14411
- "authorization": `Bearer ${mmKey}`,
14986
+ "authorization": `Bearer ${activeKey}`,
14412
14987
  "content-type": "application/json"
14413
14988
  },
14414
14989
  body: JSON.stringify({ voice_type: "all" })
@@ -14437,9 +15012,9 @@ async function listAvailableVoices() {
14437
15012
  }
14438
15013
  } catch {
14439
15014
  }
15015
+ return { voices, provider: "minimax", configured: true };
14440
15016
  }
14441
- const elKey = getKey("elevenlabs");
14442
- if (elKey) {
15017
+ if (activeProvider === "elevenlabs") {
14443
15018
  const personal = [];
14444
15019
  const shared = [];
14445
15020
  await Promise.all([
@@ -14447,7 +15022,7 @@ async function listAvailableVoices() {
14447
15022
  try {
14448
15023
  const res = await fetch(
14449
15024
  "https://api.elevenlabs.io/v1/voices?show_legacy=true&include_total_count=true",
14450
- { headers: { "xi-api-key": elKey } }
15025
+ { headers: { "xi-api-key": activeKey } }
14451
15026
  );
14452
15027
  if (!res.ok) {
14453
15028
  const errText = await res.text();
@@ -14475,7 +15050,7 @@ async function listAvailableVoices() {
14475
15050
  try {
14476
15051
  const res = await fetch(
14477
15052
  "https://api.elevenlabs.io/v1/shared-voices?page_size=100",
14478
- { headers: { "xi-api-key": elKey } }
15053
+ { headers: { "xi-api-key": activeKey } }
14479
15054
  );
14480
15055
  if (!res.ok) {
14481
15056
  const errText = await res.text();
@@ -14528,8 +15103,9 @@ async function listAvailableVoices() {
14528
15103
  }));
14529
15104
  voices = [...nonEl, ...personalMapped, ...sharedMapped];
14530
15105
  }
15106
+ return { voices, provider: "elevenlabs", configured: true };
14531
15107
  }
14532
- return voices;
15108
+ return { voices, provider: activeProvider, configured: true };
14533
15109
  }
14534
15110
  function elevenLabsSharedVoiceRows(raw) {
14535
15111
  if (!Array.isArray(raw)) return [];
@@ -14578,6 +15154,10 @@ function defaultVoiceForProvider(provider) {
14578
15154
  }
14579
15155
 
14580
15156
  // src/voice/tts.ts
15157
+ function activeVoiceKeyFor(wanted) {
15158
+ if (getActiveVoiceProvider() !== wanted) return null;
15159
+ return getActiveVoiceKeyPlaintext();
15160
+ }
14581
15161
  function minimaxBaseUrl2() {
14582
15162
  const region = getPrefs().minimaxRegion;
14583
15163
  return region === "intl" ? "https://api.minimax.io" : "https://api.minimaxi.com";
@@ -14642,9 +15222,26 @@ function cleanForSpeech(md) {
14642
15222
  return out.trim();
14643
15223
  }
14644
15224
  function voiceProfileForAgent(agent) {
14645
- if (agent.voice) return agent.voice;
15225
+ const activeProvider = getActiveVoiceProvider();
15226
+ if (agent.voice && agent.voice.provider === activeProvider) {
15227
+ return agent.voice;
15228
+ }
15229
+ if (agent.voice && activeProvider) {
15230
+ const fresh = defaultVoiceForProvider(activeProvider);
15231
+ if (fresh) {
15232
+ return {
15233
+ provider: fresh.provider,
15234
+ model: fresh.model,
15235
+ voiceId: fresh.voiceId,
15236
+ speed: agent.voice.speed ?? 1,
15237
+ pitch: agent.voice.pitch ?? 0,
15238
+ volume: agent.voice.volume ?? 1,
15239
+ ...agent.voice.emotion ? { emotion: agent.voice.emotion } : {}
15240
+ };
15241
+ }
15242
+ }
14646
15243
  const fallback = defaultVoiceForProvider(
14647
- getKey("minimax") ? "minimax" : getKey("elevenlabs") ? "elevenlabs" : getKey("openai") ? "openai" : "browser"
15244
+ activeProvider ?? (getKey("openai") ? "openai" : "browser")
14648
15245
  );
14649
15246
  return {
14650
15247
  provider: fallback?.provider ?? "browser",
@@ -14667,15 +15264,16 @@ async function synthesizeSpeech(text, profile, signal) {
14667
15264
  };
14668
15265
  }
14669
15266
  async function* synthesizeSpeechStream(text, profile, signal) {
14670
- if (profile.provider === "elevenlabs" && getKey("elevenlabs")) {
15267
+ if (profile.provider === "elevenlabs" && activeVoiceKeyFor("elevenlabs")) {
14671
15268
  yield* synthesizeElevenLabsStream(text, profile, signal);
14672
15269
  return;
14673
15270
  }
14674
- if (profile.provider !== "minimax" || !getKey("minimax")) {
15271
+ const minimaxKey = activeVoiceKeyFor("minimax");
15272
+ if (profile.provider !== "minimax" || !minimaxKey) {
14675
15273
  yield await synthesizeSpeech(text, profile, signal);
14676
15274
  return;
14677
15275
  }
14678
- const key = getKey("minimax");
15276
+ const key = minimaxKey;
14679
15277
  const model = profile.model || "speech-2.8-hd";
14680
15278
  const res = await fetch(`${minimaxBaseUrl2()}/v1/t2a_v2`, {
14681
15279
  method: "POST",
@@ -14791,7 +15389,7 @@ async function* synthesizeSpeechStream(text, profile, signal) {
14791
15389
  }
14792
15390
  }
14793
15391
  async function synthesizeMiniMax(text, profile, signal) {
14794
- const key = getKey("minimax");
15392
+ const key = activeVoiceKeyFor("minimax");
14795
15393
  if (!key) {
14796
15394
  return { provider: "browser", model: "speechSynthesis", voiceId: "system-default", text };
14797
15395
  }
@@ -14901,7 +15499,7 @@ async function synthesizeOpenAI(text, profile, signal) {
14901
15499
  };
14902
15500
  }
14903
15501
  async function synthesizeElevenLabs(text, profile, signal) {
14904
- const key = getKey("elevenlabs");
15502
+ const key = activeVoiceKeyFor("elevenlabs");
14905
15503
  if (!key) {
14906
15504
  return { provider: "browser", model: "speechSynthesis", voiceId: "system-default", text };
14907
15505
  }
@@ -14946,7 +15544,7 @@ async function synthesizeElevenLabs(text, profile, signal) {
14946
15544
  };
14947
15545
  }
14948
15546
  async function* synthesizeElevenLabsStream(text, profile, signal) {
14949
- const key = getKey("elevenlabs");
15547
+ const key = activeVoiceKeyFor("elevenlabs");
14950
15548
  if (!key) {
14951
15549
  yield await synthesizeSpeech(text, profile, signal);
14952
15550
  return;
@@ -16910,6 +17508,7 @@ function credentialsRouter() {
16910
17508
  } else {
16911
17509
  return c.json({ error: "id must be a string or null" }, 400);
16912
17510
  }
17511
+ const priorCarrier = activeCarrier();
16913
17512
  if (nextId) {
16914
17513
  const meta = getLlmCredentialMeta(nextId);
16915
17514
  if (!meta) return c.json({ error: "credential not found" }, 404);
@@ -16920,7 +17519,7 @@ function credentialsRouter() {
16920
17519
  updatePrefs({ activeLlmCredentialId: null });
16921
17520
  }
16922
17521
  try {
16923
- reconcileAgentModels({ forcePrimary: true });
17522
+ reconcileAgentModels({ forcePrimary: true, priorCarrier });
16924
17523
  } catch (e) {
16925
17524
  process.stderr.write(`[credentials.active] reconcile failed: ${e instanceof Error ? e.message : String(e)}
16926
17525
  `);
@@ -16952,7 +17551,7 @@ function credentialsRouter() {
16952
17551
  const flagship = PRIMARY_BY_CARRIER[provider];
16953
17552
  if (flagship) updatePrefs({ defaultModelV: flagship });
16954
17553
  try {
16955
- reconcileAgentModels({ forcePrimary: true });
17554
+ reconcileAgentModels({ forcePrimary: true, priorCarrier: null });
16956
17555
  } catch (e) {
16957
17556
  process.stderr.write(`[credentials.post] reconcile failed: ${e instanceof Error ? e.message : String(e)}
16958
17557
  `);
@@ -16967,6 +17566,7 @@ function credentialsRouter() {
16967
17566
  if (!meta) return c.json({ error: "credential not found" }, 404);
16968
17567
  const prefs = getPrefs();
16969
17568
  const wasActive = prefs.activeLlmCredentialId === id;
17569
+ const priorCarrier = wasActive ? activeCarrier() : null;
16970
17570
  const removedProvider = deleteLlmCredential(id);
16971
17571
  if (wasActive) {
16972
17572
  const nextId = pickNextActiveId(removedProvider);
@@ -16982,7 +17582,7 @@ function credentialsRouter() {
16982
17582
  }
16983
17583
  }
16984
17584
  try {
16985
- reconcileAgentModels(wasActive ? { forcePrimary: true } : void 0);
17585
+ reconcileAgentModels(wasActive ? { forcePrimary: true, priorCarrier } : void 0);
16986
17586
  } catch (e) {
16987
17587
  process.stderr.write(`[credentials.delete] reconcile failed: ${e instanceof Error ? e.message : String(e)}
16988
17588
  `);
@@ -17008,39 +17608,6 @@ var PROVIDERS = /* @__PURE__ */ new Set([
17008
17608
  function isProvider(s) {
17009
17609
  return PROVIDERS.has(s);
17010
17610
  }
17011
- function autoAssignVoicesOnFirstKey(provider) {
17012
- if (provider !== "minimax" && provider !== "elevenlabs") return;
17013
- const pool = listConfiguredVoices().filter((v) => v.provider === provider);
17014
- if (pool.length === 0) return;
17015
- const agents = listAllAgents();
17016
- if (agents.length === 0) return;
17017
- const shuffled = [...pool];
17018
- for (let i = shuffled.length - 1; i > 0; i--) {
17019
- const j = Math.floor(Math.random() * (i + 1));
17020
- [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
17021
- }
17022
- for (let i = 0; i < agents.length; i++) {
17023
- const v = shuffled[i % shuffled.length];
17024
- const prev = agents[i].voice;
17025
- const profile = {
17026
- provider: v.provider,
17027
- model: v.model,
17028
- voiceId: v.voiceId,
17029
- ...prev?.speed !== void 0 ? { speed: prev.speed } : {},
17030
- ...prev?.pitch !== void 0 ? { pitch: prev.pitch } : {},
17031
- ...prev?.volume !== void 0 ? { volume: prev.volume } : {},
17032
- ...prev?.emotion !== void 0 ? { emotion: prev.emotion } : {}
17033
- };
17034
- try {
17035
- updateAgent(agents[i].id, { voice: profile });
17036
- } catch (e) {
17037
- process.stderr.write(
17038
- `[keys.put] auto-assign voice failed for ${agents[i].id}: ${e instanceof Error ? e.message : String(e)}
17039
- `
17040
- );
17041
- }
17042
- }
17043
- }
17044
17611
  function keysRouter() {
17045
17612
  const r = new Hono5();
17046
17613
  r.get("/", (c) => {
@@ -17066,6 +17633,12 @@ function keysRouter() {
17066
17633
  if (isLlmProvider(provider)) {
17067
17634
  return c.json({ error: "LLM providers use POST /api/credentials" }, 410);
17068
17635
  }
17636
+ if (provider === "minimax" || provider === "elevenlabs") {
17637
+ return c.json({ error: "voice providers use POST /api/voice-credentials" }, 410);
17638
+ }
17639
+ if (provider === "brave" || provider === "tavily") {
17640
+ return c.json({ error: "search providers use POST /api/search-credentials" }, 410);
17641
+ }
17069
17642
  let body;
17070
17643
  try {
17071
17644
  body = await c.req.json();
@@ -17074,16 +17647,7 @@ function keysRouter() {
17074
17647
  }
17075
17648
  const key = body?.key;
17076
17649
  if (typeof key !== "string") return c.json({ error: "body must contain { key: string }" }, 400);
17077
- const hadAnyVoiceKeyBefore = !!getKey("minimax") || !!getKey("elevenlabs");
17078
17650
  setKey(provider, key);
17079
- if ((provider === "minimax" || provider === "elevenlabs") && key.trim().length > 0 && !hadAnyVoiceKeyBefore) {
17080
- try {
17081
- autoAssignVoicesOnFirstKey(provider);
17082
- } catch (e) {
17083
- process.stderr.write(`[keys.put] voice auto-assign failed: ${e instanceof Error ? e.message : String(e)}
17084
- `);
17085
- }
17086
- }
17087
17651
  const fresh = listKeyMeta().find((m) => m.provider === provider);
17088
17652
  return c.json(
17089
17653
  fresh ?? { provider, configured: key.trim().length > 0, updatedAt: Date.now(), preview: null }
@@ -17095,6 +17659,12 @@ function keysRouter() {
17095
17659
  if (isLlmProvider(provider)) {
17096
17660
  return c.json({ error: "LLM providers use DELETE /api/credentials/:id" }, 410);
17097
17661
  }
17662
+ if (provider === "minimax" || provider === "elevenlabs") {
17663
+ return c.json({ error: "voice providers use DELETE /api/voice-credentials/:id" }, 410);
17664
+ }
17665
+ if (provider === "brave" || provider === "tavily") {
17666
+ return c.json({ error: "search providers use DELETE /api/search-credentials/:id" }, 410);
17667
+ }
17098
17668
  deleteKey(provider);
17099
17669
  return c.json({ provider, configured: false, updatedAt: null, preview: null });
17100
17670
  });
@@ -21899,6 +22469,7 @@ async function streamChairMessage(args) {
21899
22469
  const metaKind = meta?.kind;
21900
22470
  const isRoundEndVoice = voiceMode && metaKind === "round-end";
21901
22471
  let pingDone = false;
22472
+ let tailDone = false;
21902
22473
  let voiceBuf = "";
21903
22474
  const voiceProfile = voiceMode ? voiceProfileForAgent(chair) : null;
21904
22475
  let voiceSeq = 0;
@@ -21960,20 +22531,31 @@ async function streamChairMessage(args) {
21960
22531
  });
21961
22532
  if (voiceChunker) {
21962
22533
  if (isRoundEndVoice) {
21963
- voiceBuf += chunk.delta;
21964
- if (!pingDone) {
21965
- const idx = voiceBuf.search(/POINTS\s*:/i);
21966
- if (idx >= 0) {
21967
- pingDone = true;
21968
- const after = voiceBuf.slice(voiceBuf.search(/POINTS\s*:/i));
21969
- voiceBuf = after.replace(/POINTS\s*:/i, "");
22534
+ if (tailDone) {
22535
+ } else {
22536
+ voiceBuf += chunk.delta;
22537
+ if (!pingDone) {
22538
+ const idx = voiceBuf.search(/POINTS\s*:/i);
22539
+ if (idx >= 0) {
22540
+ pingDone = true;
22541
+ const after = voiceBuf.slice(voiceBuf.search(/POINTS\s*:/i));
22542
+ voiceBuf = after.replace(/POINTS\s*:/i, "");
22543
+ }
21970
22544
  }
21971
- }
21972
- if (pingDone && voiceBuf) {
21973
- const cleaned = voiceBuf.replace(/(^|\n)\s*[-*]\s*/g, ". ");
21974
- voiceBuf = "";
21975
- for (const spoken of voiceChunker.push(cleaned)) {
21976
- await emitChairVoice(spoken);
22545
+ if (pingDone && voiceBuf) {
22546
+ const tailIdx = voiceBuf.search(/MODE[-\s]?SHIFT\s*:/i);
22547
+ if (tailIdx >= 0) {
22548
+ tailDone = true;
22549
+ const beforeTail = voiceBuf.slice(0, tailIdx);
22550
+ voiceBuf = beforeTail;
22551
+ }
22552
+ if (voiceBuf) {
22553
+ const cleaned = voiceBuf.replace(/(^|\n)\s*[-*]\s*/g, ". ").replace(/\bBECAUSE\s*:\s*/gi, "");
22554
+ voiceBuf = "";
22555
+ for (const spoken of voiceChunker.push(cleaned)) {
22556
+ await emitChairVoice(spoken);
22557
+ }
22558
+ }
21977
22559
  }
21978
22560
  }
21979
22561
  } else {
@@ -23114,6 +23696,129 @@ async function pickDirectors(opts) {
23114
23696
  };
23115
23697
  }
23116
23698
 
23699
+ // src/orchestrator/roomTitle.ts
23700
+ var MAX_TITLE_CHARS = 32;
23701
+ var REJECT_PHRASES = /* @__PURE__ */ new Set([
23702
+ "untitled",
23703
+ "untitled room",
23704
+ "discussion",
23705
+ "chat",
23706
+ "conversation",
23707
+ "topic",
23708
+ "summary",
23709
+ "\u672A\u547D\u540D",
23710
+ "\u8BA8\u8BBA",
23711
+ "\u5BF9\u8BDD",
23712
+ "\u804A\u5929"
23713
+ ]);
23714
+ async function generateRoomTitle(roomId) {
23715
+ const room = getRoom(roomId);
23716
+ if (!room) return { kind: "skipped", reason: "no-room" };
23717
+ if (!room.nameAuto) return { kind: "skipped", reason: "user-named" };
23718
+ const subject = room.subject.trim();
23719
+ if (!subject) return { kind: "skipped", reason: "no-subject" };
23720
+ const fallbackName = room.subject.slice(0, 60);
23721
+ if (room.name !== fallbackName) {
23722
+ return { kind: "skipped", reason: "already-renamed", detail: room.name.slice(0, 60) };
23723
+ }
23724
+ const modelV = utilityModelFor();
23725
+ if (!modelV) return { kind: "skipped", reason: "no-model" };
23726
+ 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.
23727
+
23728
+ How to write a representative title:
23729
+ 1. Identify the CORE SUBJECT or TASK (the noun, the deliverable, the decision being made).
23730
+ 2. Strip throat-clearing, polite framing, self-introduction, and product names that are not the subject itself.
23731
+ 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").
23732
+ 4. Use the SAME language as the opening question.
23733
+
23734
+ Length:
23735
+ - Chinese / Japanese: 5-10 characters.
23736
+ - English / Spanish / other Latin scripts: 3-6 words.
23737
+ - \u226424 characters total.
23738
+
23739
+ Format:
23740
+ - Output ONLY the title \u2014 no quotes, no brackets, no trailing punctuation, no labels like "Topic:" / "\u4E3B\u9898\uFF1A", no explanation.
23741
+ - Never output fillers like "Untitled", "Discussion", "Chat", "Conversation", "\u8BA8\u8BBA", "\u5BF9\u8BDD", "\u804A\u5929".
23742
+
23743
+ Examples:
23744
+
23745
+ 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
23746
+ Output: \u4EA7\u54C1\u5BA3\u4F20\u89C6\u9891\u811A\u672C
23747
+
23748
+ 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
23749
+ Output: Postgres \u8F6C ClickHouse \u6743\u8861
23750
+
23751
+ 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
23752
+ Output: LoRA vs \u5168\u91CF\u5FAE\u8C03
23753
+
23754
+ Input: Can you help me debug this Python regex that's failing on Unicode strings with combining marks?
23755
+ Output: Python regex Unicode bug
23756
+
23757
+ Input: I want to redesign our onboarding email sequence \u2014 currently 5 emails over 2 weeks, low click-through.
23758
+ Output: Onboarding email redesign
23759
+
23760
+ --- User's opening question ---
23761
+ ${subject}
23762
+
23763
+ --- Title ---
23764
+ `;
23765
+ let raw = "";
23766
+ try {
23767
+ raw = await callLLM({
23768
+ modelV,
23769
+ carrier: null,
23770
+ messages: [{ role: "user", content: prompt }],
23771
+ // Low but not zero · 0.2 was deterministic-ish but kept locking
23772
+ // onto a generic first-noun pick. 0.4 lets the model trade off
23773
+ // alternatives without wandering into creative territory.
23774
+ temperature: 0.4,
23775
+ // 40 was tight enough that a model thinking briefly before
23776
+ // answering would get cut off mid-title; 80 fits the title plus
23777
+ // a small margin without inviting paragraphs.
23778
+ maxTokens: 80
23779
+ });
23780
+ } catch (e) {
23781
+ const detail = e instanceof Error ? e.message : String(e);
23782
+ process.stderr.write(`[room-title] LLM call failed for ${roomId}: ${detail}
23783
+ `);
23784
+ return { kind: "skipped", reason: "llm-error", detail };
23785
+ }
23786
+ if (!raw.trim()) {
23787
+ return { kind: "skipped", reason: "empty-output", detail: `model=${modelV}` };
23788
+ }
23789
+ const phrase = sanitiseTitle(raw);
23790
+ if (!phrase) {
23791
+ return { kind: "skipped", reason: "rejected-generic", detail: raw.trim().slice(0, 80) };
23792
+ }
23793
+ const updated = setRoomNameFromAuto(roomId, phrase);
23794
+ if (!updated) return { kind: "skipped", reason: "race-after-rename" };
23795
+ roomBus.emit(roomId, {
23796
+ type: "config-event",
23797
+ kind: "settings-changed",
23798
+ payload: { changes: { name: { from: room.name, to: phrase } } },
23799
+ createdAt: Date.now()
23800
+ });
23801
+ return { kind: "ok", before: room.name, after: phrase };
23802
+ }
23803
+ function sanitiseTitle(raw) {
23804
+ let s = raw.trim();
23805
+ if (!s) return null;
23806
+ const firstLine = s.split(/\r?\n/).map((l) => l.trim()).find((l) => l.length > 0);
23807
+ if (firstLine) s = firstLine;
23808
+ s = s.replace(/^\s*output\s*[::]\s*/i, "");
23809
+ s = s.replace(/^\s*(topic|title|主题|主題|标题|標題|タイトル)\s*[::]\s*/i, "");
23810
+ s = s.replace(/^[\s"'`「『《【〈“‘]+/, "").replace(/[\s"'`」』》】〉”’]+$/, "");
23811
+ s = s.replace(/[\s.。!!??,,;;::]+$/, "");
23812
+ s = s.replace(/\s+/g, " ").trim();
23813
+ if (!s) return null;
23814
+ if (REJECT_PHRASES.has(s.toLowerCase())) return null;
23815
+ const cps = Array.from(s);
23816
+ if (cps.length > MAX_TITLE_CHARS) {
23817
+ s = cps.slice(0, MAX_TITLE_CHARS).join("");
23818
+ }
23819
+ return s;
23820
+ }
23821
+
23117
23822
  // src/routes/rooms.ts
23118
23823
  async function runAutoPickAndSeat(roomId, subject) {
23119
23824
  const candidates = listAgents().filter((a) => a.roleKind === "director");
@@ -23233,7 +23938,10 @@ function roomsRouter() {
23233
23938
  );
23234
23939
  }
23235
23940
  }
23236
- const name = typeof b.name === "string" && b.name.trim() ? b.name.trim().slice(0, 80) : subject.slice(0, 60);
23941
+ const rawName = typeof b.name === "string" ? b.name.trim() : "";
23942
+ const hasExplicitName = rawName.length > 0;
23943
+ const name = hasExplicitName ? rawName.slice(0, 80) : subject.slice(0, 60);
23944
+ const nameAuto = !hasExplicitName;
23237
23945
  const ALLOWED_MODES = /* @__PURE__ */ new Set(["brainstorm", "constructive", "research", "debate", "critique"]);
23238
23946
  const ALLOWED_INTENSITY = /* @__PURE__ */ new Set(["calm", "sharp", "terse", "brutal"]);
23239
23947
  const normalizeIntensityValue = (v) => v === "brutal" ? "terse" : v;
@@ -23256,7 +23964,8 @@ function roomsRouter() {
23256
23964
  deliveryMode,
23257
23965
  agentIds,
23258
23966
  parentRoomId,
23259
- parentBriefId
23967
+ parentBriefId,
23968
+ nameAuto
23260
23969
  });
23261
23970
  insertConfigEvent({
23262
23971
  roomId: room.id,
@@ -23282,6 +23991,25 @@ function roomsRouter() {
23282
23991
  createdAt: opening.createdAt
23283
23992
  });
23284
23993
  roomBus.emit(room.id, { type: "message-final", messageId: opening.id });
23994
+ generateRoomTitle(room.id).then((result) => {
23995
+ if (result.kind === "ok") {
23996
+ process.stderr.write(
23997
+ `[room-title] room=${room.id} renamed "${result.before.slice(0, 40)}" \u2192 "${result.after}"
23998
+ `
23999
+ );
24000
+ } else {
24001
+ const tail = result.detail ? ` \xB7 detail=${result.detail.slice(0, 100)}` : "";
24002
+ process.stderr.write(
24003
+ `[room-title] room=${room.id} skipped \xB7 reason=${result.reason}${tail}
24004
+ `
24005
+ );
24006
+ }
24007
+ }).catch((e) => {
24008
+ process.stderr.write(
24009
+ `[room-title] room=${room.id} threw: ${e instanceof Error ? e.message : String(e)}
24010
+ `
24011
+ );
24012
+ });
23285
24013
  setAwaitingClarify(room.id, true);
23286
24014
  if (mode === "research" && !hasWebSearchKey()) {
23287
24015
  const langGuess = /[一-鿿]/.test(subject) ? "zh" : "en";
@@ -24124,10 +24852,122 @@ function buildRoomExportMarkdown(opts) {
24124
24852
  return [...headerLines, ...transcriptLines, ...briefsLines].join("\n") + "\n";
24125
24853
  }
24126
24854
 
24127
- // src/routes/search.ts
24855
+ // src/routes/search-credentials.ts
24128
24856
  import { Hono as Hono10 } from "hono";
24129
- function searchRouter() {
24857
+ function payloadFor2(meta, activeId) {
24858
+ return {
24859
+ id: meta.id,
24860
+ provider: meta.provider,
24861
+ label: meta.label,
24862
+ preview: meta.preview,
24863
+ createdAt: meta.createdAt,
24864
+ updatedAt: meta.updatedAt,
24865
+ isActive: meta.id === activeId
24866
+ };
24867
+ }
24868
+ function pickNextActiveSearchId(removedProvider) {
24869
+ const all = listSearchCredentials();
24870
+ if (all.length === 0) return null;
24871
+ if (removedProvider) {
24872
+ const sameProvider = all.filter((c) => c.provider === removedProvider);
24873
+ if (sameProvider.length > 0) {
24874
+ sameProvider.sort((a, b) => a.createdAt - b.createdAt);
24875
+ return sameProvider[0].id;
24876
+ }
24877
+ }
24878
+ const sorted = all.slice().sort((a, b) => {
24879
+ const ai = SEARCH_PROVIDER_PRIORITY.indexOf(a.provider);
24880
+ const bi = SEARCH_PROVIDER_PRIORITY.indexOf(b.provider);
24881
+ if (ai !== bi) return ai - bi;
24882
+ return a.createdAt - b.createdAt;
24883
+ });
24884
+ return sorted[0]?.id ?? null;
24885
+ }
24886
+ function searchCredentialsRouter() {
24130
24887
  const r = new Hono10();
24888
+ r.get("/", (c) => {
24889
+ const activeId = getPrefs().activeSearchCredentialId;
24890
+ const items = listSearchCredentials().map((m) => payloadFor2(m, activeId));
24891
+ return c.json({
24892
+ credentials: items,
24893
+ activeId
24894
+ });
24895
+ });
24896
+ r.put("/active", async (c) => {
24897
+ let body;
24898
+ try {
24899
+ body = await c.req.json();
24900
+ } catch {
24901
+ return c.json({ error: "invalid JSON body" }, 400);
24902
+ }
24903
+ const rawId = body?.id;
24904
+ let nextId;
24905
+ if (rawId === null || rawId === void 0) {
24906
+ nextId = null;
24907
+ } else if (typeof rawId === "string") {
24908
+ nextId = rawId;
24909
+ } else {
24910
+ return c.json({ error: "id must be a string or null" }, 400);
24911
+ }
24912
+ if (nextId) {
24913
+ const meta = getSearchCredentialMeta(nextId);
24914
+ if (!meta) return c.json({ error: "credential not found" }, 404);
24915
+ updatePrefs({ activeSearchCredentialId: nextId });
24916
+ } else {
24917
+ updatePrefs({ activeSearchCredentialId: null });
24918
+ }
24919
+ return c.json({ activeId: nextId });
24920
+ });
24921
+ r.post("/", async (c) => {
24922
+ let body;
24923
+ try {
24924
+ body = await c.req.json();
24925
+ } catch {
24926
+ return c.json({ error: "invalid JSON body" }, 400);
24927
+ }
24928
+ const provider = body?.provider;
24929
+ const labelRaw = body?.label;
24930
+ const key = body?.key;
24931
+ if (typeof provider !== "string" || !isSearchProvider(provider)) {
24932
+ return c.json({ error: "provider must be 'brave' or 'tavily'" }, 400);
24933
+ }
24934
+ if (typeof key !== "string" || key.trim().length === 0) {
24935
+ return c.json({ error: "key must be a non-empty string" }, 400);
24936
+ }
24937
+ const label = typeof labelRaw === "string" ? labelRaw : null;
24938
+ const meta = createSearchCredential(provider, label, key);
24939
+ if (!meta) return c.json({ error: "failed to create credential" }, 500);
24940
+ const hadActive = !!getPrefs().activeSearchCredentialId;
24941
+ if (!hadActive) {
24942
+ updatePrefs({ activeSearchCredentialId: meta.id });
24943
+ }
24944
+ const activeId = getPrefs().activeSearchCredentialId;
24945
+ return c.json(payloadFor2(meta, activeId), 201);
24946
+ });
24947
+ r.delete("/:id", (c) => {
24948
+ const id = c.req.param("id");
24949
+ const meta = getSearchCredentialMeta(id);
24950
+ if (!meta) return c.json({ error: "credential not found" }, 404);
24951
+ const prefs = getPrefs();
24952
+ const wasActive = prefs.activeSearchCredentialId === id;
24953
+ const removedProvider = deleteSearchCredential(id);
24954
+ if (wasActive) {
24955
+ const nextId = pickNextActiveSearchId(removedProvider);
24956
+ updatePrefs({ activeSearchCredentialId: nextId });
24957
+ }
24958
+ return c.json({
24959
+ id,
24960
+ deleted: true,
24961
+ activeId: getPrefs().activeSearchCredentialId
24962
+ });
24963
+ });
24964
+ return r;
24965
+ }
24966
+
24967
+ // src/routes/search.ts
24968
+ import { Hono as Hono11 } from "hono";
24969
+ function searchRouter() {
24970
+ const r = new Hono11();
24131
24971
  r.get("/", (c) => {
24132
24972
  const q = (c.req.query("q") || "").trim();
24133
24973
  if (q.length < 1) {
@@ -24166,7 +25006,7 @@ function searchRouter() {
24166
25006
  }
24167
25007
 
24168
25008
  // src/routes/usage.ts
24169
- import { Hono as Hono11 } from "hono";
25009
+ import { Hono as Hono12 } from "hono";
24170
25010
  function modelDisplay(modelV) {
24171
25011
  if (isModelV(modelV)) {
24172
25012
  const m = MODELS[modelV];
@@ -24175,7 +25015,7 @@ function modelDisplay(modelV) {
24175
25015
  return { displayName: modelV, provider: "unknown" };
24176
25016
  }
24177
25017
  function usageRouter() {
24178
- const r = new Hono11();
25018
+ const r = new Hono12();
24179
25019
  r.get("/summary", (c) => {
24180
25020
  const s = getUsageSummary();
24181
25021
  return c.json({
@@ -24224,8 +25064,293 @@ function usageRouter() {
24224
25064
  return r;
24225
25065
  }
24226
25066
 
25067
+ // src/routes/voice-credentials.ts
25068
+ import { Hono as Hono13 } from "hono";
25069
+
25070
+ // src/storage/reconcile-voices.ts
25071
+ var MINIMAX_SEED_VOICES = [
25072
+ { provider: "minimax", model: "speech-2.8-hd", voiceId: "male-qn-qingse" },
25073
+ { provider: "minimax", model: "speech-2.8-hd", voiceId: "female-shaonv" },
25074
+ { provider: "minimax", model: "speech-2.8-hd", voiceId: "female-yujie" },
25075
+ { provider: "minimax", model: "speech-2.8-hd", voiceId: "male-qn-jingying" },
25076
+ { provider: "minimax", model: "speech-2.8-hd", voiceId: "female-chengshu" },
25077
+ { provider: "minimax", model: "speech-2.8-hd", voiceId: "female-tianmei" }
25078
+ ];
25079
+ var ELEVENLABS_SEED_VOICES = [
25080
+ { provider: "elevenlabs", model: "eleven_multilingual_v2", voiceId: "21m00Tcm4TlvDq8ikWAM" },
25081
+ { provider: "elevenlabs", model: "eleven_multilingual_v2", voiceId: "JBFqnCBsd6RMkjVDRZzb" }
25082
+ ];
25083
+ function shuffle(arr) {
25084
+ for (let i = arr.length - 1; i > 0; i--) {
25085
+ const j = Math.floor(Math.random() * (i + 1));
25086
+ [arr[i], arr[j]] = [arr[j], arr[i]];
25087
+ }
25088
+ return arr;
25089
+ }
25090
+ function snapshotPrior(agent, priorProvider, targetProvider) {
25091
+ if (!priorProvider) return;
25092
+ if (priorProvider === targetProvider) return;
25093
+ if (!agent.voice) return;
25094
+ if (agent.voice.provider !== priorProvider) return;
25095
+ try {
25096
+ writeVoiceBucketEntry(agent.id, priorProvider, agent.voice);
25097
+ } catch (e) {
25098
+ process.stderr.write(
25099
+ `[reconcile-voices] snapshot failed for ${agent.id}: ${e instanceof Error ? e.message : String(e)}
25100
+ `
25101
+ );
25102
+ }
25103
+ }
25104
+ function reconcileAgentVoices(opts) {
25105
+ const targetProvider = getActiveVoiceProvider();
25106
+ const priorProvider = opts.priorProvider ?? null;
25107
+ const agents = listAllAgents();
25108
+ if (agents.length === 0) {
25109
+ return { changed: 0, cleared: 0, reason: opts.reason, toProvider: targetProvider };
25110
+ }
25111
+ if (!targetProvider) {
25112
+ let cleared = 0;
25113
+ for (const a of agents) {
25114
+ snapshotPrior(a, priorProvider, null);
25115
+ if (a.voice) {
25116
+ try {
25117
+ updateAgent(a.id, { voice: null });
25118
+ cleared++;
25119
+ } catch (e) {
25120
+ process.stderr.write(
25121
+ `[reconcile-voices] clear failed for ${a.id}: ${e instanceof Error ? e.message : String(e)}
25122
+ `
25123
+ );
25124
+ }
25125
+ }
25126
+ }
25127
+ process.stderr.write(`[reconcile-voices] reason=${opts.reason} toProvider=null cleared=${cleared}
25128
+ `);
25129
+ return { changed: 0, cleared, reason: opts.reason, toProvider: null };
25130
+ }
25131
+ const pool = targetProvider === "minimax" ? MINIMAX_SEED_VOICES : targetProvider === "elevenlabs" ? ELEVENLABS_SEED_VOICES : [];
25132
+ if (pool.length === 0) {
25133
+ process.stderr.write(`[reconcile-voices] reason=${opts.reason} toProvider=${targetProvider} no-pool \xB7 skipped
25134
+ `);
25135
+ return { changed: 0, cleared: 0, reason: opts.reason, toProvider: targetProvider };
25136
+ }
25137
+ const shuffled = shuffle([...pool]);
25138
+ let changed = 0;
25139
+ const targetVp = targetProvider;
25140
+ for (let i = 0; i < agents.length; i++) {
25141
+ const a = agents[i];
25142
+ snapshotPrior(a, priorProvider, targetVp);
25143
+ const bucket = getVoiceBucket(a.id);
25144
+ const memorised = bucket[targetVp];
25145
+ const prev = a.voice;
25146
+ let profile;
25147
+ if (memorised) {
25148
+ profile = {
25149
+ provider: memorised.provider,
25150
+ model: memorised.model,
25151
+ voiceId: memorised.voiceId,
25152
+ ...prev?.speed !== void 0 ? { speed: prev.speed } : memorised.speed !== void 0 ? { speed: memorised.speed } : {},
25153
+ ...prev?.pitch !== void 0 ? { pitch: prev.pitch } : memorised.pitch !== void 0 ? { pitch: memorised.pitch } : {},
25154
+ ...prev?.volume !== void 0 ? { volume: prev.volume } : memorised.volume !== void 0 ? { volume: memorised.volume } : {},
25155
+ ...prev?.emotion !== void 0 ? { emotion: prev.emotion } : memorised.emotion !== void 0 ? { emotion: memorised.emotion } : {},
25156
+ ...memorised.modifyPitch !== void 0 ? { modifyPitch: memorised.modifyPitch } : {},
25157
+ ...memorised.modifyIntensity !== void 0 ? { modifyIntensity: memorised.modifyIntensity } : {},
25158
+ ...memorised.modifyTimbre !== void 0 ? { modifyTimbre: memorised.modifyTimbre } : {},
25159
+ ...memorised.instructions !== void 0 ? { instructions: memorised.instructions } : {}
25160
+ };
25161
+ } else {
25162
+ if (opts.reason === "first-key" && a.voice && a.voice.provider === targetVp) {
25163
+ if (!bucket[targetVp]) {
25164
+ try {
25165
+ writeVoiceBucketEntry(a.id, targetVp, a.voice);
25166
+ } catch (e) {
25167
+ process.stderr.write(
25168
+ `[reconcile-voices] seed failed for ${a.id}: ${e instanceof Error ? e.message : String(e)}
25169
+ `
25170
+ );
25171
+ }
25172
+ }
25173
+ continue;
25174
+ }
25175
+ const pick = shuffled[i % shuffled.length];
25176
+ profile = {
25177
+ provider: pick.provider,
25178
+ model: pick.model,
25179
+ voiceId: pick.voiceId,
25180
+ ...prev?.speed !== void 0 ? { speed: prev.speed } : {},
25181
+ ...prev?.pitch !== void 0 ? { pitch: prev.pitch } : {},
25182
+ ...prev?.volume !== void 0 ? { volume: prev.volume } : {},
25183
+ ...prev?.emotion !== void 0 ? { emotion: prev.emotion } : {}
25184
+ };
25185
+ }
25186
+ try {
25187
+ updateAgent(a.id, { voice: profile });
25188
+ writeVoiceBucketEntry(a.id, targetVp, profile);
25189
+ changed++;
25190
+ } catch (e) {
25191
+ process.stderr.write(
25192
+ `[reconcile-voices] update failed for ${a.id}: ${e instanceof Error ? e.message : String(e)}
25193
+ `
25194
+ );
25195
+ }
25196
+ }
25197
+ process.stderr.write(
25198
+ `[reconcile-voices] reason=${opts.reason} toProvider=${targetProvider} changed=${changed}
25199
+ `
25200
+ );
25201
+ return { changed, cleared: 0, reason: opts.reason, toProvider: targetProvider };
25202
+ }
25203
+
25204
+ // src/routes/voice-credentials.ts
25205
+ function payloadFor3(meta, activeId) {
25206
+ return {
25207
+ id: meta.id,
25208
+ provider: meta.provider,
25209
+ label: meta.label,
25210
+ preview: meta.preview,
25211
+ createdAt: meta.createdAt,
25212
+ updatedAt: meta.updatedAt,
25213
+ isActive: meta.id === activeId
25214
+ };
25215
+ }
25216
+ function pickNextActiveVoiceId(removedProvider) {
25217
+ const all = listVoiceCredentials();
25218
+ if (all.length === 0) return null;
25219
+ if (removedProvider) {
25220
+ const sameProvider = all.filter((c) => c.provider === removedProvider);
25221
+ if (sameProvider.length > 0) {
25222
+ sameProvider.sort((a, b) => a.createdAt - b.createdAt);
25223
+ return sameProvider[0].id;
25224
+ }
25225
+ }
25226
+ const sorted = all.slice().sort((a, b) => {
25227
+ const ai = VOICE_PROVIDER_PRIORITY.indexOf(a.provider);
25228
+ const bi = VOICE_PROVIDER_PRIORITY.indexOf(b.provider);
25229
+ if (ai !== bi) return ai - bi;
25230
+ return a.createdAt - b.createdAt;
25231
+ });
25232
+ return sorted[0]?.id ?? null;
25233
+ }
25234
+ function voiceCredentialsRouter() {
25235
+ const r = new Hono13();
25236
+ r.get("/", (c) => {
25237
+ const activeId = getPrefs().activeVoiceCredentialId;
25238
+ const items = listVoiceCredentials().map((m) => payloadFor3(m, activeId));
25239
+ return c.json({
25240
+ credentials: items,
25241
+ activeId
25242
+ });
25243
+ });
25244
+ r.put("/active", async (c) => {
25245
+ let body;
25246
+ try {
25247
+ body = await c.req.json();
25248
+ } catch {
25249
+ return c.json({ error: "invalid JSON body" }, 400);
25250
+ }
25251
+ const rawId = body?.id;
25252
+ let nextId;
25253
+ if (rawId === null || rawId === void 0) {
25254
+ nextId = null;
25255
+ } else if (typeof rawId === "string") {
25256
+ nextId = rawId;
25257
+ } else {
25258
+ return c.json({ error: "id must be a string or null" }, 400);
25259
+ }
25260
+ const prefs = getPrefs();
25261
+ const priorActiveId = prefs.activeVoiceCredentialId;
25262
+ const priorProvider = priorActiveId ? getVoiceCredentialMeta(priorActiveId)?.provider ?? null : null;
25263
+ let nextProvider = null;
25264
+ if (nextId) {
25265
+ const meta = getVoiceCredentialMeta(nextId);
25266
+ if (!meta) return c.json({ error: "credential not found" }, 404);
25267
+ nextProvider = meta.provider;
25268
+ updatePrefs({ activeVoiceCredentialId: nextId });
25269
+ } else {
25270
+ updatePrefs({ activeVoiceCredentialId: null });
25271
+ }
25272
+ if (priorProvider !== nextProvider) {
25273
+ try {
25274
+ reconcileAgentVoices({ reason: "provider-switch", priorProvider });
25275
+ } catch (e) {
25276
+ process.stderr.write(
25277
+ `[voice-credentials.active] reconcile failed: ${e instanceof Error ? e.message : String(e)}
25278
+ `
25279
+ );
25280
+ }
25281
+ }
25282
+ return c.json({ activeId: nextId });
25283
+ });
25284
+ r.post("/", async (c) => {
25285
+ let body;
25286
+ try {
25287
+ body = await c.req.json();
25288
+ } catch {
25289
+ return c.json({ error: "invalid JSON body" }, 400);
25290
+ }
25291
+ const provider = body?.provider;
25292
+ const labelRaw = body?.label;
25293
+ const key = body?.key;
25294
+ if (typeof provider !== "string" || !isVoiceProvider(provider)) {
25295
+ return c.json({ error: "provider must be 'minimax' or 'elevenlabs'" }, 400);
25296
+ }
25297
+ if (typeof key !== "string" || key.trim().length === 0) {
25298
+ return c.json({ error: "key must be a non-empty string" }, 400);
25299
+ }
25300
+ const label = typeof labelRaw === "string" ? labelRaw : null;
25301
+ const meta = createVoiceCredential(provider, label, key);
25302
+ if (!meta) return c.json({ error: "failed to create credential" }, 500);
25303
+ const hadActive = !!getPrefs().activeVoiceCredentialId;
25304
+ if (!hadActive) {
25305
+ updatePrefs({ activeVoiceCredentialId: meta.id });
25306
+ try {
25307
+ reconcileAgentVoices({ reason: "first-key", priorProvider: null });
25308
+ } catch (e) {
25309
+ process.stderr.write(
25310
+ `[voice-credentials.post] reconcile failed: ${e instanceof Error ? e.message : String(e)}
25311
+ `
25312
+ );
25313
+ }
25314
+ }
25315
+ const activeId = getPrefs().activeVoiceCredentialId;
25316
+ return c.json(payloadFor3(meta, activeId), 201);
25317
+ });
25318
+ r.delete("/:id", (c) => {
25319
+ const id = c.req.param("id");
25320
+ const meta = getVoiceCredentialMeta(id);
25321
+ if (!meta) return c.json({ error: "credential not found" }, 404);
25322
+ const prefs = getPrefs();
25323
+ const wasActive = prefs.activeVoiceCredentialId === id;
25324
+ const removedProvider = deleteVoiceCredential(id);
25325
+ let reshuffled = false;
25326
+ if (wasActive) {
25327
+ const nextId = pickNextActiveVoiceId(removedProvider);
25328
+ updatePrefs({ activeVoiceCredentialId: nextId });
25329
+ const nextProvider = nextId ? getVoiceCredentialMeta(nextId)?.provider ?? null : null;
25330
+ if (nextProvider !== removedProvider) {
25331
+ try {
25332
+ reconcileAgentVoices({ reason: "provider-switch", priorProvider: removedProvider });
25333
+ reshuffled = true;
25334
+ } catch (e) {
25335
+ process.stderr.write(
25336
+ `[voice-credentials.delete] reconcile failed: ${e instanceof Error ? e.message : String(e)}
25337
+ `
25338
+ );
25339
+ }
25340
+ }
25341
+ }
25342
+ return c.json({
25343
+ id,
25344
+ deleted: true,
25345
+ activeId: getPrefs().activeVoiceCredentialId,
25346
+ reshuffled
25347
+ });
25348
+ });
25349
+ return r;
25350
+ }
25351
+
24227
25352
  // src/routes/voices.ts
24228
- import { Hono as Hono12 } from "hono";
25353
+ import { Hono as Hono14 } from "hono";
24229
25354
  function ttsErrorMessage(e, providerLabel) {
24230
25355
  if (!(e instanceof Error)) return String(e);
24231
25356
  const cause = e.cause;
@@ -24270,8 +25395,15 @@ function ttsCacheSet(key, val) {
24270
25395
  }
24271
25396
  }
24272
25397
  function voicesRouter() {
24273
- const r = new Hono12();
24274
- r.get("/", async (c) => c.json({ voices: await listAvailableVoices() }));
25398
+ const r = new Hono14();
25399
+ r.get("/", async (c) => {
25400
+ const catalog = await listAvailableVoices();
25401
+ return c.json({
25402
+ voices: catalog.voices,
25403
+ provider: catalog.provider,
25404
+ configured: catalog.configured
25405
+ });
25406
+ });
24275
25407
  r.get("/message/:id/audio", (c) => {
24276
25408
  const messageId = c.req.param("id");
24277
25409
  const row = getUsableMessageVoice(messageId);
@@ -24411,7 +25543,7 @@ function voicesRouter() {
24411
25543
  init_paths();
24412
25544
 
24413
25545
  // src/version.ts
24414
- var VERSION = "0.1.29";
25546
+ var VERSION = "0.1.32";
24415
25547
 
24416
25548
  // src/utils/render-picker-catalog.ts
24417
25549
  function renderPickerCatalog() {
@@ -24423,7 +25555,7 @@ function renderPickerCatalog() {
24423
25555
 
24424
25556
  // src/server.ts
24425
25557
  function createApp() {
24426
- const app = new Hono13();
25558
+ const app = new Hono15();
24427
25559
  const dir = publicDir();
24428
25560
  if (!existsSync2(dir)) {
24429
25561
  throw new Error(
@@ -24471,7 +25603,9 @@ Build the package or check that public/ is bundled alongside dist/.`
24471
25603
  app.route("/api/avatar", avatarRouter());
24472
25604
  app.route("/api/usage", usageRouter());
24473
25605
  app.route("/api/voices", voicesRouter());
25606
+ app.route("/api/voice-credentials", voiceCredentialsRouter());
24474
25607
  app.route("/api/search", searchRouter());
25608
+ app.route("/api/search-credentials", searchCredentialsRouter());
24475
25609
  app.use(
24476
25610
  "/*",
24477
25611
  serveStatic({
@@ -24492,6 +25626,10 @@ async function startServer(opts) {
24492
25626
  url: `http://${host}:${opts.port}`,
24493
25627
  close: () => new Promise((resolve2, reject) => {
24494
25628
  server.close((err2) => err2 ? reject(err2) : resolve2());
25629
+ const maybeForceClose = server.closeAllConnections;
25630
+ if (typeof maybeForceClose === "function") {
25631
+ maybeForceClose.call(server);
25632
+ }
24495
25633
  })
24496
25634
  };
24497
25635
  }