privateboard 0.1.24 → 0.1.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/boot.js CHANGED
@@ -716,6 +716,61 @@ var init_remap_kimi_k2_5_to_k2_6 = __esm({
716
716
  }
717
717
  });
718
718
 
719
+ // src/storage/migrations/041_one_active_llm_key.sql
720
+ var one_active_llm_key_default;
721
+ var init_one_active_llm_key = __esm({
722
+ "src/storage/migrations/041_one_active_llm_key.sql"() {
723
+ one_active_llm_key_default = `-- 041_one_active_llm_key.sql
724
+ --
725
+ -- \u26A0\uFE0F DEPRECATED \xB7 superseded by 042_active_llm_provider_pref.sql
726
+ --
727
+ -- This migration originally collapsed multi-key LLM configurations
728
+ -- down to one row by deleting all but the highest-priority. The
729
+ -- design moved on: under the "multi-SIM" model (see migration 042)
730
+ -- users keep MULTIPLE LLM provider keys on file, with a single
731
+ -- \`prefs.active_llm_provider\` field flagging which one is currently
732
+ -- routed through. Destructive deletion was the wrong tool \u2014 users
733
+ -- want to switch between configured providers without re-pasting.
734
+ --
735
+ -- This file is kept as a no-op so the MIGRATIONS array indices stay
736
+ -- stable and so any installation that already recorded "041 applied"
737
+ -- (the developer who first ran the destructive version on a local
738
+ -- db) doesn't try to re-run anything. SQLite's _migrations table is
739
+ -- keyed by name, not content; the recorded run blocks future
740
+ -- executions regardless of what the SQL says here.
741
+ --
742
+ -- New installs of v0.1.25+ see the no-op below, so users upgrading
743
+ -- with multiple LLM keys configured under v0.1.24 don't lose data.
744
+
745
+ SELECT 1;
746
+ `;
747
+ }
748
+ });
749
+
750
+ // src/storage/migrations/042_active_llm_provider_pref.sql
751
+ var active_llm_provider_pref_default;
752
+ var init_active_llm_provider_pref = __esm({
753
+ "src/storage/migrations/042_active_llm_provider_pref.sql"() {
754
+ active_llm_provider_pref_default = "-- 042_active_llm_provider_pref.sql\n--\n-- Multi-SIM LLM provider model \xB7 users keep multiple LLM keys on file\n-- and flag exactly one as the active routing carrier. This column\n-- replaces the destructive-collapse approach 041 attempted; key rows\n-- are preserved, the user switches by changing this pref value.\n--\n-- Seed value \xB7 the highest-priority LLM key currently configured at\n-- migration time (matches the ordering used by `LLM_PROVIDER_PRIORITY`\n-- in `src/ai/providers.ts`). NULL when no LLM key exists. The runtime\n-- still derives `defaultModelV` from this carrier; reconcile sweeps\n-- agents accordingly.\n--\n-- Future addition of LLM providers: this migration's seed is one-shot\n-- and only matters for existing users. New installs hit the empty\n-- column and walk through onboarding to set their first provider as\n-- active.\n\nALTER TABLE prefs ADD COLUMN active_llm_provider TEXT;\n\n-- Seed from existing key set \xB7 pick the highest-priority configured\n-- LLM provider (openrouter > bai > anthropic > openai > google > xai).\n-- The seed is a one-shot UPDATE that only matches rows where the new\n-- column is still NULL (= every existing prefs row).\nUPDATE prefs\n SET active_llm_provider = (\n SELECT provider\n FROM provider_keys\n WHERE provider IN ('openrouter','bai','anthropic','openai','google','xai')\n AND length(key_blob) > 0\n ORDER BY CASE provider\n WHEN 'openrouter' THEN 1\n WHEN 'bai' THEN 2\n WHEN 'anthropic' THEN 3\n WHEN 'openai' THEN 4\n WHEN 'google' THEN 5\n WHEN 'xai' THEN 6\n END\n LIMIT 1\n )\n WHERE active_llm_provider IS NULL;\n";
755
+ }
756
+ });
757
+
758
+ // src/storage/migrations/043_llm_credentials.sql
759
+ var llm_credentials_default;
760
+ var init_llm_credentials = __esm({
761
+ "src/storage/migrations/043_llm_credentials.sql"() {
762
+ llm_credentials_default = "-- 043_llm_credentials.sql\n--\n-- Multi-instance LLM provider credentials \xB7 the same provider can now\n-- be added more than once (e.g. two separate OpenRouter accounts, or\n-- a personal + team B.AI key). The legacy `provider_keys` table is\n-- keyed by `provider` (one row per provider), which doesn't support\n-- this. We introduce a new table keyed by an opaque `id` so the same\n-- provider can appear multiple times with distinct user-supplied\n-- labels.\n--\n-- Voice (minimax, elevenlabs) and skill (brave, tavily) keys stay in\n-- `provider_keys` \u2014 they have no use case for multiple credentials\n-- per provider and the existing routes work fine for them.\n--\n-- `prefs.active_llm_credential_id` replaces the brief-lived\n-- `prefs.active_llm_provider` from migration 042. Switching is now a\n-- one-write change to this id; the old column stays in place (NULL\n-- after this migration) to avoid an ALTER\u2026DROP COLUMN that requires\n-- table rebuilds on older SQLite.\n\nCREATE TABLE IF NOT EXISTS llm_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 llm_credentials_provider_idx\n ON llm_credentials(provider);\n\nALTER TABLE prefs ADD COLUMN active_llm_credential_id TEXT;\n\n-- One-shot migration \xB7 for every configured LLM row in provider_keys,\n-- create a credential row carrying the same encrypted key_blob. The\n-- generated id uses a 12-char base64 slice of randomblob() for\n-- collision-resistance; labels default to the provider's display\n-- name (frontend will surface them under that label until the user\n-- renames). Empty key_blobs are skipped (legacy \"ever-configured\"\n-- rows that have since been emptied).\nINSERT INTO llm_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 'openrouter' THEN 'OpenRouter'\n WHEN 'bai' THEN 'B.AI'\n WHEN 'anthropic' THEN 'Claude'\n WHEN 'openai' THEN 'ChatGPT'\n WHEN 'google' THEN 'Gemini'\n WHEN 'xai' THEN 'Grok'\n WHEN 'deepseek' THEN 'DeepSeek'\n ELSE provider\n END AS label,\n key_blob,\n created_at,\n updated_at\nFROM provider_keys\nWHERE provider IN ('openrouter','bai','anthropic','openai','google','xai','deepseek')\n AND length(key_blob) > 0;\n\n-- Seed prefs.active_llm_credential_id with the highest-priority\n-- migrated credential. Priority mirrors LLM_PROVIDER_PRIORITY in\n-- src/ai/providers.ts.\nUPDATE prefs\n SET active_llm_credential_id = (\n SELECT id FROM llm_credentials\n ORDER BY CASE provider\n WHEN 'openrouter' THEN 1\n WHEN 'bai' THEN 2\n WHEN 'anthropic' THEN 3\n WHEN 'openai' THEN 4\n WHEN 'google' THEN 5\n WHEN 'xai' THEN 6\n WHEN 'deepseek' THEN 7\n END,\n created_at ASC\n LIMIT 1\n )\n WHERE id = 1\n AND active_llm_credential_id IS NULL;\n\n-- Clear the now-redundant active_llm_provider \xB7 the credential id is\n-- the new source of truth. The column stays in the schema (avoids a\n-- table rebuild) but post-migration it's always NULL; any reader\n-- that still consults it sees \"no active\" and falls back to the\n-- credential lookup.\nUPDATE prefs SET active_llm_provider = NULL WHERE id = 1;\n\n-- Finally \xB7 remove the migrated LLM rows from provider_keys so the\n-- table is purely voice + skill from here on. Cleared by name; an\n-- explicit IN() list matches what we inserted above.\nDELETE FROM provider_keys\n WHERE provider IN ('openrouter','bai','anthropic','openai','google','xai','deepseek');\n";
763
+ }
764
+ });
765
+
766
+ // src/storage/migrations/044_drop_topic_recs.sql
767
+ var drop_topic_recs_default;
768
+ var init_drop_topic_recs = __esm({
769
+ "src/storage/migrations/044_drop_topic_recs.sql"() {
770
+ drop_topic_recs_default = '-- 044_drop_topic_recs.sql\n--\n-- The interest-driven topic-recommendations feature ("\u627E\u4F60\u53EF\u80FD\u611F\n-- \u5174\u8DA3\u7684\u8BDD\u9898" trigger card + the recommendation cards under the\n-- home composer) has been removed. Its replacement is a static\n-- catalog of five scenario ad cards rendered purely on the\n-- frontend (see public/app.js \xB7 SCENARIO_CARDS), so there is no\n-- need to keep the storage tables that fed the old dynamic tray.\n--\n-- Tables (and their indexes) removed:\n-- \xB7 topic_rec_jobs \xB7 async generation job tracking\n-- \xB7 topic_recs \xB7 synthesised recommendation rows\n-- \xB7 topic_rec_batches \xB7 one row per generation run\n--\n-- Drop order matters: topic_recs / topic_rec_jobs both FK into\n-- topic_rec_batches (ON DELETE CASCADE / SET NULL), so SQLite\n-- needs the child tables removed first when foreign_keys is ON.\n-- `DROP TABLE IF EXISTS` is idempotent \u2014 re-runs are a no-op.\n\nDROP TABLE IF EXISTS topic_recs;\nDROP TABLE IF EXISTS topic_rec_jobs;\nDROP TABLE IF EXISTS topic_rec_batches;\n';
771
+ }
772
+ });
773
+
719
774
  // src/storage/db.ts
720
775
  var db_exports = {};
721
776
  __export(db_exports, {
@@ -813,6 +868,10 @@ var init_db = __esm({
813
868
  init_qd_archive();
814
869
  init_remap_removed_models();
815
870
  init_remap_kimi_k2_5_to_k2_6();
871
+ init_one_active_llm_key();
872
+ init_active_llm_provider_pref();
873
+ init_llm_credentials();
874
+ init_drop_topic_recs();
816
875
  MIGRATIONS = [
817
876
  { name: "001_init.sql", sql: init_default },
818
877
  { name: "002_default_opus.sql", sql: default_opus_default },
@@ -853,7 +912,11 @@ var init_db = __esm({
853
912
  { name: "037_topic_branches.sql", sql: topic_branches_default },
854
913
  { name: "038_qd_archive.sql", sql: qd_archive_default },
855
914
  { name: "039_remap_removed_models.sql", sql: remap_removed_models_default },
856
- { name: "040_remap_kimi_k2_5_to_k2_6.sql", sql: remap_kimi_k2_5_to_k2_6_default }
915
+ { name: "040_remap_kimi_k2_5_to_k2_6.sql", sql: remap_kimi_k2_5_to_k2_6_default },
916
+ { name: "041_one_active_llm_key.sql", sql: one_active_llm_key_default },
917
+ { name: "042_active_llm_provider_pref.sql", sql: active_llm_provider_pref_default },
918
+ { name: "043_llm_credentials.sql", sql: llm_credentials_default },
919
+ { name: "044_drop_topic_recs.sql", sql: drop_topic_recs_default }
857
920
  ];
858
921
  _db = null;
859
922
  }
@@ -3343,11 +3406,158 @@ import { createOpenAI } from "@ai-sdk/openai";
3343
3406
  import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
3344
3407
  import { APICallError, streamText } from "ai";
3345
3408
 
3346
- // src/storage/keys.ts
3347
- init_db();
3409
+ // src/storage/credentials.ts
3348
3410
  import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "crypto";
3349
3411
  import { userInfo } from "os";
3350
3412
 
3413
+ // src/ai/providers.ts
3414
+ var MULTI_MODEL_LLM_PROVIDERS = [
3415
+ "openrouter",
3416
+ "bai"
3417
+ ];
3418
+ var SINGLE_MODEL_LLM_PROVIDERS = [
3419
+ "anthropic",
3420
+ "openai",
3421
+ "google",
3422
+ "xai"
3423
+ ];
3424
+ var ALL_LLM_PROVIDERS = [
3425
+ ...MULTI_MODEL_LLM_PROVIDERS,
3426
+ ...SINGLE_MODEL_LLM_PROVIDERS
3427
+ ];
3428
+ var LLM_PROVIDER_PRIORITY = [
3429
+ "openrouter",
3430
+ "bai",
3431
+ "anthropic",
3432
+ "openai",
3433
+ "google",
3434
+ "xai"
3435
+ ];
3436
+ function isMultiModelProvider(p) {
3437
+ return p === "openrouter" || p === "bai";
3438
+ }
3439
+ function isLlmProvider(p) {
3440
+ return ALL_LLM_PROVIDERS.indexOf(p) >= 0;
3441
+ }
3442
+
3443
+ // src/storage/credentials.ts
3444
+ init_db();
3445
+ var SALT = "boardroom.v1.salt";
3446
+ var ALGO = "aes-256-gcm";
3447
+ var _key = null;
3448
+ function deriveKey() {
3449
+ if (_key) return _key;
3450
+ const username = userInfo().username || "boardroom-default";
3451
+ _key = scryptSync(username, SALT, 32);
3452
+ return _key;
3453
+ }
3454
+ function encrypt(plain) {
3455
+ const iv = randomBytes(12);
3456
+ const cipher = createCipheriv(ALGO, deriveKey(), iv);
3457
+ const ct = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
3458
+ const tag = cipher.getAuthTag();
3459
+ return Buffer.concat([iv, tag, ct]);
3460
+ }
3461
+ function decrypt(blob) {
3462
+ const iv = blob.subarray(0, 12);
3463
+ const tag = blob.subarray(12, 28);
3464
+ const ct = blob.subarray(28);
3465
+ const decipher = createDecipheriv(ALGO, deriveKey(), iv);
3466
+ decipher.setAuthTag(tag);
3467
+ return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8");
3468
+ }
3469
+ function maskKey(plain) {
3470
+ const trimmed = plain.trim();
3471
+ if (!trimmed) return "";
3472
+ const n = trimmed.length;
3473
+ if (n <= 4) return "\u2022".repeat(n);
3474
+ if (n <= 12) return `${trimmed.slice(0, 2)}${"\u2022".repeat(n - 4)}${trimmed.slice(-2)}`;
3475
+ return `${trimmed.slice(0, 4)}${"\u2022".repeat(n - 8)}${trimmed.slice(-4)}`;
3476
+ }
3477
+ function rowToMeta(row) {
3478
+ if (!isLlmProvider(row.provider)) return null;
3479
+ let preview = "";
3480
+ try {
3481
+ preview = maskKey(decrypt(row.key_blob));
3482
+ } catch {
3483
+ preview = "";
3484
+ }
3485
+ return {
3486
+ id: row.id,
3487
+ provider: row.provider,
3488
+ label: row.label,
3489
+ preview,
3490
+ createdAt: row.created_at,
3491
+ updatedAt: row.updated_at
3492
+ };
3493
+ }
3494
+ function listLlmCredentials() {
3495
+ const rows = getDb().prepare("SELECT id, provider, label, key_blob, created_at, updated_at FROM llm_credentials ORDER BY created_at ASC").all();
3496
+ return rows.map(rowToMeta).filter((m) => m !== null);
3497
+ }
3498
+ function getLlmCredentialMeta(id) {
3499
+ const row = getDb().prepare("SELECT id, provider, label, key_blob, created_at, updated_at FROM llm_credentials WHERE id = ?").get(id);
3500
+ if (!row) return null;
3501
+ return rowToMeta(row);
3502
+ }
3503
+ function getLlmCredentialKey(id) {
3504
+ const row = getDb().prepare("SELECT key_blob FROM llm_credentials WHERE id = ?").get(id);
3505
+ if (!row) return null;
3506
+ try {
3507
+ return decrypt(row.key_blob);
3508
+ } catch {
3509
+ return null;
3510
+ }
3511
+ }
3512
+ function resolveFreeLabel(provider, suggested) {
3513
+ const base = suggested && suggested.trim() || providerDisplayName(provider);
3514
+ const existing = new Set(
3515
+ getDb().prepare("SELECT label FROM llm_credentials").all().map((r) => r.label)
3516
+ );
3517
+ if (!existing.has(base)) return base;
3518
+ for (let n = 2; n < 1e3; n++) {
3519
+ const candidate = `${base} ${n}`;
3520
+ if (!existing.has(candidate)) return candidate;
3521
+ }
3522
+ return `${base} ${Date.now()}`;
3523
+ }
3524
+ function providerDisplayName(provider) {
3525
+ switch (provider) {
3526
+ case "openrouter":
3527
+ return "OpenRouter";
3528
+ case "bai":
3529
+ return "B.AI";
3530
+ case "anthropic":
3531
+ return "Claude";
3532
+ case "openai":
3533
+ return "ChatGPT";
3534
+ case "google":
3535
+ return "Gemini";
3536
+ case "xai":
3537
+ return "Grok";
3538
+ }
3539
+ }
3540
+ function createLlmCredential(provider, label, plain) {
3541
+ const trimmed = plain.trim();
3542
+ if (!trimmed) return null;
3543
+ if (!ALL_LLM_PROVIDERS.includes(provider)) return null;
3544
+ const resolvedLabel = resolveFreeLabel(provider, label);
3545
+ const id = randomBytes(8).toString("hex");
3546
+ const blob = encrypt(trimmed);
3547
+ const now = Date.now();
3548
+ getDb().prepare(
3549
+ `INSERT INTO llm_credentials (id, provider, label, key_blob, created_at, updated_at)
3550
+ VALUES (?, ?, ?, ?, ?, ?)`
3551
+ ).run(id, provider, resolvedLabel, blob, now, now);
3552
+ return getLlmCredentialMeta(id);
3553
+ }
3554
+ function deleteLlmCredential(id) {
3555
+ const meta = getLlmCredentialMeta(id);
3556
+ if (!meta) return null;
3557
+ getDb().prepare("DELETE FROM llm_credentials WHERE id = ?").run(id);
3558
+ return meta.provider;
3559
+ }
3560
+
3351
3561
  // src/storage/prefs.ts
3352
3562
  init_db();
3353
3563
  function normalizeWebSearchProviderPref(raw) {
@@ -3357,6 +3567,7 @@ function normalizeMinimaxRegion(raw) {
3357
3567
  return raw === "intl" ? "intl" : "cn";
3358
3568
  }
3359
3569
  function mapRow3(row) {
3570
+ const raw = row.active_llm_provider;
3360
3571
  return {
3361
3572
  name: row.name,
3362
3573
  intro: row.intro,
@@ -3364,6 +3575,8 @@ function mapRow3(row) {
3364
3575
  defaultModelV: row.default_model_v,
3365
3576
  webSearchProvider: normalizeWebSearchProviderPref(row.web_search_provider),
3366
3577
  minimaxRegion: normalizeMinimaxRegion(row.minimax_region),
3578
+ activeLlmProvider: raw && isLlmProvider(raw) ? raw : null,
3579
+ activeLlmCredentialId: row.active_llm_credential_id,
3367
3580
  createdAt: row.created_at,
3368
3581
  updatedAt: row.updated_at
3369
3582
  };
@@ -3373,6 +3586,8 @@ function getPrefs() {
3373
3586
  `SELECT name, intro, avatar_seed, default_model_v,
3374
3587
  COALESCE(web_search_provider, 'brave') AS web_search_provider,
3375
3588
  COALESCE(minimax_region, 'cn') AS minimax_region,
3589
+ active_llm_provider,
3590
+ active_llm_credential_id,
3376
3591
  created_at, updated_at FROM prefs WHERE id = 1`
3377
3592
  ).get();
3378
3593
  if (!row) {
@@ -3407,6 +3622,14 @@ function updatePrefs(patch) {
3407
3622
  fields.push("minimax_region = ?");
3408
3623
  values.push(patch.minimaxRegion === "intl" ? "intl" : "cn");
3409
3624
  }
3625
+ if (patch.activeLlmProvider !== void 0) {
3626
+ fields.push("active_llm_provider = ?");
3627
+ values.push(patch.activeLlmProvider);
3628
+ }
3629
+ if (patch.activeLlmCredentialId !== void 0) {
3630
+ fields.push("active_llm_credential_id = ?");
3631
+ values.push(patch.activeLlmCredentialId);
3632
+ }
3410
3633
  if (fields.length === 0) return getPrefs();
3411
3634
  fields.push("updated_at = ?");
3412
3635
  values.push(Date.now());
@@ -3414,118 +3637,6 @@ function updatePrefs(patch) {
3414
3637
  return getPrefs();
3415
3638
  }
3416
3639
 
3417
- // src/storage/keys.ts
3418
- var SALT = "boardroom.v1.salt";
3419
- var ALGO = "aes-256-gcm";
3420
- var _key = null;
3421
- function deriveKey() {
3422
- if (_key) return _key;
3423
- const username = userInfo().username || "boardroom-default";
3424
- _key = scryptSync(username, SALT, 32);
3425
- return _key;
3426
- }
3427
- function encrypt(plain) {
3428
- const iv = randomBytes(12);
3429
- const cipher = createCipheriv(ALGO, deriveKey(), iv);
3430
- const ct = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
3431
- const tag = cipher.getAuthTag();
3432
- return Buffer.concat([iv, tag, ct]);
3433
- }
3434
- function decrypt(blob) {
3435
- const iv = blob.subarray(0, 12);
3436
- const tag = blob.subarray(12, 28);
3437
- const ct = blob.subarray(28);
3438
- const decipher = createDecipheriv(ALGO, deriveKey(), iv);
3439
- decipher.setAuthTag(tag);
3440
- return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8");
3441
- }
3442
- function hasBraveKey() {
3443
- const k = getKey("brave");
3444
- return typeof k === "string" && k.length > 0;
3445
- }
3446
- function hasTavilyKey() {
3447
- const k = getKey("tavily");
3448
- return typeof k === "string" && k.length > 0;
3449
- }
3450
- function hasWebSearchKey() {
3451
- return hasBraveKey() || hasTavilyKey();
3452
- }
3453
- function resolveWebSearchBackend(preference) {
3454
- const b = hasBraveKey();
3455
- const t = hasTavilyKey();
3456
- if (!b && !t) return null;
3457
- if (b && !t) return "brave";
3458
- if (!b && t) return "tavily";
3459
- if (preference === "tavily" && t) return "tavily";
3460
- if (preference === "brave" && b) return "brave";
3461
- return b ? "brave" : "tavily";
3462
- }
3463
- function getActiveWebSearchCredentials() {
3464
- const prefRaw = getPrefs().webSearchProvider;
3465
- const preference = prefRaw === "tavily" ? "tavily" : "brave";
3466
- const backend = resolveWebSearchBackend(preference);
3467
- if (!backend) return null;
3468
- const apiKey = getKey(backend);
3469
- return apiKey ? { backend, apiKey } : null;
3470
- }
3471
- function maskKey(plain) {
3472
- const trimmed = plain.trim();
3473
- if (!trimmed) return "";
3474
- const n = trimmed.length;
3475
- if (n <= 4) return "\u2022".repeat(n);
3476
- if (n <= 12) {
3477
- return `${trimmed.slice(0, 2)}${"\u2022".repeat(n - 4)}${trimmed.slice(-2)}`;
3478
- }
3479
- return `${trimmed.slice(0, 4)}${"\u2022".repeat(n - 8)}${trimmed.slice(-4)}`;
3480
- }
3481
- function listKeyMeta() {
3482
- const rows = getDb().prepare("SELECT provider, key_blob, updated_at FROM provider_keys").all();
3483
- const map = /* @__PURE__ */ new Map();
3484
- for (const r of rows) {
3485
- let preview = null;
3486
- if (r.key_blob.length > 0) {
3487
- try {
3488
- preview = maskKey(decrypt(r.key_blob));
3489
- } catch {
3490
- preview = null;
3491
- }
3492
- }
3493
- map.set(r.provider, {
3494
- provider: r.provider,
3495
- configured: r.key_blob.length > 0,
3496
- updatedAt: r.updated_at,
3497
- preview
3498
- });
3499
- }
3500
- return Array.from(map.values());
3501
- }
3502
- function getKey(provider) {
3503
- const row = getDb().prepare("SELECT key_blob FROM provider_keys WHERE provider = ?").get(provider);
3504
- if (!row) return null;
3505
- try {
3506
- return decrypt(row.key_blob);
3507
- } catch {
3508
- return null;
3509
- }
3510
- }
3511
- function setKey(provider, plain) {
3512
- const trimmed = plain.trim();
3513
- if (!trimmed) {
3514
- deleteKey(provider);
3515
- return;
3516
- }
3517
- const blob = encrypt(trimmed);
3518
- const now = Date.now();
3519
- getDb().prepare(
3520
- `INSERT INTO provider_keys (provider, key_blob, created_at, updated_at)
3521
- VALUES (?, ?, ?, ?)
3522
- ON CONFLICT(provider) DO UPDATE SET key_blob = excluded.key_blob, updated_at = excluded.updated_at`
3523
- ).run(provider, blob, now, now);
3524
- }
3525
- function deleteKey(provider) {
3526
- getDb().prepare("DELETE FROM provider_keys WHERE provider = ?").run(provider);
3527
- }
3528
-
3529
3640
  // src/ai/adapter.ts
3530
3641
  var NoKeyError = class extends Error {
3531
3642
  constructor(provider) {
@@ -3666,84 +3777,34 @@ function formatStreamError(e) {
3666
3777
  }
3667
3778
  return String(e);
3668
3779
  }
3669
- function resolveModel(modelV, carrier, excludeCarriers) {
3780
+ function resolveModel(modelV, _carrier, _excludeCarriers) {
3781
+ void _carrier;
3782
+ void _excludeCarriers;
3670
3783
  const meta = getModel(modelV);
3671
- const skip = (c) => excludeCarriers?.has(c) === true;
3672
- const orKey = !skip("openrouter") ? getKey("openrouter") : void 0;
3673
- const baiKey = !skip("bai") ? getKey("bai") : void 0;
3674
- const directKey = !skip(meta.provider) ? getKey(meta.provider) : void 0;
3675
- if (carrier === "openrouter" && orKey) {
3676
- process.stderr.write(`[adapter] modelV=${modelV} \u2192 openrouter:${meta.openrouterId} (pinned)
3677
- `);
3678
- return openRouterResolved(meta, orKey);
3679
- }
3680
- if (carrier === "bai" && baiKey && meta.baiId) {
3681
- process.stderr.write(`[adapter] modelV=${modelV} \u2192 bai:${meta.baiId} (pinned)
3682
- `);
3683
- return baiResolved(meta, baiKey);
3684
- }
3685
- if (carrier && carrier !== "openrouter" && carrier !== "bai" && carrier === meta.provider) {
3686
- const pinnedKey = !skip(carrier) ? getKey(carrier) : void 0;
3687
- if (pinnedKey) {
3688
- process.stderr.write(`[adapter] modelV=${modelV} \u2192 direct:${meta.provider}/${meta.directApiId} (pinned)
3689
- `);
3690
- return directResolved(meta, pinnedKey);
3691
- }
3692
- }
3693
- if (carrier && !excludeCarriers?.size) {
3694
- process.stderr.write(
3695
- `[adapter] modelV=${modelV} pinned carrier=${carrier} unreachable; falling back to default routing
3696
- `
3697
- );
3698
- }
3699
- if (meta.viaUniversalOnly && orKey) {
3700
- process.stderr.write(`[adapter] modelV=${modelV} \u2192 openrouter:${meta.openrouterId} (preferred)
3701
- `);
3702
- return openRouterResolved(meta, orKey);
3703
- }
3704
- if (directKey && !meta.viaUniversalOnly) {
3705
- process.stderr.write(`[adapter] modelV=${modelV} \u2192 direct:${meta.provider}/${meta.directApiId}
3784
+ const credId = getPrefs().activeLlmCredentialId;
3785
+ if (!credId) throw new NoKeyError(meta.provider);
3786
+ const credMeta = getLlmCredentialMeta(credId);
3787
+ const key = getLlmCredentialKey(credId);
3788
+ if (!credMeta || !key) throw new NoKeyError(meta.provider);
3789
+ const p = credMeta.provider;
3790
+ if (p === "openrouter") {
3791
+ if (!meta.openrouterId) throw new NoKeyError(meta.provider);
3792
+ process.stderr.write(`[adapter] modelV=${modelV} \u2192 openrouter:${meta.openrouterId} (cred:${credMeta.label})
3706
3793
  `);
3707
- return directResolved(meta, directKey);
3794
+ return openRouterResolved(meta, key);
3708
3795
  }
3709
- if (baiKey && meta.baiId) {
3710
- process.stderr.write(`[adapter] modelV=${modelV} \u2192 bai:${meta.baiId}
3796
+ if (p === "bai") {
3797
+ if (!meta.baiId) throw new NoKeyError(meta.provider);
3798
+ process.stderr.write(`[adapter] modelV=${modelV} \u2192 bai:${meta.baiId} (cred:${credMeta.label})
3711
3799
  `);
3712
- return baiResolved(meta, baiKey);
3800
+ return baiResolved(meta, key);
3713
3801
  }
3714
- if (orKey) {
3715
- process.stderr.write(`[adapter] modelV=${modelV} \u2192 openrouter:${meta.openrouterId}
3716
- `);
3717
- return openRouterResolved(meta, orKey);
3802
+ if (meta.provider !== p || meta.viaUniversalOnly) {
3803
+ throw new NoKeyError(meta.provider);
3718
3804
  }
3719
- if (meta.viaUniversalOnly && directKey) {
3720
- process.stderr.write(`[adapter] modelV=${modelV} \u2192 direct:${meta.provider}/${meta.directApiId} (viaUniversalOnly fallback \xB7 no OR / B.AI key)
3805
+ process.stderr.write(`[adapter] modelV=${modelV} \u2192 direct:${meta.provider}/${meta.directApiId} (cred:${credMeta.label})
3721
3806
  `);
3722
- return directResolved(meta, directKey);
3723
- }
3724
- throw new NoKeyError(meta.provider);
3725
- }
3726
- function isCarrierAccessDenied(message) {
3727
- if (!message) return false;
3728
- const m = message.toLowerCase();
3729
- if (/\b40[23]\b/.test(m) && /(access|deposit|payment|paid|premium|subscri|quota|balance|fund)/.test(m)) return true;
3730
- if (/access[_\s-]?denied/.test(m)) return true;
3731
- if (/deposit\s+required/.test(m)) return true;
3732
- if (/paid[_\s-]?plan[_\s-]?required/.test(m)) return true;
3733
- if (/premium[_\s-]?model/.test(m)) return true;
3734
- if (/insufficient[_\s-]?(?:quota|balance|fund)/.test(m)) return true;
3735
- if (/quota[_\s-]?exceeded/.test(m)) return true;
3736
- if (/payment[_\s-]?required/.test(m)) return true;
3737
- if (/billing/.test(m) && /(disabled|inactive|required|invalid|missing)/.test(m)) return true;
3738
- if (/model[_\s-]?not[_\s-]?found/.test(m)) return true;
3739
- if (/no\s+available\s+channel/.test(m)) return true;
3740
- if (/no\s+endpoints?\s+found/.test(m)) return true;
3741
- if (/invalid\s+model/.test(m)) return true;
3742
- if (/model.*(unavailable|not\s+(?:supported|available))/.test(m)) return true;
3743
- if (/prohibited.*(?:terms?\s+of\s+service|provider)/.test(m)) return true;
3744
- if (/provider.*terms?\s+of\s+service/.test(m)) return true;
3745
- if (/violates?\s+(?:our|the)?\s*(?:terms|policy|content)/.test(m)) return true;
3746
- return false;
3807
+ return directResolved(meta, key);
3747
3808
  }
3748
3809
  function directResolved(meta, apiKey) {
3749
3810
  switch (meta.provider) {
@@ -3875,8 +3936,6 @@ async function* callLLMStream(req) {
3875
3936
  let attempt = 0;
3876
3937
  let lastTransientMessage = "";
3877
3938
  let yieldedText = false;
3878
- const triedCarriers = /* @__PURE__ */ new Set();
3879
- triedCarriers.add(resolved.carrier);
3880
3939
  while (attempt < RETRY_MAX_ATTEMPTS) {
3881
3940
  attempt++;
3882
3941
  if (req.signal?.aborted) {
@@ -3918,21 +3977,6 @@ async function* callLLMStream(req) {
3918
3977
  retriableErrorMessage = msg;
3919
3978
  break;
3920
3979
  }
3921
- if (!yieldedText && isCarrierAccessDenied(msg)) {
3922
- try {
3923
- const next = resolveModel(req.modelV, null, triedCarriers);
3924
- triedCarriers.add(next.carrier);
3925
- process.stderr.write(
3926
- `[adapter] modelV=${req.modelV} carrier=${resolved.carrier} rejected (access denied); retrying via ${next.carrier}
3927
- `
3928
- );
3929
- resolved = next;
3930
- attempt = 0;
3931
- retriableErrorMessage = msg;
3932
- break;
3933
- } catch {
3934
- }
3935
- }
3936
3980
  sawError = true;
3937
3981
  yield { type: "error", message: msg };
3938
3982
  }
@@ -3975,20 +4019,6 @@ async function* callLLMStream(req) {
3975
4019
  lastTransientMessage = msg;
3976
4020
  continue;
3977
4021
  }
3978
- if (!yieldedText && isCarrierAccessDenied(msg)) {
3979
- try {
3980
- const next = resolveModel(req.modelV, null, triedCarriers);
3981
- triedCarriers.add(next.carrier);
3982
- process.stderr.write(
3983
- `[adapter] modelV=${req.modelV} carrier=${resolved.carrier} rejected (access denied / threw); retrying via ${next.carrier}
3984
- `
3985
- );
3986
- resolved = next;
3987
- attempt = 0;
3988
- continue;
3989
- } catch {
3990
- }
3991
- }
3992
4022
  yield { type: "error", message: msg };
3993
4023
  return;
3994
4024
  }
@@ -4025,68 +4055,147 @@ async function callLLMWithUsage(req) {
4025
4055
  return { text: buf, usage };
4026
4056
  }
4027
4057
 
4028
- // src/storage/reconcile-models.ts
4029
- var PRIMARY_BY_CARRIER = {
4030
- openrouter: "opus-4-6-fast",
4031
- bai: "haiku-4-5",
4058
+ // src/storage/keys.ts
4059
+ init_db();
4060
+ import { createCipheriv as createCipheriv2, createDecipheriv as createDecipheriv2, randomBytes as randomBytes2, scryptSync as scryptSync2 } from "crypto";
4061
+ import { userInfo as userInfo2 } from "os";
4062
+ var SALT2 = "boardroom.v1.salt";
4063
+ var ALGO2 = "aes-256-gcm";
4064
+ var _key2 = null;
4065
+ function deriveKey2() {
4066
+ if (_key2) return _key2;
4067
+ const username = userInfo2().username || "boardroom-default";
4068
+ _key2 = scryptSync2(username, SALT2, 32);
4069
+ return _key2;
4070
+ }
4071
+ function encrypt2(plain) {
4072
+ const iv = randomBytes2(12);
4073
+ const cipher = createCipheriv2(ALGO2, deriveKey2(), iv);
4074
+ const ct = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
4075
+ const tag = cipher.getAuthTag();
4076
+ return Buffer.concat([iv, tag, ct]);
4077
+ }
4078
+ function decrypt2(blob) {
4079
+ const iv = blob.subarray(0, 12);
4080
+ const tag = blob.subarray(12, 28);
4081
+ const ct = blob.subarray(28);
4082
+ const decipher = createDecipheriv2(ALGO2, deriveKey2(), iv);
4083
+ decipher.setAuthTag(tag);
4084
+ return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8");
4085
+ }
4086
+ function hasBraveKey() {
4087
+ const k = getKey("brave");
4088
+ return typeof k === "string" && k.length > 0;
4089
+ }
4090
+ function hasTavilyKey() {
4091
+ const k = getKey("tavily");
4092
+ return typeof k === "string" && k.length > 0;
4093
+ }
4094
+ function hasWebSearchKey() {
4095
+ return hasBraveKey() || hasTavilyKey();
4096
+ }
4097
+ function resolveWebSearchBackend(preference) {
4098
+ const b = hasBraveKey();
4099
+ const t = hasTavilyKey();
4100
+ if (!b && !t) return null;
4101
+ if (b && !t) return "brave";
4102
+ if (!b && t) return "tavily";
4103
+ if (preference === "tavily" && t) return "tavily";
4104
+ if (preference === "brave" && b) return "brave";
4105
+ return b ? "brave" : "tavily";
4106
+ }
4107
+ function getActiveWebSearchCredentials() {
4108
+ const prefRaw = getPrefs().webSearchProvider;
4109
+ const preference = prefRaw === "tavily" ? "tavily" : "brave";
4110
+ const backend = resolveWebSearchBackend(preference);
4111
+ if (!backend) return null;
4112
+ const apiKey = getKey(backend);
4113
+ return apiKey ? { backend, apiKey } : null;
4114
+ }
4115
+ function maskKey2(plain) {
4116
+ const trimmed = plain.trim();
4117
+ if (!trimmed) return "";
4118
+ const n = trimmed.length;
4119
+ if (n <= 4) return "\u2022".repeat(n);
4120
+ if (n <= 12) {
4121
+ return `${trimmed.slice(0, 2)}${"\u2022".repeat(n - 4)}${trimmed.slice(-2)}`;
4122
+ }
4123
+ return `${trimmed.slice(0, 4)}${"\u2022".repeat(n - 8)}${trimmed.slice(-4)}`;
4124
+ }
4125
+ function listKeyMeta() {
4126
+ const rows = getDb().prepare("SELECT provider, key_blob, updated_at FROM provider_keys").all();
4127
+ const map = /* @__PURE__ */ new Map();
4128
+ for (const r of rows) {
4129
+ let preview = null;
4130
+ if (r.key_blob.length > 0) {
4131
+ try {
4132
+ preview = maskKey2(decrypt2(r.key_blob));
4133
+ } catch {
4134
+ preview = null;
4135
+ }
4136
+ }
4137
+ map.set(r.provider, {
4138
+ provider: r.provider,
4139
+ configured: r.key_blob.length > 0,
4140
+ updatedAt: r.updated_at,
4141
+ preview
4142
+ });
4143
+ }
4144
+ return Array.from(map.values());
4145
+ }
4146
+ function getKey(provider) {
4147
+ const row = getDb().prepare("SELECT key_blob FROM provider_keys WHERE provider = ?").get(provider);
4148
+ if (!row) return null;
4149
+ try {
4150
+ return decrypt2(row.key_blob);
4151
+ } catch {
4152
+ return null;
4153
+ }
4154
+ }
4155
+ function setKey(provider, plain) {
4156
+ const trimmed = plain.trim();
4157
+ if (!trimmed) {
4158
+ deleteKey(provider);
4159
+ return;
4160
+ }
4161
+ const blob = encrypt2(trimmed);
4162
+ const now = Date.now();
4163
+ getDb().prepare(
4164
+ `INSERT INTO provider_keys (provider, key_blob, created_at, updated_at)
4165
+ VALUES (?, ?, ?, ?)
4166
+ ON CONFLICT(provider) DO UPDATE SET key_blob = excluded.key_blob, updated_at = excluded.updated_at`
4167
+ ).run(provider, blob, now, now);
4168
+ }
4169
+ function deleteKey(provider) {
4170
+ getDb().prepare("DELETE FROM provider_keys WHERE provider = ?").run(provider);
4171
+ }
4172
+
4173
+ // src/storage/reconcile-models.ts
4174
+ var PRIMARY_BY_CARRIER = {
4175
+ openrouter: "opus-4-6-fast",
4176
+ bai: "haiku-4-5",
4032
4177
  anthropic: "haiku-4-5",
4033
4178
  openai: "gpt-5-4-mini",
4034
4179
  google: "gemini-3-1-flash"
4035
- // xai · no primary (no LLM modelV in registry as of 2026-05-17). The
4036
- // adapter / availability layer skip xai when this key is absent.
4180
+ // xai · no primary (no LLM modelV in registry as of 2026-05-17).
4037
4181
  };
4038
- var CARRIER_PRIORITY = ["openrouter", "bai", "anthropic", "openai", "google", "xai"];
4039
4182
  function reachableModelVs() {
4040
4183
  const out = /* @__PURE__ */ new Set();
4041
- const orKey = !!getKey("openrouter");
4042
- const baiKey = !!getKey("bai");
4184
+ const p = getProviderKeyState().activeLlmProvider;
4185
+ if (!p) return out;
4043
4186
  for (const [v, meta] of Object.entries(MODELS)) {
4044
- if (orKey) {
4045
- out.add(v);
4046
- continue;
4047
- }
4048
- if (baiKey && meta.baiId) {
4049
- out.add(v);
4050
- continue;
4051
- }
4052
- if (!meta.viaUniversalOnly && hasDirectKey(meta.provider)) {
4053
- out.add(v);
4054
- continue;
4055
- }
4056
- if (meta.viaUniversalOnly && hasDirectKey(meta.provider)) {
4057
- out.add(v);
4187
+ if (p === "openrouter") {
4188
+ if (meta.openrouterId) out.add(v);
4189
+ } else if (p === "bai") {
4190
+ if (meta.baiId) out.add(v);
4191
+ } else {
4192
+ if (meta.provider === p && !meta.viaUniversalOnly) out.add(v);
4058
4193
  }
4059
4194
  }
4060
4195
  return out;
4061
4196
  }
4062
- function hasDirectKey(provider) {
4063
- switch (provider) {
4064
- case "anthropic":
4065
- case "openai":
4066
- case "google":
4067
- case "xai":
4068
- return !!getKey(provider);
4069
- default:
4070
- return false;
4071
- }
4072
- }
4073
4197
  function activeCarrier() {
4074
- const prefs = getPrefs();
4075
- if (prefs.defaultModelV) {
4076
- const meta = MODELS[prefs.defaultModelV];
4077
- if (meta) {
4078
- if (meta.viaUniversalOnly && getKey("openrouter")) return "openrouter";
4079
- if (hasDirectKey(meta.provider)) return meta.provider;
4080
- if (getKey("bai") && meta.baiId) return "bai";
4081
- if (getKey("openrouter")) return "openrouter";
4082
- }
4083
- }
4084
- for (const c of CARRIER_PRIORITY) {
4085
- if (c === "openrouter" && getKey("openrouter")) return "openrouter";
4086
- if (c === "bai" && getKey("bai")) return "bai";
4087
- if (c !== "openrouter" && c !== "bai" && hasDirectKey(c)) return c;
4088
- }
4089
- return null;
4198
+ return getProviderKeyState().activeLlmProvider;
4090
4199
  }
4091
4200
  function reconcileAgentModels(opts = {}) {
4092
4201
  const reachable = reachableModelVs();
@@ -4095,16 +4204,9 @@ function reconcileAgentModels(opts = {}) {
4095
4204
  const forcePrimary = opts.forcePrimary === true;
4096
4205
  const switched = [];
4097
4206
  const cleared = [];
4098
- const orReachable = !!getKey("openrouter");
4099
- const baiReachable = !!getKey("bai");
4100
- function carrierKeyReachable(c) {
4101
- if (c === "openrouter") return orReachable;
4102
- if (c === "bai") return baiReachable;
4103
- return hasDirectKey(c);
4104
- }
4105
4207
  for (const agent of listAllAgents()) {
4106
4208
  const v = (agent.modelV || "").trim();
4107
- if (agent.carrierPref && !carrierKeyReachable(agent.carrierPref)) {
4209
+ if (agent.carrierPref) {
4108
4210
  updateAgent(agent.id, { carrierPref: null });
4109
4211
  }
4110
4212
  if (!forcePrimary && v && reachable.has(v)) continue;
@@ -4120,6 +4222,7 @@ function reconcileAgentModels(opts = {}) {
4120
4222
  cleared.push(agent.id);
4121
4223
  }
4122
4224
  }
4225
+ void isMultiModelProvider;
4123
4226
  const prefs = getPrefs();
4124
4227
  const currentReachable = !!prefs.defaultModelV && reachable.has(prefs.defaultModelV);
4125
4228
  const shouldBump = forcePrimary || !prefs.defaultModelV || !currentReachable;
@@ -4133,31 +4236,36 @@ function reconcileAgentModels(opts = {}) {
4133
4236
 
4134
4237
  // src/ai/availability.ts
4135
4238
  function getProviderKeyState() {
4136
- const directProviders = /* @__PURE__ */ new Set();
4137
- let hasOpenRouter = false;
4138
- let hasBai = false;
4139
- for (const meta of listKeyMeta()) {
4140
- if (!meta.configured) continue;
4141
- if (meta.provider === "openrouter") hasOpenRouter = true;
4142
- else if (meta.provider === "bai") hasBai = true;
4143
- else if (meta.provider === "brave" || meta.provider === "tavily" || meta.provider === "minimax" || meta.provider === "elevenlabs") continue;
4144
- else directProviders.add(meta.provider);
4145
- }
4146
- return { hasOpenRouter, hasBai, directProviders };
4239
+ const credId = getPrefs().activeLlmCredentialId;
4240
+ if (credId) {
4241
+ const meta = getLlmCredentialMeta(credId);
4242
+ if (meta) return { activeLlmProvider: meta.provider, hasAnyLlmKey: true };
4243
+ }
4244
+ return { activeLlmProvider: null, hasAnyLlmKey: false };
4245
+ }
4246
+ function routeFor(p) {
4247
+ if (!p) return null;
4248
+ if (p === "openrouter") return "openrouter";
4249
+ if (p === "bai") return "bai";
4250
+ return "direct";
4147
4251
  }
4148
4252
  function availabilityFor(meta, keys) {
4149
- const directReachable = !meta.viaUniversalOnly && keys.directProviders.has(meta.provider);
4150
- const orReachable = keys.hasOpenRouter && !!meta.openrouterId;
4151
- const baiReachable = keys.hasBai && !!meta.baiId;
4152
- const reachable = directReachable || orReachable || baiReachable;
4253
+ const p = keys.activeLlmProvider;
4254
+ let reachable = false;
4255
+ if (p === "openrouter") {
4256
+ reachable = !!meta.openrouterId;
4257
+ } else if (p === "bai") {
4258
+ reachable = !!meta.baiId;
4259
+ } else if (p) {
4260
+ reachable = meta.provider === p && !meta.viaUniversalOnly;
4261
+ }
4153
4262
  return {
4154
4263
  modelV: meta.v,
4155
4264
  displayName: meta.displayName,
4156
4265
  provider: meta.provider,
4157
4266
  deck: meta.deck,
4158
- routes: { direct: directReachable, openrouter: orReachable, bai: baiReachable },
4159
4267
  reachable,
4160
- preferredRoute: directReachable ? "direct" : baiReachable ? "bai" : orReachable ? "openrouter" : null
4268
+ preferredRoute: reachable ? routeFor(p) : null
4161
4269
  };
4162
4270
  }
4163
4271
  function modelAvailability() {
@@ -4168,8 +4276,7 @@ function reachableModels() {
4168
4276
  return modelAvailability().filter((m) => m.reachable);
4169
4277
  }
4170
4278
  function hasAnyModelKey() {
4171
- const keys = getProviderKeyState();
4172
- return keys.hasOpenRouter || keys.hasBai || keys.directProviders.size > 0;
4279
+ return getProviderKeyState().hasAnyLlmKey;
4173
4280
  }
4174
4281
  var PROVIDER_FLAGSHIP = {
4175
4282
  anthropic: "opus-4-7",
@@ -4281,36 +4388,15 @@ function effectiveDefaultModel() {
4281
4388
  return fresh;
4282
4389
  }
4283
4390
  function defaultModelFor(keys = getProviderKeyState()) {
4284
- const reachable = modelAvailability().filter((m) => m.reachable);
4285
- if (reachable.length === 0) return null;
4286
- if (!keys.hasOpenRouter && keys.directProviders.size === 1) {
4287
- const provider = Array.from(keys.directProviders)[0];
4288
- const fast = PROVIDER_FAST[provider];
4289
- if (fast && reachable.find((m) => m.modelV === fast)) return fast;
4290
- const flagship = PROVIDER_FLAGSHIP[provider];
4291
- if (flagship && reachable.find((m) => m.modelV === flagship)) return flagship;
4292
- }
4293
- if (keys.hasOpenRouter) {
4294
- const fast = reachable.find((m) => m.modelV === "opus-4-6-fast");
4295
- if (fast) return fast.modelV;
4296
- const opus = reachable.find((m) => m.modelV === "opus-4-7");
4297
- if (opus) return opus.modelV;
4298
- }
4299
- if (keys.hasBai) {
4300
- const fast = reachable.find((m) => m.modelV === "haiku-4-5");
4301
- if (fast) return fast.modelV;
4302
- const opus = reachable.find((m) => m.modelV === "opus-4-7");
4303
- if (opus) return opus.modelV;
4304
- }
4305
- for (const provider of keys.directProviders) {
4306
- const fast = PROVIDER_FAST[provider];
4307
- if (fast && reachable.find((m) => m.modelV === fast)) return fast;
4308
- }
4309
- for (const provider of keys.directProviders) {
4310
- const flagship = PROVIDER_FLAGSHIP[provider];
4311
- if (flagship && reachable.find((m) => m.modelV === flagship)) return flagship;
4312
- }
4313
- return reachable[0].modelV;
4391
+ const p = keys.activeLlmProvider;
4392
+ if (!p) return null;
4393
+ const reachable = new Set(reachableModels().map((m) => m.modelV));
4394
+ if (reachable.size === 0) return null;
4395
+ const fast = PROVIDER_FAST[p];
4396
+ if (fast && reachable.has(fast)) return fast;
4397
+ const flagship = PROVIDER_FLAGSHIP[p];
4398
+ if (flagship && reachable.has(flagship)) return flagship;
4399
+ return reachableModels()[0]?.modelV ?? null;
4314
4400
  }
4315
4401
  var CHEAP_BY_CARRIER = {
4316
4402
  openrouter: "haiku-4-5",
@@ -4331,11 +4417,6 @@ var UTILITY_PREFERENCE = [
4331
4417
  ];
4332
4418
  function utilityModelFor(fallback = null) {
4333
4419
  const reachable = new Set(reachableModels().map((m) => m.modelV));
4334
- const prefs = getPrefs();
4335
- const userDefault = prefs.defaultModelV;
4336
- if (userDefault && UTILITY_PREFERENCE.includes(userDefault) && reachable.has(userDefault)) {
4337
- return userDefault;
4338
- }
4339
4420
  const carrier = activeCarrier();
4340
4421
  if (carrier) {
4341
4422
  const preferred = CHEAP_BY_CARRIER[carrier];
@@ -5835,6 +5916,97 @@ function getPartialPersona(jobId) {
5835
5916
  };
5836
5917
  }
5837
5918
 
5919
+ // src/orchestrator/celebrity-seed.ts
5920
+ var SLUG_RE = /^[a-z][a-z0-9-]{1,40}$/;
5921
+ function parseSeed(raw) {
5922
+ let s = raw.trim();
5923
+ if (s.startsWith("```")) {
5924
+ s = s.replace(/^```[a-zA-Z]*\s*/, "").replace(/```\s*$/, "").trim();
5925
+ }
5926
+ let j;
5927
+ try {
5928
+ j = JSON.parse(s);
5929
+ } catch {
5930
+ return null;
5931
+ }
5932
+ if (!j || typeof j !== "object") return null;
5933
+ const o = j;
5934
+ const id = typeof o.id === "string" ? o.id.trim().toLowerCase() : "";
5935
+ const name = typeof o.name === "string" ? o.name.trim() : "";
5936
+ const roleTag = typeof o.roleTag === "string" ? o.roleTag.trim().toLowerCase() : "";
5937
+ const description = typeof o.description === "string" ? o.description.trim() : "";
5938
+ const introRaw = o.intro && typeof o.intro === "object" ? o.intro : {};
5939
+ const introEn = typeof introRaw.en === "string" ? introRaw.en.trim() : "";
5940
+ const introZh = typeof introRaw.zh === "string" ? introRaw.zh.trim() : "";
5941
+ if (!SLUG_RE.test(id)) return null;
5942
+ if (name.length < 2 || name.length > 80) return null;
5943
+ if (roleTag.length < 2 || roleTag.length > 24) return null;
5944
+ if (description.length < 60 || description.length > 1200) return null;
5945
+ if (introEn.length < 8 || introEn.length > 200) return null;
5946
+ if (introZh.length < 4 || introZh.length > 200) return null;
5947
+ return {
5948
+ id,
5949
+ name,
5950
+ roleTag,
5951
+ intro: { en: introEn, zh: introZh },
5952
+ description
5953
+ };
5954
+ }
5955
+ function buildPrompt(opts) {
5956
+ const excludeBlock = opts.excludeIds.length === 0 ? "(no exclusions)" : opts.excludeIds.slice(0, 200).map((id) => `\xB7 ${id}`).join("\n");
5957
+ const novelty = opts.emphasizeNovelty ? "CRITICAL: do NOT repeat any id from the exclusion list. Re-read it before answering. Pick a different person." : "";
5958
+ return [
5959
+ "You are inventing ONE famous-figure preset card for an app that lets users 'hire' historical or contemporary thinkers as AI directors.",
5960
+ "",
5961
+ "Pick a real, broadly recognisable person \u2014 a founder, scientist, philosopher, investor, artist, writer, or statesperson. Strong bias toward names a literate global audience would recognise instantly (Steve Jobs, Hannah Arendt, John von Neumann, Toni Morrison level).",
5962
+ "",
5963
+ "Output STRICT JSON with exactly these fields, nothing else (no prose before or after, no code fences):",
5964
+ "{",
5965
+ ` "id": "kebab-case-slug", // lowercase, 2-40 chars, letters/digits/hyphen, starts with a letter`,
5966
+ ` "name": "Display Name", // verbatim \xB7 keep their real name; CJK names stay CJK`,
5967
+ ` "roleTag": "founder", // one short English noun \xB7 examples: founder | philosopher | physicist | essayist | investor | architect | mathematician | poet | director | dissident`,
5968
+ ` "intro": {`,
5969
+ ` "en": "one-line tagline \xB7 8 to 30 words \xB7 captures the lens this person brings",`,
5970
+ ` "zh": "\u4E2D\u6587 8 \u5230 30 \u5B57 \xB7 \u4E0E en \u540C\u4E3B\u65E8"`,
5971
+ ` },`,
5972
+ ` "description": "60-400 word seed describing the persona in the second-person mold \u2014 'A first-principles industrialist in the mold of X \u2014 strips problems to physics, ...'. Names the thinking style, the canonical reference points, what they refuse to do, what they always push for. Drives a downstream persona-builder pipeline so concrete is better than abstract."`,
5973
+ "}",
5974
+ "",
5975
+ "Avoid these ids (already in the pool):",
5976
+ excludeBlock,
5977
+ "",
5978
+ novelty
5979
+ ].filter(Boolean).join("\n");
5980
+ }
5981
+ async function generateCelebritySeed(opts) {
5982
+ const modelV = utilityModelFor();
5983
+ if (!modelV) throw new Error("no utility model available");
5984
+ const excludeSet = new Set(opts.excludeIds);
5985
+ for (let attempt = 0; attempt < 2; attempt++) {
5986
+ const prompt = buildPrompt({
5987
+ excludeIds: opts.excludeIds,
5988
+ emphasizeNovelty: attempt > 0
5989
+ });
5990
+ let raw;
5991
+ try {
5992
+ raw = await callLLM({
5993
+ modelV,
5994
+ messages: [{ role: "user", content: prompt }],
5995
+ temperature: 0.85,
5996
+ maxTokens: 900
5997
+ });
5998
+ } catch (e) {
5999
+ if (attempt === 1) throw e;
6000
+ continue;
6001
+ }
6002
+ const parsed = parseSeed(raw);
6003
+ if (!parsed) continue;
6004
+ if (excludeSet.has(parsed.id)) continue;
6005
+ return parsed;
6006
+ }
6007
+ throw new Error("celebrity-seed \xB7 model failed to produce a valid novel entry");
6008
+ }
6009
+
5838
6010
  // src/ai/prompts/persona-render.ts
5839
6011
  var INSTRUCTION_MAX = 6e3;
5840
6012
  function synthesizePersonaInstruction(spec, meta) {
@@ -6079,12 +6251,12 @@ function renderRules(lines, rules) {
6079
6251
  init_db();
6080
6252
 
6081
6253
  // src/utils/id.ts
6082
- import { randomBytes as randomBytes2 } from "crypto";
6254
+ import { randomBytes as randomBytes3 } from "crypto";
6083
6255
  var ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
6084
6256
  var ALPHABET_LEN = ALPHABET.length;
6085
6257
  var MASK = (1 << 5) - 1;
6086
6258
  function newId(len = 12) {
6087
- const bytes = randomBytes2(len);
6259
+ const bytes = randomBytes3(len);
6088
6260
  let out = "";
6089
6261
  for (let i = 0; i < len; i++) {
6090
6262
  out += ALPHABET[bytes[i] & MASK];
@@ -6455,7 +6627,7 @@ var TIPS_MAX_COUNT = 8;
6455
6627
  var BODY_MAX_BYTES = 32 * 1024;
6456
6628
  var ABILITY_MIN = -3;
6457
6629
  var ABILITY_MAX = 3;
6458
- var SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
6630
+ var SLUG_RE2 = /^[a-z0-9][a-z0-9-]*$/;
6459
6631
  function slugifyName(name) {
6460
6632
  return name.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, SLUG_MAX);
6461
6633
  }
@@ -6508,7 +6680,7 @@ function parseSkillMd(src) {
6508
6680
  }
6509
6681
  slug = raw.slug.trim();
6510
6682
  if (slug.length > SLUG_MAX) return err(`\`slug\` too long (max ${SLUG_MAX})`);
6511
- if (!SLUG_RE.test(slug)) {
6683
+ if (!SLUG_RE2.test(slug)) {
6512
6684
  return err("`slug` must be lowercase letters, digits, and hyphens (start with letter/digit)");
6513
6685
  }
6514
6686
  }
@@ -7580,6 +7752,23 @@ function agentsRouter() {
7580
7752
  const jobId = startPersonaBuild({ description, locale });
7581
7753
  return c.json({ jobId });
7582
7754
  });
7755
+ r.post("/celebrity-seed", async (c) => {
7756
+ let body;
7757
+ try {
7758
+ body = await c.req.json();
7759
+ } catch {
7760
+ body = {};
7761
+ }
7762
+ const b = body ?? {};
7763
+ const excludeIds = Array.isArray(b.excludeIds) ? b.excludeIds.filter((x) => typeof x === "string").slice(0, 200) : [];
7764
+ try {
7765
+ const seed = await generateCelebritySeed({ excludeIds });
7766
+ return c.json({ seed });
7767
+ } catch (e) {
7768
+ const msg = e instanceof Error ? e.message : String(e);
7769
+ return c.json({ error: msg }, 502);
7770
+ }
7771
+ });
7583
7772
  r.get("/generate-persona/:jobId/stream", (c) => {
7584
7773
  const jobId = c.req.param("jobId");
7585
7774
  const job = getPersonaJob(jobId);
@@ -12996,13 +13185,36 @@ function searchMessages(query, limit = 200) {
12996
13185
  createdAt: r.created_at
12997
13186
  }));
12998
13187
  }
12999
- function cleanupOrphanedStreams() {
13188
+ function finalizeStreamingMessage(messageId, reason) {
13189
+ const db = getDb();
13190
+ const before = db.prepare(
13191
+ `SELECT json_extract(meta_json, '$.streaming') AS streaming
13192
+ FROM messages WHERE id = ?`
13193
+ ).get(messageId);
13194
+ if (!before) return false;
13195
+ if (before.streaming !== 1) return false;
13196
+ db.prepare(
13197
+ `UPDATE messages
13198
+ SET meta_json = json_set(
13199
+ COALESCE(meta_json, '{}'),
13200
+ '$.streaming', 0,
13201
+ '$.speakerStatus', 'final',
13202
+ '$.error', ?
13203
+ )
13204
+ WHERE id = ?`
13205
+ ).run(String(reason || "finalized"), messageId);
13206
+ return true;
13207
+ }
13208
+ function cleanupOrphanedStreams(opts = {}) {
13000
13209
  const db = getDb();
13210
+ const reason = opts.reason || "orphaned \xB7 server restarted mid-stream";
13211
+ const ageClause = typeof opts.maxAgeMs === "number" ? `AND created_at < ${Math.floor(Date.now() - opts.maxAgeMs)}` : "";
13001
13212
  const del = db.prepare(
13002
13213
  `DELETE FROM messages
13003
13214
  WHERE json_valid(meta_json)
13004
13215
  AND json_extract(meta_json, '$.streaming') = 1
13005
- AND (body IS NULL OR trim(body) = '')`
13216
+ AND (body IS NULL OR trim(body) = '')
13217
+ ${ageClause}`
13006
13218
  ).run();
13007
13219
  const upd = db.prepare(
13008
13220
  `UPDATE messages
@@ -13010,11 +13222,12 @@ function cleanupOrphanedStreams() {
13010
13222
  meta_json,
13011
13223
  '$.streaming', 0,
13012
13224
  '$.speakerStatus', 'final',
13013
- '$.error', 'orphaned \xB7 server restarted mid-stream'
13225
+ '$.error', ?
13014
13226
  )
13015
13227
  WHERE json_valid(meta_json)
13016
- AND json_extract(meta_json, '$.streaming') = 1`
13017
- ).run();
13228
+ AND json_extract(meta_json, '$.streaming') = 1
13229
+ ${ageClause}`
13230
+ ).run(reason);
13018
13231
  return { fixed: upd.changes, deleted: del.changes };
13019
13232
  }
13020
13233
 
@@ -13560,8 +13773,17 @@ function estimateTokens(text) {
13560
13773
 
13561
13774
  // src/orchestrator/stream.ts
13562
13775
  import { EventEmitter as EventEmitter2 } from "events";
13776
+ var MAX_BUFFER_PER_ROOM = 100;
13777
+ var BUFFER_TTL_MS = 5 * 60 * 1e3;
13563
13778
  var RoomBus = class {
13564
13779
  emitters = /* @__PURE__ */ new Map();
13780
+ buffers = /* @__PURE__ */ new Map();
13781
+ /** Monotonic event id (global across rooms · simpler than per-room
13782
+ * counters and equivalent for SSE's Last-Event-ID use). Survives
13783
+ * the lifetime of the process; reset on restart, which is fine ·
13784
+ * EventSource treats a smaller id from the server as "fresh
13785
+ * stream" and starts over. */
13786
+ nextId = 1;
13565
13787
  get(roomId) {
13566
13788
  let e = this.emitters.get(roomId);
13567
13789
  if (!e) {
@@ -13571,15 +13793,48 @@ var RoomBus = class {
13571
13793
  }
13572
13794
  return e;
13573
13795
  }
13574
- emit(roomId, event) {
13575
- this.get(roomId).emit("event", event);
13796
+ buffer(roomId) {
13797
+ let b = this.buffers.get(roomId);
13798
+ if (!b) {
13799
+ b = [];
13800
+ this.buffers.set(roomId, b);
13801
+ }
13802
+ return b;
13576
13803
  }
13577
- /** Subscribe; returns an unsubscribe fn. */
13804
+ emit(roomId, event) {
13805
+ const id = this.nextId++;
13806
+ const ts = Date.now();
13807
+ const buf = this.buffer(roomId);
13808
+ buf.push({ id, event, ts });
13809
+ while (buf.length > MAX_BUFFER_PER_ROOM) buf.shift();
13810
+ while (buf.length && ts - buf[0].ts > BUFFER_TTL_MS) buf.shift();
13811
+ this.get(roomId).emit("event", id, event);
13812
+ }
13813
+ /** Legacy subscribe · returns events without ids. Kept for callers
13814
+ * that don't care about replay (none currently, but the surface
13815
+ * stays compatible). */
13578
13816
  subscribe(roomId, listener) {
13817
+ return this.subscribeWithId(roomId, (_id, event) => listener(event));
13818
+ }
13819
+ /** Subscribe with monotonic event id · the SSE route uses this so
13820
+ * every event written to the client carries an `id: N` line. The
13821
+ * client's EventSource then sends `Last-Event-ID: N` on auto-
13822
+ * reconnect, which the route maps back to replay() below. */
13823
+ subscribeWithId(roomId, listener) {
13579
13824
  const e = this.get(roomId);
13580
13825
  e.on("event", listener);
13581
13826
  return () => e.off("event", listener);
13582
13827
  }
13828
+ /** Return cached events with id > sinceId in emit order. Used by
13829
+ * the SSE route on reconnect to replay the gap before subscribing
13830
+ * fresh. Returns [] when there is no cache OR the gap is older
13831
+ * than the buffer's retained window (caller treats as "no replay
13832
+ * possible · client may have missed events permanently"). */
13833
+ replay(roomId, sinceId) {
13834
+ const buf = this.buffers.get(roomId);
13835
+ if (!buf || buf.length === 0) return [];
13836
+ return buf.filter((e) => e.id > sinceId);
13837
+ }
13583
13838
  /** Drop all listeners for a room (e.g. when it's deleted). */
13584
13839
  drop(roomId) {
13585
13840
  const e = this.emitters.get(roomId);
@@ -13587,6 +13842,7 @@ var RoomBus = class {
13587
13842
  e.removeAllListeners();
13588
13843
  this.emitters.delete(roomId);
13589
13844
  }
13845
+ this.buffers.delete(roomId);
13590
13846
  }
13591
13847
  };
13592
13848
  var roomBus = new RoomBus();
@@ -15162,8 +15418,144 @@ function briefsRouter() {
15162
15418
  return r;
15163
15419
  }
15164
15420
 
15165
- // src/routes/keys.ts
15421
+ // src/routes/credentials.ts
15166
15422
  import { Hono as Hono4 } from "hono";
15423
+ function payloadFor(meta, activeId) {
15424
+ return {
15425
+ id: meta.id,
15426
+ provider: meta.provider,
15427
+ label: meta.label,
15428
+ preview: meta.preview,
15429
+ createdAt: meta.createdAt,
15430
+ updatedAt: meta.updatedAt,
15431
+ isActive: meta.id === activeId
15432
+ };
15433
+ }
15434
+ function pickNextActiveId(removedProvider) {
15435
+ const all = listLlmCredentials();
15436
+ if (all.length === 0) return null;
15437
+ if (removedProvider) {
15438
+ const sameProvider = all.filter((c) => c.provider === removedProvider);
15439
+ if (sameProvider.length > 0) {
15440
+ sameProvider.sort((a, b) => a.createdAt - b.createdAt);
15441
+ return sameProvider[0].id;
15442
+ }
15443
+ }
15444
+ const sorted = all.slice().sort((a, b) => {
15445
+ const ai = LLM_PROVIDER_PRIORITY.indexOf(a.provider);
15446
+ const bi = LLM_PROVIDER_PRIORITY.indexOf(b.provider);
15447
+ if (ai !== bi) return ai - bi;
15448
+ return a.createdAt - b.createdAt;
15449
+ });
15450
+ return sorted[0]?.id ?? null;
15451
+ }
15452
+ function credentialsRouter() {
15453
+ const r = new Hono4();
15454
+ r.get("/", (c) => {
15455
+ const activeId = getPrefs().activeLlmCredentialId;
15456
+ const items = listLlmCredentials().map((m) => payloadFor(m, activeId));
15457
+ return c.json({
15458
+ credentials: items,
15459
+ activeId
15460
+ });
15461
+ });
15462
+ r.put("/active", async (c) => {
15463
+ let body;
15464
+ try {
15465
+ body = await c.req.json();
15466
+ } catch {
15467
+ return c.json({ error: "invalid JSON body" }, 400);
15468
+ }
15469
+ const rawId = body?.id;
15470
+ let nextId;
15471
+ if (rawId === null || rawId === void 0) {
15472
+ nextId = null;
15473
+ } else if (typeof rawId === "string") {
15474
+ nextId = rawId;
15475
+ } else {
15476
+ return c.json({ error: "id must be a string or null" }, 400);
15477
+ }
15478
+ if (nextId) {
15479
+ const meta = getLlmCredentialMeta(nextId);
15480
+ if (!meta) return c.json({ error: "credential not found" }, 404);
15481
+ updatePrefs({ activeLlmCredentialId: nextId });
15482
+ const flagship = PRIMARY_BY_CARRIER[meta.provider];
15483
+ if (flagship) updatePrefs({ defaultModelV: flagship });
15484
+ } else {
15485
+ updatePrefs({ activeLlmCredentialId: null });
15486
+ }
15487
+ try {
15488
+ reconcileAgentModels({ forcePrimary: true });
15489
+ } catch (e) {
15490
+ process.stderr.write(`[credentials.active] reconcile failed: ${e instanceof Error ? e.message : String(e)}
15491
+ `);
15492
+ }
15493
+ return c.json({ activeId: nextId });
15494
+ });
15495
+ r.post("/", async (c) => {
15496
+ let body;
15497
+ try {
15498
+ body = await c.req.json();
15499
+ } catch {
15500
+ return c.json({ error: "invalid JSON body" }, 400);
15501
+ }
15502
+ const provider = body?.provider;
15503
+ const labelRaw = body?.label;
15504
+ const key = body?.key;
15505
+ if (typeof provider !== "string" || !isLlmProvider(provider)) {
15506
+ return c.json({ error: "provider must be a known LLM slug" }, 400);
15507
+ }
15508
+ if (typeof key !== "string" || key.trim().length === 0) {
15509
+ return c.json({ error: "key must be a non-empty string" }, 400);
15510
+ }
15511
+ const label = typeof labelRaw === "string" ? labelRaw : null;
15512
+ const meta = createLlmCredential(provider, label, key);
15513
+ if (!meta) return c.json({ error: "failed to create credential" }, 500);
15514
+ updatePrefs({ activeLlmCredentialId: meta.id });
15515
+ const flagship = PRIMARY_BY_CARRIER[provider];
15516
+ if (flagship) updatePrefs({ defaultModelV: flagship });
15517
+ try {
15518
+ reconcileAgentModels({ forcePrimary: true });
15519
+ } catch (e) {
15520
+ process.stderr.write(`[credentials.post] reconcile failed: ${e instanceof Error ? e.message : String(e)}
15521
+ `);
15522
+ }
15523
+ const activeId = getPrefs().activeLlmCredentialId;
15524
+ return c.json(payloadFor(meta, activeId), 201);
15525
+ });
15526
+ r.delete("/:id", (c) => {
15527
+ const id = c.req.param("id");
15528
+ const meta = getLlmCredentialMeta(id);
15529
+ if (!meta) return c.json({ error: "credential not found" }, 404);
15530
+ const prefs = getPrefs();
15531
+ const wasActive = prefs.activeLlmCredentialId === id;
15532
+ const removedProvider = deleteLlmCredential(id);
15533
+ if (wasActive) {
15534
+ const nextId = pickNextActiveId(removedProvider);
15535
+ updatePrefs({ activeLlmCredentialId: nextId });
15536
+ if (nextId) {
15537
+ const nextMeta = getLlmCredentialMeta(nextId);
15538
+ if (nextMeta) {
15539
+ const flagship = PRIMARY_BY_CARRIER[nextMeta.provider];
15540
+ if (flagship) updatePrefs({ defaultModelV: flagship });
15541
+ }
15542
+ } else {
15543
+ updatePrefs({ defaultModelV: null });
15544
+ }
15545
+ }
15546
+ try {
15547
+ reconcileAgentModels(wasActive ? { forcePrimary: true } : void 0);
15548
+ } catch (e) {
15549
+ process.stderr.write(`[credentials.delete] reconcile failed: ${e instanceof Error ? e.message : String(e)}
15550
+ `);
15551
+ }
15552
+ return c.json({ id, deleted: true, activeId: getPrefs().activeLlmCredentialId });
15553
+ });
15554
+ return r;
15555
+ }
15556
+
15557
+ // src/routes/keys.ts
15558
+ import { Hono as Hono5 } from "hono";
15167
15559
 
15168
15560
  // src/voice/registry.ts
15169
15561
  function minimaxBaseUrl() {
@@ -15401,27 +15793,16 @@ function defaultVoiceForProvider(provider) {
15401
15793
 
15402
15794
  // src/routes/keys.ts
15403
15795
  var PROVIDERS = /* @__PURE__ */ new Set([
15404
- "openrouter",
15405
- "bai",
15406
- "anthropic",
15407
- "openai",
15408
- "google",
15409
- "xai",
15796
+ ...ALL_LLM_PROVIDERS,
15797
+ // `deepseek` lives in the storage Provider union for type-compat
15798
+ // with the model registry; no @ai-sdk client routes through it, so
15799
+ // the row is accepted but unused.
15410
15800
  "deepseek",
15411
15801
  "minimax",
15412
15802
  "elevenlabs",
15413
15803
  "brave",
15414
15804
  "tavily"
15415
15805
  ]);
15416
- var LLM_PROVIDERS = /* @__PURE__ */ new Set([
15417
- "openrouter",
15418
- "bai",
15419
- "anthropic",
15420
- "openai",
15421
- "google",
15422
- "xai",
15423
- "deepseek"
15424
- ]);
15425
15806
  function isProvider(s) {
15426
15807
  return PROVIDERS.has(s);
15427
15808
  }
@@ -15459,7 +15840,7 @@ function autoAssignVoicesOnFirstKey(provider) {
15459
15840
  }
15460
15841
  }
15461
15842
  function keysRouter() {
15462
- const r = new Hono4();
15843
+ const r = new Hono5();
15463
15844
  r.get("/", (c) => {
15464
15845
  const meta = listKeyMeta();
15465
15846
  const map = /* @__PURE__ */ new Map();
@@ -15467,11 +15848,22 @@ function keysRouter() {
15467
15848
  const out = Array.from(PROVIDERS).map(
15468
15849
  (p) => map.get(p) ?? { provider: p, configured: false, updatedAt: null, preview: null }
15469
15850
  );
15470
- return c.json({ keys: out });
15471
- });
15472
- r.put("/:provider", async (c) => {
15851
+ return c.json({
15852
+ keys: out,
15853
+ classification: {
15854
+ multiModel: [...MULTI_MODEL_LLM_PROVIDERS],
15855
+ singleModel: [...SINGLE_MODEL_LLM_PROVIDERS],
15856
+ voice: ["minimax", "elevenlabs"],
15857
+ skill: ["brave", "tavily"]
15858
+ }
15859
+ });
15860
+ });
15861
+ r.put("/:provider", async (c) => {
15473
15862
  const provider = c.req.param("provider");
15474
15863
  if (!isProvider(provider)) return c.json({ error: "unknown provider" }, 400);
15864
+ if (isLlmProvider(provider)) {
15865
+ return c.json({ error: "LLM providers use POST /api/credentials" }, 410);
15866
+ }
15475
15867
  let body;
15476
15868
  try {
15477
15869
  body = await c.req.json();
@@ -15480,29 +15872,8 @@ function keysRouter() {
15480
15872
  }
15481
15873
  const key = body?.key;
15482
15874
  if (typeof key !== "string") return c.json({ error: "body must contain { key: string }" }, 400);
15483
- const makeDefault = body?.makeDefault === true;
15484
15875
  const hadAnyVoiceKeyBefore = !!getKey("minimax") || !!getKey("elevenlabs");
15485
15876
  setKey(provider, key);
15486
- if (provider !== "brave" && provider !== "tavily" && provider !== "minimax" && provider !== "elevenlabs") {
15487
- const willForce = makeDefault && key.trim().length > 0;
15488
- if (willForce) {
15489
- const flagship = PRIMARY_BY_CARRIER[provider];
15490
- if (flagship) {
15491
- try {
15492
- updatePrefs({ defaultModelV: flagship });
15493
- } catch (e) {
15494
- process.stderr.write(`[keys.put] updatePrefs failed: ${e instanceof Error ? e.message : String(e)}
15495
- `);
15496
- }
15497
- }
15498
- }
15499
- try {
15500
- reconcileAgentModels({ forcePrimary: willForce });
15501
- } catch (e) {
15502
- process.stderr.write(`[keys.put] reconcile failed: ${e instanceof Error ? e.message : String(e)}
15503
- `);
15504
- }
15505
- }
15506
15877
  if ((provider === "minimax" || provider === "elevenlabs") && key.trim().length > 0 && !hadAnyVoiceKeyBefore) {
15507
15878
  try {
15508
15879
  autoAssignVoicesOnFirstKey(provider);
@@ -15519,42 +15890,23 @@ function keysRouter() {
15519
15890
  r.delete("/:provider", (c) => {
15520
15891
  const provider = c.req.param("provider");
15521
15892
  if (!isProvider(provider)) return c.json({ error: "unknown provider" }, 400);
15522
- if (LLM_PROVIDERS.has(provider)) {
15523
- const allMeta = listKeyMeta();
15524
- const targetConfigured = !!allMeta.find((m) => m.provider === provider && m.configured);
15525
- const configuredLlmCount = allMeta.filter(
15526
- (m) => LLM_PROVIDERS.has(m.provider) && m.configured
15527
- ).length;
15528
- if (targetConfigured && configuredLlmCount <= 1) {
15529
- return c.json(
15530
- {
15531
- error: "Can't remove your only LLM key \u2014 add another LLM provider first, then remove this one."
15532
- },
15533
- 409
15534
- );
15535
- }
15893
+ if (isLlmProvider(provider)) {
15894
+ return c.json({ error: "LLM providers use DELETE /api/credentials/:id" }, 410);
15536
15895
  }
15537
15896
  deleteKey(provider);
15538
- if (provider !== "brave" && provider !== "tavily" && provider !== "minimax" && provider !== "elevenlabs") {
15539
- try {
15540
- reconcileAgentModels();
15541
- } catch (e) {
15542
- process.stderr.write(`[keys.delete] reconcile failed: ${e instanceof Error ? e.message : String(e)}
15543
- `);
15544
- }
15545
- }
15546
15897
  return c.json({ provider, configured: false, updatedAt: null, preview: null });
15547
15898
  });
15548
15899
  return r;
15549
15900
  }
15550
15901
 
15551
15902
  // src/routes/models.ts
15552
- import { Hono as Hono5 } from "hono";
15903
+ import { Hono as Hono6 } from "hono";
15553
15904
  function modelsRouter() {
15554
- const r = new Hono5();
15905
+ const r = new Hono6();
15555
15906
  r.get("/", (c) => {
15556
15907
  const all = modelAvailability();
15557
15908
  const reachable = all.filter((m) => m.reachable);
15909
+ const active = getProviderKeyState().activeLlmProvider;
15558
15910
  return c.json({
15559
15911
  /** Whether any LLM provider key is configured. False → frontend
15560
15912
  * redirects the user to the API Key settings before letting
@@ -15577,763 +15929,28 @@ function modelsRouter() {
15577
15929
  utilityModelV: utilityModelFor(),
15578
15930
  /** Provider summary · so the frontend can show "you have OR +
15579
15931
  * OpenAI direct" at a glance without iterating models. */
15580
- providers: collectProviderSummary(all)
15581
- });
15582
- });
15583
- return r;
15584
- }
15585
- function collectProviderSummary(models) {
15586
- const map = /* @__PURE__ */ new Map();
15587
- for (const m of models) {
15588
- const cur = map.get(m.provider) ?? { reachable: 0, total: 0 };
15589
- cur.total++;
15590
- if (m.reachable) cur.reachable++;
15591
- map.set(m.provider, cur);
15592
- }
15593
- return Array.from(map.entries()).map(([provider, v]) => ({ provider, ...v }));
15594
- }
15595
-
15596
- // src/routes/topic-recs.ts
15597
- import { Hono as Hono6 } from "hono";
15598
- import { streamSSE as streamSSE2 } from "hono/streaming";
15599
-
15600
- // src/orchestrator/topic-recommender.ts
15601
- import { randomUUID as randomUUID2 } from "crypto";
15602
-
15603
- // src/storage/topic-recs.ts
15604
- init_db();
15605
- function createTopicRecBatch(input) {
15606
- const now = Date.now();
15607
- getDb().prepare("INSERT INTO topic_rec_batches (id, has_web, keywords_json, created_at) VALUES (?, ?, ?, ?)").run(input.id, input.hasWeb ? 1 : 0, JSON.stringify(input.keywords), now);
15608
- return { id: input.id, hasWeb: input.hasWeb, keywords: input.keywords, createdAt: now };
15609
- }
15610
- function mapRec(r) {
15611
- let seedContext = null;
15612
- if (r.seed_context_json) {
15613
- try {
15614
- const parsed = JSON.parse(r.seed_context_json);
15615
- if (Array.isArray(parsed)) {
15616
- seedContext = parsed.filter(
15617
- (s) => s && typeof s.title === "string" && typeof s.url === "string" && typeof s.description === "string"
15618
- );
15619
- }
15620
- } catch {
15621
- }
15622
- }
15623
- return {
15624
- id: r.id,
15625
- batchId: r.batch_id,
15626
- subject: r.subject,
15627
- rationale: r.rationale,
15628
- source: r.source === "web" ? "web" : "memory",
15629
- tag: typeof r.tag === "string" && r.tag.trim().length > 0 ? r.tag.trim() : null,
15630
- seedContext,
15631
- createdAt: r.created_at,
15632
- openedRoomId: r.opened_room_id
15633
- };
15634
- }
15635
- var REC_COLS = "id, batch_id, subject, rationale, source, tag, seed_context_json, created_at, opened_room_id";
15636
- function insertTopicRec(input) {
15637
- const now = Date.now();
15638
- getDb().prepare(
15639
- `INSERT INTO topic_recs
15640
- (id, batch_id, subject, rationale, source, tag, seed_context_json, created_at, opened_room_id)
15641
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)`
15642
- ).run(
15643
- input.id,
15644
- input.batchId,
15645
- input.subject,
15646
- input.rationale,
15647
- input.source,
15648
- input.tag,
15649
- input.seedContext ? JSON.stringify(input.seedContext) : null,
15650
- now
15651
- );
15652
- return {
15653
- id: input.id,
15654
- batchId: input.batchId,
15655
- subject: input.subject,
15656
- rationale: input.rationale,
15657
- source: input.source,
15658
- tag: input.tag,
15659
- seedContext: input.seedContext,
15660
- createdAt: now,
15661
- openedRoomId: null
15662
- };
15663
- }
15664
- function getTopicRec(id) {
15665
- const row = getDb().prepare(`SELECT ${REC_COLS} FROM topic_recs WHERE id = ?`).get(id);
15666
- return row ? mapRec(row) : null;
15667
- }
15668
- function listTopicRecs(opts) {
15669
- const limit = Math.max(1, Math.min(100, opts.limit));
15670
- const stmt = opts.cursor === null ? getDb().prepare(`SELECT ${REC_COLS} FROM topic_recs ORDER BY created_at DESC LIMIT ?`) : getDb().prepare(`SELECT ${REC_COLS} FROM topic_recs WHERE created_at < ? ORDER BY created_at DESC LIMIT ?`);
15671
- const rows = opts.cursor === null ? stmt.all(limit) : stmt.all(opts.cursor, limit);
15672
- const items = rows.map(mapRec);
15673
- const nextCursor = items.length === limit ? items[items.length - 1].createdAt : null;
15674
- return { items, nextCursor };
15675
- }
15676
- function markTopicRecOpened(recId, roomId) {
15677
- getDb().prepare("UPDATE topic_recs SET opened_room_id = ? WHERE id = ?").run(roomId, recId);
15678
- }
15679
- function clearAllTopicRecs() {
15680
- const r = getDb().prepare("DELETE FROM topic_recs").run();
15681
- return r.changes;
15682
- }
15683
- function mapJob(r) {
15684
- const status = ["running", "done", "failed", "aborted"].includes(r.status) ? r.status : "failed";
15685
- return {
15686
- id: r.id,
15687
- status,
15688
- currentPhase: r.current_phase,
15689
- progressPct: r.progress_pct,
15690
- batchId: r.batch_id,
15691
- error: r.error,
15692
- startedAt: r.started_at,
15693
- updatedAt: r.updated_at
15694
- };
15695
- }
15696
- var JOB_COLS = "id, status, current_phase, progress_pct, batch_id, error, started_at, updated_at";
15697
- function createTopicRecJob(id) {
15698
- const now = Date.now();
15699
- getDb().prepare(
15700
- `INSERT INTO topic_rec_jobs (id, status, current_phase, progress_pct, batch_id, error, started_at, updated_at)
15701
- VALUES (?, 'running', 0, 0, NULL, NULL, ?, ?)`
15702
- ).run(id, now, now);
15703
- return getTopicRecJob(id);
15704
- }
15705
- function getTopicRecJob(id) {
15706
- const row = getDb().prepare(`SELECT ${JOB_COLS} FROM topic_rec_jobs WHERE id = ?`).get(id);
15707
- return row ? mapJob(row) : null;
15708
- }
15709
- function updateTopicRecJob(id, patch) {
15710
- const fields = [];
15711
- const values = [];
15712
- if (patch.status !== void 0) {
15713
- fields.push("status = ?");
15714
- values.push(patch.status);
15715
- }
15716
- if (typeof patch.currentPhase === "number") {
15717
- fields.push("current_phase = ?");
15718
- values.push(patch.currentPhase);
15719
- }
15720
- if (typeof patch.progressPct === "number") {
15721
- fields.push("progress_pct = ?");
15722
- values.push(Math.max(0, Math.min(100, Math.round(patch.progressPct))));
15723
- }
15724
- if (patch.batchId !== void 0) {
15725
- fields.push("batch_id = ?");
15726
- values.push(patch.batchId);
15727
- }
15728
- if (patch.error !== void 0) {
15729
- fields.push("error = ?");
15730
- values.push(patch.error);
15731
- }
15732
- if (fields.length === 0) return getTopicRecJob(id);
15733
- fields.push("updated_at = ?");
15734
- values.push(Date.now());
15735
- values.push(id);
15736
- getDb().prepare(`UPDATE topic_rec_jobs SET ${fields.join(", ")} WHERE id = ?`).run(...values);
15737
- return getTopicRecJob(id);
15738
- }
15739
- function markRunningTopicRecJobsFailed() {
15740
- const r = getDb().prepare(
15741
- `UPDATE topic_rec_jobs
15742
- SET status = 'failed',
15743
- error = COALESCE(error, 'server restarted mid-build'),
15744
- updated_at = ?
15745
- WHERE status = 'running'`
15746
- ).run(Date.now());
15747
- return r.changes;
15748
- }
15749
-
15750
- // src/orchestrator/topic-stream.ts
15751
- import { EventEmitter as EventEmitter3 } from "events";
15752
- var TopicRecBus = class {
15753
- emitters = /* @__PURE__ */ new Map();
15754
- get(jobId) {
15755
- let e = this.emitters.get(jobId);
15756
- if (!e) {
15757
- e = new EventEmitter3();
15758
- e.setMaxListeners(16);
15759
- this.emitters.set(jobId, e);
15760
- }
15761
- return e;
15762
- }
15763
- emit(jobId, event) {
15764
- this.get(jobId).emit("event", event);
15765
- }
15766
- subscribe(jobId, listener) {
15767
- const e = this.get(jobId);
15768
- e.on("event", listener);
15769
- return () => e.off("event", listener);
15770
- }
15771
- /** Free the EventEmitter for a job. Call on terminal events
15772
- * (`topic-final`, `topic-error`, `topic-aborted`) so the Map
15773
- * doesn't grow unbounded across many runs in one process. */
15774
- drop(jobId) {
15775
- const e = this.emitters.get(jobId);
15776
- if (e) {
15777
- e.removeAllListeners();
15778
- this.emitters.delete(jobId);
15779
- }
15780
- }
15781
- };
15782
- var topicRecBus = new TopicRecBus();
15783
-
15784
- // src/orchestrator/topic-recommender.ts
15785
- var LLM_CALL_TIMEOUT_MS2 = 6e4;
15786
- var PIPELINE_WALL_CLOCK_MS = 12e4;
15787
- var SEARCH_PARALLEL_CHUNK = 3;
15788
- var SEARCH_CHUNK_GAP_MS = 1e3;
15789
- var SEARCH_RESULTS_PER_QUERY = 5;
15790
- var inFlightJobs2 = /* @__PURE__ */ new Map();
15791
- function signalWithTimeout3(parent, timeoutMs) {
15792
- const controller = new AbortController();
15793
- const onParentAbort = () => controller.abort();
15794
- if (parent?.aborted) controller.abort();
15795
- else parent?.addEventListener("abort", onParentAbort, { once: true });
15796
- const timer = setTimeout(() => controller.abort(), timeoutMs);
15797
- return {
15798
- signal: controller.signal,
15799
- cleanup: () => {
15800
- clearTimeout(timer);
15801
- parent?.removeEventListener("abort", onParentAbort);
15802
- }
15803
- };
15804
- }
15805
- function extractJson5(raw) {
15806
- const fence = /```(?:json)?\s*([\s\S]*?)```/i.exec(raw);
15807
- const candidate = fence ? fence[1] : raw;
15808
- if (!candidate) return null;
15809
- const start = candidate.indexOf("{");
15810
- if (start === -1) return null;
15811
- let depth = 0;
15812
- let end = -1;
15813
- for (let i = start; i < candidate.length; i++) {
15814
- if (candidate[i] === "{") depth++;
15815
- else if (candidate[i] === "}") {
15816
- depth--;
15817
- if (depth === 0) {
15818
- end = i;
15819
- break;
15820
- }
15821
- }
15822
- }
15823
- if (end === -1) return null;
15824
- try {
15825
- return JSON.parse(candidate.slice(start, end + 1));
15826
- } catch {
15827
- return null;
15828
- }
15829
- }
15830
- async function callPhaseLLM2(state, modelV, messages, opts) {
15831
- if (!isModelV(modelV)) return null;
15832
- const t = signalWithTimeout3(state.controller.signal, LLM_CALL_TIMEOUT_MS2);
15833
- try {
15834
- const r = await callLLMWithUsage({
15835
- modelV,
15836
- messages,
15837
- temperature: opts.temperature,
15838
- maxTokens: opts.maxTokens,
15839
- signal: t.signal
15840
- });
15841
- return r.text;
15842
- } catch (e) {
15843
- process.stderr.write(
15844
- `[topic-recommender] ${modelV} failed: ${e instanceof Error ? e.message : String(e)}
15845
- `
15846
- );
15847
- return null;
15848
- } finally {
15849
- t.cleanup();
15850
- }
15851
- }
15852
- function sleepWithSignal2(ms, signal) {
15853
- return new Promise((resolve2) => {
15854
- if (signal.aborted) return resolve2();
15855
- const t = setTimeout(() => {
15856
- signal.removeEventListener("abort", onAbort);
15857
- resolve2();
15858
- }, ms);
15859
- function onAbort() {
15860
- clearTimeout(t);
15861
- resolve2();
15862
- }
15863
- signal.addEventListener("abort", onAbort, { once: true });
15864
- });
15865
- }
15866
- function startTopicRecommend() {
15867
- const jobId = randomUUID2();
15868
- createTopicRecJob(jobId);
15869
- const state = {
15870
- id: jobId,
15871
- startedAt: Date.now(),
15872
- controller: new AbortController()
15873
- };
15874
- inFlightJobs2.set(jobId, state);
15875
- const wallClock = setTimeout(() => {
15876
- if (inFlightJobs2.has(jobId)) state.controller.abort();
15877
- }, PIPELINE_WALL_CLOCK_MS);
15878
- void runPipeline3(state).finally(() => {
15879
- clearTimeout(wallClock);
15880
- inFlightJobs2.delete(jobId);
15881
- });
15882
- return jobId;
15883
- }
15884
- function abortTopicRecommend(jobId) {
15885
- const state = inFlightJobs2.get(jobId);
15886
- if (!state) return false;
15887
- try {
15888
- state.controller.abort();
15889
- } catch {
15890
- }
15891
- return true;
15892
- }
15893
- function isTopicRecJobRunning(jobId) {
15894
- return inFlightJobs2.has(jobId);
15895
- }
15896
- async function runPipeline3(state) {
15897
- const phaseLabels = [
15898
- "Reading your boardroom history",
15899
- "Distilling interests",
15900
- "Scanning trending topics",
15901
- "Synthesising recommendations"
15902
- ];
15903
- const emitPhaseStart = (phase) => {
15904
- topicRecBus.emit(state.id, {
15905
- type: "topic-phase-start",
15906
- phase,
15907
- label: phaseLabels[phase - 1] ?? `Phase ${phase}`
15908
- });
15909
- };
15910
- const emitPhaseProgress = (phase, detail, pct) => {
15911
- topicRecBus.emit(state.id, {
15912
- type: "topic-phase-progress",
15913
- phase,
15914
- detail,
15915
- progressPct: Math.max(0, Math.min(100, Math.round(pct)))
15916
- });
15917
- updateTopicRecJob(state.id, { currentPhase: phase, progressPct: pct });
15918
- };
15919
- const emitPhaseEnd = (phase, pct) => {
15920
- topicRecBus.emit(state.id, {
15921
- type: "topic-phase-end",
15922
- phase,
15923
- progressPct: Math.max(0, Math.min(100, Math.round(pct)))
15924
- });
15925
- updateTopicRecJob(state.id, { currentPhase: phase, progressPct: pct });
15926
- };
15927
- const fail = (message) => {
15928
- updateTopicRecJob(state.id, { status: "failed", error: message });
15929
- topicRecBus.emit(state.id, { type: "topic-error", message });
15930
- topicRecBus.drop(state.id);
15931
- };
15932
- const cancel = () => {
15933
- updateTopicRecJob(state.id, { status: "aborted" });
15934
- topicRecBus.emit(state.id, { type: "topic-aborted" });
15935
- topicRecBus.drop(state.id);
15936
- };
15937
- try {
15938
- emitPhaseStart(1);
15939
- const chair = getChairAgent();
15940
- if (!chair) {
15941
- fail("chair agent missing \u2014 set up onboarding first");
15942
- return;
15943
- }
15944
- const memories = memoriesForContext(chair.id, 50);
15945
- emitPhaseProgress(1, `read ${memories.length} memories`, 8);
15946
- emitPhaseEnd(1, 10);
15947
- if (state.controller.signal.aborted) {
15948
- cancel();
15949
- return;
15950
- }
15951
- emitPhaseStart(2);
15952
- const modelV = utilityModelFor();
15953
- if (!modelV) {
15954
- fail("no LLM provider configured \u2014 add an API key first");
15955
- return;
15956
- }
15957
- const keywords = await distilKeywords(state, modelV, memories);
15958
- if (state.controller.signal.aborted) {
15959
- cancel();
15960
- return;
15961
- }
15962
- if (keywords.length === 0) {
15963
- fail("couldn't distil any keywords from the chair's memory yet \u2014 try again after a couple of rooms");
15964
- return;
15965
- }
15966
- emitPhaseProgress(2, `picked ${keywords.length} keywords`, 25);
15967
- emitPhaseEnd(2, 30);
15968
- const hasWeb = hasWebSearchKey();
15969
- let snippetsByKeyword = /* @__PURE__ */ new Map();
15970
- if (hasWeb) {
15971
- emitPhaseStart(3);
15972
- snippetsByKeyword = await runWebSweep(state, keywords, (kw, snippets, idx) => {
15973
- emitPhaseProgress(
15974
- 3,
15975
- `scanned "${kw}" (${snippets.length} hits) \xB7 ${idx}/${keywords.length}`,
15976
- 30 + Math.round(idx / keywords.length * 40)
15977
- );
15978
- topicRecBus.emit(state.id, {
15979
- type: "topic-search-round",
15980
- keyword: kw,
15981
- query: `${kw} site:x.com`,
15982
- resultsCount: snippets.length,
15983
- snippets
15984
- });
15985
- });
15986
- if (state.controller.signal.aborted) {
15987
- cancel();
15988
- return;
15989
- }
15990
- emitPhaseEnd(3, 70);
15991
- } else {
15992
- emitPhaseProgress(3, "no web-search key \u2014 skipping", 70);
15993
- }
15994
- emitPhaseStart(4);
15995
- const batchId = randomUUID2();
15996
- createTopicRecBatch({ id: batchId, hasWeb, keywords });
15997
- updateTopicRecJob(state.id, { batchId });
15998
- const synth = await synthesiseTopics(state, modelV, {
15999
- memories,
16000
- keywords,
16001
- snippetsByKeyword,
16002
- hasWeb
16003
- });
16004
- if (state.controller.signal.aborted) {
16005
- cancel();
16006
- return;
16007
- }
16008
- if (synth.length === 0) {
16009
- fail("synthesis returned no topics \u2014 try again or refine your boardroom history first");
16010
- return;
16011
- }
16012
- clearAllTopicRecs();
16013
- let inserted = 0;
16014
- for (const t of synth) {
16015
- const rec = insertTopicRec({
16016
- id: randomUUID2(),
16017
- batchId,
16018
- subject: t.subject,
16019
- rationale: t.rationale,
16020
- source: t.source,
16021
- tag: t.tag,
16022
- seedContext: t.seedContext
16023
- });
16024
- inserted++;
16025
- topicRecBus.emit(state.id, { type: "topic-rec", rec });
16026
- emitPhaseProgress(
16027
- 4,
16028
- `synthesised ${inserted}/${synth.length}`,
16029
- 70 + Math.round(inserted / synth.length * 28)
16030
- );
16031
- }
16032
- emitPhaseEnd(4, 100);
16033
- updateTopicRecJob(state.id, {
16034
- status: "done",
16035
- progressPct: 100,
16036
- currentPhase: 4
16037
- });
16038
- topicRecBus.emit(state.id, {
16039
- type: "topic-final",
16040
- batchId,
16041
- totalRecs: inserted,
16042
- hasWeb
16043
- });
16044
- topicRecBus.drop(state.id);
16045
- } catch (e) {
16046
- if (state.controller.signal.aborted) {
16047
- cancel();
16048
- return;
16049
- }
16050
- const msg = e instanceof Error ? e.message : String(e);
16051
- process.stderr.write(`[topic-recommender] pipeline crashed: ${msg}
16052
- `);
16053
- fail(msg);
16054
- }
16055
- }
16056
- async function distilKeywords(state, modelV, memories) {
16057
- if (memories.length === 0) return [];
16058
- const memoryLines = memories.slice(0, 60).map((m, i) => {
16059
- const tier = m.tier === "long" ? "STABLE" : "fresh";
16060
- const prov = m.provenanceRooms > 1 ? ` \xB7 \xD7${m.provenanceRooms} rooms` : "";
16061
- const recency = Math.max(0, Math.round((Date.now() - m.createdAt) / 864e5));
16062
- return `${i + 1}. [${tier}${prov} \xB7 ${recency}d ago \xB7 ${m.kind}] ${m.content}`;
16063
- }).join("\n");
16064
- const system = `You distil a user's interests from a chair's accumulated memory log. Pick the 10 keywords / domains / themes the user is MOST currently engaged with. Weight: recency \xD7 kind salience (goal > preference > observation > fact) \xD7 cross-room provenance. Reject any keyword that wouldn't make a good boardroom subject (too narrow, too transient, too personal-irrelevant). Output strict JSON only: { "keywords": ["...", "...", ...] } with up to 10 entries.`;
16065
- const user = `# Chair's memory about the user (newest first within each tier)
16066
- ${memoryLines}
16067
-
16068
- Return up to 10 keywords as JSON.`;
16069
- const raw = await callPhaseLLM2(state, modelV, [
16070
- { role: "system", content: system },
16071
- { role: "user", content: user }
16072
- ], { temperature: 0.3, maxTokens: 600 });
16073
- if (!raw) return [];
16074
- const parsed = extractJson5(raw);
16075
- if (!parsed || !Array.isArray(parsed.keywords)) return [];
16076
- return parsed.keywords.filter((k) => typeof k === "string").map((k) => k.trim()).filter((k) => k.length > 0).slice(0, 10);
16077
- }
16078
- async function runWebSweep(state, keywords, onKeywordDone) {
16079
- const out = /* @__PURE__ */ new Map();
16080
- const creds = getActiveWebSearchCredentials();
16081
- if (!creds) return out;
16082
- let doneCount = 0;
16083
- for (let i = 0; i < keywords.length; i += SEARCH_PARALLEL_CHUNK) {
16084
- if (state.controller.signal.aborted) break;
16085
- const chunk = keywords.slice(i, i + SEARCH_PARALLEL_CHUNK);
16086
- const settled = await Promise.allSettled(
16087
- chunk.map((kw) => fetchKeywordSnippets(creds.backend, creds.apiKey, kw))
16088
- );
16089
- settled.forEach((res, j) => {
16090
- const kw = chunk[j];
16091
- const snippets = res.status === "fulfilled" ? res.value : [];
16092
- out.set(kw, snippets);
16093
- doneCount++;
16094
- onKeywordDone(kw, snippets, doneCount);
16095
- });
16096
- if (i + SEARCH_PARALLEL_CHUNK < keywords.length) {
16097
- await sleepWithSignal2(SEARCH_CHUNK_GAP_MS, state.controller.signal);
16098
- }
16099
- }
16100
- return out;
16101
- }
16102
- async function fetchKeywordSnippets(backend, apiKey, keyword) {
16103
- const xQuery = `${keyword} site:x.com`;
16104
- const xResults = await runWebSearch(backend, apiKey, xQuery, {
16105
- count: SEARCH_RESULTS_PER_QUERY
16106
- });
16107
- if (xResults && xResults.length > 0) {
16108
- return xResults.map(toSnippet);
16109
- }
16110
- const generic = await runWebSearch(backend, apiKey, keyword, {
16111
- count: SEARCH_RESULTS_PER_QUERY
16112
- });
16113
- return (generic ?? []).map(toSnippet);
16114
- }
16115
- function toSnippet(r) {
16116
- return {
16117
- title: r.title || "(untitled)",
16118
- url: r.url,
16119
- description: r.description || ""
16120
- };
16121
- }
16122
- async function synthesiseTopics(state, modelV, opts) {
16123
- const { memories, keywords, snippetsByKeyword, hasWeb } = opts;
16124
- const flatSnippets = [];
16125
- if (hasWeb) {
16126
- for (const kw of keywords) {
16127
- for (const s of snippetsByKeyword.get(kw) ?? []) {
16128
- flatSnippets.push({ ...s, keyword: kw });
16129
- }
16130
- }
16131
- }
16132
- const memorySummary = memories.length === 0 ? "(no chair memory yet \u2014 recommend topics that introduce the user to the boardroom format)" : memories.slice(0, 24).map((m, i) => `M${i + 1}. [${m.kind}] ${m.content}`).join("\n");
16133
- const snippetBlock = flatSnippets.length === 0 ? "(no web snippets \u2014 synthesise from memory only)" : flatSnippets.map(
16134
- (s, i) => `S${i + 1}. [keyword: ${s.keyword}] ${s.title}
16135
- ${s.description}
16136
- ${s.url}`
16137
- ).join("\n\n");
16138
- const system = 'You recommend boardroom discussion topics to a user, based on (a) the chair\'s long-term memory of who they are + what they care about, and optionally (b) a set of currently-trending web/x.com snippets keyed off the user\'s recent interests. Produce EXACTLY 5 distinct topics \u2014 not 4, not 6, five. Each topic is a subject line a user could plausibly drop into the convene composer.\n\nThe 5 topics MUST span DIFFERENT dimensions/categories \u2014 don\'t return five pricing topics. Use the 10 keywords as a multi-dimensional search index; the 5 final topics should distil ACROSS those dimensions so the picker reads as a balanced board agenda, not a single-angle obsession. Each topic gets a different `tag`.\n\nVoice: tight, specific, opinionated. Avoid corporate-speak. Skew toward questions the user would actually want to debate, not generic explainers.\n\nEVERY topic MUST include a `tag` field \xB7 a SHORT CATEGORY in 1-2 lowercase words naming what bucket the topic falls into. Pick from the user\'s actual subject matter (examples: strategy / product / market / pricing / positioning / brand / hiring / fundraising / ops / infra / research / craft / ethics / personal / leadership / growth / sales / design / data / partnerships / regulation). FORBIDDEN tag values: "web", "memory", "general", "misc", "other", "topic", "recommendation" \u2014 these are meta-vocabulary that leaks the system, not real categories. If the topic spans two areas, pick the dominant one. NEVER omit the tag field.\n\nEach topic must cite either (a) at least one snippet ref (S<n>) \u2014 in which case set "source":"web" \u2014 OR (b) no snippet refs at all \u2014 in which case set "source":"memory". The `source` and `tag` fields are independent: `source` is the data provenance, `tag` is the topic category. Never use "web" or "memory" as a tag.\n\nStrict JSON output only:\n{ "topics": [ { "tag": "pricing", "subject": "...", "rationale": "one sentence on why this fits the user", "source": "web|memory", "snippetRefs": [<S indexes, omit when source=memory>] } ] }';
16139
- const user = `# Keywords distilled from chair memory
16140
- ${keywords.map((k, i) => `K${i + 1}. ${k}`).join("\n")}
16141
-
16142
- # Memory excerpts
16143
- ${memorySummary}
16144
-
16145
- # Web snippets ${hasWeb ? "(use these to ground at least some recs as source=web)" : "(none \u2014 synthesise from memory only)"}
16146
- ${snippetBlock}
16147
-
16148
- Return EXACTLY 5 topics as JSON, each with a different tag, spanning different dimensions.`;
16149
- const raw = await callPhaseLLM2(state, modelV, [
16150
- { role: "system", content: system },
16151
- { role: "user", content: user }
16152
- ], { temperature: 0.6, maxTokens: 2e3 });
16153
- if (!raw) return [];
16154
- const parsed = extractJson5(raw);
16155
- if (!parsed || !Array.isArray(parsed.topics)) return [];
16156
- const out = [];
16157
- for (const t of parsed.topics) {
16158
- const subject = typeof t.subject === "string" ? t.subject.trim() : "";
16159
- const rationale = typeof t.rationale === "string" ? t.rationale.trim() : "";
16160
- if (!subject || !rationale) continue;
16161
- let tag = null;
16162
- const TAG_BLOCKLIST = /* @__PURE__ */ new Set([
16163
- "web",
16164
- "memory",
16165
- "general",
16166
- "misc",
16167
- "other",
16168
- "topic",
16169
- "recommendation",
16170
- "recommendations",
16171
- "rec",
16172
- "category",
16173
- "n/a",
16174
- "na",
16175
- "none"
16176
- ]);
16177
- if (typeof t.tag === "string") {
16178
- const cleaned = t.tag.trim().replace(/^\/\/\s*/, "").toLowerCase().replace(/[^a-z0-9 -]/g, "").replace(/\s+/g, " ").trim().slice(0, 28);
16179
- if (cleaned.length > 0 && !TAG_BLOCKLIST.has(cleaned)) {
16180
- tag = cleaned;
16181
- }
16182
- }
16183
- if (!tag) {
16184
- const words = subject.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length > 2 && !["the", "and", "for", "are", "you", "your", "what", "how", "why", "when", "with", "from", "this", "that"].includes(w));
16185
- if (words.length > 0) {
16186
- tag = words.slice(0, 2).join(" ").slice(0, 28);
16187
- } else {
16188
- tag = "topic";
16189
- }
16190
- }
16191
- let source = t.source === "web" ? "web" : "memory";
16192
- let seedContext = null;
16193
- if (source === "web" && hasWeb && Array.isArray(t.snippetRefs)) {
16194
- const refs = t.snippetRefs.map((r) => {
16195
- if (typeof r === "number") return r;
16196
- if (typeof r === "string") {
16197
- const m = r.match(/^S?(\d+)$/i);
16198
- return m ? Number(m[1]) : NaN;
16199
- }
16200
- return NaN;
16201
- }).filter((n) => Number.isInteger(n) && n > 0 && n <= flatSnippets.length);
16202
- const seen = /* @__PURE__ */ new Set();
16203
- const cited = [];
16204
- for (const ref of refs) {
16205
- const snip = flatSnippets[ref - 1];
16206
- if (!snip || seen.has(snip.url)) continue;
16207
- seen.add(snip.url);
16208
- cited.push({ title: snip.title, url: snip.url, description: snip.description });
16209
- }
16210
- if (cited.length > 0) {
16211
- seedContext = cited;
16212
- } else {
16213
- source = "memory";
16214
- }
16215
- } else if (source === "web") {
16216
- source = "memory";
16217
- }
16218
- out.push({ subject, rationale, source, tag, seedContext });
16219
- if (out.length >= 6) break;
16220
- }
16221
- return out;
16222
- }
16223
-
16224
- // src/routes/topic-recs.ts
16225
- function topicRecsRouter() {
16226
- const r = new Hono6();
16227
- r.post("/", (c) => {
16228
- if (!hasAnyModelKey()) {
16229
- return c.json({ error: "configure an LLM provider key first" }, 400);
16230
- }
16231
- const jobId = startTopicRecommend();
16232
- return c.json({ jobId });
16233
- });
16234
- r.get("/jobs/:id/stream", (c) => {
16235
- const jobId = c.req.param("id");
16236
- const job = getTopicRecJob(jobId);
16237
- if (!job) return c.json({ error: "job not found" }, 404);
16238
- return streamSSE2(c, async (s) => {
16239
- await s.writeSSE({
16240
- event: "hello",
16241
- data: JSON.stringify({
16242
- jobId,
16243
- status: job.status,
16244
- currentPhase: job.currentPhase,
16245
- progressPct: job.progressPct,
16246
- batchId: job.batchId,
16247
- error: job.error
16248
- })
16249
- });
16250
- if (!isTopicRecJobRunning(jobId)) {
16251
- if (job.status === "done") {
16252
- await s.writeSSE({
16253
- event: "topic-final",
16254
- data: JSON.stringify({
16255
- type: "topic-final",
16256
- batchId: job.batchId,
16257
- totalRecs: null,
16258
- hasWeb: null
16259
- })
16260
- });
16261
- } else if (job.status === "aborted") {
16262
- await s.writeSSE({
16263
- event: "topic-aborted",
16264
- data: JSON.stringify({ type: "topic-aborted" })
16265
- });
16266
- } else if (job.status === "failed") {
16267
- await s.writeSSE({
16268
- event: "topic-error",
16269
- data: JSON.stringify({
16270
- type: "topic-error",
16271
- message: job.error || "generation failed"
16272
- })
16273
- });
16274
- }
16275
- return;
16276
- }
16277
- const queue = [];
16278
- let resolveWaiter = null;
16279
- let closed = false;
16280
- const off = topicRecBus.subscribe(jobId, (event) => {
16281
- queue.push(event);
16282
- if (resolveWaiter) {
16283
- resolveWaiter();
16284
- resolveWaiter = null;
16285
- }
16286
- });
16287
- s.onAbort(() => {
16288
- closed = true;
16289
- off();
16290
- if (resolveWaiter) {
16291
- resolveWaiter();
16292
- resolveWaiter = null;
16293
- }
16294
- });
16295
- while (!closed) {
16296
- if (queue.length === 0) {
16297
- await new Promise((resolve2) => {
16298
- resolveWaiter = resolve2;
16299
- });
16300
- continue;
16301
- }
16302
- const event = queue.shift();
16303
- await s.writeSSE({ event: event.type, data: JSON.stringify(event) });
16304
- if (event.type === "topic-final" || event.type === "topic-error" || event.type === "topic-aborted") {
16305
- closed = true;
16306
- off();
16307
- }
16308
- }
15932
+ providers: collectProviderSummary(all),
15933
+ /** The user's single active LLM provider (under the single-
15934
+ * active-LLM-provider invariant). Null when none configured.
15935
+ * Frontend pickers use this to decide grouping strategy:
15936
+ * multi-model → group by family with "via {provider}" caption,
15937
+ * single-model flat list. */
15938
+ activeLlmProvider: active,
15939
+ activeLlmClassification: active ? isMultiModelProvider(active) ? "multi-model" : "single-model" : null
16309
15940
  });
16310
15941
  });
16311
- r.post("/jobs/:id/abort", (c) => {
16312
- const jobId = c.req.param("id");
16313
- const ok = abortTopicRecommend(jobId);
16314
- if (!ok) {
16315
- const job = getTopicRecJob(jobId);
16316
- if (!job) return c.json({ error: "job not found" }, 404);
16317
- return c.json({ ok: true, status: job.status });
16318
- }
16319
- return c.json({ ok: true });
16320
- });
16321
- r.get("/", (c) => {
16322
- const cursorRaw = c.req.query("cursor");
16323
- const limitRaw = c.req.query("limit");
16324
- const cursor = cursorRaw && /^\d+$/.test(cursorRaw) ? Number(cursorRaw) : null;
16325
- const limit = limitRaw && /^\d+$/.test(limitRaw) ? Math.max(1, Math.min(100, Number(limitRaw))) : 20;
16326
- const { items, nextCursor } = listTopicRecs({ cursor, limit });
16327
- return c.json({ items, nextCursor });
16328
- });
16329
- r.get("/:id", (c) => {
16330
- const id = c.req.param("id");
16331
- const rec = getTopicRec(id);
16332
- if (!rec) return c.json({ error: "not found" }, 404);
16333
- return c.json(rec);
16334
- });
16335
15942
  return r;
16336
15943
  }
15944
+ function collectProviderSummary(models) {
15945
+ const map = /* @__PURE__ */ new Map();
15946
+ for (const m of models) {
15947
+ const cur = map.get(m.provider) ?? { reachable: 0, total: 0 };
15948
+ cur.total++;
15949
+ if (m.reachable) cur.reachable++;
15950
+ map.set(m.provider, cur);
15951
+ }
15952
+ return Array.from(map.entries()).map(([provider, v]) => ({ provider, ...v }));
15953
+ }
16337
15954
 
16338
15955
  // src/routes/notes.ts
16339
15956
  import { Hono as Hono7 } from "hono";
@@ -16545,7 +16162,7 @@ function prefsRouter() {
16545
16162
 
16546
16163
  // src/routes/rooms.ts
16547
16164
  import { Hono as Hono9 } from "hono";
16548
- import { streamSSE as streamSSE3 } from "hono/streaming";
16165
+ import { streamSSE as streamSSE2 } from "hono/streaming";
16549
16166
 
16550
16167
  // src/storage/key_points.ts
16551
16168
  init_db();
@@ -16663,7 +16280,7 @@ Does the chair need to ask a clarifying question before opening the room?`
16663
16280
  }
16664
16281
  return { shouldAsk: true, rationale: "" };
16665
16282
  }
16666
- const parsed = extractJson6(raw);
16283
+ const parsed = extractJson5(raw);
16667
16284
  if (!parsed || typeof parsed !== "object") {
16668
16285
  return { shouldAsk: true, rationale: "" };
16669
16286
  }
@@ -16752,7 +16369,7 @@ async function pickRoundWrap(opts) {
16752
16369
  }
16753
16370
  return { recommendation: "continue", rationale: "" };
16754
16371
  }
16755
- const parsed = extractJson6(raw);
16372
+ const parsed = extractJson5(raw);
16756
16373
  if (!parsed || typeof parsed !== "object") {
16757
16374
  return { recommendation: "continue", rationale: "" };
16758
16375
  }
@@ -16919,7 +16536,7 @@ ${extras.join("\n")}` : baseRow;
16919
16536
  }
16920
16537
  return { agentId: null, rationale: "", intervention: null };
16921
16538
  }
16922
- const parsed = extractJson6(raw);
16539
+ const parsed = extractJson5(raw);
16923
16540
  if (!parsed || typeof parsed !== "object") {
16924
16541
  return { agentId: null, rationale: "", intervention: null };
16925
16542
  }
@@ -17041,7 +16658,7 @@ async function pickChairWebSearch(opts) {
17041
16658
  }
17042
16659
  return null;
17043
16660
  }
17044
- const parsed = extractJson6(raw);
16661
+ const parsed = extractJson5(raw);
17045
16662
  if (!parsed || typeof parsed !== "object") return null;
17046
16663
  const ws = parsed;
17047
16664
  if (typeof ws.query !== "string") return null;
@@ -17066,7 +16683,7 @@ function buildSkillsIndex(skills) {
17066
16683
  function loadSkillBody(skill) {
17067
16684
  return skill.bodyMd;
17068
16685
  }
17069
- function extractJson6(text) {
16686
+ function extractJson5(text) {
17070
16687
  if (!text) return null;
17071
16688
  let s = text.trim();
17072
16689
  s = s.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/i, "").trim();
@@ -17180,7 +16797,7 @@ Which skills apply, and does this turn need web search?`
17180
16797
  continue;
17181
16798
  }
17182
16799
  }
17183
- const parsed = extractJson6(raw);
16800
+ const parsed = extractJson5(raw);
17184
16801
  if (!parsed || typeof parsed !== "object") {
17185
16802
  return { used: [], reason: "", webSearchQuery: null };
17186
16803
  }
@@ -17569,22 +17186,22 @@ var TONE_GUIDANCE = {
17569
17186
  "",
17570
17187
  "## Each turn MUST",
17571
17188
  " (1) Name the lens you're auditing from this turn.",
17572
- " (2) Surface 2\u20133 specific flaws, EACH labelled by both axes:",
17573
- " **Severity** \xB7 BLOCKER (ship is unsafe) \xB7 MAJOR (fix before commit) \xB7 MINOR (nice-to-fix)",
17574
- " **Likelihood** \xB7 LIKELY (>50%) \xB7 PLAUSIBLE (10-50%) \xB7 EDGE (<10%)",
17575
- " A BLOCKER \xD7 LIKELY is the room's headline. A BLOCKER \xD7 EDGE only matters when the consequence is asymmetric (catastrophic, or cheap to mitigate). MINOR \xD7 EDGE is noise \u2014 drop it.",
17189
+ " (2) Surface 2\u20133 specific flaws. For each, communicate BOTH how bad and how likely \u2014 but in plain prose, not as a stamped tag.",
17190
+ " How bad \xB7 `blocker` (ship is unsafe) / `major` (fix before commit) / `minor` (nice-to-fix)",
17191
+ " How likely \xB7 `likely` (>50%) / `plausible` (10-50%) / `edge` (<10%)",
17192
+ ` Weave these into ordinary sentences \u2014 "a likely blocker on the auth path", "a plausible major flaw if X scales past Y", "an edge-case minor issue". The room's headline finding is whatever combines high severity with high likelihood. A blocker that's only an edge case still matters when the consequence is asymmetric (catastrophic, or cheap to mitigate). Edge-case minors are noise \u2014 drop them.`,
17576
17193
  ' (3) For each flaw: point at the specific load-bearing piece ("the X claim in \xA72", "the assumption that Y"); state the **concrete failure mode** (the specific scenario where this breaks); state the **downstream consequence** (what falls over if it breaks); indicate the **direction a fix would lie** in ONE sentence \u2014 pointer, not redesign.',
17577
- " (4) **Strength-preservation** \xB7 every BLOCKER you raise MUST include 1 sentence on what the artifact gets RIGHT \u2014 the part that should survive any rebuild. Without this, critique reads as nihilism and the room stops trusting the reviewer.",
17194
+ " (4) **Strength-preservation** \xB7 every blocker you raise MUST include 1 sentence on what the artifact gets RIGHT \u2014 the part that should survive any rebuild. Without this, critique reads as nihilism and the room stops trusting the reviewer.",
17578
17195
  "",
17579
- `At least one BLOCKER or MAJOR per turn is mandatory. If you can't find one, state the conditional plainly: "this would have a major issue if Z, but I don't see Z here" \u2014 explicit absence-of-flaw, not a wave-through.`,
17196
+ `At least one blocker or major flaw per turn is mandatory. If you can't find one, state the conditional plainly: "this would have a major issue if Z, but I don't see Z here" \u2014 explicit absence-of-flaw, not a wave-through.`,
17580
17197
  "",
17581
17198
  "## Forbidden",
17582
17199
  " \xB7 Redesigning or reframing the work. You audit as-is. The fix-direction pointer is ONE sentence; never a rewrite.",
17583
17200
  ' \xB7 Vague "feels off" / "not quite right" without a mechanism.',
17584
17201
  " \xB7 Praise-only turns; attacking the author rather than the work.",
17585
- " \xB7 Cherry-picking edge cases \u2014 naming a 1% failure mode as BLOCKER without anchoring it to its likelihood AND its consequence asymmetry.",
17202
+ " \xB7 Cherry-picking edge cases \u2014 naming a 1% failure mode as a blocker without anchoring it to its likelihood AND its consequence asymmetry.",
17586
17203
  " \xB7 Repeating another director's critique under a different label.",
17587
- " \xB7 Skipping severity / likelihood labels \u2014 they're required, not optional.",
17204
+ ' \xB7 **Stamped axis tags** \xB7 DO NOT write rubber-stamp labels like `MAJOR \xD7 LIKELY`, `BLOCKER \xB7 LIKELY`, `[MAJOR/LIKELY]`, etc. The severity + likelihood live INSIDE your prose ("this is a likely major flaw because\u2026"), not as a literal `X \xD7 Y` stamp at the head of each bullet. Stamping turns the room into form-filling; the audit discipline is in the *reasoning*, not in the labels.',
17588
17205
  "",
17589
17206
  "PERSONA OVERRIDE \xB7 your director instruction's voice / boundaries section may default to softening criticism, finding the constructive frame, validating effort before fault-finding, or refusing to hold the work to a high bar \u2014 typical patterns for empathic / mentor / co-creator personas. For THIS room those defaults are PAUSED. Rigour beats kindness. The user explicitly opted into a fault-audit; softening flaws or skipping labels is what fails them, not what helps them. Lean INTO the audit discipline \u2014 but stay rigorous, not cynical: every claim falsifiable, every BLOCKER paired with a strength preserved."
17590
17207
  ].join("\n")
@@ -18095,10 +17712,8 @@ function buildChairClarifyMessages(opts) {
18095
17712
  ``,
18096
17713
  `Output: either <ack + blank line + READY> OR the 2-part question block (in the user's language).`
18097
17714
  ].join("\n");
18098
- const seedSystem = buildSeedContextSystem(opts.history);
18099
17715
  return [
18100
17716
  buildChairSystem(opts, isFirstTurn ? firstTurnTask : followUpTask),
18101
- ...seedSystem ? [seedSystem] : [],
18102
17717
  ...renderHistoryForChair(opts.history, opts.cast, opts.prefs),
18103
17718
  {
18104
17719
  role: "user",
@@ -18106,39 +17721,6 @@ function buildChairClarifyMessages(opts) {
18106
17721
  }
18107
17722
  ];
18108
17723
  }
18109
- function buildSeedContextSystem(history) {
18110
- for (let i = 0; i < history.length; i++) {
18111
- const m = history[i];
18112
- if (m.authorKind !== "user") continue;
18113
- const meta = m.meta;
18114
- const rationale = typeof meta?.seedContext?.rationale === "string" ? meta.seedContext.rationale.trim() : "";
18115
- const rawSnippets = meta?.seedContext?.snippets;
18116
- const snippets = Array.isArray(rawSnippets) ? rawSnippets : [];
18117
- const snippetLines = [];
18118
- for (const s of snippets) {
18119
- if (!s || typeof s !== "object") continue;
18120
- const title = typeof s.title === "string" ? s.title.trim() : "";
18121
- const url = typeof s.url === "string" ? s.url.trim() : "";
18122
- const desc = typeof s.description === "string" ? s.description.trim() : "";
18123
- if (!title && !url && !desc) continue;
18124
- snippetLines.push(`\xB7 ${title || "(untitled)"} \u2014 ${url || "(no url)"}
18125
- ${desc.slice(0, 360)}`);
18126
- }
18127
- if (!rationale && snippetLines.length === 0) continue;
18128
- const blocks = [
18129
- `\u2500\u2500\u2500 BACKGROUND MATERIAL \xB7 pre-attached by the user \u2500\u2500\u2500`,
18130
- `The user opened this room from a topic recommendation. Treat the material below as hidden context they've already seen \u2014 reference it naturally when useful, don't re-summarise it, don't pretend it doesn't exist.`
18131
- ];
18132
- if (rationale) {
18133
- blocks.push(``, `Why this topic was recommended (hidden from the user \u2014 your reasoning context):`, `\xB7 ${rationale}`);
18134
- }
18135
- if (snippetLines.length > 0) {
18136
- blocks.push(``, `Source snippets the recommendation was grounded in:`, ...snippetLines);
18137
- }
18138
- return { role: "system", content: blocks.join("\n") };
18139
- }
18140
- return null;
18141
- }
18142
17724
  function buildChairConveningMessages(opts) {
18143
17725
  const subject = opts.room.subject;
18144
17726
  const directorList = opts.picksWithReasons.map((p, i) => {
@@ -18206,14 +17788,15 @@ function buildChairRoundEndMessages(opts) {
18206
17788
  ``,
18207
17789
  `\u2500\u2500\u2500 CRITIQUE-MODE POINT SELECTION \u2500\u2500\u2500`,
18208
17790
  `Override the default "what got said" rule with severity-aware curation. Pick points that maximise audit value:`,
18209
- ` \xB7 Prefer 1 BLOCKER \xD7 LIKELY flaw over 3 MINOR \xD7 EDGE flaws.`,
17791
+ ` \xB7 Prefer 1 likely blocker over 3 edge-case minors.`,
18210
17792
  ` \xB7 Surface the dimension NO director attacked this round (the lens-coverage gap).`,
18211
- ` \xB7 If the room only produced LIKELY MINOR flaws this round, name that explicitly \u2014 it's a signal the artifact is more resilient than first thought, not a reason to inflate severity.`,
17793
+ ` \xB7 If the room only produced likely-but-minor flaws this round, name that explicitly \u2014 it's a signal the artifact is more resilient than first thought, not a reason to inflate severity.`,
18212
17794
  `Use these prompts to test what should rise to a key point:`,
18213
17795
  ` \xB7 Which flaw is fatal, and which is fixable?`,
18214
17796
  ` \xB7 What sounds plausible now but probably won't survive execution?`,
18215
17797
  ` \xB7 Which lens is conspicuously absent from this round's critique?`,
18216
- ` \xB7 What would a competitor / regulator / power user attack that didn't get raised?`
17798
+ ` \xB7 What would a competitor / regulator / power user attack that didn't get raised?`,
17799
+ `Phrase points in plain prose \u2014 do NOT carry over the directors' \`X \xD7 Y\` axis-tag notation if any leaked through (e.g. \`MAJOR \xD7 LIKELY\`). Rephrase as natural language ("a likely major flaw on \u2026").`
18217
17800
  ].join("\n") : "";
18218
17801
  const task = [
18219
17802
  `\u2500\u2500\u2500 YOUR TASK \xB7 CLOSE THIS ROUND \u2500\u2500\u2500`,
@@ -19131,6 +18714,37 @@ Rules:
19131
18714
  return branch.id;
19132
18715
  }
19133
18716
 
18717
+ // src/orchestrator/timeouts.ts
18718
+ var TimeoutError = class extends Error {
18719
+ constructor(ms, label) {
18720
+ super(`timeout after ${ms}ms${label ? ` \xB7 ${label}` : ""}`);
18721
+ this.name = "TimeoutError";
18722
+ }
18723
+ };
18724
+ function withTimeout(promise, ms, label) {
18725
+ let timer = null;
18726
+ const timeout = new Promise((_, reject) => {
18727
+ timer = setTimeout(() => reject(new TimeoutError(ms, label)), ms);
18728
+ });
18729
+ return Promise.race([promise, timeout]).finally(() => {
18730
+ if (timer) clearTimeout(timer);
18731
+ });
18732
+ }
18733
+
18734
+ // src/orchestrator/auto-skip.ts
18735
+ function emitAutoSkipped(roomId, phase, reason, messageId) {
18736
+ roomBus.emit(roomId, {
18737
+ type: "config-event",
18738
+ kind: "auto-skipped",
18739
+ payload: {
18740
+ phase,
18741
+ reason,
18742
+ ...messageId ? { messageId } : {}
18743
+ },
18744
+ createdAt: Date.now()
18745
+ });
18746
+ }
18747
+
19134
18748
  // src/voice/sentence-splitter.ts
19135
18749
  var END_RE = /[。!?!?;;::\n]|[.](?=\s|$)/;
19136
18750
  var SentenceChunker = class {
@@ -19191,6 +18805,39 @@ function minimaxBaseUrl2() {
19191
18805
  }
19192
18806
  var ELEVENLABS_API = "https://api.elevenlabs.io/v1";
19193
18807
  var OPENAI_API = "https://api.openai.com/v1";
18808
+ function makeMiniMaxBalanceError() {
18809
+ const err2 = new Error(
18810
+ "Your MiniMax account balance is insufficient for TTS. Top up your account in the MiniMax console and try again."
18811
+ );
18812
+ err2.code = "paid-plan-required";
18813
+ err2.provider = "minimax";
18814
+ err2.upgradeUrl = getPrefs().minimaxRegion === "intl" ? "https://platform.minimax.io/user-center/billing/overview" : "https://platform.minimaxi.com/user-center/payment";
18815
+ return err2;
18816
+ }
18817
+ function makeElevenLabsBillingError(message) {
18818
+ const err2 = new Error(message);
18819
+ err2.code = "paid-plan-required";
18820
+ err2.provider = "elevenlabs";
18821
+ err2.upgradeUrl = "https://elevenlabs.io/pricing";
18822
+ return err2;
18823
+ }
18824
+ function isElevenLabsCreditError(errText) {
18825
+ return /quota_exceeded|insufficient[ _-]?(?:credit|quota|balance|fund)|out\s+of\s+credits?|voice_limit_reached|余额不足/i.test(errText);
18826
+ }
18827
+ function tryExtractTtsBillingError(err2) {
18828
+ if (!err2 || typeof err2 !== "object") return null;
18829
+ const tagged = err2;
18830
+ if (tagged.code !== "paid-plan-required") return null;
18831
+ if (typeof tagged.provider !== "string") return null;
18832
+ const out = err2;
18833
+ if (typeof tagged.upgradeUrl !== "string") {
18834
+ out.upgradeUrl = "";
18835
+ }
18836
+ if (typeof tagged.message !== "string") {
18837
+ out.message = "Voice synthesis requires a paid plan.";
18838
+ }
18839
+ return out;
18840
+ }
19194
18841
  function cleanForSpeech(md) {
19195
18842
  if (!md) return "";
19196
18843
  let out = md;
@@ -19290,11 +18937,17 @@ async function* synthesizeSpeechStream(text, profile, signal) {
19290
18937
  });
19291
18938
  if (!res.ok) {
19292
18939
  const errText = await res.text();
18940
+ if (res.status === 402 || /"status_code"\s*:\s*1008|insufficient[ _-]?(?:balance|quota|credit|fund)|余额不足|余额[^a-zA-Z]?(?:不足|不够)/i.test(errText)) {
18941
+ throw makeMiniMaxBalanceError();
18942
+ }
19293
18943
  throw new Error(`MiniMax TTS stream HTTP ${res.status}: ${errText}`);
19294
18944
  }
19295
18945
  const contentType = res.headers.get("content-type") || "";
19296
18946
  if (!contentType.includes("text/event-stream")) {
19297
18947
  const errBody = await res.text();
18948
+ if (/"status_code"\s*:\s*1008|insufficient[ _-]?(?:balance|quota|credit|fund)|余额不足|余额[^a-zA-Z]?(?:不足|不够)/i.test(errBody)) {
18949
+ throw makeMiniMaxBalanceError();
18950
+ }
19298
18951
  throw new Error(`MiniMax TTS: expected event-stream but got ${contentType}: ${errBody.slice(0, 200)}`);
19299
18952
  }
19300
18953
  const body = res.body;
@@ -19401,13 +19054,7 @@ async function synthesizeMiniMax(text, profile, signal) {
19401
19054
  if (!res.ok) {
19402
19055
  const errText = await res.text();
19403
19056
  if (res.status === 402 || /insufficient[ _-]?(?:balance|quota|credit|fund)|余额不足|余额[^a-zA-Z]?(?:不足|不够)/i.test(errText)) {
19404
- const err2 = new Error(
19405
- "Your MiniMax account balance is insufficient for TTS. Top up your account in the MiniMax console and try again."
19406
- );
19407
- err2.code = "paid-plan-required";
19408
- err2.provider = "minimax";
19409
- err2.upgradeUrl = getPrefs().minimaxRegion === "intl" ? "https://platform.minimax.io/user-center/billing/overview" : "https://platform.minimaxi.com/user-center/payment";
19410
- throw err2;
19057
+ throw makeMiniMaxBalanceError();
19411
19058
  }
19412
19059
  throw new Error(`MiniMax TTS HTTP ${res.status}: ${errText}`);
19413
19060
  }
@@ -19415,13 +19062,7 @@ async function synthesizeMiniMax(text, profile, signal) {
19415
19062
  const status = json.base_resp?.status_code ?? 0;
19416
19063
  const statusMsg = json.base_resp?.status_msg || "";
19417
19064
  if (status !== 0 && (status === 1008 || /insufficient[ _-]?(?:balance|quota|credit|fund)|余额不足|余额[^a-zA-Z]?(?:不足|不够)/i.test(statusMsg))) {
19418
- const err2 = new Error(
19419
- "Your MiniMax account balance is insufficient for TTS. Top up your account in the MiniMax console and try again."
19420
- );
19421
- err2.code = "paid-plan-required";
19422
- err2.provider = "minimax";
19423
- err2.upgradeUrl = getPrefs().minimaxRegion === "intl" ? "https://platform.minimax.io/user-center/billing/overview" : "https://platform.minimaxi.com/user-center/payment";
19424
- throw err2;
19065
+ throw makeMiniMaxBalanceError();
19425
19066
  }
19426
19067
  const hex = json.data?.audio ?? json.audio ?? "";
19427
19068
  if (!hex) {
@@ -19504,13 +19145,14 @@ async function synthesizeElevenLabs(text, profile, signal) {
19504
19145
  if (!res.ok) {
19505
19146
  const errText = await res.text();
19506
19147
  if (res.status === 402 && /paid_plan_required|library voices/i.test(errText)) {
19507
- const err2 = new Error(
19148
+ throw makeElevenLabsBillingError(
19508
19149
  "ElevenLabs library voices (Rachel, George, etc.) require a paid plan to use via the API. Either upgrade your ElevenLabs subscription, or clone your own voice in the ElevenLabs dashboard and pick it here."
19509
19150
  );
19510
- err2.code = "paid-plan-required";
19511
- err2.provider = "elevenlabs";
19512
- err2.upgradeUrl = "https://elevenlabs.io/pricing";
19513
- throw err2;
19151
+ }
19152
+ if (isElevenLabsCreditError(errText)) {
19153
+ throw makeElevenLabsBillingError(
19154
+ "Your ElevenLabs account is out of credits. Top up your ElevenLabs plan and try again."
19155
+ );
19514
19156
  }
19515
19157
  throw new Error(`ElevenLabs TTS HTTP ${res.status}: ${errText.slice(0, 400)}`);
19516
19158
  }
@@ -19549,13 +19191,14 @@ async function* synthesizeElevenLabsStream(text, profile, signal) {
19549
19191
  if (!res.ok) {
19550
19192
  const errText = await res.text();
19551
19193
  if (res.status === 402 && /paid_plan_required|library voices/i.test(errText)) {
19552
- const err2 = new Error(
19194
+ throw makeElevenLabsBillingError(
19553
19195
  "ElevenLabs library voices (Rachel, George, etc.) require a paid plan to use via the API. Either upgrade your ElevenLabs subscription, or clone your own voice in the ElevenLabs dashboard and pick it here."
19554
19196
  );
19555
- err2.code = "paid-plan-required";
19556
- err2.provider = "elevenlabs";
19557
- err2.upgradeUrl = "https://elevenlabs.io/pricing";
19558
- throw err2;
19197
+ }
19198
+ if (isElevenLabsCreditError(errText)) {
19199
+ throw makeElevenLabsBillingError(
19200
+ "Your ElevenLabs account is out of credits. Top up your ElevenLabs plan and try again."
19201
+ );
19559
19202
  }
19560
19203
  throw new Error(`ElevenLabs TTS stream HTTP ${res.status}: ${errText.slice(0, 400)}`);
19561
19204
  }
@@ -19614,7 +19257,8 @@ function ensureState(roomId) {
19614
19257
  if (!s) {
19615
19258
  s = {
19616
19259
  queue: [],
19617
- inflight: null,
19260
+ inflight: /* @__PURE__ */ new Map(),
19261
+ preWarmed: null,
19618
19262
  processing: false,
19619
19263
  roundNum: 1,
19620
19264
  speakersThisTurn: 0,
@@ -19628,7 +19272,8 @@ function ensureState(roomId) {
19628
19272
  pendingFrameBreakerRole: null,
19629
19273
  lastFrameBreakerAgentId: null,
19630
19274
  billingHaltedThisTurn: false,
19631
- voiceWaiters: /* @__PURE__ */ new Map()
19275
+ voiceWaiters: /* @__PURE__ */ new Map(),
19276
+ voicePredone: /* @__PURE__ */ new Set()
19632
19277
  };
19633
19278
  _state.set(roomId, s);
19634
19279
  }
@@ -19636,29 +19281,59 @@ function ensureState(roomId) {
19636
19281
  }
19637
19282
  function markVoicePlaybackDone(roomId, messageId) {
19638
19283
  const s = _state.get(roomId);
19639
- const waiter = s?.voiceWaiters.get(messageId);
19640
- if (!waiter || !s) return false;
19641
- s.voiceWaiters.delete(messageId);
19642
- waiter();
19284
+ if (!s) return false;
19285
+ const waiter = s.voiceWaiters.get(messageId);
19286
+ if (waiter) {
19287
+ s.voiceWaiters.delete(messageId);
19288
+ waiter.resolve();
19289
+ return true;
19290
+ }
19291
+ s.voicePredone.add(messageId);
19292
+ return false;
19293
+ }
19294
+ function bumpVoicePlaybackHeartbeat(roomId, messageId) {
19295
+ const s = _state.get(roomId);
19296
+ if (!s) return false;
19297
+ const waiter = s.voiceWaiters.get(messageId);
19298
+ if (!waiter) return false;
19299
+ waiter.bump();
19643
19300
  return true;
19644
19301
  }
19645
- function waitForVoicePlayback(roomId, messageId, timeoutMs = 12e4) {
19302
+ function waitForVoicePlayback(roomId, messageId, timeoutMs = 6e4) {
19646
19303
  const s = ensureState(roomId);
19304
+ if (s.voicePredone.has(messageId)) {
19305
+ s.voicePredone.delete(messageId);
19306
+ return Promise.resolve();
19307
+ }
19647
19308
  return new Promise((resolve2) => {
19648
- const timer = setTimeout(() => {
19649
- s.voiceWaiters.delete(messageId);
19650
- resolve2();
19651
- }, timeoutMs);
19652
- s.voiceWaiters.set(messageId, () => {
19653
- clearTimeout(timer);
19654
- resolve2();
19309
+ let timer;
19310
+ const arm = () => {
19311
+ timer = setTimeout(() => {
19312
+ s.voiceWaiters.delete(messageId);
19313
+ process.stderr.write(
19314
+ `[voice-wait] no-heartbeat fallback fired for msg=${messageId.slice(0, 8)} after ${timeoutMs}ms
19315
+ `
19316
+ );
19317
+ resolve2();
19318
+ }, timeoutMs);
19319
+ };
19320
+ arm();
19321
+ s.voiceWaiters.set(messageId, {
19322
+ resolve: () => {
19323
+ clearTimeout(timer);
19324
+ resolve2();
19325
+ },
19326
+ bump: () => {
19327
+ clearTimeout(timer);
19328
+ arm();
19329
+ }
19655
19330
  });
19656
19331
  });
19657
19332
  }
19658
19333
  function isRoomSpeaking(roomId) {
19659
19334
  const s = _state.get(roomId);
19660
19335
  if (!s) return false;
19661
- return s.inflight !== null;
19336
+ return s.inflight.size > 0;
19662
19337
  }
19663
19338
  function injectSpeakers(roomId, agentIds) {
19664
19339
  if (agentIds.length === 0) return;
@@ -19695,7 +19370,7 @@ function injectSpeakers(roomId, agentIds) {
19695
19370
  function requestSoftPause(roomId) {
19696
19371
  const s = ensureState(roomId);
19697
19372
  s.pauseAfterCurrent = true;
19698
- rlog(roomId, "soft-pause-requested", { remaining: s.queue.length, speaking: s.inflight ? 1 : 0 });
19373
+ rlog(roomId, "soft-pause-requested", { remaining: s.queue.length, speaking: s.inflight.size });
19699
19374
  }
19700
19375
  function setPendingUserAfterCurrent(roomId, payload) {
19701
19376
  const s = ensureState(roomId);
@@ -19704,7 +19379,7 @@ function setPendingUserAfterCurrent(roomId, payload) {
19704
19379
  function requestRoundEndAfterCurrent(roomId) {
19705
19380
  const s = ensureState(roomId);
19706
19381
  s.pendingRoundEnd = true;
19707
- rlog(roomId, "round-end-deferred", { remaining: s.queue.length, speaking: s.inflight ? 1 : 0 });
19382
+ rlog(roomId, "round-end-deferred", { remaining: s.queue.length, speaking: s.inflight.size });
19708
19383
  }
19709
19384
  async function chairInterrupt(roomId) {
19710
19385
  const state = ensureState(roomId);
@@ -19713,23 +19388,30 @@ async function chairInterrupt(roomId) {
19713
19388
  status: "queued"
19714
19389
  }));
19715
19390
  let interruptedAgentId = null;
19716
- if (state.inflight) {
19391
+ if (state.inflight.size > 0) {
19717
19392
  interruptedAgentId = state.queue[0]?.agentId ?? null;
19718
- state.inflight.abort();
19719
- state.inflight = null;
19720
- if (interruptedAgentId) {
19721
- const recent = listRecentMessages(roomId, 8);
19722
- for (let i = recent.length - 1; i >= 0; i--) {
19723
- const m = recent[i];
19724
- if (m.authorKind === "agent" && m.authorId === interruptedAgentId && m.meta && m.meta.streaming === true) {
19725
- deleteMessage(m.id);
19726
- roomBus.emit(roomId, {
19727
- type: "message-removed",
19728
- messageId: m.id,
19729
- reason: "chair-interrupt"
19730
- });
19731
- break;
19732
- }
19393
+ for (const ac of state.inflight.values()) ac.abort();
19394
+ state.inflight.clear();
19395
+ }
19396
+ if (state.preWarmed) {
19397
+ try {
19398
+ state.preWarmed.abortController.abort();
19399
+ } catch (_) {
19400
+ }
19401
+ state.preWarmed = null;
19402
+ }
19403
+ if (interruptedAgentId) {
19404
+ const recent = listRecentMessages(roomId, 8);
19405
+ for (let i = recent.length - 1; i >= 0; i--) {
19406
+ const m = recent[i];
19407
+ if (m.authorKind === "agent" && m.authorId === interruptedAgentId && m.meta && m.meta.streaming === true) {
19408
+ deleteMessage(m.id);
19409
+ roomBus.emit(roomId, {
19410
+ type: "message-removed",
19411
+ messageId: m.id,
19412
+ reason: "chair-interrupt"
19413
+ });
19414
+ break;
19733
19415
  }
19734
19416
  }
19735
19417
  }
@@ -19769,15 +19451,21 @@ function abortRoom(roomId) {
19769
19451
  maxSpeakersThisTurn: s.maxSpeakersThisTurn
19770
19452
  };
19771
19453
  s.queue = [];
19772
- const wasSpeaking = s.inflight !== null;
19773
- if (s.inflight) {
19774
- s.inflight.abort();
19775
- s.inflight = null;
19454
+ const wasSpeaking = s.inflight.size > 0;
19455
+ for (const ac of s.inflight.values()) ac.abort();
19456
+ s.inflight.clear();
19457
+ if (s.preWarmed) {
19458
+ try {
19459
+ s.preWarmed.abortController.abort();
19460
+ } catch (_) {
19461
+ }
19462
+ s.preWarmed = null;
19776
19463
  }
19777
- for (const [id, waiter] of s.voiceWaiters) {
19778
- waiter();
19464
+ for (const [, waiter] of s.voiceWaiters) {
19465
+ waiter.resolve();
19779
19466
  }
19780
19467
  s.voiceWaiters.clear();
19468
+ s.voicePredone.clear();
19781
19469
  rlog(roomId, "abort", {
19782
19470
  snapshot: remaining.length,
19783
19471
  round: s.roundNum,
@@ -19869,9 +19557,17 @@ function tickRoom(roomId, opts) {
19869
19557
  }
19870
19558
  if (plan.length === 0) return;
19871
19559
  const state = ensureState(roomId);
19872
- if (state.inflight) state.inflight.abort();
19560
+ for (const ac of state.inflight.values()) ac.abort();
19561
+ state.inflight.clear();
19562
+ if (state.preWarmed) {
19563
+ try {
19564
+ state.preWarmed.abortController.abort();
19565
+ } catch (_) {
19566
+ }
19567
+ state.preWarmed = null;
19568
+ }
19873
19569
  for (const [, waiter] of state.voiceWaiters) {
19874
- waiter();
19570
+ waiter.resolve();
19875
19571
  }
19876
19572
  state.voiceWaiters.clear();
19877
19573
  state.queue = plan.map((a) => ({ agentId: a.id, status: "queued" }));
@@ -19899,6 +19595,106 @@ function tickRoom(roomId, opts) {
19899
19595
  void pumpQueue(roomId);
19900
19596
  }
19901
19597
  }
19598
+ function schedulePreWarm(roomId, currentMessageId) {
19599
+ void runPickerThenPrewarm(roomId, currentMessageId).catch((e) => {
19600
+ process.stderr.write(`[pre-warm] failed: ${e instanceof Error ? e.message : String(e)}
19601
+ `);
19602
+ });
19603
+ }
19604
+ async function runPickerThenPrewarm(roomId, _currentMessageId) {
19605
+ const state = ensureState(roomId);
19606
+ if (state.preWarmed) return;
19607
+ if (state.queue.length < 2) return;
19608
+ const room = getRoom(roomId);
19609
+ if (!room || room.status !== "live") return;
19610
+ if (room.deliveryMode !== "voice") return;
19611
+ if (state.pendingRoundEnd || state.pauseAfterCurrent || state.billingHaltedThisTurn) return;
19612
+ if (room.awaitingClarify || room.awaitingContinue) return;
19613
+ const recent = listRecentMessages(roomId, 30);
19614
+ const directorAlreadySpoke = recent.some((m) => {
19615
+ if (m.authorKind !== "agent" || m.roundNum !== state.roundNum) return false;
19616
+ if (m.meta?.kind) return false;
19617
+ const a = m.authorId ? getAgent(m.authorId) : null;
19618
+ return a?.roleKind === "director";
19619
+ });
19620
+ const candidates = state.queue.map((q) => getAgent(q.agentId)).filter((a) => a !== null);
19621
+ let pickedAgentId = null;
19622
+ if (directorAlreadySpoke && candidates.length >= 2) {
19623
+ try {
19624
+ const pick = await withTimeout(
19625
+ pickNextSpeaker({
19626
+ candidates,
19627
+ history: recent,
19628
+ room: { subject: room.subject ?? null },
19629
+ mode: "lens-gap"
19630
+ }),
19631
+ 15e3,
19632
+ "prewarm-picker"
19633
+ );
19634
+ pickedAgentId = pick.agentId;
19635
+ } catch (e) {
19636
+ process.stderr.write(`[pre-warm picker] ${e instanceof Error ? e.message : String(e)}
19637
+ `);
19638
+ }
19639
+ }
19640
+ if (state.preWarmed) return;
19641
+ const live = getRoom(roomId);
19642
+ if (!live || live.status !== "live") return;
19643
+ if (state.queue.length < 2) return;
19644
+ if (pickedAgentId) {
19645
+ const idx = state.queue.findIndex((q) => q.agentId === pickedAgentId);
19646
+ if (idx > 1) {
19647
+ const [picked] = state.queue.splice(idx, 1);
19648
+ state.queue.splice(1, 0, picked);
19649
+ emitQueueUpdate(roomId, state);
19650
+ }
19651
+ }
19652
+ const nextEntry = state.queue[1];
19653
+ if (!nextEntry) return;
19654
+ const nextSpeaker = getAgent(nextEntry.agentId);
19655
+ if (!nextSpeaker) return;
19656
+ const ac = new AbortController();
19657
+ const sentinel = `pending:${nextSpeaker.id}`;
19658
+ state.inflight.set(sentinel, ac);
19659
+ rlog(roomId, "prewarm-start", {
19660
+ agent: nextSpeaker.name,
19661
+ agentId: nextSpeaker.id,
19662
+ pickedByHaiku: !!pickedAgentId,
19663
+ queueHead: state.queue[0]?.agentId
19664
+ });
19665
+ const preWarmed = {
19666
+ agentId: nextEntry.agentId,
19667
+ messageId: "",
19668
+ promise: Promise.resolve(null),
19669
+ // backfilled below
19670
+ abortController: ac
19671
+ };
19672
+ preWarmed.promise = streamSpeakerTurn({
19673
+ roomId,
19674
+ speaker: nextSpeaker,
19675
+ roundNum: state.roundNum,
19676
+ signal: ac.signal,
19677
+ preWarmed: true,
19678
+ onPlaceholder: (info) => {
19679
+ preWarmed.messageId = info.messageId;
19680
+ if (state.inflight.has(sentinel)) {
19681
+ state.inflight.delete(sentinel);
19682
+ state.inflight.set(info.messageId, ac);
19683
+ }
19684
+ }
19685
+ // Chain trigger lives in pumpQueue's consume point, NOT here.
19686
+ // Rationale: B's `message-final` fires while B is still occupying
19687
+ // `state.preWarmed`. A nested schedulePreWarm() call from inside
19688
+ // B's pre-warm stream would hit the `if (state.preWarmed) return`
19689
+ // guard at the top of runPickerThenPrewarm and bail — C never
19690
+ // gets pre-warmed, depth-1 collapses to "first pair only". The
19691
+ // correct hook is the moment pumpQueue clears preWarmed (consume
19692
+ // path); at that instant the slot is free, the queue head has
19693
+ // advanced, and the next pre-warm has the right context.
19694
+ // onMessageFinal intentionally omitted.
19695
+ });
19696
+ state.preWarmed = preWarmed;
19697
+ }
19902
19698
  async function pumpQueue(roomId) {
19903
19699
  const state = ensureState(roomId);
19904
19700
  if (state.processing) {
@@ -19919,7 +19715,8 @@ async function pumpQueue(roomId) {
19919
19715
  emitQueueUpdate(roomId, state);
19920
19716
  break;
19921
19717
  }
19922
- if (state.queue.length >= 2) {
19718
+ const preWarmedHit = !!(state.preWarmed && state.queue[0] && state.preWarmed.agentId === state.queue[0].agentId);
19719
+ if (!preWarmedHit && state.queue.length >= 2) {
19923
19720
  const recent = listRecentMessages(roomId, 30);
19924
19721
  const round = state.roundNum;
19925
19722
  let isReactive = false;
@@ -19986,13 +19783,37 @@ async function pumpQueue(roomId) {
19986
19783
  } catch {
19987
19784
  }
19988
19785
  }
19989
- const pick = await pickNextSpeaker({
19990
- candidates: pickerCandidates,
19991
- history: recent,
19992
- room: pickRoom ?? void 0,
19993
- mode: useDissentMode ? "dissent-gap" : "lens-gap",
19994
- convergentTerms: useDissentMode ? convergentTerms : void 0
19995
- });
19786
+ const fallbackPick = {
19787
+ agentId: null,
19788
+ rationale: "",
19789
+ intervention: null
19790
+ };
19791
+ let pick = fallbackPick;
19792
+ try {
19793
+ pick = await withTimeout(
19794
+ pickNextSpeaker({
19795
+ candidates: pickerCandidates,
19796
+ history: recent,
19797
+ room: pickRoom ?? void 0,
19798
+ mode: useDissentMode ? "dissent-gap" : "lens-gap",
19799
+ convergentTerms: useDissentMode ? convergentTerms : void 0
19800
+ }),
19801
+ 15e3,
19802
+ "speaker-picker"
19803
+ );
19804
+ } catch (e) {
19805
+ if (e instanceof TimeoutError) {
19806
+ process.stderr.write(`[picker] timeout \u2014 falling back to round-robin
19807
+ `);
19808
+ emitAutoSkipped(roomId, "picker", "picker-timeout");
19809
+ } else {
19810
+ process.stderr.write(
19811
+ `[picker] error: ${e instanceof Error ? e.message : String(e)}
19812
+ `
19813
+ );
19814
+ }
19815
+ pick = fallbackPick;
19816
+ }
19996
19817
  if (useDissentMode && convergentTerms.length > 0) {
19997
19818
  const lastBreaker = state.lastFrameBreakerAgentId;
19998
19819
  const chosenId = pick.agentId ?? state.queue[0]?.agentId;
@@ -20087,23 +19908,57 @@ async function pumpQueue(roomId) {
20087
19908
  emitQueueUpdate(roomId, state);
20088
19909
  continue;
20089
19910
  }
20090
- const ac = new AbortController();
20091
- state.inflight = ac;
19911
+ let ac;
19912
+ let streamPromise;
19913
+ if (preWarmedHit && state.preWarmed) {
19914
+ const justConsumed = state.preWarmed;
19915
+ ac = state.preWarmed.abortController;
19916
+ streamPromise = state.preWarmed.promise;
19917
+ state.preWarmed = null;
19918
+ rlog(roomId, "speaker-prewarm-consumed", {
19919
+ agent: speaker.name,
19920
+ agentId: speaker.id
19921
+ });
19922
+ schedulePreWarm(roomId, justConsumed.messageId);
19923
+ } else {
19924
+ rlog(roomId, "speaker-fresh-path", {
19925
+ agent: speaker.name,
19926
+ agentId: speaker.id,
19927
+ hasPrewarm: !!state.preWarmed,
19928
+ prewarmAgent: state.preWarmed?.agentId ?? null,
19929
+ queueHead: state.queue[0]?.agentId,
19930
+ note: "Pre-warm did NOT cover this speaker \xB7 they go through fresh path. Their meta.preWarmed will be false; their bubble will NOT be hidden during a prior TTS."
19931
+ });
19932
+ ac = new AbortController();
19933
+ const sentinel = `pending:${speaker.id}`;
19934
+ state.inflight.set(sentinel, ac);
19935
+ streamPromise = streamSpeakerTurn({
19936
+ roomId,
19937
+ speaker,
19938
+ roundNum: state.roundNum,
19939
+ signal: ac.signal,
19940
+ onPlaceholder: (info) => {
19941
+ if (state.inflight.has(sentinel)) {
19942
+ state.inflight.delete(sentinel);
19943
+ state.inflight.set(info.messageId, ac);
19944
+ }
19945
+ },
19946
+ onMessageFinal: (info) => {
19947
+ schedulePreWarm(roomId, info.messageId);
19948
+ }
19949
+ });
19950
+ }
20092
19951
  const turnStart = Date.now();
20093
19952
  rlog(roomId, "speaker-start", {
20094
19953
  agent: speaker.name,
20095
19954
  agentId: speaker.id,
20096
19955
  modelV: speaker.modelV,
20097
19956
  round: state.roundNum,
20098
- position: `${state.speakersThisTurn + 1}/${state.maxSpeakersThisTurn}`
19957
+ position: `${state.speakersThisTurn + 1}/${state.maxSpeakersThisTurn}`,
19958
+ preWarmed: preWarmedHit
20099
19959
  });
20100
19960
  try {
20101
- const messageId = await streamSpeakerTurn({
20102
- roomId,
20103
- speaker,
20104
- roundNum: state.roundNum,
20105
- signal: ac.signal
20106
- });
19961
+ const messageId = await streamPromise;
20107
19962
  if (messageId && getRoom(roomId)?.deliveryMode === "voice") {
20108
19963
  await waitForVoicePlayback(roomId, messageId);
20109
19964
  }
@@ -20125,7 +19980,11 @@ async function pumpQueue(roomId) {
20125
19980
  process.stderr.write(`[orchestrator] stream error: ${msg}
20126
19981
  `);
20127
19982
  } finally {
20128
- state.inflight = null;
19983
+ const keysToDel = [];
19984
+ for (const [key, val] of state.inflight) {
19985
+ if (val === ac) keysToDel.push(key);
19986
+ }
19987
+ for (const key of keysToDel) state.inflight.delete(key);
20129
19988
  }
20130
19989
  if (state.queue[0] !== entry) {
20131
19990
  continue;
@@ -20224,15 +20083,26 @@ async function pumpQueue(roomId) {
20224
20083
  // same path the chat round-prompt button takes).
20225
20084
  room.voteTrigger !== "manual") {
20226
20085
  const wrappedRound = state.roundNum;
20086
+ emitChairPending(roomId, "vote-summary");
20227
20087
  let recommendation;
20228
20088
  try {
20229
20089
  const recent = listRecentMessages(roomId, 30);
20230
- const wrap = await pickRoundWrap({ history: recent, roundNum: wrappedRound, room });
20090
+ const wrap = await withTimeout(
20091
+ pickRoundWrap({ history: recent, roundNum: wrappedRound, room }),
20092
+ 15e3,
20093
+ "pickRoundWrap"
20094
+ );
20231
20095
  recommendation = { kind: wrap.recommendation, rationale: wrap.rationale };
20232
20096
  } catch (e) {
20233
- rlog(roomId, "round-wrap-error", {
20234
- error: e instanceof Error ? e.message : String(e)
20235
- });
20097
+ if (e instanceof TimeoutError) {
20098
+ process.stderr.write(`[round-wrap] timeout \u2014 using neutral prompt
20099
+ `);
20100
+ emitAutoSkipped(roomId, "picker", "pickRoundWrap-timeout");
20101
+ } else {
20102
+ rlog(roomId, "round-wrap-error", {
20103
+ error: e instanceof Error ? e.message : String(e)
20104
+ });
20105
+ }
20236
20106
  }
20237
20107
  const roomAgain = getRoom(roomId);
20238
20108
  const stateNow = ensureState(roomId);
@@ -20257,7 +20127,7 @@ async function pumpQueue(roomId) {
20257
20127
  }
20258
20128
  }
20259
20129
  async function streamSpeakerTurn(args) {
20260
- const { roomId, speaker, roundNum, signal } = args;
20130
+ const { roomId, speaker, roundNum, signal, preWarmed = false, onPlaceholder, onMessageFinal } = args;
20261
20131
  const room = getRoom(roomId);
20262
20132
  if (!room) return null;
20263
20133
  const memberRows = listRoomMembers(roomId);
@@ -20433,6 +20303,9 @@ async function streamSpeakerTurn(args) {
20433
20303
  speakerStatus: "streaming",
20434
20304
  streaming: true
20435
20305
  };
20306
+ if (preWarmed) {
20307
+ placeholderMeta.preWarmed = true;
20308
+ }
20436
20309
  if (activeSkills.length > 0) {
20437
20310
  placeholderMeta.skillsUsed = activeSkills.map((s) => s.slug);
20438
20311
  if (pickerReason) placeholderMeta.skillsReason = pickerReason;
@@ -20464,6 +20337,14 @@ async function streamSpeakerTurn(args) {
20464
20337
  roundNum: placeholder.roundNum,
20465
20338
  createdAt: placeholder.createdAt
20466
20339
  });
20340
+ if (onPlaceholder) {
20341
+ try {
20342
+ onPlaceholder({ messageId: placeholder.id });
20343
+ } catch (e) {
20344
+ process.stderr.write(`[onPlaceholder] ${e instanceof Error ? e.message : String(e)}
20345
+ `);
20346
+ }
20347
+ }
20467
20348
  let buf = "";
20468
20349
  let finishReason;
20469
20350
  let errored = false;
@@ -20490,32 +20371,90 @@ async function streamSpeakerTurn(args) {
20490
20371
  if (!voiceProfile) return;
20491
20372
  process.stderr.write(`[tts] emitVoiceText called: provider=${voiceProfile.provider} voiceId=${voiceProfile.voiceId} textLen=${text.length} text="${text.slice(0, 50)}"
20492
20373
  `);
20493
- let chunkCount = 0;
20494
- try {
20495
- for await (const chunk of synthesizeSpeechStream(text, voiceProfile, signal)) {
20496
- if (signal.aborted) break;
20497
- chunkCount++;
20374
+ const MAX_ATTEMPTS = 2;
20375
+ const TIMEOUT_MS = 3e4;
20376
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
20377
+ if (signal.aborted) return;
20378
+ const timeoutCtrl = new AbortController();
20379
+ const timer = setTimeout(() => timeoutCtrl.abort(), TIMEOUT_MS);
20380
+ const onOuterAbort = () => timeoutCtrl.abort();
20381
+ signal.addEventListener("abort", onOuterAbort);
20382
+ let chunkCount = 0;
20383
+ let failure = null;
20384
+ try {
20385
+ for await (const chunk of synthesizeSpeechStream(text, voiceProfile, timeoutCtrl.signal)) {
20386
+ if (signal.aborted) break;
20387
+ chunkCount++;
20388
+ roomBus.emit(roomId, {
20389
+ type: "voice-chunk",
20390
+ messageId: placeholder.id,
20391
+ seq: voiceSeq++,
20392
+ text: chunk.text,
20393
+ provider: chunk.provider,
20394
+ model: chunk.model,
20395
+ voiceId: chunk.voiceId,
20396
+ ...chunk.mimeType ? { mimeType: chunk.mimeType } : {},
20397
+ ...chunk.audioBase64 ? { audioBase64: chunk.audioBase64 } : {}
20398
+ });
20399
+ }
20400
+ } catch (e) {
20401
+ failure = e instanceof Error ? e : new Error(String(e));
20402
+ } finally {
20403
+ clearTimeout(timer);
20404
+ signal.removeEventListener("abort", onOuterAbort);
20405
+ }
20406
+ if (signal.aborted) {
20407
+ process.stderr.write(`[tts] outer abort during attempt ${attempt}, giving up
20408
+ `);
20409
+ return;
20410
+ }
20411
+ if (!failure) {
20412
+ process.stderr.write(`[tts] emitVoiceText done (attempt ${attempt}/${MAX_ATTEMPTS}): ${chunkCount} chunks emitted
20413
+ `);
20414
+ return;
20415
+ }
20416
+ const billing = tryExtractTtsBillingError(failure);
20417
+ if (billing) {
20498
20418
  roomBus.emit(roomId, {
20499
- type: "voice-chunk",
20419
+ type: "voice-error",
20500
20420
  messageId: placeholder.id,
20501
- seq: voiceSeq++,
20502
- text: chunk.text,
20503
- provider: chunk.provider,
20504
- model: chunk.model,
20505
- voiceId: chunk.voiceId,
20506
- ...chunk.mimeType ? { mimeType: chunk.mimeType } : {},
20507
- ...chunk.audioBase64 ? { audioBase64: chunk.audioBase64 } : {}
20421
+ code: billing.code,
20422
+ provider: billing.provider,
20423
+ message: billing.message,
20424
+ upgradeUrl: billing.upgradeUrl
20508
20425
  });
20426
+ process.stderr.write(
20427
+ `[tts] BILLING-ERROR room=${roomId} agent=${speaker.name} provider=${voiceProfile.provider} \xB7 ${billing.message}
20428
+ `
20429
+ );
20430
+ return;
20509
20431
  }
20510
- process.stderr.write(`[tts] emitVoiceText done: ${chunkCount} chunks emitted
20511
- `);
20512
- } catch (e) {
20432
+ const willRetry = attempt < MAX_ATTEMPTS && chunkCount === 0;
20513
20433
  process.stderr.write(
20514
- `[tts] ERROR room=${roomId} agent=${speaker.name} provider=${voiceProfile.provider} voiceId=${voiceProfile.voiceId} \xB7 ${e instanceof Error ? e.stack || e.message : String(e)}
20515
- `
20434
+ `[tts] ERROR attempt=${attempt}/${MAX_ATTEMPTS} room=${roomId} agent=${speaker.name} provider=${voiceProfile.provider} voiceId=${voiceProfile.voiceId} chunks=${chunkCount} \xB7 ${failure.stack || failure.message}` + (willRetry ? " \xB7 retrying\n" : " \xB7 giving up\n")
20516
20435
  );
20517
- }
20518
- }
20436
+ if (!willRetry) return;
20437
+ await new Promise((r) => setTimeout(r, 500));
20438
+ }
20439
+ }
20440
+ const turnCtrl = new AbortController();
20441
+ const onRoomAbort = () => turnCtrl.abort();
20442
+ if (signal.aborted) turnCtrl.abort();
20443
+ else signal.addEventListener("abort", onRoomAbort);
20444
+ let hardCapTimedOut = false;
20445
+ let firstTokenTimedOut = false;
20446
+ const hardCapTimer = setTimeout(() => {
20447
+ if (!turnCtrl.signal.aborted) {
20448
+ hardCapTimedOut = true;
20449
+ turnCtrl.abort();
20450
+ }
20451
+ }, 12e4);
20452
+ let firstTokenTimer = setTimeout(() => {
20453
+ if (buf.length === 0 && !turnCtrl.signal.aborted) {
20454
+ firstTokenTimedOut = true;
20455
+ turnCtrl.abort();
20456
+ }
20457
+ }, 6e4);
20519
20458
  try {
20520
20459
  for await (const chunk of callLLMStream({
20521
20460
  modelV: speaker.modelV,
@@ -20534,10 +20473,14 @@ async function streamSpeakerTurn(args) {
20534
20473
  // the next move is to cap reasoning explicitly via providerOptions
20535
20474
  // (`reasoning.max_tokens`) instead of just enlarging the total cap.
20536
20475
  maxTokens: 4e3,
20537
- signal
20476
+ signal: turnCtrl.signal
20538
20477
  })) {
20539
20478
  if (signal.aborted) break;
20540
20479
  if (chunk.type === "text") {
20480
+ if (firstTokenTimer) {
20481
+ clearTimeout(firstTokenTimer);
20482
+ firstTokenTimer = null;
20483
+ }
20541
20484
  buf += chunk.delta;
20542
20485
  updateMessageBody(placeholder.id, buf, {
20543
20486
  ...placeholderMeta,
@@ -20610,7 +20553,14 @@ async function streamSpeakerTurn(args) {
20610
20553
  }
20611
20554
  } catch (e) {
20612
20555
  errored = true;
20613
- const msg = e instanceof Error ? e.message : String(e);
20556
+ let msg = e instanceof Error ? e.message : String(e);
20557
+ if (firstTokenTimedOut) {
20558
+ msg = `LLM did not produce any token within 60s \xB7 auto-skipped`;
20559
+ emitAutoSkipped(roomId, "llm", "llm-first-token-timeout", placeholder.id);
20560
+ } else if (hardCapTimedOut) {
20561
+ msg = `LLM stream exceeded 120s hard cap \xB7 auto-skipped`;
20562
+ emitAutoSkipped(roomId, "llm", "llm-timeout", placeholder.id);
20563
+ }
20614
20564
  process.stderr.write(
20615
20565
  `[stream-throw] room=${roomId} agent=${speaker.name} modelV=${speaker.modelV} \xB7 ${msg}
20616
20566
  `
@@ -20626,7 +20576,16 @@ async function streamSpeakerTurn(args) {
20626
20576
  messageId: placeholder.id,
20627
20577
  message: msg
20628
20578
  });
20629
- throw e;
20579
+ markVoicePlaybackDone(roomId, placeholder.id);
20580
+ if (!firstTokenTimedOut && !hardCapTimedOut) throw e;
20581
+ } finally {
20582
+ clearTimeout(hardCapTimer);
20583
+ if (firstTokenTimer) {
20584
+ clearTimeout(firstTokenTimer);
20585
+ firstTokenTimer = null;
20586
+ }
20587
+ signal.removeEventListener("abort", onRoomAbort);
20588
+ finalizeStreamingMessage(placeholder.id, "turn-cleanup");
20630
20589
  }
20631
20590
  if (signal.aborted) {
20632
20591
  finishReason = finishReason ?? "aborted";
@@ -20664,6 +20623,14 @@ async function streamSpeakerTurn(args) {
20664
20623
  messageId: placeholder.id,
20665
20624
  finishReason
20666
20625
  });
20626
+ if (onMessageFinal) {
20627
+ try {
20628
+ onMessageFinal({ messageId: placeholder.id });
20629
+ } catch (e) {
20630
+ process.stderr.write(`[onMessageFinal] ${e instanceof Error ? e.message : String(e)}
20631
+ `);
20632
+ }
20633
+ }
20667
20634
  if (buf.trim().length >= 40) {
20668
20635
  void (async () => {
20669
20636
  try {
@@ -21160,6 +21127,17 @@ async function streamChairMessage(args) {
21160
21127
  });
21161
21128
  }
21162
21129
  } catch (e) {
21130
+ const billing = tryExtractTtsBillingError(e);
21131
+ if (billing) {
21132
+ roomBus.emit(roomId, {
21133
+ type: "voice-error",
21134
+ messageId: placeholder.id,
21135
+ code: billing.code,
21136
+ provider: billing.provider,
21137
+ message: billing.message,
21138
+ upgradeUrl: billing.upgradeUrl
21139
+ });
21140
+ }
21163
21141
  process.stderr.write(`[tts-chair] ${e instanceof Error ? e.message : String(e)}
21164
21142
  `);
21165
21143
  }
@@ -21335,13 +21313,34 @@ async function runChairClarify(roomId) {
21335
21313
  }
21336
21314
  }
21337
21315
  if (turnNumber === 1) {
21338
- const decision = await pickChairClarifyDecision({ history });
21339
- if (!decision.shouldAsk) {
21316
+ emitChairPending(roomId, "clarify-deciding");
21317
+ let decision = null;
21318
+ try {
21319
+ decision = await withTimeout(
21320
+ pickChairClarifyDecision({ history }),
21321
+ 15e3,
21322
+ "chair-clarify-decision"
21323
+ );
21324
+ } catch (e) {
21325
+ if (e instanceof TimeoutError) {
21326
+ process.stderr.write(`[chair-clarify] decision timeout \u2014 skipping clarify
21327
+ `);
21328
+ emitAutoSkipped(roomId, "clarify", "clarify-timeout");
21329
+ decision = { shouldAsk: false, rationale: "timeout" };
21330
+ } else {
21331
+ process.stderr.write(
21332
+ `[chair-clarify] decision error: ${e instanceof Error ? e.message : String(e)}
21333
+ `
21334
+ );
21335
+ decision = { shouldAsk: false, rationale: "error" };
21336
+ }
21337
+ }
21338
+ if (!decision || !decision.shouldAsk) {
21340
21339
  setAwaitingClarify(roomId, false);
21341
21340
  roomBus.emit(roomId, {
21342
21341
  type: "config-event",
21343
21342
  kind: "clarify-ready",
21344
- payload: { skipped: true, rationale: decision.rationale },
21343
+ payload: { skipped: true, rationale: decision?.rationale },
21345
21344
  createdAt: Date.now()
21346
21345
  });
21347
21346
  return { asked: false, ready: true, exhausted: false };
@@ -21551,6 +21550,17 @@ async function emitChairAnnouncementVoice(roomId, messageId, body) {
21551
21550
  roomBus.emit(roomId, { type: "voice-final", messageId });
21552
21551
  await waitForVoicePlayback(roomId, messageId, 6e4);
21553
21552
  } catch (e) {
21553
+ const billing = tryExtractTtsBillingError(e);
21554
+ if (billing) {
21555
+ roomBus.emit(roomId, {
21556
+ type: "voice-error",
21557
+ messageId,
21558
+ code: billing.code,
21559
+ provider: billing.provider,
21560
+ message: billing.message,
21561
+ upgradeUrl: billing.upgradeUrl
21562
+ });
21563
+ }
21554
21564
  process.stderr.write(`[tts-chair-announce] ${e instanceof Error ? e.message : String(e)}
21555
21565
  `);
21556
21566
  }
@@ -22458,42 +22468,12 @@ function roomsRouter() {
22458
22468
  payload: { mode, intensity, briefStyle, deliveryMode, members: members.map((m) => m.agentId), autoPick },
22459
22469
  actorKind: "user"
22460
22470
  });
22461
- let seedContext = null;
22462
- if (b.seedContext && typeof b.seedContext === "object") {
22463
- const raw = b.seedContext;
22464
- const topicRecId = typeof raw.topicRecId === "string" && raw.topicRecId.trim().length > 0 ? raw.topicRecId.trim().slice(0, 64) : void 0;
22465
- const rationale = typeof raw.rationale === "string" && raw.rationale.trim().length > 0 ? raw.rationale.trim().slice(0, 400) : void 0;
22466
- const rawSnippets = Array.isArray(raw.snippets) ? raw.snippets : [];
22467
- const snippets = rawSnippets.filter(
22468
- (s) => !!s && typeof s === "object" && typeof s.title === "string" && typeof s.url === "string" && typeof s.description === "string"
22469
- ).slice(0, 12).map((s) => ({
22470
- title: s.title.slice(0, 200),
22471
- url: s.url.slice(0, 600),
22472
- description: s.description.slice(0, 600)
22473
- }));
22474
- if (topicRecId || rationale || snippets.length > 0) {
22475
- seedContext = {
22476
- ...topicRecId ? { topicRecId } : {},
22477
- ...rationale ? { rationale } : {},
22478
- ...snippets.length > 0 ? { snippets } : {}
22479
- };
22480
- }
22481
- }
22482
22471
  const opening = insertMessage({
22483
22472
  roomId: room.id,
22484
22473
  authorKind: "user",
22485
22474
  body: subject,
22486
- roundNum: 1,
22487
- meta: seedContext ? { seedContext } : void 0
22475
+ roundNum: 1
22488
22476
  });
22489
- if (seedContext?.topicRecId) {
22490
- try {
22491
- markTopicRecOpened(seedContext.topicRecId, room.id);
22492
- } catch (e) {
22493
- process.stderr.write(`[rooms] topic-rec link failed: ${e instanceof Error ? e.message : String(e)}
22494
- `);
22495
- }
22496
- }
22497
22477
  roomBus.emit(room.id, {
22498
22478
  type: "message-appended",
22499
22479
  messageId: opening.id,
@@ -22625,13 +22605,19 @@ function roomsRouter() {
22625
22605
  r.get("/:id/stream", (c) => {
22626
22606
  const id = c.req.param("id");
22627
22607
  if (!getRoom(id)) return c.json({ error: "not found" }, 404);
22628
- return streamSSE3(c, async (s) => {
22608
+ const lastIdHeader = c.req.header("last-event-id");
22609
+ const sinceId = lastIdHeader ? Number.parseInt(lastIdHeader, 10) : NaN;
22610
+ return streamSSE2(c, async (s) => {
22629
22611
  await s.writeSSE({ event: "hello", data: JSON.stringify({ roomId: id, ts: Date.now() }) });
22630
22612
  const queue = [];
22631
22613
  let resolveWaiter = null;
22632
22614
  let closed = false;
22633
- const off = roomBus.subscribe(id, (event) => {
22634
- queue.push(event);
22615
+ if (Number.isFinite(sinceId) && sinceId > 0) {
22616
+ const missed = roomBus.replay(id, sinceId);
22617
+ for (const m of missed) queue.push({ id: m.id, event: m.event });
22618
+ }
22619
+ const off = roomBus.subscribeWithId(id, (eventId, event) => {
22620
+ queue.push({ id: eventId, event });
22635
22621
  if (resolveWaiter) {
22636
22622
  resolveWaiter();
22637
22623
  resolveWaiter = null;
@@ -22652,8 +22638,12 @@ function roomsRouter() {
22652
22638
  });
22653
22639
  continue;
22654
22640
  }
22655
- const event = queue.shift();
22656
- await s.writeSSE({ event: event.type, data: JSON.stringify(event) });
22641
+ const { id: eventId, event } = queue.shift();
22642
+ await s.writeSSE({
22643
+ id: String(eventId),
22644
+ event: event.type,
22645
+ data: JSON.stringify(event)
22646
+ });
22657
22647
  }
22658
22648
  });
22659
22649
  });
@@ -22749,6 +22739,12 @@ function roomsRouter() {
22749
22739
  if (!getRoom(id)) return c.json({ error: "not found" }, 404);
22750
22740
  return c.json({ ok: markVoicePlaybackDone(id, messageId) });
22751
22741
  });
22742
+ r.post("/:id/messages/:messageId/voice-progress", (c) => {
22743
+ const id = c.req.param("id");
22744
+ const messageId = c.req.param("messageId");
22745
+ if (!getRoom(id)) return c.json({ error: "not found" }, 404);
22746
+ return c.json({ ok: bumpVoicePlaybackHeartbeat(id, messageId) });
22747
+ });
22752
22748
  r.post("/:id/pause", async (c) => {
22753
22749
  const id = c.req.param("id");
22754
22750
  const room = getRoom(id);
@@ -23555,13 +23551,22 @@ function voicesRouter() {
23555
23551
  ttsCacheSet(key, out);
23556
23552
  return c.json(out);
23557
23553
  } catch (e) {
23554
+ const tagged = e ?? {};
23558
23555
  const msg = ttsErrorMessage(e, profile.provider);
23559
23556
  const isNoKey = /401|403|api[\s-]?key|unauthor/i.test(msg);
23560
- return c.json({
23557
+ const payload = {
23561
23558
  error: msg,
23562
- code: isNoKey ? "no-key" : "tts-error",
23563
- provider: profile.provider
23564
- }, 502);
23559
+ provider: typeof tagged.provider === "string" ? tagged.provider : profile.provider
23560
+ };
23561
+ if (typeof tagged.code === "string") {
23562
+ payload.code = tagged.code;
23563
+ } else {
23564
+ payload.code = isNoKey ? "no-key" : "tts-error";
23565
+ }
23566
+ if (typeof tagged.upgradeUrl === "string") {
23567
+ payload.upgradeUrl = tagged.upgradeUrl;
23568
+ }
23569
+ return c.json(payload, 502);
23565
23570
  }
23566
23571
  });
23567
23572
  return r;
@@ -23571,7 +23576,7 @@ function voicesRouter() {
23571
23576
  init_paths();
23572
23577
 
23573
23578
  // src/version.ts
23574
- var VERSION = "0.1.24";
23579
+ var VERSION = "0.1.27";
23575
23580
 
23576
23581
  // src/server.ts
23577
23582
  function createApp() {
@@ -23614,8 +23619,8 @@ Build the package or check that public/ is bundled alongside dist/.`
23614
23619
  app.route("/api/prefs", prefsRouter());
23615
23620
  app.route("/api/agents", agentsRouter());
23616
23621
  app.route("/api/keys", keysRouter());
23622
+ app.route("/api/credentials", credentialsRouter());
23617
23623
  app.route("/api/models", modelsRouter());
23618
- app.route("/api/topic-recs", topicRecsRouter());
23619
23624
  app.route("/api/rooms", roomsRouter());
23620
23625
  app.route("/api/briefs", briefsRouter());
23621
23626
  app.route("/api/notes", notesRouter());
@@ -23694,6 +23699,7 @@ async function bootApp(opts = {}) {
23694
23699
  process.stderr.write(`[boot] orphan cleanup failed: ${errMsg(e)}
23695
23700
  `);
23696
23701
  }
23702
+ startRuntimeOrphanSweep();
23697
23703
  try {
23698
23704
  const fixed = recoverStuckClarifyRooms();
23699
23705
  if (fixed > 0) {
@@ -23712,16 +23718,6 @@ async function bootApp(opts = {}) {
23712
23718
  }
23713
23719
  } catch (e) {
23714
23720
  process.stderr.write(`[boot] persona-job recovery failed: ${errMsg(e)}
23715
- `);
23716
- }
23717
- try {
23718
- const failed = markRunningTopicRecJobsFailed();
23719
- if (failed > 0) {
23720
- process.stderr.write(`[boot] marked ${failed} topic-rec job(s) failed (server restarted mid-build)
23721
- `);
23722
- }
23723
- } catch (e) {
23724
- process.stderr.write(`[boot] topic-rec recovery failed: ${errMsg(e)}
23725
23721
  `);
23726
23722
  }
23727
23723
  void (async () => {
@@ -23752,6 +23748,7 @@ var shuttingDown = false;
23752
23748
  async function shutdownApp(server) {
23753
23749
  if (shuttingDown) return;
23754
23750
  shuttingDown = true;
23751
+ stopRuntimeOrphanSweep();
23755
23752
  try {
23756
23753
  await server?.close();
23757
23754
  } catch (e) {
@@ -23763,6 +23760,35 @@ async function shutdownApp(server) {
23763
23760
  console.error(" ! error closing db", e);
23764
23761
  }
23765
23762
  }
23763
+ var RUNTIME_SWEEP_INTERVAL_MS = 6e4;
23764
+ var RUNTIME_SWEEP_MAX_AGE_MS = 5 * 6e4;
23765
+ var runtimeSweepTimer = null;
23766
+ function startRuntimeOrphanSweep() {
23767
+ if (runtimeSweepTimer) return;
23768
+ runtimeSweepTimer = setInterval(() => {
23769
+ try {
23770
+ const r = cleanupOrphanedStreams({
23771
+ maxAgeMs: RUNTIME_SWEEP_MAX_AGE_MS,
23772
+ reason: "runtime-sweep \xB7 5min stuck"
23773
+ });
23774
+ if (r.fixed + r.deleted > 0) {
23775
+ process.stderr.write(
23776
+ `[runtime-sweep] finalised ${r.fixed} stuck stream(s), dropped ${r.deleted} empty placeholder(s)
23777
+ `
23778
+ );
23779
+ }
23780
+ } catch (e) {
23781
+ process.stderr.write(`[runtime-sweep] failed: ${errMsg(e)}
23782
+ `);
23783
+ }
23784
+ }, RUNTIME_SWEEP_INTERVAL_MS);
23785
+ }
23786
+ function stopRuntimeOrphanSweep() {
23787
+ if (runtimeSweepTimer) {
23788
+ clearInterval(runtimeSweepTimer);
23789
+ runtimeSweepTimer = null;
23790
+ }
23791
+ }
23766
23792
  function errMsg(e) {
23767
23793
  return e instanceof Error ? e.message : String(e);
23768
23794
  }