privateboard 0.1.23 → 0.1.25
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 +1404 -1378
- package/dist/boot.js.map +1 -1
- package/dist/cli.js +1404 -1378
- package/dist/cli.js.map +1 -1
- package/dist/server.js +1370 -1359
- package/dist/server.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +1 -1
- package/public/agent-overlay.css +82 -0
- package/public/agent-overlay.js +459 -17
- package/public/agent-profile.js +34 -54
- package/public/app-updater.css +318 -0
- package/public/app-updater.js +247 -0
- package/public/app.js +1590 -691
- package/public/home.html +1 -1
- package/public/i18n.js +477 -52
- package/public/icons/floor.png +0 -0
- package/public/index.html +600 -213
- package/public/keys-store.js +112 -1
- package/public/mention-picker.js +573 -0
- package/public/new-agent.js +17 -7
- package/public/onboarding.js +108 -117
- package/public/themes.css +44 -0
- package/public/user-settings.css +503 -3
- package/public/user-settings.js +526 -217
- package/public/voice-replay.css +33 -20
- package/public/voice-replay.js +16 -0
package/dist/cli.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
|
}
|
|
@@ -3347,11 +3410,158 @@ import { createOpenAI } from "@ai-sdk/openai";
|
|
|
3347
3410
|
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
|
3348
3411
|
import { APICallError, streamText } from "ai";
|
|
3349
3412
|
|
|
3350
|
-
// src/storage/
|
|
3351
|
-
init_db();
|
|
3413
|
+
// src/storage/credentials.ts
|
|
3352
3414
|
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "crypto";
|
|
3353
3415
|
import { userInfo } from "os";
|
|
3354
3416
|
|
|
3417
|
+
// src/ai/providers.ts
|
|
3418
|
+
var MULTI_MODEL_LLM_PROVIDERS = [
|
|
3419
|
+
"openrouter",
|
|
3420
|
+
"bai"
|
|
3421
|
+
];
|
|
3422
|
+
var SINGLE_MODEL_LLM_PROVIDERS = [
|
|
3423
|
+
"anthropic",
|
|
3424
|
+
"openai",
|
|
3425
|
+
"google",
|
|
3426
|
+
"xai"
|
|
3427
|
+
];
|
|
3428
|
+
var ALL_LLM_PROVIDERS = [
|
|
3429
|
+
...MULTI_MODEL_LLM_PROVIDERS,
|
|
3430
|
+
...SINGLE_MODEL_LLM_PROVIDERS
|
|
3431
|
+
];
|
|
3432
|
+
var LLM_PROVIDER_PRIORITY = [
|
|
3433
|
+
"openrouter",
|
|
3434
|
+
"bai",
|
|
3435
|
+
"anthropic",
|
|
3436
|
+
"openai",
|
|
3437
|
+
"google",
|
|
3438
|
+
"xai"
|
|
3439
|
+
];
|
|
3440
|
+
function isMultiModelProvider(p) {
|
|
3441
|
+
return p === "openrouter" || p === "bai";
|
|
3442
|
+
}
|
|
3443
|
+
function isLlmProvider(p) {
|
|
3444
|
+
return ALL_LLM_PROVIDERS.indexOf(p) >= 0;
|
|
3445
|
+
}
|
|
3446
|
+
|
|
3447
|
+
// src/storage/credentials.ts
|
|
3448
|
+
init_db();
|
|
3449
|
+
var SALT = "boardroom.v1.salt";
|
|
3450
|
+
var ALGO = "aes-256-gcm";
|
|
3451
|
+
var _key = null;
|
|
3452
|
+
function deriveKey() {
|
|
3453
|
+
if (_key) return _key;
|
|
3454
|
+
const username = userInfo().username || "boardroom-default";
|
|
3455
|
+
_key = scryptSync(username, SALT, 32);
|
|
3456
|
+
return _key;
|
|
3457
|
+
}
|
|
3458
|
+
function encrypt(plain) {
|
|
3459
|
+
const iv = randomBytes(12);
|
|
3460
|
+
const cipher = createCipheriv(ALGO, deriveKey(), iv);
|
|
3461
|
+
const ct = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
|
|
3462
|
+
const tag = cipher.getAuthTag();
|
|
3463
|
+
return Buffer.concat([iv, tag, ct]);
|
|
3464
|
+
}
|
|
3465
|
+
function decrypt(blob) {
|
|
3466
|
+
const iv = blob.subarray(0, 12);
|
|
3467
|
+
const tag = blob.subarray(12, 28);
|
|
3468
|
+
const ct = blob.subarray(28);
|
|
3469
|
+
const decipher = createDecipheriv(ALGO, deriveKey(), iv);
|
|
3470
|
+
decipher.setAuthTag(tag);
|
|
3471
|
+
return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8");
|
|
3472
|
+
}
|
|
3473
|
+
function maskKey(plain) {
|
|
3474
|
+
const trimmed = plain.trim();
|
|
3475
|
+
if (!trimmed) return "";
|
|
3476
|
+
const n = trimmed.length;
|
|
3477
|
+
if (n <= 4) return "\u2022".repeat(n);
|
|
3478
|
+
if (n <= 12) return `${trimmed.slice(0, 2)}${"\u2022".repeat(n - 4)}${trimmed.slice(-2)}`;
|
|
3479
|
+
return `${trimmed.slice(0, 4)}${"\u2022".repeat(n - 8)}${trimmed.slice(-4)}`;
|
|
3480
|
+
}
|
|
3481
|
+
function rowToMeta(row) {
|
|
3482
|
+
if (!isLlmProvider(row.provider)) return null;
|
|
3483
|
+
let preview = "";
|
|
3484
|
+
try {
|
|
3485
|
+
preview = maskKey(decrypt(row.key_blob));
|
|
3486
|
+
} catch {
|
|
3487
|
+
preview = "";
|
|
3488
|
+
}
|
|
3489
|
+
return {
|
|
3490
|
+
id: row.id,
|
|
3491
|
+
provider: row.provider,
|
|
3492
|
+
label: row.label,
|
|
3493
|
+
preview,
|
|
3494
|
+
createdAt: row.created_at,
|
|
3495
|
+
updatedAt: row.updated_at
|
|
3496
|
+
};
|
|
3497
|
+
}
|
|
3498
|
+
function listLlmCredentials() {
|
|
3499
|
+
const rows = getDb().prepare("SELECT id, provider, label, key_blob, created_at, updated_at FROM llm_credentials ORDER BY created_at ASC").all();
|
|
3500
|
+
return rows.map(rowToMeta).filter((m) => m !== null);
|
|
3501
|
+
}
|
|
3502
|
+
function getLlmCredentialMeta(id) {
|
|
3503
|
+
const row = getDb().prepare("SELECT id, provider, label, key_blob, created_at, updated_at FROM llm_credentials WHERE id = ?").get(id);
|
|
3504
|
+
if (!row) return null;
|
|
3505
|
+
return rowToMeta(row);
|
|
3506
|
+
}
|
|
3507
|
+
function getLlmCredentialKey(id) {
|
|
3508
|
+
const row = getDb().prepare("SELECT key_blob FROM llm_credentials WHERE id = ?").get(id);
|
|
3509
|
+
if (!row) return null;
|
|
3510
|
+
try {
|
|
3511
|
+
return decrypt(row.key_blob);
|
|
3512
|
+
} catch {
|
|
3513
|
+
return null;
|
|
3514
|
+
}
|
|
3515
|
+
}
|
|
3516
|
+
function resolveFreeLabel(provider, suggested) {
|
|
3517
|
+
const base = suggested && suggested.trim() || providerDisplayName(provider);
|
|
3518
|
+
const existing = new Set(
|
|
3519
|
+
getDb().prepare("SELECT label FROM llm_credentials").all().map((r) => r.label)
|
|
3520
|
+
);
|
|
3521
|
+
if (!existing.has(base)) return base;
|
|
3522
|
+
for (let n = 2; n < 1e3; n++) {
|
|
3523
|
+
const candidate = `${base} ${n}`;
|
|
3524
|
+
if (!existing.has(candidate)) return candidate;
|
|
3525
|
+
}
|
|
3526
|
+
return `${base} ${Date.now()}`;
|
|
3527
|
+
}
|
|
3528
|
+
function providerDisplayName(provider) {
|
|
3529
|
+
switch (provider) {
|
|
3530
|
+
case "openrouter":
|
|
3531
|
+
return "OpenRouter";
|
|
3532
|
+
case "bai":
|
|
3533
|
+
return "B.AI";
|
|
3534
|
+
case "anthropic":
|
|
3535
|
+
return "Claude";
|
|
3536
|
+
case "openai":
|
|
3537
|
+
return "ChatGPT";
|
|
3538
|
+
case "google":
|
|
3539
|
+
return "Gemini";
|
|
3540
|
+
case "xai":
|
|
3541
|
+
return "Grok";
|
|
3542
|
+
}
|
|
3543
|
+
}
|
|
3544
|
+
function createLlmCredential(provider, label, plain) {
|
|
3545
|
+
const trimmed = plain.trim();
|
|
3546
|
+
if (!trimmed) return null;
|
|
3547
|
+
if (!ALL_LLM_PROVIDERS.includes(provider)) return null;
|
|
3548
|
+
const resolvedLabel = resolveFreeLabel(provider, label);
|
|
3549
|
+
const id = randomBytes(8).toString("hex");
|
|
3550
|
+
const blob = encrypt(trimmed);
|
|
3551
|
+
const now = Date.now();
|
|
3552
|
+
getDb().prepare(
|
|
3553
|
+
`INSERT INTO llm_credentials (id, provider, label, key_blob, created_at, updated_at)
|
|
3554
|
+
VALUES (?, ?, ?, ?, ?, ?)`
|
|
3555
|
+
).run(id, provider, resolvedLabel, blob, now, now);
|
|
3556
|
+
return getLlmCredentialMeta(id);
|
|
3557
|
+
}
|
|
3558
|
+
function deleteLlmCredential(id) {
|
|
3559
|
+
const meta = getLlmCredentialMeta(id);
|
|
3560
|
+
if (!meta) return null;
|
|
3561
|
+
getDb().prepare("DELETE FROM llm_credentials WHERE id = ?").run(id);
|
|
3562
|
+
return meta.provider;
|
|
3563
|
+
}
|
|
3564
|
+
|
|
3355
3565
|
// src/storage/prefs.ts
|
|
3356
3566
|
init_db();
|
|
3357
3567
|
function normalizeWebSearchProviderPref(raw) {
|
|
@@ -3361,6 +3571,7 @@ function normalizeMinimaxRegion(raw) {
|
|
|
3361
3571
|
return raw === "intl" ? "intl" : "cn";
|
|
3362
3572
|
}
|
|
3363
3573
|
function mapRow3(row) {
|
|
3574
|
+
const raw = row.active_llm_provider;
|
|
3364
3575
|
return {
|
|
3365
3576
|
name: row.name,
|
|
3366
3577
|
intro: row.intro,
|
|
@@ -3368,6 +3579,8 @@ function mapRow3(row) {
|
|
|
3368
3579
|
defaultModelV: row.default_model_v,
|
|
3369
3580
|
webSearchProvider: normalizeWebSearchProviderPref(row.web_search_provider),
|
|
3370
3581
|
minimaxRegion: normalizeMinimaxRegion(row.minimax_region),
|
|
3582
|
+
activeLlmProvider: raw && isLlmProvider(raw) ? raw : null,
|
|
3583
|
+
activeLlmCredentialId: row.active_llm_credential_id,
|
|
3371
3584
|
createdAt: row.created_at,
|
|
3372
3585
|
updatedAt: row.updated_at
|
|
3373
3586
|
};
|
|
@@ -3377,6 +3590,8 @@ function getPrefs() {
|
|
|
3377
3590
|
`SELECT name, intro, avatar_seed, default_model_v,
|
|
3378
3591
|
COALESCE(web_search_provider, 'brave') AS web_search_provider,
|
|
3379
3592
|
COALESCE(minimax_region, 'cn') AS minimax_region,
|
|
3593
|
+
active_llm_provider,
|
|
3594
|
+
active_llm_credential_id,
|
|
3380
3595
|
created_at, updated_at FROM prefs WHERE id = 1`
|
|
3381
3596
|
).get();
|
|
3382
3597
|
if (!row) {
|
|
@@ -3411,6 +3626,14 @@ function updatePrefs(patch) {
|
|
|
3411
3626
|
fields.push("minimax_region = ?");
|
|
3412
3627
|
values.push(patch.minimaxRegion === "intl" ? "intl" : "cn");
|
|
3413
3628
|
}
|
|
3629
|
+
if (patch.activeLlmProvider !== void 0) {
|
|
3630
|
+
fields.push("active_llm_provider = ?");
|
|
3631
|
+
values.push(patch.activeLlmProvider);
|
|
3632
|
+
}
|
|
3633
|
+
if (patch.activeLlmCredentialId !== void 0) {
|
|
3634
|
+
fields.push("active_llm_credential_id = ?");
|
|
3635
|
+
values.push(patch.activeLlmCredentialId);
|
|
3636
|
+
}
|
|
3414
3637
|
if (fields.length === 0) return getPrefs();
|
|
3415
3638
|
fields.push("updated_at = ?");
|
|
3416
3639
|
values.push(Date.now());
|
|
@@ -3418,118 +3641,6 @@ function updatePrefs(patch) {
|
|
|
3418
3641
|
return getPrefs();
|
|
3419
3642
|
}
|
|
3420
3643
|
|
|
3421
|
-
// src/storage/keys.ts
|
|
3422
|
-
var SALT = "boardroom.v1.salt";
|
|
3423
|
-
var ALGO = "aes-256-gcm";
|
|
3424
|
-
var _key = null;
|
|
3425
|
-
function deriveKey() {
|
|
3426
|
-
if (_key) return _key;
|
|
3427
|
-
const username = userInfo().username || "boardroom-default";
|
|
3428
|
-
_key = scryptSync(username, SALT, 32);
|
|
3429
|
-
return _key;
|
|
3430
|
-
}
|
|
3431
|
-
function encrypt(plain) {
|
|
3432
|
-
const iv = randomBytes(12);
|
|
3433
|
-
const cipher = createCipheriv(ALGO, deriveKey(), iv);
|
|
3434
|
-
const ct = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
|
|
3435
|
-
const tag = cipher.getAuthTag();
|
|
3436
|
-
return Buffer.concat([iv, tag, ct]);
|
|
3437
|
-
}
|
|
3438
|
-
function decrypt(blob) {
|
|
3439
|
-
const iv = blob.subarray(0, 12);
|
|
3440
|
-
const tag = blob.subarray(12, 28);
|
|
3441
|
-
const ct = blob.subarray(28);
|
|
3442
|
-
const decipher = createDecipheriv(ALGO, deriveKey(), iv);
|
|
3443
|
-
decipher.setAuthTag(tag);
|
|
3444
|
-
return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8");
|
|
3445
|
-
}
|
|
3446
|
-
function hasBraveKey() {
|
|
3447
|
-
const k = getKey("brave");
|
|
3448
|
-
return typeof k === "string" && k.length > 0;
|
|
3449
|
-
}
|
|
3450
|
-
function hasTavilyKey() {
|
|
3451
|
-
const k = getKey("tavily");
|
|
3452
|
-
return typeof k === "string" && k.length > 0;
|
|
3453
|
-
}
|
|
3454
|
-
function hasWebSearchKey() {
|
|
3455
|
-
return hasBraveKey() || hasTavilyKey();
|
|
3456
|
-
}
|
|
3457
|
-
function resolveWebSearchBackend(preference) {
|
|
3458
|
-
const b = hasBraveKey();
|
|
3459
|
-
const t = hasTavilyKey();
|
|
3460
|
-
if (!b && !t) return null;
|
|
3461
|
-
if (b && !t) return "brave";
|
|
3462
|
-
if (!b && t) return "tavily";
|
|
3463
|
-
if (preference === "tavily" && t) return "tavily";
|
|
3464
|
-
if (preference === "brave" && b) return "brave";
|
|
3465
|
-
return b ? "brave" : "tavily";
|
|
3466
|
-
}
|
|
3467
|
-
function getActiveWebSearchCredentials() {
|
|
3468
|
-
const prefRaw = getPrefs().webSearchProvider;
|
|
3469
|
-
const preference = prefRaw === "tavily" ? "tavily" : "brave";
|
|
3470
|
-
const backend = resolveWebSearchBackend(preference);
|
|
3471
|
-
if (!backend) return null;
|
|
3472
|
-
const apiKey = getKey(backend);
|
|
3473
|
-
return apiKey ? { backend, apiKey } : null;
|
|
3474
|
-
}
|
|
3475
|
-
function maskKey(plain) {
|
|
3476
|
-
const trimmed = plain.trim();
|
|
3477
|
-
if (!trimmed) return "";
|
|
3478
|
-
const n = trimmed.length;
|
|
3479
|
-
if (n <= 4) return "\u2022".repeat(n);
|
|
3480
|
-
if (n <= 12) {
|
|
3481
|
-
return `${trimmed.slice(0, 2)}${"\u2022".repeat(n - 4)}${trimmed.slice(-2)}`;
|
|
3482
|
-
}
|
|
3483
|
-
return `${trimmed.slice(0, 4)}${"\u2022".repeat(n - 8)}${trimmed.slice(-4)}`;
|
|
3484
|
-
}
|
|
3485
|
-
function listKeyMeta() {
|
|
3486
|
-
const rows = getDb().prepare("SELECT provider, key_blob, updated_at FROM provider_keys").all();
|
|
3487
|
-
const map = /* @__PURE__ */ new Map();
|
|
3488
|
-
for (const r of rows) {
|
|
3489
|
-
let preview = null;
|
|
3490
|
-
if (r.key_blob.length > 0) {
|
|
3491
|
-
try {
|
|
3492
|
-
preview = maskKey(decrypt(r.key_blob));
|
|
3493
|
-
} catch {
|
|
3494
|
-
preview = null;
|
|
3495
|
-
}
|
|
3496
|
-
}
|
|
3497
|
-
map.set(r.provider, {
|
|
3498
|
-
provider: r.provider,
|
|
3499
|
-
configured: r.key_blob.length > 0,
|
|
3500
|
-
updatedAt: r.updated_at,
|
|
3501
|
-
preview
|
|
3502
|
-
});
|
|
3503
|
-
}
|
|
3504
|
-
return Array.from(map.values());
|
|
3505
|
-
}
|
|
3506
|
-
function getKey(provider) {
|
|
3507
|
-
const row = getDb().prepare("SELECT key_blob FROM provider_keys WHERE provider = ?").get(provider);
|
|
3508
|
-
if (!row) return null;
|
|
3509
|
-
try {
|
|
3510
|
-
return decrypt(row.key_blob);
|
|
3511
|
-
} catch {
|
|
3512
|
-
return null;
|
|
3513
|
-
}
|
|
3514
|
-
}
|
|
3515
|
-
function setKey(provider, plain) {
|
|
3516
|
-
const trimmed = plain.trim();
|
|
3517
|
-
if (!trimmed) {
|
|
3518
|
-
deleteKey(provider);
|
|
3519
|
-
return;
|
|
3520
|
-
}
|
|
3521
|
-
const blob = encrypt(trimmed);
|
|
3522
|
-
const now = Date.now();
|
|
3523
|
-
getDb().prepare(
|
|
3524
|
-
`INSERT INTO provider_keys (provider, key_blob, created_at, updated_at)
|
|
3525
|
-
VALUES (?, ?, ?, ?)
|
|
3526
|
-
ON CONFLICT(provider) DO UPDATE SET key_blob = excluded.key_blob, updated_at = excluded.updated_at`
|
|
3527
|
-
).run(provider, blob, now, now);
|
|
3528
|
-
}
|
|
3529
|
-
function deleteKey(provider) {
|
|
3530
|
-
getDb().prepare("DELETE FROM provider_keys WHERE provider = ?").run(provider);
|
|
3531
|
-
}
|
|
3532
|
-
|
|
3533
3644
|
// src/ai/adapter.ts
|
|
3534
3645
|
var NoKeyError = class extends Error {
|
|
3535
3646
|
constructor(provider) {
|
|
@@ -3670,84 +3781,34 @@ function formatStreamError(e) {
|
|
|
3670
3781
|
}
|
|
3671
3782
|
return String(e);
|
|
3672
3783
|
}
|
|
3673
|
-
function resolveModel(modelV,
|
|
3784
|
+
function resolveModel(modelV, _carrier, _excludeCarriers) {
|
|
3785
|
+
void _carrier;
|
|
3786
|
+
void _excludeCarriers;
|
|
3674
3787
|
const meta = getModel(modelV);
|
|
3675
|
-
const
|
|
3676
|
-
|
|
3677
|
-
const
|
|
3678
|
-
const
|
|
3679
|
-
if (
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
if (carrier === "bai" && baiKey && meta.baiId) {
|
|
3685
|
-
process.stderr.write(`[adapter] modelV=${modelV} \u2192 bai:${meta.baiId} (pinned)
|
|
3686
|
-
`);
|
|
3687
|
-
return baiResolved(meta, baiKey);
|
|
3688
|
-
}
|
|
3689
|
-
if (carrier && carrier !== "openrouter" && carrier !== "bai" && carrier === meta.provider) {
|
|
3690
|
-
const pinnedKey = !skip(carrier) ? getKey(carrier) : void 0;
|
|
3691
|
-
if (pinnedKey) {
|
|
3692
|
-
process.stderr.write(`[adapter] modelV=${modelV} \u2192 direct:${meta.provider}/${meta.directApiId} (pinned)
|
|
3693
|
-
`);
|
|
3694
|
-
return directResolved(meta, pinnedKey);
|
|
3695
|
-
}
|
|
3696
|
-
}
|
|
3697
|
-
if (carrier && !excludeCarriers?.size) {
|
|
3698
|
-
process.stderr.write(
|
|
3699
|
-
`[adapter] modelV=${modelV} pinned carrier=${carrier} unreachable; falling back to default routing
|
|
3700
|
-
`
|
|
3701
|
-
);
|
|
3702
|
-
}
|
|
3703
|
-
if (meta.viaUniversalOnly && orKey) {
|
|
3704
|
-
process.stderr.write(`[adapter] modelV=${modelV} \u2192 openrouter:${meta.openrouterId} (preferred)
|
|
3705
|
-
`);
|
|
3706
|
-
return openRouterResolved(meta, orKey);
|
|
3707
|
-
}
|
|
3708
|
-
if (directKey && !meta.viaUniversalOnly) {
|
|
3709
|
-
process.stderr.write(`[adapter] modelV=${modelV} \u2192 direct:${meta.provider}/${meta.directApiId}
|
|
3788
|
+
const credId = getPrefs().activeLlmCredentialId;
|
|
3789
|
+
if (!credId) throw new NoKeyError(meta.provider);
|
|
3790
|
+
const credMeta = getLlmCredentialMeta(credId);
|
|
3791
|
+
const key = getLlmCredentialKey(credId);
|
|
3792
|
+
if (!credMeta || !key) throw new NoKeyError(meta.provider);
|
|
3793
|
+
const p = credMeta.provider;
|
|
3794
|
+
if (p === "openrouter") {
|
|
3795
|
+
if (!meta.openrouterId) throw new NoKeyError(meta.provider);
|
|
3796
|
+
process.stderr.write(`[adapter] modelV=${modelV} \u2192 openrouter:${meta.openrouterId} (cred:${credMeta.label})
|
|
3710
3797
|
`);
|
|
3711
|
-
return
|
|
3798
|
+
return openRouterResolved(meta, key);
|
|
3712
3799
|
}
|
|
3713
|
-
if (
|
|
3714
|
-
|
|
3800
|
+
if (p === "bai") {
|
|
3801
|
+
if (!meta.baiId) throw new NoKeyError(meta.provider);
|
|
3802
|
+
process.stderr.write(`[adapter] modelV=${modelV} \u2192 bai:${meta.baiId} (cred:${credMeta.label})
|
|
3715
3803
|
`);
|
|
3716
|
-
return baiResolved(meta,
|
|
3804
|
+
return baiResolved(meta, key);
|
|
3717
3805
|
}
|
|
3718
|
-
if (
|
|
3719
|
-
|
|
3720
|
-
`);
|
|
3721
|
-
return openRouterResolved(meta, orKey);
|
|
3806
|
+
if (meta.provider !== p || meta.viaUniversalOnly) {
|
|
3807
|
+
throw new NoKeyError(meta.provider);
|
|
3722
3808
|
}
|
|
3723
|
-
|
|
3724
|
-
process.stderr.write(`[adapter] modelV=${modelV} \u2192 direct:${meta.provider}/${meta.directApiId} (viaUniversalOnly fallback \xB7 no OR / B.AI key)
|
|
3809
|
+
process.stderr.write(`[adapter] modelV=${modelV} \u2192 direct:${meta.provider}/${meta.directApiId} (cred:${credMeta.label})
|
|
3725
3810
|
`);
|
|
3726
|
-
|
|
3727
|
-
}
|
|
3728
|
-
throw new NoKeyError(meta.provider);
|
|
3729
|
-
}
|
|
3730
|
-
function isCarrierAccessDenied(message) {
|
|
3731
|
-
if (!message) return false;
|
|
3732
|
-
const m = message.toLowerCase();
|
|
3733
|
-
if (/\b40[23]\b/.test(m) && /(access|deposit|payment|paid|premium|subscri|quota|balance|fund)/.test(m)) return true;
|
|
3734
|
-
if (/access[_\s-]?denied/.test(m)) return true;
|
|
3735
|
-
if (/deposit\s+required/.test(m)) return true;
|
|
3736
|
-
if (/paid[_\s-]?plan[_\s-]?required/.test(m)) return true;
|
|
3737
|
-
if (/premium[_\s-]?model/.test(m)) return true;
|
|
3738
|
-
if (/insufficient[_\s-]?(?:quota|balance|fund)/.test(m)) return true;
|
|
3739
|
-
if (/quota[_\s-]?exceeded/.test(m)) return true;
|
|
3740
|
-
if (/payment[_\s-]?required/.test(m)) return true;
|
|
3741
|
-
if (/billing/.test(m) && /(disabled|inactive|required|invalid|missing)/.test(m)) return true;
|
|
3742
|
-
if (/model[_\s-]?not[_\s-]?found/.test(m)) return true;
|
|
3743
|
-
if (/no\s+available\s+channel/.test(m)) return true;
|
|
3744
|
-
if (/no\s+endpoints?\s+found/.test(m)) return true;
|
|
3745
|
-
if (/invalid\s+model/.test(m)) return true;
|
|
3746
|
-
if (/model.*(unavailable|not\s+(?:supported|available))/.test(m)) return true;
|
|
3747
|
-
if (/prohibited.*(?:terms?\s+of\s+service|provider)/.test(m)) return true;
|
|
3748
|
-
if (/provider.*terms?\s+of\s+service/.test(m)) return true;
|
|
3749
|
-
if (/violates?\s+(?:our|the)?\s*(?:terms|policy|content)/.test(m)) return true;
|
|
3750
|
-
return false;
|
|
3811
|
+
return directResolved(meta, key);
|
|
3751
3812
|
}
|
|
3752
3813
|
function directResolved(meta, apiKey) {
|
|
3753
3814
|
switch (meta.provider) {
|
|
@@ -3879,8 +3940,6 @@ async function* callLLMStream(req) {
|
|
|
3879
3940
|
let attempt = 0;
|
|
3880
3941
|
let lastTransientMessage = "";
|
|
3881
3942
|
let yieldedText = false;
|
|
3882
|
-
const triedCarriers = /* @__PURE__ */ new Set();
|
|
3883
|
-
triedCarriers.add(resolved.carrier);
|
|
3884
3943
|
while (attempt < RETRY_MAX_ATTEMPTS) {
|
|
3885
3944
|
attempt++;
|
|
3886
3945
|
if (req.signal?.aborted) {
|
|
@@ -3922,21 +3981,6 @@ async function* callLLMStream(req) {
|
|
|
3922
3981
|
retriableErrorMessage = msg;
|
|
3923
3982
|
break;
|
|
3924
3983
|
}
|
|
3925
|
-
if (!yieldedText && isCarrierAccessDenied(msg)) {
|
|
3926
|
-
try {
|
|
3927
|
-
const next = resolveModel(req.modelV, null, triedCarriers);
|
|
3928
|
-
triedCarriers.add(next.carrier);
|
|
3929
|
-
process.stderr.write(
|
|
3930
|
-
`[adapter] modelV=${req.modelV} carrier=${resolved.carrier} rejected (access denied); retrying via ${next.carrier}
|
|
3931
|
-
`
|
|
3932
|
-
);
|
|
3933
|
-
resolved = next;
|
|
3934
|
-
attempt = 0;
|
|
3935
|
-
retriableErrorMessage = msg;
|
|
3936
|
-
break;
|
|
3937
|
-
} catch {
|
|
3938
|
-
}
|
|
3939
|
-
}
|
|
3940
3984
|
sawError = true;
|
|
3941
3985
|
yield { type: "error", message: msg };
|
|
3942
3986
|
}
|
|
@@ -3979,20 +4023,6 @@ async function* callLLMStream(req) {
|
|
|
3979
4023
|
lastTransientMessage = msg;
|
|
3980
4024
|
continue;
|
|
3981
4025
|
}
|
|
3982
|
-
if (!yieldedText && isCarrierAccessDenied(msg)) {
|
|
3983
|
-
try {
|
|
3984
|
-
const next = resolveModel(req.modelV, null, triedCarriers);
|
|
3985
|
-
triedCarriers.add(next.carrier);
|
|
3986
|
-
process.stderr.write(
|
|
3987
|
-
`[adapter] modelV=${req.modelV} carrier=${resolved.carrier} rejected (access denied / threw); retrying via ${next.carrier}
|
|
3988
|
-
`
|
|
3989
|
-
);
|
|
3990
|
-
resolved = next;
|
|
3991
|
-
attempt = 0;
|
|
3992
|
-
continue;
|
|
3993
|
-
} catch {
|
|
3994
|
-
}
|
|
3995
|
-
}
|
|
3996
4026
|
yield { type: "error", message: msg };
|
|
3997
4027
|
return;
|
|
3998
4028
|
}
|
|
@@ -4029,68 +4059,147 @@ async function callLLMWithUsage(req) {
|
|
|
4029
4059
|
return { text: buf, usage };
|
|
4030
4060
|
}
|
|
4031
4061
|
|
|
4032
|
-
// src/storage/
|
|
4033
|
-
|
|
4034
|
-
|
|
4035
|
-
|
|
4062
|
+
// src/storage/keys.ts
|
|
4063
|
+
init_db();
|
|
4064
|
+
import { createCipheriv as createCipheriv2, createDecipheriv as createDecipheriv2, randomBytes as randomBytes2, scryptSync as scryptSync2 } from "crypto";
|
|
4065
|
+
import { userInfo as userInfo2 } from "os";
|
|
4066
|
+
var SALT2 = "boardroom.v1.salt";
|
|
4067
|
+
var ALGO2 = "aes-256-gcm";
|
|
4068
|
+
var _key2 = null;
|
|
4069
|
+
function deriveKey2() {
|
|
4070
|
+
if (_key2) return _key2;
|
|
4071
|
+
const username = userInfo2().username || "boardroom-default";
|
|
4072
|
+
_key2 = scryptSync2(username, SALT2, 32);
|
|
4073
|
+
return _key2;
|
|
4074
|
+
}
|
|
4075
|
+
function encrypt2(plain) {
|
|
4076
|
+
const iv = randomBytes2(12);
|
|
4077
|
+
const cipher = createCipheriv2(ALGO2, deriveKey2(), iv);
|
|
4078
|
+
const ct = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
|
|
4079
|
+
const tag = cipher.getAuthTag();
|
|
4080
|
+
return Buffer.concat([iv, tag, ct]);
|
|
4081
|
+
}
|
|
4082
|
+
function decrypt2(blob) {
|
|
4083
|
+
const iv = blob.subarray(0, 12);
|
|
4084
|
+
const tag = blob.subarray(12, 28);
|
|
4085
|
+
const ct = blob.subarray(28);
|
|
4086
|
+
const decipher = createDecipheriv2(ALGO2, deriveKey2(), iv);
|
|
4087
|
+
decipher.setAuthTag(tag);
|
|
4088
|
+
return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8");
|
|
4089
|
+
}
|
|
4090
|
+
function hasBraveKey() {
|
|
4091
|
+
const k = getKey("brave");
|
|
4092
|
+
return typeof k === "string" && k.length > 0;
|
|
4093
|
+
}
|
|
4094
|
+
function hasTavilyKey() {
|
|
4095
|
+
const k = getKey("tavily");
|
|
4096
|
+
return typeof k === "string" && k.length > 0;
|
|
4097
|
+
}
|
|
4098
|
+
function hasWebSearchKey() {
|
|
4099
|
+
return hasBraveKey() || hasTavilyKey();
|
|
4100
|
+
}
|
|
4101
|
+
function resolveWebSearchBackend(preference) {
|
|
4102
|
+
const b = hasBraveKey();
|
|
4103
|
+
const t = hasTavilyKey();
|
|
4104
|
+
if (!b && !t) return null;
|
|
4105
|
+
if (b && !t) return "brave";
|
|
4106
|
+
if (!b && t) return "tavily";
|
|
4107
|
+
if (preference === "tavily" && t) return "tavily";
|
|
4108
|
+
if (preference === "brave" && b) return "brave";
|
|
4109
|
+
return b ? "brave" : "tavily";
|
|
4110
|
+
}
|
|
4111
|
+
function getActiveWebSearchCredentials() {
|
|
4112
|
+
const prefRaw = getPrefs().webSearchProvider;
|
|
4113
|
+
const preference = prefRaw === "tavily" ? "tavily" : "brave";
|
|
4114
|
+
const backend = resolveWebSearchBackend(preference);
|
|
4115
|
+
if (!backend) return null;
|
|
4116
|
+
const apiKey = getKey(backend);
|
|
4117
|
+
return apiKey ? { backend, apiKey } : null;
|
|
4118
|
+
}
|
|
4119
|
+
function maskKey2(plain) {
|
|
4120
|
+
const trimmed = plain.trim();
|
|
4121
|
+
if (!trimmed) return "";
|
|
4122
|
+
const n = trimmed.length;
|
|
4123
|
+
if (n <= 4) return "\u2022".repeat(n);
|
|
4124
|
+
if (n <= 12) {
|
|
4125
|
+
return `${trimmed.slice(0, 2)}${"\u2022".repeat(n - 4)}${trimmed.slice(-2)}`;
|
|
4126
|
+
}
|
|
4127
|
+
return `${trimmed.slice(0, 4)}${"\u2022".repeat(n - 8)}${trimmed.slice(-4)}`;
|
|
4128
|
+
}
|
|
4129
|
+
function listKeyMeta() {
|
|
4130
|
+
const rows = getDb().prepare("SELECT provider, key_blob, updated_at FROM provider_keys").all();
|
|
4131
|
+
const map = /* @__PURE__ */ new Map();
|
|
4132
|
+
for (const r of rows) {
|
|
4133
|
+
let preview = null;
|
|
4134
|
+
if (r.key_blob.length > 0) {
|
|
4135
|
+
try {
|
|
4136
|
+
preview = maskKey2(decrypt2(r.key_blob));
|
|
4137
|
+
} catch {
|
|
4138
|
+
preview = null;
|
|
4139
|
+
}
|
|
4140
|
+
}
|
|
4141
|
+
map.set(r.provider, {
|
|
4142
|
+
provider: r.provider,
|
|
4143
|
+
configured: r.key_blob.length > 0,
|
|
4144
|
+
updatedAt: r.updated_at,
|
|
4145
|
+
preview
|
|
4146
|
+
});
|
|
4147
|
+
}
|
|
4148
|
+
return Array.from(map.values());
|
|
4149
|
+
}
|
|
4150
|
+
function getKey(provider) {
|
|
4151
|
+
const row = getDb().prepare("SELECT key_blob FROM provider_keys WHERE provider = ?").get(provider);
|
|
4152
|
+
if (!row) return null;
|
|
4153
|
+
try {
|
|
4154
|
+
return decrypt2(row.key_blob);
|
|
4155
|
+
} catch {
|
|
4156
|
+
return null;
|
|
4157
|
+
}
|
|
4158
|
+
}
|
|
4159
|
+
function setKey(provider, plain) {
|
|
4160
|
+
const trimmed = plain.trim();
|
|
4161
|
+
if (!trimmed) {
|
|
4162
|
+
deleteKey(provider);
|
|
4163
|
+
return;
|
|
4164
|
+
}
|
|
4165
|
+
const blob = encrypt2(trimmed);
|
|
4166
|
+
const now = Date.now();
|
|
4167
|
+
getDb().prepare(
|
|
4168
|
+
`INSERT INTO provider_keys (provider, key_blob, created_at, updated_at)
|
|
4169
|
+
VALUES (?, ?, ?, ?)
|
|
4170
|
+
ON CONFLICT(provider) DO UPDATE SET key_blob = excluded.key_blob, updated_at = excluded.updated_at`
|
|
4171
|
+
).run(provider, blob, now, now);
|
|
4172
|
+
}
|
|
4173
|
+
function deleteKey(provider) {
|
|
4174
|
+
getDb().prepare("DELETE FROM provider_keys WHERE provider = ?").run(provider);
|
|
4175
|
+
}
|
|
4176
|
+
|
|
4177
|
+
// src/storage/reconcile-models.ts
|
|
4178
|
+
var PRIMARY_BY_CARRIER = {
|
|
4179
|
+
openrouter: "opus-4-6-fast",
|
|
4180
|
+
bai: "haiku-4-5",
|
|
4036
4181
|
anthropic: "haiku-4-5",
|
|
4037
4182
|
openai: "gpt-5-4-mini",
|
|
4038
4183
|
google: "gemini-3-1-flash"
|
|
4039
|
-
// xai · no primary (no LLM modelV in registry as of 2026-05-17).
|
|
4040
|
-
// adapter / availability layer skip xai when this key is absent.
|
|
4184
|
+
// xai · no primary (no LLM modelV in registry as of 2026-05-17).
|
|
4041
4185
|
};
|
|
4042
|
-
var CARRIER_PRIORITY = ["openrouter", "bai", "anthropic", "openai", "google", "xai"];
|
|
4043
4186
|
function reachableModelVs() {
|
|
4044
4187
|
const out = /* @__PURE__ */ new Set();
|
|
4045
|
-
const
|
|
4046
|
-
|
|
4188
|
+
const p = getProviderKeyState().activeLlmProvider;
|
|
4189
|
+
if (!p) return out;
|
|
4047
4190
|
for (const [v, meta] of Object.entries(MODELS)) {
|
|
4048
|
-
if (
|
|
4049
|
-
out.add(v);
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
out.add(v);
|
|
4054
|
-
continue;
|
|
4055
|
-
}
|
|
4056
|
-
if (!meta.viaUniversalOnly && hasDirectKey(meta.provider)) {
|
|
4057
|
-
out.add(v);
|
|
4058
|
-
continue;
|
|
4059
|
-
}
|
|
4060
|
-
if (meta.viaUniversalOnly && hasDirectKey(meta.provider)) {
|
|
4061
|
-
out.add(v);
|
|
4191
|
+
if (p === "openrouter") {
|
|
4192
|
+
if (meta.openrouterId) out.add(v);
|
|
4193
|
+
} else if (p === "bai") {
|
|
4194
|
+
if (meta.baiId) out.add(v);
|
|
4195
|
+
} else {
|
|
4196
|
+
if (meta.provider === p && !meta.viaUniversalOnly) out.add(v);
|
|
4062
4197
|
}
|
|
4063
4198
|
}
|
|
4064
4199
|
return out;
|
|
4065
4200
|
}
|
|
4066
|
-
function hasDirectKey(provider) {
|
|
4067
|
-
switch (provider) {
|
|
4068
|
-
case "anthropic":
|
|
4069
|
-
case "openai":
|
|
4070
|
-
case "google":
|
|
4071
|
-
case "xai":
|
|
4072
|
-
return !!getKey(provider);
|
|
4073
|
-
default:
|
|
4074
|
-
return false;
|
|
4075
|
-
}
|
|
4076
|
-
}
|
|
4077
4201
|
function activeCarrier() {
|
|
4078
|
-
|
|
4079
|
-
if (prefs.defaultModelV) {
|
|
4080
|
-
const meta = MODELS[prefs.defaultModelV];
|
|
4081
|
-
if (meta) {
|
|
4082
|
-
if (meta.viaUniversalOnly && getKey("openrouter")) return "openrouter";
|
|
4083
|
-
if (hasDirectKey(meta.provider)) return meta.provider;
|
|
4084
|
-
if (getKey("bai") && meta.baiId) return "bai";
|
|
4085
|
-
if (getKey("openrouter")) return "openrouter";
|
|
4086
|
-
}
|
|
4087
|
-
}
|
|
4088
|
-
for (const c of CARRIER_PRIORITY) {
|
|
4089
|
-
if (c === "openrouter" && getKey("openrouter")) return "openrouter";
|
|
4090
|
-
if (c === "bai" && getKey("bai")) return "bai";
|
|
4091
|
-
if (c !== "openrouter" && c !== "bai" && hasDirectKey(c)) return c;
|
|
4092
|
-
}
|
|
4093
|
-
return null;
|
|
4202
|
+
return getProviderKeyState().activeLlmProvider;
|
|
4094
4203
|
}
|
|
4095
4204
|
function reconcileAgentModels(opts = {}) {
|
|
4096
4205
|
const reachable = reachableModelVs();
|
|
@@ -4099,16 +4208,9 @@ function reconcileAgentModels(opts = {}) {
|
|
|
4099
4208
|
const forcePrimary = opts.forcePrimary === true;
|
|
4100
4209
|
const switched = [];
|
|
4101
4210
|
const cleared = [];
|
|
4102
|
-
const orReachable = !!getKey("openrouter");
|
|
4103
|
-
const baiReachable = !!getKey("bai");
|
|
4104
|
-
function carrierKeyReachable(c) {
|
|
4105
|
-
if (c === "openrouter") return orReachable;
|
|
4106
|
-
if (c === "bai") return baiReachable;
|
|
4107
|
-
return hasDirectKey(c);
|
|
4108
|
-
}
|
|
4109
4211
|
for (const agent of listAllAgents()) {
|
|
4110
4212
|
const v = (agent.modelV || "").trim();
|
|
4111
|
-
if (agent.carrierPref
|
|
4213
|
+
if (agent.carrierPref) {
|
|
4112
4214
|
updateAgent(agent.id, { carrierPref: null });
|
|
4113
4215
|
}
|
|
4114
4216
|
if (!forcePrimary && v && reachable.has(v)) continue;
|
|
@@ -4124,6 +4226,7 @@ function reconcileAgentModels(opts = {}) {
|
|
|
4124
4226
|
cleared.push(agent.id);
|
|
4125
4227
|
}
|
|
4126
4228
|
}
|
|
4229
|
+
void isMultiModelProvider;
|
|
4127
4230
|
const prefs = getPrefs();
|
|
4128
4231
|
const currentReachable = !!prefs.defaultModelV && reachable.has(prefs.defaultModelV);
|
|
4129
4232
|
const shouldBump = forcePrimary || !prefs.defaultModelV || !currentReachable;
|
|
@@ -4137,31 +4240,36 @@ function reconcileAgentModels(opts = {}) {
|
|
|
4137
4240
|
|
|
4138
4241
|
// src/ai/availability.ts
|
|
4139
4242
|
function getProviderKeyState() {
|
|
4140
|
-
const
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4243
|
+
const credId = getPrefs().activeLlmCredentialId;
|
|
4244
|
+
if (credId) {
|
|
4245
|
+
const meta = getLlmCredentialMeta(credId);
|
|
4246
|
+
if (meta) return { activeLlmProvider: meta.provider, hasAnyLlmKey: true };
|
|
4247
|
+
}
|
|
4248
|
+
return { activeLlmProvider: null, hasAnyLlmKey: false };
|
|
4249
|
+
}
|
|
4250
|
+
function routeFor(p) {
|
|
4251
|
+
if (!p) return null;
|
|
4252
|
+
if (p === "openrouter") return "openrouter";
|
|
4253
|
+
if (p === "bai") return "bai";
|
|
4254
|
+
return "direct";
|
|
4151
4255
|
}
|
|
4152
4256
|
function availabilityFor(meta, keys) {
|
|
4153
|
-
const
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4257
|
+
const p = keys.activeLlmProvider;
|
|
4258
|
+
let reachable = false;
|
|
4259
|
+
if (p === "openrouter") {
|
|
4260
|
+
reachable = !!meta.openrouterId;
|
|
4261
|
+
} else if (p === "bai") {
|
|
4262
|
+
reachable = !!meta.baiId;
|
|
4263
|
+
} else if (p) {
|
|
4264
|
+
reachable = meta.provider === p && !meta.viaUniversalOnly;
|
|
4265
|
+
}
|
|
4157
4266
|
return {
|
|
4158
4267
|
modelV: meta.v,
|
|
4159
4268
|
displayName: meta.displayName,
|
|
4160
4269
|
provider: meta.provider,
|
|
4161
4270
|
deck: meta.deck,
|
|
4162
|
-
routes: { direct: directReachable, openrouter: orReachable, bai: baiReachable },
|
|
4163
4271
|
reachable,
|
|
4164
|
-
preferredRoute:
|
|
4272
|
+
preferredRoute: reachable ? routeFor(p) : null
|
|
4165
4273
|
};
|
|
4166
4274
|
}
|
|
4167
4275
|
function modelAvailability() {
|
|
@@ -4172,8 +4280,7 @@ function reachableModels() {
|
|
|
4172
4280
|
return modelAvailability().filter((m) => m.reachable);
|
|
4173
4281
|
}
|
|
4174
4282
|
function hasAnyModelKey() {
|
|
4175
|
-
|
|
4176
|
-
return keys.hasOpenRouter || keys.hasBai || keys.directProviders.size > 0;
|
|
4283
|
+
return getProviderKeyState().hasAnyLlmKey;
|
|
4177
4284
|
}
|
|
4178
4285
|
var PROVIDER_FLAGSHIP = {
|
|
4179
4286
|
anthropic: "opus-4-7",
|
|
@@ -4285,36 +4392,15 @@ function effectiveDefaultModel() {
|
|
|
4285
4392
|
return fresh;
|
|
4286
4393
|
}
|
|
4287
4394
|
function defaultModelFor(keys = getProviderKeyState()) {
|
|
4288
|
-
const
|
|
4289
|
-
if (
|
|
4290
|
-
|
|
4291
|
-
|
|
4292
|
-
|
|
4293
|
-
|
|
4294
|
-
|
|
4295
|
-
|
|
4296
|
-
|
|
4297
|
-
if (keys.hasOpenRouter) {
|
|
4298
|
-
const fast = reachable.find((m) => m.modelV === "opus-4-6-fast");
|
|
4299
|
-
if (fast) return fast.modelV;
|
|
4300
|
-
const opus = reachable.find((m) => m.modelV === "opus-4-7");
|
|
4301
|
-
if (opus) return opus.modelV;
|
|
4302
|
-
}
|
|
4303
|
-
if (keys.hasBai) {
|
|
4304
|
-
const fast = reachable.find((m) => m.modelV === "haiku-4-5");
|
|
4305
|
-
if (fast) return fast.modelV;
|
|
4306
|
-
const opus = reachable.find((m) => m.modelV === "opus-4-7");
|
|
4307
|
-
if (opus) return opus.modelV;
|
|
4308
|
-
}
|
|
4309
|
-
for (const provider of keys.directProviders) {
|
|
4310
|
-
const fast = PROVIDER_FAST[provider];
|
|
4311
|
-
if (fast && reachable.find((m) => m.modelV === fast)) return fast;
|
|
4312
|
-
}
|
|
4313
|
-
for (const provider of keys.directProviders) {
|
|
4314
|
-
const flagship = PROVIDER_FLAGSHIP[provider];
|
|
4315
|
-
if (flagship && reachable.find((m) => m.modelV === flagship)) return flagship;
|
|
4316
|
-
}
|
|
4317
|
-
return reachable[0].modelV;
|
|
4395
|
+
const p = keys.activeLlmProvider;
|
|
4396
|
+
if (!p) return null;
|
|
4397
|
+
const reachable = new Set(reachableModels().map((m) => m.modelV));
|
|
4398
|
+
if (reachable.size === 0) return null;
|
|
4399
|
+
const fast = PROVIDER_FAST[p];
|
|
4400
|
+
if (fast && reachable.has(fast)) return fast;
|
|
4401
|
+
const flagship = PROVIDER_FLAGSHIP[p];
|
|
4402
|
+
if (flagship && reachable.has(flagship)) return flagship;
|
|
4403
|
+
return reachableModels()[0]?.modelV ?? null;
|
|
4318
4404
|
}
|
|
4319
4405
|
var CHEAP_BY_CARRIER = {
|
|
4320
4406
|
openrouter: "haiku-4-5",
|
|
@@ -4335,11 +4421,6 @@ var UTILITY_PREFERENCE = [
|
|
|
4335
4421
|
];
|
|
4336
4422
|
function utilityModelFor(fallback = null) {
|
|
4337
4423
|
const reachable = new Set(reachableModels().map((m) => m.modelV));
|
|
4338
|
-
const prefs = getPrefs();
|
|
4339
|
-
const userDefault = prefs.defaultModelV;
|
|
4340
|
-
if (userDefault && UTILITY_PREFERENCE.includes(userDefault) && reachable.has(userDefault)) {
|
|
4341
|
-
return userDefault;
|
|
4342
|
-
}
|
|
4343
4424
|
const carrier = activeCarrier();
|
|
4344
4425
|
if (carrier) {
|
|
4345
4426
|
const preferred = CHEAP_BY_CARRIER[carrier];
|
|
@@ -5839,6 +5920,97 @@ function getPartialPersona(jobId) {
|
|
|
5839
5920
|
};
|
|
5840
5921
|
}
|
|
5841
5922
|
|
|
5923
|
+
// src/orchestrator/celebrity-seed.ts
|
|
5924
|
+
var SLUG_RE = /^[a-z][a-z0-9-]{1,40}$/;
|
|
5925
|
+
function parseSeed(raw) {
|
|
5926
|
+
let s = raw.trim();
|
|
5927
|
+
if (s.startsWith("```")) {
|
|
5928
|
+
s = s.replace(/^```[a-zA-Z]*\s*/, "").replace(/```\s*$/, "").trim();
|
|
5929
|
+
}
|
|
5930
|
+
let j;
|
|
5931
|
+
try {
|
|
5932
|
+
j = JSON.parse(s);
|
|
5933
|
+
} catch {
|
|
5934
|
+
return null;
|
|
5935
|
+
}
|
|
5936
|
+
if (!j || typeof j !== "object") return null;
|
|
5937
|
+
const o = j;
|
|
5938
|
+
const id = typeof o.id === "string" ? o.id.trim().toLowerCase() : "";
|
|
5939
|
+
const name = typeof o.name === "string" ? o.name.trim() : "";
|
|
5940
|
+
const roleTag = typeof o.roleTag === "string" ? o.roleTag.trim().toLowerCase() : "";
|
|
5941
|
+
const description = typeof o.description === "string" ? o.description.trim() : "";
|
|
5942
|
+
const introRaw = o.intro && typeof o.intro === "object" ? o.intro : {};
|
|
5943
|
+
const introEn = typeof introRaw.en === "string" ? introRaw.en.trim() : "";
|
|
5944
|
+
const introZh = typeof introRaw.zh === "string" ? introRaw.zh.trim() : "";
|
|
5945
|
+
if (!SLUG_RE.test(id)) return null;
|
|
5946
|
+
if (name.length < 2 || name.length > 80) return null;
|
|
5947
|
+
if (roleTag.length < 2 || roleTag.length > 24) return null;
|
|
5948
|
+
if (description.length < 60 || description.length > 1200) return null;
|
|
5949
|
+
if (introEn.length < 8 || introEn.length > 200) return null;
|
|
5950
|
+
if (introZh.length < 4 || introZh.length > 200) return null;
|
|
5951
|
+
return {
|
|
5952
|
+
id,
|
|
5953
|
+
name,
|
|
5954
|
+
roleTag,
|
|
5955
|
+
intro: { en: introEn, zh: introZh },
|
|
5956
|
+
description
|
|
5957
|
+
};
|
|
5958
|
+
}
|
|
5959
|
+
function buildPrompt(opts) {
|
|
5960
|
+
const excludeBlock = opts.excludeIds.length === 0 ? "(no exclusions)" : opts.excludeIds.slice(0, 200).map((id) => `\xB7 ${id}`).join("\n");
|
|
5961
|
+
const novelty = opts.emphasizeNovelty ? "CRITICAL: do NOT repeat any id from the exclusion list. Re-read it before answering. Pick a different person." : "";
|
|
5962
|
+
return [
|
|
5963
|
+
"You are inventing ONE famous-figure preset card for an app that lets users 'hire' historical or contemporary thinkers as AI directors.",
|
|
5964
|
+
"",
|
|
5965
|
+
"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).",
|
|
5966
|
+
"",
|
|
5967
|
+
"Output STRICT JSON with exactly these fields, nothing else (no prose before or after, no code fences):",
|
|
5968
|
+
"{",
|
|
5969
|
+
` "id": "kebab-case-slug", // lowercase, 2-40 chars, letters/digits/hyphen, starts with a letter`,
|
|
5970
|
+
` "name": "Display Name", // verbatim \xB7 keep their real name; CJK names stay CJK`,
|
|
5971
|
+
` "roleTag": "founder", // one short English noun \xB7 examples: founder | philosopher | physicist | essayist | investor | architect | mathematician | poet | director | dissident`,
|
|
5972
|
+
` "intro": {`,
|
|
5973
|
+
` "en": "one-line tagline \xB7 8 to 30 words \xB7 captures the lens this person brings",`,
|
|
5974
|
+
` "zh": "\u4E2D\u6587 8 \u5230 30 \u5B57 \xB7 \u4E0E en \u540C\u4E3B\u65E8"`,
|
|
5975
|
+
` },`,
|
|
5976
|
+
` "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."`,
|
|
5977
|
+
"}",
|
|
5978
|
+
"",
|
|
5979
|
+
"Avoid these ids (already in the pool):",
|
|
5980
|
+
excludeBlock,
|
|
5981
|
+
"",
|
|
5982
|
+
novelty
|
|
5983
|
+
].filter(Boolean).join("\n");
|
|
5984
|
+
}
|
|
5985
|
+
async function generateCelebritySeed(opts) {
|
|
5986
|
+
const modelV = utilityModelFor();
|
|
5987
|
+
if (!modelV) throw new Error("no utility model available");
|
|
5988
|
+
const excludeSet = new Set(opts.excludeIds);
|
|
5989
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
5990
|
+
const prompt = buildPrompt({
|
|
5991
|
+
excludeIds: opts.excludeIds,
|
|
5992
|
+
emphasizeNovelty: attempt > 0
|
|
5993
|
+
});
|
|
5994
|
+
let raw;
|
|
5995
|
+
try {
|
|
5996
|
+
raw = await callLLM({
|
|
5997
|
+
modelV,
|
|
5998
|
+
messages: [{ role: "user", content: prompt }],
|
|
5999
|
+
temperature: 0.85,
|
|
6000
|
+
maxTokens: 900
|
|
6001
|
+
});
|
|
6002
|
+
} catch (e) {
|
|
6003
|
+
if (attempt === 1) throw e;
|
|
6004
|
+
continue;
|
|
6005
|
+
}
|
|
6006
|
+
const parsed = parseSeed(raw);
|
|
6007
|
+
if (!parsed) continue;
|
|
6008
|
+
if (excludeSet.has(parsed.id)) continue;
|
|
6009
|
+
return parsed;
|
|
6010
|
+
}
|
|
6011
|
+
throw new Error("celebrity-seed \xB7 model failed to produce a valid novel entry");
|
|
6012
|
+
}
|
|
6013
|
+
|
|
5842
6014
|
// src/ai/prompts/persona-render.ts
|
|
5843
6015
|
var INSTRUCTION_MAX = 6e3;
|
|
5844
6016
|
function synthesizePersonaInstruction(spec, meta) {
|
|
@@ -6083,12 +6255,12 @@ function renderRules(lines, rules) {
|
|
|
6083
6255
|
init_db();
|
|
6084
6256
|
|
|
6085
6257
|
// src/utils/id.ts
|
|
6086
|
-
import { randomBytes as
|
|
6258
|
+
import { randomBytes as randomBytes3 } from "crypto";
|
|
6087
6259
|
var ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
|
|
6088
6260
|
var ALPHABET_LEN = ALPHABET.length;
|
|
6089
6261
|
var MASK = (1 << 5) - 1;
|
|
6090
6262
|
function newId(len = 12) {
|
|
6091
|
-
const bytes =
|
|
6263
|
+
const bytes = randomBytes3(len);
|
|
6092
6264
|
let out = "";
|
|
6093
6265
|
for (let i = 0; i < len; i++) {
|
|
6094
6266
|
out += ALPHABET[bytes[i] & MASK];
|
|
@@ -6459,7 +6631,7 @@ var TIPS_MAX_COUNT = 8;
|
|
|
6459
6631
|
var BODY_MAX_BYTES = 32 * 1024;
|
|
6460
6632
|
var ABILITY_MIN = -3;
|
|
6461
6633
|
var ABILITY_MAX = 3;
|
|
6462
|
-
var
|
|
6634
|
+
var SLUG_RE2 = /^[a-z0-9][a-z0-9-]*$/;
|
|
6463
6635
|
function slugifyName(name) {
|
|
6464
6636
|
return name.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, SLUG_MAX);
|
|
6465
6637
|
}
|
|
@@ -6512,7 +6684,7 @@ function parseSkillMd(src) {
|
|
|
6512
6684
|
}
|
|
6513
6685
|
slug = raw.slug.trim();
|
|
6514
6686
|
if (slug.length > SLUG_MAX) return err(`\`slug\` too long (max ${SLUG_MAX})`);
|
|
6515
|
-
if (!
|
|
6687
|
+
if (!SLUG_RE2.test(slug)) {
|
|
6516
6688
|
return err("`slug` must be lowercase letters, digits, and hyphens (start with letter/digit)");
|
|
6517
6689
|
}
|
|
6518
6690
|
}
|
|
@@ -7584,6 +7756,23 @@ function agentsRouter() {
|
|
|
7584
7756
|
const jobId = startPersonaBuild({ description, locale });
|
|
7585
7757
|
return c.json({ jobId });
|
|
7586
7758
|
});
|
|
7759
|
+
r.post("/celebrity-seed", async (c) => {
|
|
7760
|
+
let body;
|
|
7761
|
+
try {
|
|
7762
|
+
body = await c.req.json();
|
|
7763
|
+
} catch {
|
|
7764
|
+
body = {};
|
|
7765
|
+
}
|
|
7766
|
+
const b = body ?? {};
|
|
7767
|
+
const excludeIds = Array.isArray(b.excludeIds) ? b.excludeIds.filter((x) => typeof x === "string").slice(0, 200) : [];
|
|
7768
|
+
try {
|
|
7769
|
+
const seed = await generateCelebritySeed({ excludeIds });
|
|
7770
|
+
return c.json({ seed });
|
|
7771
|
+
} catch (e) {
|
|
7772
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
7773
|
+
return c.json({ error: msg }, 502);
|
|
7774
|
+
}
|
|
7775
|
+
});
|
|
7587
7776
|
r.get("/generate-persona/:jobId/stream", (c) => {
|
|
7588
7777
|
const jobId = c.req.param("jobId");
|
|
7589
7778
|
const job = getPersonaJob(jobId);
|
|
@@ -13000,13 +13189,36 @@ function searchMessages(query, limit = 200) {
|
|
|
13000
13189
|
createdAt: r.created_at
|
|
13001
13190
|
}));
|
|
13002
13191
|
}
|
|
13003
|
-
function
|
|
13192
|
+
function finalizeStreamingMessage(messageId, reason) {
|
|
13193
|
+
const db = getDb();
|
|
13194
|
+
const before = db.prepare(
|
|
13195
|
+
`SELECT json_extract(meta_json, '$.streaming') AS streaming
|
|
13196
|
+
FROM messages WHERE id = ?`
|
|
13197
|
+
).get(messageId);
|
|
13198
|
+
if (!before) return false;
|
|
13199
|
+
if (before.streaming !== 1) return false;
|
|
13200
|
+
db.prepare(
|
|
13201
|
+
`UPDATE messages
|
|
13202
|
+
SET meta_json = json_set(
|
|
13203
|
+
COALESCE(meta_json, '{}'),
|
|
13204
|
+
'$.streaming', 0,
|
|
13205
|
+
'$.speakerStatus', 'final',
|
|
13206
|
+
'$.error', ?
|
|
13207
|
+
)
|
|
13208
|
+
WHERE id = ?`
|
|
13209
|
+
).run(String(reason || "finalized"), messageId);
|
|
13210
|
+
return true;
|
|
13211
|
+
}
|
|
13212
|
+
function cleanupOrphanedStreams(opts = {}) {
|
|
13004
13213
|
const db = getDb();
|
|
13214
|
+
const reason = opts.reason || "orphaned \xB7 server restarted mid-stream";
|
|
13215
|
+
const ageClause = typeof opts.maxAgeMs === "number" ? `AND created_at < ${Math.floor(Date.now() - opts.maxAgeMs)}` : "";
|
|
13005
13216
|
const del = db.prepare(
|
|
13006
13217
|
`DELETE FROM messages
|
|
13007
13218
|
WHERE json_valid(meta_json)
|
|
13008
13219
|
AND json_extract(meta_json, '$.streaming') = 1
|
|
13009
|
-
AND (body IS NULL OR trim(body) = '')
|
|
13220
|
+
AND (body IS NULL OR trim(body) = '')
|
|
13221
|
+
${ageClause}`
|
|
13010
13222
|
).run();
|
|
13011
13223
|
const upd = db.prepare(
|
|
13012
13224
|
`UPDATE messages
|
|
@@ -13014,11 +13226,12 @@ function cleanupOrphanedStreams() {
|
|
|
13014
13226
|
meta_json,
|
|
13015
13227
|
'$.streaming', 0,
|
|
13016
13228
|
'$.speakerStatus', 'final',
|
|
13017
|
-
'$.error',
|
|
13229
|
+
'$.error', ?
|
|
13018
13230
|
)
|
|
13019
13231
|
WHERE json_valid(meta_json)
|
|
13020
|
-
AND json_extract(meta_json, '$.streaming') = 1
|
|
13021
|
-
|
|
13232
|
+
AND json_extract(meta_json, '$.streaming') = 1
|
|
13233
|
+
${ageClause}`
|
|
13234
|
+
).run(reason);
|
|
13022
13235
|
return { fixed: upd.changes, deleted: del.changes };
|
|
13023
13236
|
}
|
|
13024
13237
|
|
|
@@ -13564,8 +13777,17 @@ function estimateTokens(text) {
|
|
|
13564
13777
|
|
|
13565
13778
|
// src/orchestrator/stream.ts
|
|
13566
13779
|
import { EventEmitter as EventEmitter2 } from "events";
|
|
13780
|
+
var MAX_BUFFER_PER_ROOM = 100;
|
|
13781
|
+
var BUFFER_TTL_MS = 5 * 60 * 1e3;
|
|
13567
13782
|
var RoomBus = class {
|
|
13568
13783
|
emitters = /* @__PURE__ */ new Map();
|
|
13784
|
+
buffers = /* @__PURE__ */ new Map();
|
|
13785
|
+
/** Monotonic event id (global across rooms · simpler than per-room
|
|
13786
|
+
* counters and equivalent for SSE's Last-Event-ID use). Survives
|
|
13787
|
+
* the lifetime of the process; reset on restart, which is fine ·
|
|
13788
|
+
* EventSource treats a smaller id from the server as "fresh
|
|
13789
|
+
* stream" and starts over. */
|
|
13790
|
+
nextId = 1;
|
|
13569
13791
|
get(roomId) {
|
|
13570
13792
|
let e = this.emitters.get(roomId);
|
|
13571
13793
|
if (!e) {
|
|
@@ -13575,15 +13797,48 @@ var RoomBus = class {
|
|
|
13575
13797
|
}
|
|
13576
13798
|
return e;
|
|
13577
13799
|
}
|
|
13578
|
-
|
|
13579
|
-
this.get(roomId)
|
|
13800
|
+
buffer(roomId) {
|
|
13801
|
+
let b = this.buffers.get(roomId);
|
|
13802
|
+
if (!b) {
|
|
13803
|
+
b = [];
|
|
13804
|
+
this.buffers.set(roomId, b);
|
|
13805
|
+
}
|
|
13806
|
+
return b;
|
|
13580
13807
|
}
|
|
13581
|
-
|
|
13808
|
+
emit(roomId, event) {
|
|
13809
|
+
const id = this.nextId++;
|
|
13810
|
+
const ts = Date.now();
|
|
13811
|
+
const buf = this.buffer(roomId);
|
|
13812
|
+
buf.push({ id, event, ts });
|
|
13813
|
+
while (buf.length > MAX_BUFFER_PER_ROOM) buf.shift();
|
|
13814
|
+
while (buf.length && ts - buf[0].ts > BUFFER_TTL_MS) buf.shift();
|
|
13815
|
+
this.get(roomId).emit("event", id, event);
|
|
13816
|
+
}
|
|
13817
|
+
/** Legacy subscribe · returns events without ids. Kept for callers
|
|
13818
|
+
* that don't care about replay (none currently, but the surface
|
|
13819
|
+
* stays compatible). */
|
|
13582
13820
|
subscribe(roomId, listener) {
|
|
13821
|
+
return this.subscribeWithId(roomId, (_id, event) => listener(event));
|
|
13822
|
+
}
|
|
13823
|
+
/** Subscribe with monotonic event id · the SSE route uses this so
|
|
13824
|
+
* every event written to the client carries an `id: N` line. The
|
|
13825
|
+
* client's EventSource then sends `Last-Event-ID: N` on auto-
|
|
13826
|
+
* reconnect, which the route maps back to replay() below. */
|
|
13827
|
+
subscribeWithId(roomId, listener) {
|
|
13583
13828
|
const e = this.get(roomId);
|
|
13584
13829
|
e.on("event", listener);
|
|
13585
13830
|
return () => e.off("event", listener);
|
|
13586
13831
|
}
|
|
13832
|
+
/** Return cached events with id > sinceId in emit order. Used by
|
|
13833
|
+
* the SSE route on reconnect to replay the gap before subscribing
|
|
13834
|
+
* fresh. Returns [] when there is no cache OR the gap is older
|
|
13835
|
+
* than the buffer's retained window (caller treats as "no replay
|
|
13836
|
+
* possible · client may have missed events permanently"). */
|
|
13837
|
+
replay(roomId, sinceId) {
|
|
13838
|
+
const buf = this.buffers.get(roomId);
|
|
13839
|
+
if (!buf || buf.length === 0) return [];
|
|
13840
|
+
return buf.filter((e) => e.id > sinceId);
|
|
13841
|
+
}
|
|
13587
13842
|
/** Drop all listeners for a room (e.g. when it's deleted). */
|
|
13588
13843
|
drop(roomId) {
|
|
13589
13844
|
const e = this.emitters.get(roomId);
|
|
@@ -13591,6 +13846,7 @@ var RoomBus = class {
|
|
|
13591
13846
|
e.removeAllListeners();
|
|
13592
13847
|
this.emitters.delete(roomId);
|
|
13593
13848
|
}
|
|
13849
|
+
this.buffers.delete(roomId);
|
|
13594
13850
|
}
|
|
13595
13851
|
};
|
|
13596
13852
|
var roomBus = new RoomBus();
|
|
@@ -15166,8 +15422,144 @@ function briefsRouter() {
|
|
|
15166
15422
|
return r;
|
|
15167
15423
|
}
|
|
15168
15424
|
|
|
15169
|
-
// src/routes/
|
|
15425
|
+
// src/routes/credentials.ts
|
|
15170
15426
|
import { Hono as Hono4 } from "hono";
|
|
15427
|
+
function payloadFor(meta, activeId) {
|
|
15428
|
+
return {
|
|
15429
|
+
id: meta.id,
|
|
15430
|
+
provider: meta.provider,
|
|
15431
|
+
label: meta.label,
|
|
15432
|
+
preview: meta.preview,
|
|
15433
|
+
createdAt: meta.createdAt,
|
|
15434
|
+
updatedAt: meta.updatedAt,
|
|
15435
|
+
isActive: meta.id === activeId
|
|
15436
|
+
};
|
|
15437
|
+
}
|
|
15438
|
+
function pickNextActiveId(removedProvider) {
|
|
15439
|
+
const all = listLlmCredentials();
|
|
15440
|
+
if (all.length === 0) return null;
|
|
15441
|
+
if (removedProvider) {
|
|
15442
|
+
const sameProvider = all.filter((c) => c.provider === removedProvider);
|
|
15443
|
+
if (sameProvider.length > 0) {
|
|
15444
|
+
sameProvider.sort((a, b) => a.createdAt - b.createdAt);
|
|
15445
|
+
return sameProvider[0].id;
|
|
15446
|
+
}
|
|
15447
|
+
}
|
|
15448
|
+
const sorted = all.slice().sort((a, b) => {
|
|
15449
|
+
const ai = LLM_PROVIDER_PRIORITY.indexOf(a.provider);
|
|
15450
|
+
const bi = LLM_PROVIDER_PRIORITY.indexOf(b.provider);
|
|
15451
|
+
if (ai !== bi) return ai - bi;
|
|
15452
|
+
return a.createdAt - b.createdAt;
|
|
15453
|
+
});
|
|
15454
|
+
return sorted[0]?.id ?? null;
|
|
15455
|
+
}
|
|
15456
|
+
function credentialsRouter() {
|
|
15457
|
+
const r = new Hono4();
|
|
15458
|
+
r.get("/", (c) => {
|
|
15459
|
+
const activeId = getPrefs().activeLlmCredentialId;
|
|
15460
|
+
const items = listLlmCredentials().map((m) => payloadFor(m, activeId));
|
|
15461
|
+
return c.json({
|
|
15462
|
+
credentials: items,
|
|
15463
|
+
activeId
|
|
15464
|
+
});
|
|
15465
|
+
});
|
|
15466
|
+
r.put("/active", async (c) => {
|
|
15467
|
+
let body;
|
|
15468
|
+
try {
|
|
15469
|
+
body = await c.req.json();
|
|
15470
|
+
} catch {
|
|
15471
|
+
return c.json({ error: "invalid JSON body" }, 400);
|
|
15472
|
+
}
|
|
15473
|
+
const rawId = body?.id;
|
|
15474
|
+
let nextId;
|
|
15475
|
+
if (rawId === null || rawId === void 0) {
|
|
15476
|
+
nextId = null;
|
|
15477
|
+
} else if (typeof rawId === "string") {
|
|
15478
|
+
nextId = rawId;
|
|
15479
|
+
} else {
|
|
15480
|
+
return c.json({ error: "id must be a string or null" }, 400);
|
|
15481
|
+
}
|
|
15482
|
+
if (nextId) {
|
|
15483
|
+
const meta = getLlmCredentialMeta(nextId);
|
|
15484
|
+
if (!meta) return c.json({ error: "credential not found" }, 404);
|
|
15485
|
+
updatePrefs({ activeLlmCredentialId: nextId });
|
|
15486
|
+
const flagship = PRIMARY_BY_CARRIER[meta.provider];
|
|
15487
|
+
if (flagship) updatePrefs({ defaultModelV: flagship });
|
|
15488
|
+
} else {
|
|
15489
|
+
updatePrefs({ activeLlmCredentialId: null });
|
|
15490
|
+
}
|
|
15491
|
+
try {
|
|
15492
|
+
reconcileAgentModels({ forcePrimary: true });
|
|
15493
|
+
} catch (e) {
|
|
15494
|
+
process.stderr.write(`[credentials.active] reconcile failed: ${e instanceof Error ? e.message : String(e)}
|
|
15495
|
+
`);
|
|
15496
|
+
}
|
|
15497
|
+
return c.json({ activeId: nextId });
|
|
15498
|
+
});
|
|
15499
|
+
r.post("/", async (c) => {
|
|
15500
|
+
let body;
|
|
15501
|
+
try {
|
|
15502
|
+
body = await c.req.json();
|
|
15503
|
+
} catch {
|
|
15504
|
+
return c.json({ error: "invalid JSON body" }, 400);
|
|
15505
|
+
}
|
|
15506
|
+
const provider = body?.provider;
|
|
15507
|
+
const labelRaw = body?.label;
|
|
15508
|
+
const key = body?.key;
|
|
15509
|
+
if (typeof provider !== "string" || !isLlmProvider(provider)) {
|
|
15510
|
+
return c.json({ error: "provider must be a known LLM slug" }, 400);
|
|
15511
|
+
}
|
|
15512
|
+
if (typeof key !== "string" || key.trim().length === 0) {
|
|
15513
|
+
return c.json({ error: "key must be a non-empty string" }, 400);
|
|
15514
|
+
}
|
|
15515
|
+
const label = typeof labelRaw === "string" ? labelRaw : null;
|
|
15516
|
+
const meta = createLlmCredential(provider, label, key);
|
|
15517
|
+
if (!meta) return c.json({ error: "failed to create credential" }, 500);
|
|
15518
|
+
updatePrefs({ activeLlmCredentialId: meta.id });
|
|
15519
|
+
const flagship = PRIMARY_BY_CARRIER[provider];
|
|
15520
|
+
if (flagship) updatePrefs({ defaultModelV: flagship });
|
|
15521
|
+
try {
|
|
15522
|
+
reconcileAgentModels({ forcePrimary: true });
|
|
15523
|
+
} catch (e) {
|
|
15524
|
+
process.stderr.write(`[credentials.post] reconcile failed: ${e instanceof Error ? e.message : String(e)}
|
|
15525
|
+
`);
|
|
15526
|
+
}
|
|
15527
|
+
const activeId = getPrefs().activeLlmCredentialId;
|
|
15528
|
+
return c.json(payloadFor(meta, activeId), 201);
|
|
15529
|
+
});
|
|
15530
|
+
r.delete("/:id", (c) => {
|
|
15531
|
+
const id = c.req.param("id");
|
|
15532
|
+
const meta = getLlmCredentialMeta(id);
|
|
15533
|
+
if (!meta) return c.json({ error: "credential not found" }, 404);
|
|
15534
|
+
const prefs = getPrefs();
|
|
15535
|
+
const wasActive = prefs.activeLlmCredentialId === id;
|
|
15536
|
+
const removedProvider = deleteLlmCredential(id);
|
|
15537
|
+
if (wasActive) {
|
|
15538
|
+
const nextId = pickNextActiveId(removedProvider);
|
|
15539
|
+
updatePrefs({ activeLlmCredentialId: nextId });
|
|
15540
|
+
if (nextId) {
|
|
15541
|
+
const nextMeta = getLlmCredentialMeta(nextId);
|
|
15542
|
+
if (nextMeta) {
|
|
15543
|
+
const flagship = PRIMARY_BY_CARRIER[nextMeta.provider];
|
|
15544
|
+
if (flagship) updatePrefs({ defaultModelV: flagship });
|
|
15545
|
+
}
|
|
15546
|
+
} else {
|
|
15547
|
+
updatePrefs({ defaultModelV: null });
|
|
15548
|
+
}
|
|
15549
|
+
}
|
|
15550
|
+
try {
|
|
15551
|
+
reconcileAgentModels(wasActive ? { forcePrimary: true } : void 0);
|
|
15552
|
+
} catch (e) {
|
|
15553
|
+
process.stderr.write(`[credentials.delete] reconcile failed: ${e instanceof Error ? e.message : String(e)}
|
|
15554
|
+
`);
|
|
15555
|
+
}
|
|
15556
|
+
return c.json({ id, deleted: true, activeId: getPrefs().activeLlmCredentialId });
|
|
15557
|
+
});
|
|
15558
|
+
return r;
|
|
15559
|
+
}
|
|
15560
|
+
|
|
15561
|
+
// src/routes/keys.ts
|
|
15562
|
+
import { Hono as Hono5 } from "hono";
|
|
15171
15563
|
|
|
15172
15564
|
// src/voice/registry.ts
|
|
15173
15565
|
function minimaxBaseUrl() {
|
|
@@ -15405,27 +15797,16 @@ function defaultVoiceForProvider(provider) {
|
|
|
15405
15797
|
|
|
15406
15798
|
// src/routes/keys.ts
|
|
15407
15799
|
var PROVIDERS = /* @__PURE__ */ new Set([
|
|
15408
|
-
|
|
15409
|
-
|
|
15410
|
-
|
|
15411
|
-
|
|
15412
|
-
"google",
|
|
15413
|
-
"xai",
|
|
15800
|
+
...ALL_LLM_PROVIDERS,
|
|
15801
|
+
// `deepseek` lives in the storage Provider union for type-compat
|
|
15802
|
+
// with the model registry; no @ai-sdk client routes through it, so
|
|
15803
|
+
// the row is accepted but unused.
|
|
15414
15804
|
"deepseek",
|
|
15415
15805
|
"minimax",
|
|
15416
15806
|
"elevenlabs",
|
|
15417
15807
|
"brave",
|
|
15418
15808
|
"tavily"
|
|
15419
15809
|
]);
|
|
15420
|
-
var LLM_PROVIDERS = /* @__PURE__ */ new Set([
|
|
15421
|
-
"openrouter",
|
|
15422
|
-
"bai",
|
|
15423
|
-
"anthropic",
|
|
15424
|
-
"openai",
|
|
15425
|
-
"google",
|
|
15426
|
-
"xai",
|
|
15427
|
-
"deepseek"
|
|
15428
|
-
]);
|
|
15429
15810
|
function isProvider(s) {
|
|
15430
15811
|
return PROVIDERS.has(s);
|
|
15431
15812
|
}
|
|
@@ -15463,7 +15844,7 @@ function autoAssignVoicesOnFirstKey(provider) {
|
|
|
15463
15844
|
}
|
|
15464
15845
|
}
|
|
15465
15846
|
function keysRouter() {
|
|
15466
|
-
const r = new
|
|
15847
|
+
const r = new Hono5();
|
|
15467
15848
|
r.get("/", (c) => {
|
|
15468
15849
|
const meta = listKeyMeta();
|
|
15469
15850
|
const map = /* @__PURE__ */ new Map();
|
|
@@ -15471,11 +15852,22 @@ function keysRouter() {
|
|
|
15471
15852
|
const out = Array.from(PROVIDERS).map(
|
|
15472
15853
|
(p) => map.get(p) ?? { provider: p, configured: false, updatedAt: null, preview: null }
|
|
15473
15854
|
);
|
|
15474
|
-
return c.json({
|
|
15475
|
-
|
|
15476
|
-
|
|
15855
|
+
return c.json({
|
|
15856
|
+
keys: out,
|
|
15857
|
+
classification: {
|
|
15858
|
+
multiModel: [...MULTI_MODEL_LLM_PROVIDERS],
|
|
15859
|
+
singleModel: [...SINGLE_MODEL_LLM_PROVIDERS],
|
|
15860
|
+
voice: ["minimax", "elevenlabs"],
|
|
15861
|
+
skill: ["brave", "tavily"]
|
|
15862
|
+
}
|
|
15863
|
+
});
|
|
15864
|
+
});
|
|
15865
|
+
r.put("/:provider", async (c) => {
|
|
15477
15866
|
const provider = c.req.param("provider");
|
|
15478
15867
|
if (!isProvider(provider)) return c.json({ error: "unknown provider" }, 400);
|
|
15868
|
+
if (isLlmProvider(provider)) {
|
|
15869
|
+
return c.json({ error: "LLM providers use POST /api/credentials" }, 410);
|
|
15870
|
+
}
|
|
15479
15871
|
let body;
|
|
15480
15872
|
try {
|
|
15481
15873
|
body = await c.req.json();
|
|
@@ -15484,29 +15876,8 @@ function keysRouter() {
|
|
|
15484
15876
|
}
|
|
15485
15877
|
const key = body?.key;
|
|
15486
15878
|
if (typeof key !== "string") return c.json({ error: "body must contain { key: string }" }, 400);
|
|
15487
|
-
const makeDefault = body?.makeDefault === true;
|
|
15488
15879
|
const hadAnyVoiceKeyBefore = !!getKey("minimax") || !!getKey("elevenlabs");
|
|
15489
15880
|
setKey(provider, key);
|
|
15490
|
-
if (provider !== "brave" && provider !== "tavily" && provider !== "minimax" && provider !== "elevenlabs") {
|
|
15491
|
-
const willForce = makeDefault && key.trim().length > 0;
|
|
15492
|
-
if (willForce) {
|
|
15493
|
-
const flagship = PRIMARY_BY_CARRIER[provider];
|
|
15494
|
-
if (flagship) {
|
|
15495
|
-
try {
|
|
15496
|
-
updatePrefs({ defaultModelV: flagship });
|
|
15497
|
-
} catch (e) {
|
|
15498
|
-
process.stderr.write(`[keys.put] updatePrefs failed: ${e instanceof Error ? e.message : String(e)}
|
|
15499
|
-
`);
|
|
15500
|
-
}
|
|
15501
|
-
}
|
|
15502
|
-
}
|
|
15503
|
-
try {
|
|
15504
|
-
reconcileAgentModels({ forcePrimary: willForce });
|
|
15505
|
-
} catch (e) {
|
|
15506
|
-
process.stderr.write(`[keys.put] reconcile failed: ${e instanceof Error ? e.message : String(e)}
|
|
15507
|
-
`);
|
|
15508
|
-
}
|
|
15509
|
-
}
|
|
15510
15881
|
if ((provider === "minimax" || provider === "elevenlabs") && key.trim().length > 0 && !hadAnyVoiceKeyBefore) {
|
|
15511
15882
|
try {
|
|
15512
15883
|
autoAssignVoicesOnFirstKey(provider);
|
|
@@ -15523,42 +15894,23 @@ function keysRouter() {
|
|
|
15523
15894
|
r.delete("/:provider", (c) => {
|
|
15524
15895
|
const provider = c.req.param("provider");
|
|
15525
15896
|
if (!isProvider(provider)) return c.json({ error: "unknown provider" }, 400);
|
|
15526
|
-
if (
|
|
15527
|
-
|
|
15528
|
-
const targetConfigured = !!allMeta.find((m) => m.provider === provider && m.configured);
|
|
15529
|
-
const configuredLlmCount = allMeta.filter(
|
|
15530
|
-
(m) => LLM_PROVIDERS.has(m.provider) && m.configured
|
|
15531
|
-
).length;
|
|
15532
|
-
if (targetConfigured && configuredLlmCount <= 1) {
|
|
15533
|
-
return c.json(
|
|
15534
|
-
{
|
|
15535
|
-
error: "Can't remove your only LLM key \u2014 add another LLM provider first, then remove this one."
|
|
15536
|
-
},
|
|
15537
|
-
409
|
|
15538
|
-
);
|
|
15539
|
-
}
|
|
15897
|
+
if (isLlmProvider(provider)) {
|
|
15898
|
+
return c.json({ error: "LLM providers use DELETE /api/credentials/:id" }, 410);
|
|
15540
15899
|
}
|
|
15541
15900
|
deleteKey(provider);
|
|
15542
|
-
if (provider !== "brave" && provider !== "tavily" && provider !== "minimax" && provider !== "elevenlabs") {
|
|
15543
|
-
try {
|
|
15544
|
-
reconcileAgentModels();
|
|
15545
|
-
} catch (e) {
|
|
15546
|
-
process.stderr.write(`[keys.delete] reconcile failed: ${e instanceof Error ? e.message : String(e)}
|
|
15547
|
-
`);
|
|
15548
|
-
}
|
|
15549
|
-
}
|
|
15550
15901
|
return c.json({ provider, configured: false, updatedAt: null, preview: null });
|
|
15551
15902
|
});
|
|
15552
15903
|
return r;
|
|
15553
15904
|
}
|
|
15554
15905
|
|
|
15555
15906
|
// src/routes/models.ts
|
|
15556
|
-
import { Hono as
|
|
15907
|
+
import { Hono as Hono6 } from "hono";
|
|
15557
15908
|
function modelsRouter() {
|
|
15558
|
-
const r = new
|
|
15909
|
+
const r = new Hono6();
|
|
15559
15910
|
r.get("/", (c) => {
|
|
15560
15911
|
const all = modelAvailability();
|
|
15561
15912
|
const reachable = all.filter((m) => m.reachable);
|
|
15913
|
+
const active = getProviderKeyState().activeLlmProvider;
|
|
15562
15914
|
return c.json({
|
|
15563
15915
|
/** Whether any LLM provider key is configured. False → frontend
|
|
15564
15916
|
* redirects the user to the API Key settings before letting
|
|
@@ -15581,763 +15933,28 @@ function modelsRouter() {
|
|
|
15581
15933
|
utilityModelV: utilityModelFor(),
|
|
15582
15934
|
/** Provider summary · so the frontend can show "you have OR +
|
|
15583
15935
|
* OpenAI direct" at a glance without iterating models. */
|
|
15584
|
-
providers: collectProviderSummary(all)
|
|
15585
|
-
|
|
15586
|
-
|
|
15587
|
-
|
|
15588
|
-
}
|
|
15589
|
-
|
|
15590
|
-
|
|
15591
|
-
|
|
15592
|
-
const cur = map.get(m.provider) ?? { reachable: 0, total: 0 };
|
|
15593
|
-
cur.total++;
|
|
15594
|
-
if (m.reachable) cur.reachable++;
|
|
15595
|
-
map.set(m.provider, cur);
|
|
15596
|
-
}
|
|
15597
|
-
return Array.from(map.entries()).map(([provider, v]) => ({ provider, ...v }));
|
|
15598
|
-
}
|
|
15599
|
-
|
|
15600
|
-
// src/routes/topic-recs.ts
|
|
15601
|
-
import { Hono as Hono6 } from "hono";
|
|
15602
|
-
import { streamSSE as streamSSE2 } from "hono/streaming";
|
|
15603
|
-
|
|
15604
|
-
// src/orchestrator/topic-recommender.ts
|
|
15605
|
-
import { randomUUID as randomUUID2 } from "crypto";
|
|
15606
|
-
|
|
15607
|
-
// src/storage/topic-recs.ts
|
|
15608
|
-
init_db();
|
|
15609
|
-
function createTopicRecBatch(input) {
|
|
15610
|
-
const now = Date.now();
|
|
15611
|
-
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);
|
|
15612
|
-
return { id: input.id, hasWeb: input.hasWeb, keywords: input.keywords, createdAt: now };
|
|
15613
|
-
}
|
|
15614
|
-
function mapRec(r) {
|
|
15615
|
-
let seedContext = null;
|
|
15616
|
-
if (r.seed_context_json) {
|
|
15617
|
-
try {
|
|
15618
|
-
const parsed = JSON.parse(r.seed_context_json);
|
|
15619
|
-
if (Array.isArray(parsed)) {
|
|
15620
|
-
seedContext = parsed.filter(
|
|
15621
|
-
(s) => s && typeof s.title === "string" && typeof s.url === "string" && typeof s.description === "string"
|
|
15622
|
-
);
|
|
15623
|
-
}
|
|
15624
|
-
} catch {
|
|
15625
|
-
}
|
|
15626
|
-
}
|
|
15627
|
-
return {
|
|
15628
|
-
id: r.id,
|
|
15629
|
-
batchId: r.batch_id,
|
|
15630
|
-
subject: r.subject,
|
|
15631
|
-
rationale: r.rationale,
|
|
15632
|
-
source: r.source === "web" ? "web" : "memory",
|
|
15633
|
-
tag: typeof r.tag === "string" && r.tag.trim().length > 0 ? r.tag.trim() : null,
|
|
15634
|
-
seedContext,
|
|
15635
|
-
createdAt: r.created_at,
|
|
15636
|
-
openedRoomId: r.opened_room_id
|
|
15637
|
-
};
|
|
15638
|
-
}
|
|
15639
|
-
var REC_COLS = "id, batch_id, subject, rationale, source, tag, seed_context_json, created_at, opened_room_id";
|
|
15640
|
-
function insertTopicRec(input) {
|
|
15641
|
-
const now = Date.now();
|
|
15642
|
-
getDb().prepare(
|
|
15643
|
-
`INSERT INTO topic_recs
|
|
15644
|
-
(id, batch_id, subject, rationale, source, tag, seed_context_json, created_at, opened_room_id)
|
|
15645
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)`
|
|
15646
|
-
).run(
|
|
15647
|
-
input.id,
|
|
15648
|
-
input.batchId,
|
|
15649
|
-
input.subject,
|
|
15650
|
-
input.rationale,
|
|
15651
|
-
input.source,
|
|
15652
|
-
input.tag,
|
|
15653
|
-
input.seedContext ? JSON.stringify(input.seedContext) : null,
|
|
15654
|
-
now
|
|
15655
|
-
);
|
|
15656
|
-
return {
|
|
15657
|
-
id: input.id,
|
|
15658
|
-
batchId: input.batchId,
|
|
15659
|
-
subject: input.subject,
|
|
15660
|
-
rationale: input.rationale,
|
|
15661
|
-
source: input.source,
|
|
15662
|
-
tag: input.tag,
|
|
15663
|
-
seedContext: input.seedContext,
|
|
15664
|
-
createdAt: now,
|
|
15665
|
-
openedRoomId: null
|
|
15666
|
-
};
|
|
15667
|
-
}
|
|
15668
|
-
function getTopicRec(id) {
|
|
15669
|
-
const row = getDb().prepare(`SELECT ${REC_COLS} FROM topic_recs WHERE id = ?`).get(id);
|
|
15670
|
-
return row ? mapRec(row) : null;
|
|
15671
|
-
}
|
|
15672
|
-
function listTopicRecs(opts) {
|
|
15673
|
-
const limit = Math.max(1, Math.min(100, opts.limit));
|
|
15674
|
-
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 ?`);
|
|
15675
|
-
const rows = opts.cursor === null ? stmt.all(limit) : stmt.all(opts.cursor, limit);
|
|
15676
|
-
const items = rows.map(mapRec);
|
|
15677
|
-
const nextCursor = items.length === limit ? items[items.length - 1].createdAt : null;
|
|
15678
|
-
return { items, nextCursor };
|
|
15679
|
-
}
|
|
15680
|
-
function markTopicRecOpened(recId, roomId) {
|
|
15681
|
-
getDb().prepare("UPDATE topic_recs SET opened_room_id = ? WHERE id = ?").run(roomId, recId);
|
|
15682
|
-
}
|
|
15683
|
-
function clearAllTopicRecs() {
|
|
15684
|
-
const r = getDb().prepare("DELETE FROM topic_recs").run();
|
|
15685
|
-
return r.changes;
|
|
15686
|
-
}
|
|
15687
|
-
function mapJob(r) {
|
|
15688
|
-
const status = ["running", "done", "failed", "aborted"].includes(r.status) ? r.status : "failed";
|
|
15689
|
-
return {
|
|
15690
|
-
id: r.id,
|
|
15691
|
-
status,
|
|
15692
|
-
currentPhase: r.current_phase,
|
|
15693
|
-
progressPct: r.progress_pct,
|
|
15694
|
-
batchId: r.batch_id,
|
|
15695
|
-
error: r.error,
|
|
15696
|
-
startedAt: r.started_at,
|
|
15697
|
-
updatedAt: r.updated_at
|
|
15698
|
-
};
|
|
15699
|
-
}
|
|
15700
|
-
var JOB_COLS = "id, status, current_phase, progress_pct, batch_id, error, started_at, updated_at";
|
|
15701
|
-
function createTopicRecJob(id) {
|
|
15702
|
-
const now = Date.now();
|
|
15703
|
-
getDb().prepare(
|
|
15704
|
-
`INSERT INTO topic_rec_jobs (id, status, current_phase, progress_pct, batch_id, error, started_at, updated_at)
|
|
15705
|
-
VALUES (?, 'running', 0, 0, NULL, NULL, ?, ?)`
|
|
15706
|
-
).run(id, now, now);
|
|
15707
|
-
return getTopicRecJob(id);
|
|
15708
|
-
}
|
|
15709
|
-
function getTopicRecJob(id) {
|
|
15710
|
-
const row = getDb().prepare(`SELECT ${JOB_COLS} FROM topic_rec_jobs WHERE id = ?`).get(id);
|
|
15711
|
-
return row ? mapJob(row) : null;
|
|
15712
|
-
}
|
|
15713
|
-
function updateTopicRecJob(id, patch) {
|
|
15714
|
-
const fields = [];
|
|
15715
|
-
const values = [];
|
|
15716
|
-
if (patch.status !== void 0) {
|
|
15717
|
-
fields.push("status = ?");
|
|
15718
|
-
values.push(patch.status);
|
|
15719
|
-
}
|
|
15720
|
-
if (typeof patch.currentPhase === "number") {
|
|
15721
|
-
fields.push("current_phase = ?");
|
|
15722
|
-
values.push(patch.currentPhase);
|
|
15723
|
-
}
|
|
15724
|
-
if (typeof patch.progressPct === "number") {
|
|
15725
|
-
fields.push("progress_pct = ?");
|
|
15726
|
-
values.push(Math.max(0, Math.min(100, Math.round(patch.progressPct))));
|
|
15727
|
-
}
|
|
15728
|
-
if (patch.batchId !== void 0) {
|
|
15729
|
-
fields.push("batch_id = ?");
|
|
15730
|
-
values.push(patch.batchId);
|
|
15731
|
-
}
|
|
15732
|
-
if (patch.error !== void 0) {
|
|
15733
|
-
fields.push("error = ?");
|
|
15734
|
-
values.push(patch.error);
|
|
15735
|
-
}
|
|
15736
|
-
if (fields.length === 0) return getTopicRecJob(id);
|
|
15737
|
-
fields.push("updated_at = ?");
|
|
15738
|
-
values.push(Date.now());
|
|
15739
|
-
values.push(id);
|
|
15740
|
-
getDb().prepare(`UPDATE topic_rec_jobs SET ${fields.join(", ")} WHERE id = ?`).run(...values);
|
|
15741
|
-
return getTopicRecJob(id);
|
|
15742
|
-
}
|
|
15743
|
-
function markRunningTopicRecJobsFailed() {
|
|
15744
|
-
const r = getDb().prepare(
|
|
15745
|
-
`UPDATE topic_rec_jobs
|
|
15746
|
-
SET status = 'failed',
|
|
15747
|
-
error = COALESCE(error, 'server restarted mid-build'),
|
|
15748
|
-
updated_at = ?
|
|
15749
|
-
WHERE status = 'running'`
|
|
15750
|
-
).run(Date.now());
|
|
15751
|
-
return r.changes;
|
|
15752
|
-
}
|
|
15753
|
-
|
|
15754
|
-
// src/orchestrator/topic-stream.ts
|
|
15755
|
-
import { EventEmitter as EventEmitter3 } from "events";
|
|
15756
|
-
var TopicRecBus = class {
|
|
15757
|
-
emitters = /* @__PURE__ */ new Map();
|
|
15758
|
-
get(jobId) {
|
|
15759
|
-
let e = this.emitters.get(jobId);
|
|
15760
|
-
if (!e) {
|
|
15761
|
-
e = new EventEmitter3();
|
|
15762
|
-
e.setMaxListeners(16);
|
|
15763
|
-
this.emitters.set(jobId, e);
|
|
15764
|
-
}
|
|
15765
|
-
return e;
|
|
15766
|
-
}
|
|
15767
|
-
emit(jobId, event) {
|
|
15768
|
-
this.get(jobId).emit("event", event);
|
|
15769
|
-
}
|
|
15770
|
-
subscribe(jobId, listener) {
|
|
15771
|
-
const e = this.get(jobId);
|
|
15772
|
-
e.on("event", listener);
|
|
15773
|
-
return () => e.off("event", listener);
|
|
15774
|
-
}
|
|
15775
|
-
/** Free the EventEmitter for a job. Call on terminal events
|
|
15776
|
-
* (`topic-final`, `topic-error`, `topic-aborted`) so the Map
|
|
15777
|
-
* doesn't grow unbounded across many runs in one process. */
|
|
15778
|
-
drop(jobId) {
|
|
15779
|
-
const e = this.emitters.get(jobId);
|
|
15780
|
-
if (e) {
|
|
15781
|
-
e.removeAllListeners();
|
|
15782
|
-
this.emitters.delete(jobId);
|
|
15783
|
-
}
|
|
15784
|
-
}
|
|
15785
|
-
};
|
|
15786
|
-
var topicRecBus = new TopicRecBus();
|
|
15787
|
-
|
|
15788
|
-
// src/orchestrator/topic-recommender.ts
|
|
15789
|
-
var LLM_CALL_TIMEOUT_MS2 = 6e4;
|
|
15790
|
-
var PIPELINE_WALL_CLOCK_MS = 12e4;
|
|
15791
|
-
var SEARCH_PARALLEL_CHUNK = 3;
|
|
15792
|
-
var SEARCH_CHUNK_GAP_MS = 1e3;
|
|
15793
|
-
var SEARCH_RESULTS_PER_QUERY = 5;
|
|
15794
|
-
var inFlightJobs2 = /* @__PURE__ */ new Map();
|
|
15795
|
-
function signalWithTimeout3(parent, timeoutMs) {
|
|
15796
|
-
const controller = new AbortController();
|
|
15797
|
-
const onParentAbort = () => controller.abort();
|
|
15798
|
-
if (parent?.aborted) controller.abort();
|
|
15799
|
-
else parent?.addEventListener("abort", onParentAbort, { once: true });
|
|
15800
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
15801
|
-
return {
|
|
15802
|
-
signal: controller.signal,
|
|
15803
|
-
cleanup: () => {
|
|
15804
|
-
clearTimeout(timer);
|
|
15805
|
-
parent?.removeEventListener("abort", onParentAbort);
|
|
15806
|
-
}
|
|
15807
|
-
};
|
|
15808
|
-
}
|
|
15809
|
-
function extractJson5(raw) {
|
|
15810
|
-
const fence = /```(?:json)?\s*([\s\S]*?)```/i.exec(raw);
|
|
15811
|
-
const candidate = fence ? fence[1] : raw;
|
|
15812
|
-
if (!candidate) return null;
|
|
15813
|
-
const start = candidate.indexOf("{");
|
|
15814
|
-
if (start === -1) return null;
|
|
15815
|
-
let depth = 0;
|
|
15816
|
-
let end = -1;
|
|
15817
|
-
for (let i = start; i < candidate.length; i++) {
|
|
15818
|
-
if (candidate[i] === "{") depth++;
|
|
15819
|
-
else if (candidate[i] === "}") {
|
|
15820
|
-
depth--;
|
|
15821
|
-
if (depth === 0) {
|
|
15822
|
-
end = i;
|
|
15823
|
-
break;
|
|
15824
|
-
}
|
|
15825
|
-
}
|
|
15826
|
-
}
|
|
15827
|
-
if (end === -1) return null;
|
|
15828
|
-
try {
|
|
15829
|
-
return JSON.parse(candidate.slice(start, end + 1));
|
|
15830
|
-
} catch {
|
|
15831
|
-
return null;
|
|
15832
|
-
}
|
|
15833
|
-
}
|
|
15834
|
-
async function callPhaseLLM2(state, modelV, messages, opts) {
|
|
15835
|
-
if (!isModelV(modelV)) return null;
|
|
15836
|
-
const t = signalWithTimeout3(state.controller.signal, LLM_CALL_TIMEOUT_MS2);
|
|
15837
|
-
try {
|
|
15838
|
-
const r = await callLLMWithUsage({
|
|
15839
|
-
modelV,
|
|
15840
|
-
messages,
|
|
15841
|
-
temperature: opts.temperature,
|
|
15842
|
-
maxTokens: opts.maxTokens,
|
|
15843
|
-
signal: t.signal
|
|
15844
|
-
});
|
|
15845
|
-
return r.text;
|
|
15846
|
-
} catch (e) {
|
|
15847
|
-
process.stderr.write(
|
|
15848
|
-
`[topic-recommender] ${modelV} failed: ${e instanceof Error ? e.message : String(e)}
|
|
15849
|
-
`
|
|
15850
|
-
);
|
|
15851
|
-
return null;
|
|
15852
|
-
} finally {
|
|
15853
|
-
t.cleanup();
|
|
15854
|
-
}
|
|
15855
|
-
}
|
|
15856
|
-
function sleepWithSignal2(ms, signal) {
|
|
15857
|
-
return new Promise((resolve2) => {
|
|
15858
|
-
if (signal.aborted) return resolve2();
|
|
15859
|
-
const t = setTimeout(() => {
|
|
15860
|
-
signal.removeEventListener("abort", onAbort);
|
|
15861
|
-
resolve2();
|
|
15862
|
-
}, ms);
|
|
15863
|
-
function onAbort() {
|
|
15864
|
-
clearTimeout(t);
|
|
15865
|
-
resolve2();
|
|
15866
|
-
}
|
|
15867
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
15868
|
-
});
|
|
15869
|
-
}
|
|
15870
|
-
function startTopicRecommend() {
|
|
15871
|
-
const jobId = randomUUID2();
|
|
15872
|
-
createTopicRecJob(jobId);
|
|
15873
|
-
const state = {
|
|
15874
|
-
id: jobId,
|
|
15875
|
-
startedAt: Date.now(),
|
|
15876
|
-
controller: new AbortController()
|
|
15877
|
-
};
|
|
15878
|
-
inFlightJobs2.set(jobId, state);
|
|
15879
|
-
const wallClock = setTimeout(() => {
|
|
15880
|
-
if (inFlightJobs2.has(jobId)) state.controller.abort();
|
|
15881
|
-
}, PIPELINE_WALL_CLOCK_MS);
|
|
15882
|
-
void runPipeline3(state).finally(() => {
|
|
15883
|
-
clearTimeout(wallClock);
|
|
15884
|
-
inFlightJobs2.delete(jobId);
|
|
15885
|
-
});
|
|
15886
|
-
return jobId;
|
|
15887
|
-
}
|
|
15888
|
-
function abortTopicRecommend(jobId) {
|
|
15889
|
-
const state = inFlightJobs2.get(jobId);
|
|
15890
|
-
if (!state) return false;
|
|
15891
|
-
try {
|
|
15892
|
-
state.controller.abort();
|
|
15893
|
-
} catch {
|
|
15894
|
-
}
|
|
15895
|
-
return true;
|
|
15896
|
-
}
|
|
15897
|
-
function isTopicRecJobRunning(jobId) {
|
|
15898
|
-
return inFlightJobs2.has(jobId);
|
|
15899
|
-
}
|
|
15900
|
-
async function runPipeline3(state) {
|
|
15901
|
-
const phaseLabels = [
|
|
15902
|
-
"Reading your boardroom history",
|
|
15903
|
-
"Distilling interests",
|
|
15904
|
-
"Scanning trending topics",
|
|
15905
|
-
"Synthesising recommendations"
|
|
15906
|
-
];
|
|
15907
|
-
const emitPhaseStart = (phase) => {
|
|
15908
|
-
topicRecBus.emit(state.id, {
|
|
15909
|
-
type: "topic-phase-start",
|
|
15910
|
-
phase,
|
|
15911
|
-
label: phaseLabels[phase - 1] ?? `Phase ${phase}`
|
|
15912
|
-
});
|
|
15913
|
-
};
|
|
15914
|
-
const emitPhaseProgress = (phase, detail, pct) => {
|
|
15915
|
-
topicRecBus.emit(state.id, {
|
|
15916
|
-
type: "topic-phase-progress",
|
|
15917
|
-
phase,
|
|
15918
|
-
detail,
|
|
15919
|
-
progressPct: Math.max(0, Math.min(100, Math.round(pct)))
|
|
15920
|
-
});
|
|
15921
|
-
updateTopicRecJob(state.id, { currentPhase: phase, progressPct: pct });
|
|
15922
|
-
};
|
|
15923
|
-
const emitPhaseEnd = (phase, pct) => {
|
|
15924
|
-
topicRecBus.emit(state.id, {
|
|
15925
|
-
type: "topic-phase-end",
|
|
15926
|
-
phase,
|
|
15927
|
-
progressPct: Math.max(0, Math.min(100, Math.round(pct)))
|
|
15928
|
-
});
|
|
15929
|
-
updateTopicRecJob(state.id, { currentPhase: phase, progressPct: pct });
|
|
15930
|
-
};
|
|
15931
|
-
const fail = (message) => {
|
|
15932
|
-
updateTopicRecJob(state.id, { status: "failed", error: message });
|
|
15933
|
-
topicRecBus.emit(state.id, { type: "topic-error", message });
|
|
15934
|
-
topicRecBus.drop(state.id);
|
|
15935
|
-
};
|
|
15936
|
-
const cancel = () => {
|
|
15937
|
-
updateTopicRecJob(state.id, { status: "aborted" });
|
|
15938
|
-
topicRecBus.emit(state.id, { type: "topic-aborted" });
|
|
15939
|
-
topicRecBus.drop(state.id);
|
|
15940
|
-
};
|
|
15941
|
-
try {
|
|
15942
|
-
emitPhaseStart(1);
|
|
15943
|
-
const chair = getChairAgent();
|
|
15944
|
-
if (!chair) {
|
|
15945
|
-
fail("chair agent missing \u2014 set up onboarding first");
|
|
15946
|
-
return;
|
|
15947
|
-
}
|
|
15948
|
-
const memories = memoriesForContext(chair.id, 50);
|
|
15949
|
-
emitPhaseProgress(1, `read ${memories.length} memories`, 8);
|
|
15950
|
-
emitPhaseEnd(1, 10);
|
|
15951
|
-
if (state.controller.signal.aborted) {
|
|
15952
|
-
cancel();
|
|
15953
|
-
return;
|
|
15954
|
-
}
|
|
15955
|
-
emitPhaseStart(2);
|
|
15956
|
-
const modelV = utilityModelFor();
|
|
15957
|
-
if (!modelV) {
|
|
15958
|
-
fail("no LLM provider configured \u2014 add an API key first");
|
|
15959
|
-
return;
|
|
15960
|
-
}
|
|
15961
|
-
const keywords = await distilKeywords(state, modelV, memories);
|
|
15962
|
-
if (state.controller.signal.aborted) {
|
|
15963
|
-
cancel();
|
|
15964
|
-
return;
|
|
15965
|
-
}
|
|
15966
|
-
if (keywords.length === 0) {
|
|
15967
|
-
fail("couldn't distil any keywords from the chair's memory yet \u2014 try again after a couple of rooms");
|
|
15968
|
-
return;
|
|
15969
|
-
}
|
|
15970
|
-
emitPhaseProgress(2, `picked ${keywords.length} keywords`, 25);
|
|
15971
|
-
emitPhaseEnd(2, 30);
|
|
15972
|
-
const hasWeb = hasWebSearchKey();
|
|
15973
|
-
let snippetsByKeyword = /* @__PURE__ */ new Map();
|
|
15974
|
-
if (hasWeb) {
|
|
15975
|
-
emitPhaseStart(3);
|
|
15976
|
-
snippetsByKeyword = await runWebSweep(state, keywords, (kw, snippets, idx) => {
|
|
15977
|
-
emitPhaseProgress(
|
|
15978
|
-
3,
|
|
15979
|
-
`scanned "${kw}" (${snippets.length} hits) \xB7 ${idx}/${keywords.length}`,
|
|
15980
|
-
30 + Math.round(idx / keywords.length * 40)
|
|
15981
|
-
);
|
|
15982
|
-
topicRecBus.emit(state.id, {
|
|
15983
|
-
type: "topic-search-round",
|
|
15984
|
-
keyword: kw,
|
|
15985
|
-
query: `${kw} site:x.com`,
|
|
15986
|
-
resultsCount: snippets.length,
|
|
15987
|
-
snippets
|
|
15988
|
-
});
|
|
15989
|
-
});
|
|
15990
|
-
if (state.controller.signal.aborted) {
|
|
15991
|
-
cancel();
|
|
15992
|
-
return;
|
|
15993
|
-
}
|
|
15994
|
-
emitPhaseEnd(3, 70);
|
|
15995
|
-
} else {
|
|
15996
|
-
emitPhaseProgress(3, "no web-search key \u2014 skipping", 70);
|
|
15997
|
-
}
|
|
15998
|
-
emitPhaseStart(4);
|
|
15999
|
-
const batchId = randomUUID2();
|
|
16000
|
-
createTopicRecBatch({ id: batchId, hasWeb, keywords });
|
|
16001
|
-
updateTopicRecJob(state.id, { batchId });
|
|
16002
|
-
const synth = await synthesiseTopics(state, modelV, {
|
|
16003
|
-
memories,
|
|
16004
|
-
keywords,
|
|
16005
|
-
snippetsByKeyword,
|
|
16006
|
-
hasWeb
|
|
16007
|
-
});
|
|
16008
|
-
if (state.controller.signal.aborted) {
|
|
16009
|
-
cancel();
|
|
16010
|
-
return;
|
|
16011
|
-
}
|
|
16012
|
-
if (synth.length === 0) {
|
|
16013
|
-
fail("synthesis returned no topics \u2014 try again or refine your boardroom history first");
|
|
16014
|
-
return;
|
|
16015
|
-
}
|
|
16016
|
-
clearAllTopicRecs();
|
|
16017
|
-
let inserted = 0;
|
|
16018
|
-
for (const t of synth) {
|
|
16019
|
-
const rec = insertTopicRec({
|
|
16020
|
-
id: randomUUID2(),
|
|
16021
|
-
batchId,
|
|
16022
|
-
subject: t.subject,
|
|
16023
|
-
rationale: t.rationale,
|
|
16024
|
-
source: t.source,
|
|
16025
|
-
tag: t.tag,
|
|
16026
|
-
seedContext: t.seedContext
|
|
16027
|
-
});
|
|
16028
|
-
inserted++;
|
|
16029
|
-
topicRecBus.emit(state.id, { type: "topic-rec", rec });
|
|
16030
|
-
emitPhaseProgress(
|
|
16031
|
-
4,
|
|
16032
|
-
`synthesised ${inserted}/${synth.length}`,
|
|
16033
|
-
70 + Math.round(inserted / synth.length * 28)
|
|
16034
|
-
);
|
|
16035
|
-
}
|
|
16036
|
-
emitPhaseEnd(4, 100);
|
|
16037
|
-
updateTopicRecJob(state.id, {
|
|
16038
|
-
status: "done",
|
|
16039
|
-
progressPct: 100,
|
|
16040
|
-
currentPhase: 4
|
|
16041
|
-
});
|
|
16042
|
-
topicRecBus.emit(state.id, {
|
|
16043
|
-
type: "topic-final",
|
|
16044
|
-
batchId,
|
|
16045
|
-
totalRecs: inserted,
|
|
16046
|
-
hasWeb
|
|
16047
|
-
});
|
|
16048
|
-
topicRecBus.drop(state.id);
|
|
16049
|
-
} catch (e) {
|
|
16050
|
-
if (state.controller.signal.aborted) {
|
|
16051
|
-
cancel();
|
|
16052
|
-
return;
|
|
16053
|
-
}
|
|
16054
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
16055
|
-
process.stderr.write(`[topic-recommender] pipeline crashed: ${msg}
|
|
16056
|
-
`);
|
|
16057
|
-
fail(msg);
|
|
16058
|
-
}
|
|
16059
|
-
}
|
|
16060
|
-
async function distilKeywords(state, modelV, memories) {
|
|
16061
|
-
if (memories.length === 0) return [];
|
|
16062
|
-
const memoryLines = memories.slice(0, 60).map((m, i) => {
|
|
16063
|
-
const tier = m.tier === "long" ? "STABLE" : "fresh";
|
|
16064
|
-
const prov = m.provenanceRooms > 1 ? ` \xB7 \xD7${m.provenanceRooms} rooms` : "";
|
|
16065
|
-
const recency = Math.max(0, Math.round((Date.now() - m.createdAt) / 864e5));
|
|
16066
|
-
return `${i + 1}. [${tier}${prov} \xB7 ${recency}d ago \xB7 ${m.kind}] ${m.content}`;
|
|
16067
|
-
}).join("\n");
|
|
16068
|
-
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.`;
|
|
16069
|
-
const user = `# Chair's memory about the user (newest first within each tier)
|
|
16070
|
-
${memoryLines}
|
|
16071
|
-
|
|
16072
|
-
Return up to 10 keywords as JSON.`;
|
|
16073
|
-
const raw = await callPhaseLLM2(state, modelV, [
|
|
16074
|
-
{ role: "system", content: system },
|
|
16075
|
-
{ role: "user", content: user }
|
|
16076
|
-
], { temperature: 0.3, maxTokens: 600 });
|
|
16077
|
-
if (!raw) return [];
|
|
16078
|
-
const parsed = extractJson5(raw);
|
|
16079
|
-
if (!parsed || !Array.isArray(parsed.keywords)) return [];
|
|
16080
|
-
return parsed.keywords.filter((k) => typeof k === "string").map((k) => k.trim()).filter((k) => k.length > 0).slice(0, 10);
|
|
16081
|
-
}
|
|
16082
|
-
async function runWebSweep(state, keywords, onKeywordDone) {
|
|
16083
|
-
const out = /* @__PURE__ */ new Map();
|
|
16084
|
-
const creds = getActiveWebSearchCredentials();
|
|
16085
|
-
if (!creds) return out;
|
|
16086
|
-
let doneCount = 0;
|
|
16087
|
-
for (let i = 0; i < keywords.length; i += SEARCH_PARALLEL_CHUNK) {
|
|
16088
|
-
if (state.controller.signal.aborted) break;
|
|
16089
|
-
const chunk = keywords.slice(i, i + SEARCH_PARALLEL_CHUNK);
|
|
16090
|
-
const settled = await Promise.allSettled(
|
|
16091
|
-
chunk.map((kw) => fetchKeywordSnippets(creds.backend, creds.apiKey, kw))
|
|
16092
|
-
);
|
|
16093
|
-
settled.forEach((res, j) => {
|
|
16094
|
-
const kw = chunk[j];
|
|
16095
|
-
const snippets = res.status === "fulfilled" ? res.value : [];
|
|
16096
|
-
out.set(kw, snippets);
|
|
16097
|
-
doneCount++;
|
|
16098
|
-
onKeywordDone(kw, snippets, doneCount);
|
|
16099
|
-
});
|
|
16100
|
-
if (i + SEARCH_PARALLEL_CHUNK < keywords.length) {
|
|
16101
|
-
await sleepWithSignal2(SEARCH_CHUNK_GAP_MS, state.controller.signal);
|
|
16102
|
-
}
|
|
16103
|
-
}
|
|
16104
|
-
return out;
|
|
16105
|
-
}
|
|
16106
|
-
async function fetchKeywordSnippets(backend, apiKey, keyword) {
|
|
16107
|
-
const xQuery = `${keyword} site:x.com`;
|
|
16108
|
-
const xResults = await runWebSearch(backend, apiKey, xQuery, {
|
|
16109
|
-
count: SEARCH_RESULTS_PER_QUERY
|
|
16110
|
-
});
|
|
16111
|
-
if (xResults && xResults.length > 0) {
|
|
16112
|
-
return xResults.map(toSnippet);
|
|
16113
|
-
}
|
|
16114
|
-
const generic = await runWebSearch(backend, apiKey, keyword, {
|
|
16115
|
-
count: SEARCH_RESULTS_PER_QUERY
|
|
16116
|
-
});
|
|
16117
|
-
return (generic ?? []).map(toSnippet);
|
|
16118
|
-
}
|
|
16119
|
-
function toSnippet(r) {
|
|
16120
|
-
return {
|
|
16121
|
-
title: r.title || "(untitled)",
|
|
16122
|
-
url: r.url,
|
|
16123
|
-
description: r.description || ""
|
|
16124
|
-
};
|
|
16125
|
-
}
|
|
16126
|
-
async function synthesiseTopics(state, modelV, opts) {
|
|
16127
|
-
const { memories, keywords, snippetsByKeyword, hasWeb } = opts;
|
|
16128
|
-
const flatSnippets = [];
|
|
16129
|
-
if (hasWeb) {
|
|
16130
|
-
for (const kw of keywords) {
|
|
16131
|
-
for (const s of snippetsByKeyword.get(kw) ?? []) {
|
|
16132
|
-
flatSnippets.push({ ...s, keyword: kw });
|
|
16133
|
-
}
|
|
16134
|
-
}
|
|
16135
|
-
}
|
|
16136
|
-
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");
|
|
16137
|
-
const snippetBlock = flatSnippets.length === 0 ? "(no web snippets \u2014 synthesise from memory only)" : flatSnippets.map(
|
|
16138
|
-
(s, i) => `S${i + 1}. [keyword: ${s.keyword}] ${s.title}
|
|
16139
|
-
${s.description}
|
|
16140
|
-
${s.url}`
|
|
16141
|
-
).join("\n\n");
|
|
16142
|
-
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>] } ] }';
|
|
16143
|
-
const user = `# Keywords distilled from chair memory
|
|
16144
|
-
${keywords.map((k, i) => `K${i + 1}. ${k}`).join("\n")}
|
|
16145
|
-
|
|
16146
|
-
# Memory excerpts
|
|
16147
|
-
${memorySummary}
|
|
16148
|
-
|
|
16149
|
-
# Web snippets ${hasWeb ? "(use these to ground at least some recs as source=web)" : "(none \u2014 synthesise from memory only)"}
|
|
16150
|
-
${snippetBlock}
|
|
16151
|
-
|
|
16152
|
-
Return EXACTLY 5 topics as JSON, each with a different tag, spanning different dimensions.`;
|
|
16153
|
-
const raw = await callPhaseLLM2(state, modelV, [
|
|
16154
|
-
{ role: "system", content: system },
|
|
16155
|
-
{ role: "user", content: user }
|
|
16156
|
-
], { temperature: 0.6, maxTokens: 2e3 });
|
|
16157
|
-
if (!raw) return [];
|
|
16158
|
-
const parsed = extractJson5(raw);
|
|
16159
|
-
if (!parsed || !Array.isArray(parsed.topics)) return [];
|
|
16160
|
-
const out = [];
|
|
16161
|
-
for (const t of parsed.topics) {
|
|
16162
|
-
const subject = typeof t.subject === "string" ? t.subject.trim() : "";
|
|
16163
|
-
const rationale = typeof t.rationale === "string" ? t.rationale.trim() : "";
|
|
16164
|
-
if (!subject || !rationale) continue;
|
|
16165
|
-
let tag = null;
|
|
16166
|
-
const TAG_BLOCKLIST = /* @__PURE__ */ new Set([
|
|
16167
|
-
"web",
|
|
16168
|
-
"memory",
|
|
16169
|
-
"general",
|
|
16170
|
-
"misc",
|
|
16171
|
-
"other",
|
|
16172
|
-
"topic",
|
|
16173
|
-
"recommendation",
|
|
16174
|
-
"recommendations",
|
|
16175
|
-
"rec",
|
|
16176
|
-
"category",
|
|
16177
|
-
"n/a",
|
|
16178
|
-
"na",
|
|
16179
|
-
"none"
|
|
16180
|
-
]);
|
|
16181
|
-
if (typeof t.tag === "string") {
|
|
16182
|
-
const cleaned = t.tag.trim().replace(/^\/\/\s*/, "").toLowerCase().replace(/[^a-z0-9 -]/g, "").replace(/\s+/g, " ").trim().slice(0, 28);
|
|
16183
|
-
if (cleaned.length > 0 && !TAG_BLOCKLIST.has(cleaned)) {
|
|
16184
|
-
tag = cleaned;
|
|
16185
|
-
}
|
|
16186
|
-
}
|
|
16187
|
-
if (!tag) {
|
|
16188
|
-
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));
|
|
16189
|
-
if (words.length > 0) {
|
|
16190
|
-
tag = words.slice(0, 2).join(" ").slice(0, 28);
|
|
16191
|
-
} else {
|
|
16192
|
-
tag = "topic";
|
|
16193
|
-
}
|
|
16194
|
-
}
|
|
16195
|
-
let source = t.source === "web" ? "web" : "memory";
|
|
16196
|
-
let seedContext = null;
|
|
16197
|
-
if (source === "web" && hasWeb && Array.isArray(t.snippetRefs)) {
|
|
16198
|
-
const refs = t.snippetRefs.map((r) => {
|
|
16199
|
-
if (typeof r === "number") return r;
|
|
16200
|
-
if (typeof r === "string") {
|
|
16201
|
-
const m = r.match(/^S?(\d+)$/i);
|
|
16202
|
-
return m ? Number(m[1]) : NaN;
|
|
16203
|
-
}
|
|
16204
|
-
return NaN;
|
|
16205
|
-
}).filter((n) => Number.isInteger(n) && n > 0 && n <= flatSnippets.length);
|
|
16206
|
-
const seen = /* @__PURE__ */ new Set();
|
|
16207
|
-
const cited = [];
|
|
16208
|
-
for (const ref of refs) {
|
|
16209
|
-
const snip = flatSnippets[ref - 1];
|
|
16210
|
-
if (!snip || seen.has(snip.url)) continue;
|
|
16211
|
-
seen.add(snip.url);
|
|
16212
|
-
cited.push({ title: snip.title, url: snip.url, description: snip.description });
|
|
16213
|
-
}
|
|
16214
|
-
if (cited.length > 0) {
|
|
16215
|
-
seedContext = cited;
|
|
16216
|
-
} else {
|
|
16217
|
-
source = "memory";
|
|
16218
|
-
}
|
|
16219
|
-
} else if (source === "web") {
|
|
16220
|
-
source = "memory";
|
|
16221
|
-
}
|
|
16222
|
-
out.push({ subject, rationale, source, tag, seedContext });
|
|
16223
|
-
if (out.length >= 6) break;
|
|
16224
|
-
}
|
|
16225
|
-
return out;
|
|
16226
|
-
}
|
|
16227
|
-
|
|
16228
|
-
// src/routes/topic-recs.ts
|
|
16229
|
-
function topicRecsRouter() {
|
|
16230
|
-
const r = new Hono6();
|
|
16231
|
-
r.post("/", (c) => {
|
|
16232
|
-
if (!hasAnyModelKey()) {
|
|
16233
|
-
return c.json({ error: "configure an LLM provider key first" }, 400);
|
|
16234
|
-
}
|
|
16235
|
-
const jobId = startTopicRecommend();
|
|
16236
|
-
return c.json({ jobId });
|
|
16237
|
-
});
|
|
16238
|
-
r.get("/jobs/:id/stream", (c) => {
|
|
16239
|
-
const jobId = c.req.param("id");
|
|
16240
|
-
const job = getTopicRecJob(jobId);
|
|
16241
|
-
if (!job) return c.json({ error: "job not found" }, 404);
|
|
16242
|
-
return streamSSE2(c, async (s) => {
|
|
16243
|
-
await s.writeSSE({
|
|
16244
|
-
event: "hello",
|
|
16245
|
-
data: JSON.stringify({
|
|
16246
|
-
jobId,
|
|
16247
|
-
status: job.status,
|
|
16248
|
-
currentPhase: job.currentPhase,
|
|
16249
|
-
progressPct: job.progressPct,
|
|
16250
|
-
batchId: job.batchId,
|
|
16251
|
-
error: job.error
|
|
16252
|
-
})
|
|
16253
|
-
});
|
|
16254
|
-
if (!isTopicRecJobRunning(jobId)) {
|
|
16255
|
-
if (job.status === "done") {
|
|
16256
|
-
await s.writeSSE({
|
|
16257
|
-
event: "topic-final",
|
|
16258
|
-
data: JSON.stringify({
|
|
16259
|
-
type: "topic-final",
|
|
16260
|
-
batchId: job.batchId,
|
|
16261
|
-
totalRecs: null,
|
|
16262
|
-
hasWeb: null
|
|
16263
|
-
})
|
|
16264
|
-
});
|
|
16265
|
-
} else if (job.status === "aborted") {
|
|
16266
|
-
await s.writeSSE({
|
|
16267
|
-
event: "topic-aborted",
|
|
16268
|
-
data: JSON.stringify({ type: "topic-aborted" })
|
|
16269
|
-
});
|
|
16270
|
-
} else if (job.status === "failed") {
|
|
16271
|
-
await s.writeSSE({
|
|
16272
|
-
event: "topic-error",
|
|
16273
|
-
data: JSON.stringify({
|
|
16274
|
-
type: "topic-error",
|
|
16275
|
-
message: job.error || "generation failed"
|
|
16276
|
-
})
|
|
16277
|
-
});
|
|
16278
|
-
}
|
|
16279
|
-
return;
|
|
16280
|
-
}
|
|
16281
|
-
const queue = [];
|
|
16282
|
-
let resolveWaiter = null;
|
|
16283
|
-
let closed = false;
|
|
16284
|
-
const off = topicRecBus.subscribe(jobId, (event) => {
|
|
16285
|
-
queue.push(event);
|
|
16286
|
-
if (resolveWaiter) {
|
|
16287
|
-
resolveWaiter();
|
|
16288
|
-
resolveWaiter = null;
|
|
16289
|
-
}
|
|
16290
|
-
});
|
|
16291
|
-
s.onAbort(() => {
|
|
16292
|
-
closed = true;
|
|
16293
|
-
off();
|
|
16294
|
-
if (resolveWaiter) {
|
|
16295
|
-
resolveWaiter();
|
|
16296
|
-
resolveWaiter = null;
|
|
16297
|
-
}
|
|
16298
|
-
});
|
|
16299
|
-
while (!closed) {
|
|
16300
|
-
if (queue.length === 0) {
|
|
16301
|
-
await new Promise((resolve2) => {
|
|
16302
|
-
resolveWaiter = resolve2;
|
|
16303
|
-
});
|
|
16304
|
-
continue;
|
|
16305
|
-
}
|
|
16306
|
-
const event = queue.shift();
|
|
16307
|
-
await s.writeSSE({ event: event.type, data: JSON.stringify(event) });
|
|
16308
|
-
if (event.type === "topic-final" || event.type === "topic-error" || event.type === "topic-aborted") {
|
|
16309
|
-
closed = true;
|
|
16310
|
-
off();
|
|
16311
|
-
}
|
|
16312
|
-
}
|
|
15936
|
+
providers: collectProviderSummary(all),
|
|
15937
|
+
/** The user's single active LLM provider (under the single-
|
|
15938
|
+
* active-LLM-provider invariant). Null when none configured.
|
|
15939
|
+
* Frontend pickers use this to decide grouping strategy:
|
|
15940
|
+
* multi-model → group by family with "via {provider}" caption,
|
|
15941
|
+
* single-model → flat list. */
|
|
15942
|
+
activeLlmProvider: active,
|
|
15943
|
+
activeLlmClassification: active ? isMultiModelProvider(active) ? "multi-model" : "single-model" : null
|
|
16313
15944
|
});
|
|
16314
15945
|
});
|
|
16315
|
-
r.post("/jobs/:id/abort", (c) => {
|
|
16316
|
-
const jobId = c.req.param("id");
|
|
16317
|
-
const ok = abortTopicRecommend(jobId);
|
|
16318
|
-
if (!ok) {
|
|
16319
|
-
const job = getTopicRecJob(jobId);
|
|
16320
|
-
if (!job) return c.json({ error: "job not found" }, 404);
|
|
16321
|
-
return c.json({ ok: true, status: job.status });
|
|
16322
|
-
}
|
|
16323
|
-
return c.json({ ok: true });
|
|
16324
|
-
});
|
|
16325
|
-
r.get("/", (c) => {
|
|
16326
|
-
const cursorRaw = c.req.query("cursor");
|
|
16327
|
-
const limitRaw = c.req.query("limit");
|
|
16328
|
-
const cursor = cursorRaw && /^\d+$/.test(cursorRaw) ? Number(cursorRaw) : null;
|
|
16329
|
-
const limit = limitRaw && /^\d+$/.test(limitRaw) ? Math.max(1, Math.min(100, Number(limitRaw))) : 20;
|
|
16330
|
-
const { items, nextCursor } = listTopicRecs({ cursor, limit });
|
|
16331
|
-
return c.json({ items, nextCursor });
|
|
16332
|
-
});
|
|
16333
|
-
r.get("/:id", (c) => {
|
|
16334
|
-
const id = c.req.param("id");
|
|
16335
|
-
const rec = getTopicRec(id);
|
|
16336
|
-
if (!rec) return c.json({ error: "not found" }, 404);
|
|
16337
|
-
return c.json(rec);
|
|
16338
|
-
});
|
|
16339
15946
|
return r;
|
|
16340
15947
|
}
|
|
15948
|
+
function collectProviderSummary(models) {
|
|
15949
|
+
const map = /* @__PURE__ */ new Map();
|
|
15950
|
+
for (const m of models) {
|
|
15951
|
+
const cur = map.get(m.provider) ?? { reachable: 0, total: 0 };
|
|
15952
|
+
cur.total++;
|
|
15953
|
+
if (m.reachable) cur.reachable++;
|
|
15954
|
+
map.set(m.provider, cur);
|
|
15955
|
+
}
|
|
15956
|
+
return Array.from(map.entries()).map(([provider, v]) => ({ provider, ...v }));
|
|
15957
|
+
}
|
|
16341
15958
|
|
|
16342
15959
|
// src/routes/notes.ts
|
|
16343
15960
|
import { Hono as Hono7 } from "hono";
|
|
@@ -16549,7 +16166,7 @@ function prefsRouter() {
|
|
|
16549
16166
|
|
|
16550
16167
|
// src/routes/rooms.ts
|
|
16551
16168
|
import { Hono as Hono9 } from "hono";
|
|
16552
|
-
import { streamSSE as
|
|
16169
|
+
import { streamSSE as streamSSE2 } from "hono/streaming";
|
|
16553
16170
|
|
|
16554
16171
|
// src/storage/key_points.ts
|
|
16555
16172
|
init_db();
|
|
@@ -16667,7 +16284,7 @@ Does the chair need to ask a clarifying question before opening the room?`
|
|
|
16667
16284
|
}
|
|
16668
16285
|
return { shouldAsk: true, rationale: "" };
|
|
16669
16286
|
}
|
|
16670
|
-
const parsed =
|
|
16287
|
+
const parsed = extractJson5(raw);
|
|
16671
16288
|
if (!parsed || typeof parsed !== "object") {
|
|
16672
16289
|
return { shouldAsk: true, rationale: "" };
|
|
16673
16290
|
}
|
|
@@ -16756,7 +16373,7 @@ async function pickRoundWrap(opts) {
|
|
|
16756
16373
|
}
|
|
16757
16374
|
return { recommendation: "continue", rationale: "" };
|
|
16758
16375
|
}
|
|
16759
|
-
const parsed =
|
|
16376
|
+
const parsed = extractJson5(raw);
|
|
16760
16377
|
if (!parsed || typeof parsed !== "object") {
|
|
16761
16378
|
return { recommendation: "continue", rationale: "" };
|
|
16762
16379
|
}
|
|
@@ -16923,7 +16540,7 @@ ${extras.join("\n")}` : baseRow;
|
|
|
16923
16540
|
}
|
|
16924
16541
|
return { agentId: null, rationale: "", intervention: null };
|
|
16925
16542
|
}
|
|
16926
|
-
const parsed =
|
|
16543
|
+
const parsed = extractJson5(raw);
|
|
16927
16544
|
if (!parsed || typeof parsed !== "object") {
|
|
16928
16545
|
return { agentId: null, rationale: "", intervention: null };
|
|
16929
16546
|
}
|
|
@@ -17045,7 +16662,7 @@ async function pickChairWebSearch(opts) {
|
|
|
17045
16662
|
}
|
|
17046
16663
|
return null;
|
|
17047
16664
|
}
|
|
17048
|
-
const parsed =
|
|
16665
|
+
const parsed = extractJson5(raw);
|
|
17049
16666
|
if (!parsed || typeof parsed !== "object") return null;
|
|
17050
16667
|
const ws = parsed;
|
|
17051
16668
|
if (typeof ws.query !== "string") return null;
|
|
@@ -17070,7 +16687,7 @@ function buildSkillsIndex(skills) {
|
|
|
17070
16687
|
function loadSkillBody(skill) {
|
|
17071
16688
|
return skill.bodyMd;
|
|
17072
16689
|
}
|
|
17073
|
-
function
|
|
16690
|
+
function extractJson5(text) {
|
|
17074
16691
|
if (!text) return null;
|
|
17075
16692
|
let s = text.trim();
|
|
17076
16693
|
s = s.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/i, "").trim();
|
|
@@ -17184,7 +16801,7 @@ Which skills apply, and does this turn need web search?`
|
|
|
17184
16801
|
continue;
|
|
17185
16802
|
}
|
|
17186
16803
|
}
|
|
17187
|
-
const parsed =
|
|
16804
|
+
const parsed = extractJson5(raw);
|
|
17188
16805
|
if (!parsed || typeof parsed !== "object") {
|
|
17189
16806
|
return { used: [], reason: "", webSearchQuery: null };
|
|
17190
16807
|
}
|
|
@@ -17573,22 +17190,22 @@ var TONE_GUIDANCE = {
|
|
|
17573
17190
|
"",
|
|
17574
17191
|
"## Each turn MUST",
|
|
17575
17192
|
" (1) Name the lens you're auditing from this turn.",
|
|
17576
|
-
" (2) Surface 2\u20133 specific flaws,
|
|
17577
|
-
"
|
|
17578
|
-
"
|
|
17579
|
-
|
|
17193
|
+
" (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.",
|
|
17194
|
+
" How bad \xB7 `blocker` (ship is unsafe) / `major` (fix before commit) / `minor` (nice-to-fix)",
|
|
17195
|
+
" How likely \xB7 `likely` (>50%) / `plausible` (10-50%) / `edge` (<10%)",
|
|
17196
|
+
` 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.`,
|
|
17580
17197
|
' (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.',
|
|
17581
|
-
" (4) **Strength-preservation** \xB7 every
|
|
17198
|
+
" (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.",
|
|
17582
17199
|
"",
|
|
17583
|
-
`At least one
|
|
17200
|
+
`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.`,
|
|
17584
17201
|
"",
|
|
17585
17202
|
"## Forbidden",
|
|
17586
17203
|
" \xB7 Redesigning or reframing the work. You audit as-is. The fix-direction pointer is ONE sentence; never a rewrite.",
|
|
17587
17204
|
' \xB7 Vague "feels off" / "not quite right" without a mechanism.',
|
|
17588
17205
|
" \xB7 Praise-only turns; attacking the author rather than the work.",
|
|
17589
|
-
" \xB7 Cherry-picking edge cases \u2014 naming a 1% failure mode as
|
|
17206
|
+
" \xB7 Cherry-picking edge cases \u2014 naming a 1% failure mode as a blocker without anchoring it to its likelihood AND its consequence asymmetry.",
|
|
17590
17207
|
" \xB7 Repeating another director's critique under a different label.",
|
|
17591
|
-
|
|
17208
|
+
' \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.',
|
|
17592
17209
|
"",
|
|
17593
17210
|
"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."
|
|
17594
17211
|
].join("\n")
|
|
@@ -18099,10 +17716,8 @@ function buildChairClarifyMessages(opts) {
|
|
|
18099
17716
|
``,
|
|
18100
17717
|
`Output: either <ack + blank line + READY> OR the 2-part question block (in the user's language).`
|
|
18101
17718
|
].join("\n");
|
|
18102
|
-
const seedSystem = buildSeedContextSystem(opts.history);
|
|
18103
17719
|
return [
|
|
18104
17720
|
buildChairSystem(opts, isFirstTurn ? firstTurnTask : followUpTask),
|
|
18105
|
-
...seedSystem ? [seedSystem] : [],
|
|
18106
17721
|
...renderHistoryForChair(opts.history, opts.cast, opts.prefs),
|
|
18107
17722
|
{
|
|
18108
17723
|
role: "user",
|
|
@@ -18110,39 +17725,6 @@ function buildChairClarifyMessages(opts) {
|
|
|
18110
17725
|
}
|
|
18111
17726
|
];
|
|
18112
17727
|
}
|
|
18113
|
-
function buildSeedContextSystem(history) {
|
|
18114
|
-
for (let i = 0; i < history.length; i++) {
|
|
18115
|
-
const m = history[i];
|
|
18116
|
-
if (m.authorKind !== "user") continue;
|
|
18117
|
-
const meta = m.meta;
|
|
18118
|
-
const rationale = typeof meta?.seedContext?.rationale === "string" ? meta.seedContext.rationale.trim() : "";
|
|
18119
|
-
const rawSnippets = meta?.seedContext?.snippets;
|
|
18120
|
-
const snippets = Array.isArray(rawSnippets) ? rawSnippets : [];
|
|
18121
|
-
const snippetLines = [];
|
|
18122
|
-
for (const s of snippets) {
|
|
18123
|
-
if (!s || typeof s !== "object") continue;
|
|
18124
|
-
const title = typeof s.title === "string" ? s.title.trim() : "";
|
|
18125
|
-
const url = typeof s.url === "string" ? s.url.trim() : "";
|
|
18126
|
-
const desc = typeof s.description === "string" ? s.description.trim() : "";
|
|
18127
|
-
if (!title && !url && !desc) continue;
|
|
18128
|
-
snippetLines.push(`\xB7 ${title || "(untitled)"} \u2014 ${url || "(no url)"}
|
|
18129
|
-
${desc.slice(0, 360)}`);
|
|
18130
|
-
}
|
|
18131
|
-
if (!rationale && snippetLines.length === 0) continue;
|
|
18132
|
-
const blocks = [
|
|
18133
|
-
`\u2500\u2500\u2500 BACKGROUND MATERIAL \xB7 pre-attached by the user \u2500\u2500\u2500`,
|
|
18134
|
-
`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.`
|
|
18135
|
-
];
|
|
18136
|
-
if (rationale) {
|
|
18137
|
-
blocks.push(``, `Why this topic was recommended (hidden from the user \u2014 your reasoning context):`, `\xB7 ${rationale}`);
|
|
18138
|
-
}
|
|
18139
|
-
if (snippetLines.length > 0) {
|
|
18140
|
-
blocks.push(``, `Source snippets the recommendation was grounded in:`, ...snippetLines);
|
|
18141
|
-
}
|
|
18142
|
-
return { role: "system", content: blocks.join("\n") };
|
|
18143
|
-
}
|
|
18144
|
-
return null;
|
|
18145
|
-
}
|
|
18146
17728
|
function buildChairConveningMessages(opts) {
|
|
18147
17729
|
const subject = opts.room.subject;
|
|
18148
17730
|
const directorList = opts.picksWithReasons.map((p, i) => {
|
|
@@ -18210,14 +17792,15 @@ function buildChairRoundEndMessages(opts) {
|
|
|
18210
17792
|
``,
|
|
18211
17793
|
`\u2500\u2500\u2500 CRITIQUE-MODE POINT SELECTION \u2500\u2500\u2500`,
|
|
18212
17794
|
`Override the default "what got said" rule with severity-aware curation. Pick points that maximise audit value:`,
|
|
18213
|
-
` \xB7 Prefer 1
|
|
17795
|
+
` \xB7 Prefer 1 likely blocker over 3 edge-case minors.`,
|
|
18214
17796
|
` \xB7 Surface the dimension NO director attacked this round (the lens-coverage gap).`,
|
|
18215
|
-
` \xB7 If the room only produced
|
|
17797
|
+
` \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.`,
|
|
18216
17798
|
`Use these prompts to test what should rise to a key point:`,
|
|
18217
17799
|
` \xB7 Which flaw is fatal, and which is fixable?`,
|
|
18218
17800
|
` \xB7 What sounds plausible now but probably won't survive execution?`,
|
|
18219
17801
|
` \xB7 Which lens is conspicuously absent from this round's critique?`,
|
|
18220
|
-
` \xB7 What would a competitor / regulator / power user attack that didn't get raised
|
|
17802
|
+
` \xB7 What would a competitor / regulator / power user attack that didn't get raised?`,
|
|
17803
|
+
`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").`
|
|
18221
17804
|
].join("\n") : "";
|
|
18222
17805
|
const task = [
|
|
18223
17806
|
`\u2500\u2500\u2500 YOUR TASK \xB7 CLOSE THIS ROUND \u2500\u2500\u2500`,
|
|
@@ -19135,6 +18718,37 @@ Rules:
|
|
|
19135
18718
|
return branch.id;
|
|
19136
18719
|
}
|
|
19137
18720
|
|
|
18721
|
+
// src/orchestrator/timeouts.ts
|
|
18722
|
+
var TimeoutError = class extends Error {
|
|
18723
|
+
constructor(ms, label) {
|
|
18724
|
+
super(`timeout after ${ms}ms${label ? ` \xB7 ${label}` : ""}`);
|
|
18725
|
+
this.name = "TimeoutError";
|
|
18726
|
+
}
|
|
18727
|
+
};
|
|
18728
|
+
function withTimeout(promise, ms, label) {
|
|
18729
|
+
let timer = null;
|
|
18730
|
+
const timeout = new Promise((_, reject) => {
|
|
18731
|
+
timer = setTimeout(() => reject(new TimeoutError(ms, label)), ms);
|
|
18732
|
+
});
|
|
18733
|
+
return Promise.race([promise, timeout]).finally(() => {
|
|
18734
|
+
if (timer) clearTimeout(timer);
|
|
18735
|
+
});
|
|
18736
|
+
}
|
|
18737
|
+
|
|
18738
|
+
// src/orchestrator/auto-skip.ts
|
|
18739
|
+
function emitAutoSkipped(roomId, phase, reason, messageId) {
|
|
18740
|
+
roomBus.emit(roomId, {
|
|
18741
|
+
type: "config-event",
|
|
18742
|
+
kind: "auto-skipped",
|
|
18743
|
+
payload: {
|
|
18744
|
+
phase,
|
|
18745
|
+
reason,
|
|
18746
|
+
...messageId ? { messageId } : {}
|
|
18747
|
+
},
|
|
18748
|
+
createdAt: Date.now()
|
|
18749
|
+
});
|
|
18750
|
+
}
|
|
18751
|
+
|
|
19138
18752
|
// src/voice/sentence-splitter.ts
|
|
19139
18753
|
var END_RE = /[。!?!?;;::\n]|[.](?=\s|$)/;
|
|
19140
18754
|
var SentenceChunker = class {
|
|
@@ -19195,6 +18809,39 @@ function minimaxBaseUrl2() {
|
|
|
19195
18809
|
}
|
|
19196
18810
|
var ELEVENLABS_API = "https://api.elevenlabs.io/v1";
|
|
19197
18811
|
var OPENAI_API = "https://api.openai.com/v1";
|
|
18812
|
+
function makeMiniMaxBalanceError() {
|
|
18813
|
+
const err2 = new Error(
|
|
18814
|
+
"Your MiniMax account balance is insufficient for TTS. Top up your account in the MiniMax console and try again."
|
|
18815
|
+
);
|
|
18816
|
+
err2.code = "paid-plan-required";
|
|
18817
|
+
err2.provider = "minimax";
|
|
18818
|
+
err2.upgradeUrl = getPrefs().minimaxRegion === "intl" ? "https://platform.minimax.io/user-center/billing/overview" : "https://platform.minimaxi.com/user-center/payment";
|
|
18819
|
+
return err2;
|
|
18820
|
+
}
|
|
18821
|
+
function makeElevenLabsBillingError(message) {
|
|
18822
|
+
const err2 = new Error(message);
|
|
18823
|
+
err2.code = "paid-plan-required";
|
|
18824
|
+
err2.provider = "elevenlabs";
|
|
18825
|
+
err2.upgradeUrl = "https://elevenlabs.io/pricing";
|
|
18826
|
+
return err2;
|
|
18827
|
+
}
|
|
18828
|
+
function isElevenLabsCreditError(errText) {
|
|
18829
|
+
return /quota_exceeded|insufficient[ _-]?(?:credit|quota|balance|fund)|out\s+of\s+credits?|voice_limit_reached|余额不足/i.test(errText);
|
|
18830
|
+
}
|
|
18831
|
+
function tryExtractTtsBillingError(err2) {
|
|
18832
|
+
if (!err2 || typeof err2 !== "object") return null;
|
|
18833
|
+
const tagged = err2;
|
|
18834
|
+
if (tagged.code !== "paid-plan-required") return null;
|
|
18835
|
+
if (typeof tagged.provider !== "string") return null;
|
|
18836
|
+
const out = err2;
|
|
18837
|
+
if (typeof tagged.upgradeUrl !== "string") {
|
|
18838
|
+
out.upgradeUrl = "";
|
|
18839
|
+
}
|
|
18840
|
+
if (typeof tagged.message !== "string") {
|
|
18841
|
+
out.message = "Voice synthesis requires a paid plan.";
|
|
18842
|
+
}
|
|
18843
|
+
return out;
|
|
18844
|
+
}
|
|
19198
18845
|
function cleanForSpeech(md) {
|
|
19199
18846
|
if (!md) return "";
|
|
19200
18847
|
let out = md;
|
|
@@ -19294,11 +18941,17 @@ async function* synthesizeSpeechStream(text, profile, signal) {
|
|
|
19294
18941
|
});
|
|
19295
18942
|
if (!res.ok) {
|
|
19296
18943
|
const errText = await res.text();
|
|
18944
|
+
if (res.status === 402 || /"status_code"\s*:\s*1008|insufficient[ _-]?(?:balance|quota|credit|fund)|余额不足|余额[^a-zA-Z]?(?:不足|不够)/i.test(errText)) {
|
|
18945
|
+
throw makeMiniMaxBalanceError();
|
|
18946
|
+
}
|
|
19297
18947
|
throw new Error(`MiniMax TTS stream HTTP ${res.status}: ${errText}`);
|
|
19298
18948
|
}
|
|
19299
18949
|
const contentType = res.headers.get("content-type") || "";
|
|
19300
18950
|
if (!contentType.includes("text/event-stream")) {
|
|
19301
18951
|
const errBody = await res.text();
|
|
18952
|
+
if (/"status_code"\s*:\s*1008|insufficient[ _-]?(?:balance|quota|credit|fund)|余额不足|余额[^a-zA-Z]?(?:不足|不够)/i.test(errBody)) {
|
|
18953
|
+
throw makeMiniMaxBalanceError();
|
|
18954
|
+
}
|
|
19302
18955
|
throw new Error(`MiniMax TTS: expected event-stream but got ${contentType}: ${errBody.slice(0, 200)}`);
|
|
19303
18956
|
}
|
|
19304
18957
|
const body = res.body;
|
|
@@ -19405,13 +19058,7 @@ async function synthesizeMiniMax(text, profile, signal) {
|
|
|
19405
19058
|
if (!res.ok) {
|
|
19406
19059
|
const errText = await res.text();
|
|
19407
19060
|
if (res.status === 402 || /insufficient[ _-]?(?:balance|quota|credit|fund)|余额不足|余额[^a-zA-Z]?(?:不足|不够)/i.test(errText)) {
|
|
19408
|
-
|
|
19409
|
-
"Your MiniMax account balance is insufficient for TTS. Top up your account in the MiniMax console and try again."
|
|
19410
|
-
);
|
|
19411
|
-
err2.code = "paid-plan-required";
|
|
19412
|
-
err2.provider = "minimax";
|
|
19413
|
-
err2.upgradeUrl = getPrefs().minimaxRegion === "intl" ? "https://platform.minimax.io/user-center/billing/overview" : "https://platform.minimaxi.com/user-center/payment";
|
|
19414
|
-
throw err2;
|
|
19061
|
+
throw makeMiniMaxBalanceError();
|
|
19415
19062
|
}
|
|
19416
19063
|
throw new Error(`MiniMax TTS HTTP ${res.status}: ${errText}`);
|
|
19417
19064
|
}
|
|
@@ -19419,13 +19066,7 @@ async function synthesizeMiniMax(text, profile, signal) {
|
|
|
19419
19066
|
const status = json.base_resp?.status_code ?? 0;
|
|
19420
19067
|
const statusMsg = json.base_resp?.status_msg || "";
|
|
19421
19068
|
if (status !== 0 && (status === 1008 || /insufficient[ _-]?(?:balance|quota|credit|fund)|余额不足|余额[^a-zA-Z]?(?:不足|不够)/i.test(statusMsg))) {
|
|
19422
|
-
|
|
19423
|
-
"Your MiniMax account balance is insufficient for TTS. Top up your account in the MiniMax console and try again."
|
|
19424
|
-
);
|
|
19425
|
-
err2.code = "paid-plan-required";
|
|
19426
|
-
err2.provider = "minimax";
|
|
19427
|
-
err2.upgradeUrl = getPrefs().minimaxRegion === "intl" ? "https://platform.minimax.io/user-center/billing/overview" : "https://platform.minimaxi.com/user-center/payment";
|
|
19428
|
-
throw err2;
|
|
19069
|
+
throw makeMiniMaxBalanceError();
|
|
19429
19070
|
}
|
|
19430
19071
|
const hex = json.data?.audio ?? json.audio ?? "";
|
|
19431
19072
|
if (!hex) {
|
|
@@ -19508,13 +19149,14 @@ async function synthesizeElevenLabs(text, profile, signal) {
|
|
|
19508
19149
|
if (!res.ok) {
|
|
19509
19150
|
const errText = await res.text();
|
|
19510
19151
|
if (res.status === 402 && /paid_plan_required|library voices/i.test(errText)) {
|
|
19511
|
-
|
|
19152
|
+
throw makeElevenLabsBillingError(
|
|
19512
19153
|
"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."
|
|
19513
19154
|
);
|
|
19514
|
-
|
|
19515
|
-
|
|
19516
|
-
|
|
19517
|
-
|
|
19155
|
+
}
|
|
19156
|
+
if (isElevenLabsCreditError(errText)) {
|
|
19157
|
+
throw makeElevenLabsBillingError(
|
|
19158
|
+
"Your ElevenLabs account is out of credits. Top up your ElevenLabs plan and try again."
|
|
19159
|
+
);
|
|
19518
19160
|
}
|
|
19519
19161
|
throw new Error(`ElevenLabs TTS HTTP ${res.status}: ${errText.slice(0, 400)}`);
|
|
19520
19162
|
}
|
|
@@ -19553,13 +19195,14 @@ async function* synthesizeElevenLabsStream(text, profile, signal) {
|
|
|
19553
19195
|
if (!res.ok) {
|
|
19554
19196
|
const errText = await res.text();
|
|
19555
19197
|
if (res.status === 402 && /paid_plan_required|library voices/i.test(errText)) {
|
|
19556
|
-
|
|
19198
|
+
throw makeElevenLabsBillingError(
|
|
19557
19199
|
"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."
|
|
19558
19200
|
);
|
|
19559
|
-
|
|
19560
|
-
|
|
19561
|
-
|
|
19562
|
-
|
|
19201
|
+
}
|
|
19202
|
+
if (isElevenLabsCreditError(errText)) {
|
|
19203
|
+
throw makeElevenLabsBillingError(
|
|
19204
|
+
"Your ElevenLabs account is out of credits. Top up your ElevenLabs plan and try again."
|
|
19205
|
+
);
|
|
19563
19206
|
}
|
|
19564
19207
|
throw new Error(`ElevenLabs TTS stream HTTP ${res.status}: ${errText.slice(0, 400)}`);
|
|
19565
19208
|
}
|
|
@@ -19618,7 +19261,8 @@ function ensureState(roomId) {
|
|
|
19618
19261
|
if (!s) {
|
|
19619
19262
|
s = {
|
|
19620
19263
|
queue: [],
|
|
19621
|
-
inflight:
|
|
19264
|
+
inflight: /* @__PURE__ */ new Map(),
|
|
19265
|
+
preWarmed: null,
|
|
19622
19266
|
processing: false,
|
|
19623
19267
|
roundNum: 1,
|
|
19624
19268
|
speakersThisTurn: 0,
|
|
@@ -19632,7 +19276,8 @@ function ensureState(roomId) {
|
|
|
19632
19276
|
pendingFrameBreakerRole: null,
|
|
19633
19277
|
lastFrameBreakerAgentId: null,
|
|
19634
19278
|
billingHaltedThisTurn: false,
|
|
19635
|
-
voiceWaiters: /* @__PURE__ */ new Map()
|
|
19279
|
+
voiceWaiters: /* @__PURE__ */ new Map(),
|
|
19280
|
+
voicePredone: /* @__PURE__ */ new Set()
|
|
19636
19281
|
};
|
|
19637
19282
|
_state.set(roomId, s);
|
|
19638
19283
|
}
|
|
@@ -19640,29 +19285,59 @@ function ensureState(roomId) {
|
|
|
19640
19285
|
}
|
|
19641
19286
|
function markVoicePlaybackDone(roomId, messageId) {
|
|
19642
19287
|
const s = _state.get(roomId);
|
|
19643
|
-
|
|
19644
|
-
|
|
19645
|
-
|
|
19646
|
-
|
|
19288
|
+
if (!s) return false;
|
|
19289
|
+
const waiter = s.voiceWaiters.get(messageId);
|
|
19290
|
+
if (waiter) {
|
|
19291
|
+
s.voiceWaiters.delete(messageId);
|
|
19292
|
+
waiter.resolve();
|
|
19293
|
+
return true;
|
|
19294
|
+
}
|
|
19295
|
+
s.voicePredone.add(messageId);
|
|
19296
|
+
return false;
|
|
19297
|
+
}
|
|
19298
|
+
function bumpVoicePlaybackHeartbeat(roomId, messageId) {
|
|
19299
|
+
const s = _state.get(roomId);
|
|
19300
|
+
if (!s) return false;
|
|
19301
|
+
const waiter = s.voiceWaiters.get(messageId);
|
|
19302
|
+
if (!waiter) return false;
|
|
19303
|
+
waiter.bump();
|
|
19647
19304
|
return true;
|
|
19648
19305
|
}
|
|
19649
|
-
function waitForVoicePlayback(roomId, messageId, timeoutMs =
|
|
19306
|
+
function waitForVoicePlayback(roomId, messageId, timeoutMs = 6e4) {
|
|
19650
19307
|
const s = ensureState(roomId);
|
|
19308
|
+
if (s.voicePredone.has(messageId)) {
|
|
19309
|
+
s.voicePredone.delete(messageId);
|
|
19310
|
+
return Promise.resolve();
|
|
19311
|
+
}
|
|
19651
19312
|
return new Promise((resolve2) => {
|
|
19652
|
-
|
|
19653
|
-
|
|
19654
|
-
|
|
19655
|
-
|
|
19656
|
-
|
|
19657
|
-
|
|
19658
|
-
|
|
19313
|
+
let timer;
|
|
19314
|
+
const arm = () => {
|
|
19315
|
+
timer = setTimeout(() => {
|
|
19316
|
+
s.voiceWaiters.delete(messageId);
|
|
19317
|
+
process.stderr.write(
|
|
19318
|
+
`[voice-wait] no-heartbeat fallback fired for msg=${messageId.slice(0, 8)} after ${timeoutMs}ms
|
|
19319
|
+
`
|
|
19320
|
+
);
|
|
19321
|
+
resolve2();
|
|
19322
|
+
}, timeoutMs);
|
|
19323
|
+
};
|
|
19324
|
+
arm();
|
|
19325
|
+
s.voiceWaiters.set(messageId, {
|
|
19326
|
+
resolve: () => {
|
|
19327
|
+
clearTimeout(timer);
|
|
19328
|
+
resolve2();
|
|
19329
|
+
},
|
|
19330
|
+
bump: () => {
|
|
19331
|
+
clearTimeout(timer);
|
|
19332
|
+
arm();
|
|
19333
|
+
}
|
|
19659
19334
|
});
|
|
19660
19335
|
});
|
|
19661
19336
|
}
|
|
19662
19337
|
function isRoomSpeaking(roomId) {
|
|
19663
19338
|
const s = _state.get(roomId);
|
|
19664
19339
|
if (!s) return false;
|
|
19665
|
-
return s.inflight
|
|
19340
|
+
return s.inflight.size > 0;
|
|
19666
19341
|
}
|
|
19667
19342
|
function injectSpeakers(roomId, agentIds) {
|
|
19668
19343
|
if (agentIds.length === 0) return;
|
|
@@ -19699,7 +19374,7 @@ function injectSpeakers(roomId, agentIds) {
|
|
|
19699
19374
|
function requestSoftPause(roomId) {
|
|
19700
19375
|
const s = ensureState(roomId);
|
|
19701
19376
|
s.pauseAfterCurrent = true;
|
|
19702
|
-
rlog(roomId, "soft-pause-requested", { remaining: s.queue.length, speaking: s.inflight
|
|
19377
|
+
rlog(roomId, "soft-pause-requested", { remaining: s.queue.length, speaking: s.inflight.size });
|
|
19703
19378
|
}
|
|
19704
19379
|
function setPendingUserAfterCurrent(roomId, payload) {
|
|
19705
19380
|
const s = ensureState(roomId);
|
|
@@ -19708,7 +19383,7 @@ function setPendingUserAfterCurrent(roomId, payload) {
|
|
|
19708
19383
|
function requestRoundEndAfterCurrent(roomId) {
|
|
19709
19384
|
const s = ensureState(roomId);
|
|
19710
19385
|
s.pendingRoundEnd = true;
|
|
19711
|
-
rlog(roomId, "round-end-deferred", { remaining: s.queue.length, speaking: s.inflight
|
|
19386
|
+
rlog(roomId, "round-end-deferred", { remaining: s.queue.length, speaking: s.inflight.size });
|
|
19712
19387
|
}
|
|
19713
19388
|
async function chairInterrupt(roomId) {
|
|
19714
19389
|
const state = ensureState(roomId);
|
|
@@ -19717,23 +19392,30 @@ async function chairInterrupt(roomId) {
|
|
|
19717
19392
|
status: "queued"
|
|
19718
19393
|
}));
|
|
19719
19394
|
let interruptedAgentId = null;
|
|
19720
|
-
if (state.inflight) {
|
|
19395
|
+
if (state.inflight.size > 0) {
|
|
19721
19396
|
interruptedAgentId = state.queue[0]?.agentId ?? null;
|
|
19722
|
-
state.inflight.abort();
|
|
19723
|
-
state.inflight
|
|
19724
|
-
|
|
19725
|
-
|
|
19726
|
-
|
|
19727
|
-
|
|
19728
|
-
|
|
19729
|
-
|
|
19730
|
-
|
|
19731
|
-
|
|
19732
|
-
|
|
19733
|
-
|
|
19734
|
-
|
|
19735
|
-
|
|
19736
|
-
|
|
19397
|
+
for (const ac of state.inflight.values()) ac.abort();
|
|
19398
|
+
state.inflight.clear();
|
|
19399
|
+
}
|
|
19400
|
+
if (state.preWarmed) {
|
|
19401
|
+
try {
|
|
19402
|
+
state.preWarmed.abortController.abort();
|
|
19403
|
+
} catch (_) {
|
|
19404
|
+
}
|
|
19405
|
+
state.preWarmed = null;
|
|
19406
|
+
}
|
|
19407
|
+
if (interruptedAgentId) {
|
|
19408
|
+
const recent = listRecentMessages(roomId, 8);
|
|
19409
|
+
for (let i = recent.length - 1; i >= 0; i--) {
|
|
19410
|
+
const m = recent[i];
|
|
19411
|
+
if (m.authorKind === "agent" && m.authorId === interruptedAgentId && m.meta && m.meta.streaming === true) {
|
|
19412
|
+
deleteMessage(m.id);
|
|
19413
|
+
roomBus.emit(roomId, {
|
|
19414
|
+
type: "message-removed",
|
|
19415
|
+
messageId: m.id,
|
|
19416
|
+
reason: "chair-interrupt"
|
|
19417
|
+
});
|
|
19418
|
+
break;
|
|
19737
19419
|
}
|
|
19738
19420
|
}
|
|
19739
19421
|
}
|
|
@@ -19773,15 +19455,21 @@ function abortRoom(roomId) {
|
|
|
19773
19455
|
maxSpeakersThisTurn: s.maxSpeakersThisTurn
|
|
19774
19456
|
};
|
|
19775
19457
|
s.queue = [];
|
|
19776
|
-
const wasSpeaking = s.inflight
|
|
19777
|
-
|
|
19778
|
-
|
|
19779
|
-
|
|
19458
|
+
const wasSpeaking = s.inflight.size > 0;
|
|
19459
|
+
for (const ac of s.inflight.values()) ac.abort();
|
|
19460
|
+
s.inflight.clear();
|
|
19461
|
+
if (s.preWarmed) {
|
|
19462
|
+
try {
|
|
19463
|
+
s.preWarmed.abortController.abort();
|
|
19464
|
+
} catch (_) {
|
|
19465
|
+
}
|
|
19466
|
+
s.preWarmed = null;
|
|
19780
19467
|
}
|
|
19781
|
-
for (const [
|
|
19782
|
-
waiter();
|
|
19468
|
+
for (const [, waiter] of s.voiceWaiters) {
|
|
19469
|
+
waiter.resolve();
|
|
19783
19470
|
}
|
|
19784
19471
|
s.voiceWaiters.clear();
|
|
19472
|
+
s.voicePredone.clear();
|
|
19785
19473
|
rlog(roomId, "abort", {
|
|
19786
19474
|
snapshot: remaining.length,
|
|
19787
19475
|
round: s.roundNum,
|
|
@@ -19873,9 +19561,17 @@ function tickRoom(roomId, opts) {
|
|
|
19873
19561
|
}
|
|
19874
19562
|
if (plan.length === 0) return;
|
|
19875
19563
|
const state = ensureState(roomId);
|
|
19876
|
-
|
|
19564
|
+
for (const ac of state.inflight.values()) ac.abort();
|
|
19565
|
+
state.inflight.clear();
|
|
19566
|
+
if (state.preWarmed) {
|
|
19567
|
+
try {
|
|
19568
|
+
state.preWarmed.abortController.abort();
|
|
19569
|
+
} catch (_) {
|
|
19570
|
+
}
|
|
19571
|
+
state.preWarmed = null;
|
|
19572
|
+
}
|
|
19877
19573
|
for (const [, waiter] of state.voiceWaiters) {
|
|
19878
|
-
waiter();
|
|
19574
|
+
waiter.resolve();
|
|
19879
19575
|
}
|
|
19880
19576
|
state.voiceWaiters.clear();
|
|
19881
19577
|
state.queue = plan.map((a) => ({ agentId: a.id, status: "queued" }));
|
|
@@ -19903,6 +19599,106 @@ function tickRoom(roomId, opts) {
|
|
|
19903
19599
|
void pumpQueue(roomId);
|
|
19904
19600
|
}
|
|
19905
19601
|
}
|
|
19602
|
+
function schedulePreWarm(roomId, currentMessageId) {
|
|
19603
|
+
void runPickerThenPrewarm(roomId, currentMessageId).catch((e) => {
|
|
19604
|
+
process.stderr.write(`[pre-warm] failed: ${e instanceof Error ? e.message : String(e)}
|
|
19605
|
+
`);
|
|
19606
|
+
});
|
|
19607
|
+
}
|
|
19608
|
+
async function runPickerThenPrewarm(roomId, _currentMessageId) {
|
|
19609
|
+
const state = ensureState(roomId);
|
|
19610
|
+
if (state.preWarmed) return;
|
|
19611
|
+
if (state.queue.length < 2) return;
|
|
19612
|
+
const room = getRoom(roomId);
|
|
19613
|
+
if (!room || room.status !== "live") return;
|
|
19614
|
+
if (room.deliveryMode !== "voice") return;
|
|
19615
|
+
if (state.pendingRoundEnd || state.pauseAfterCurrent || state.billingHaltedThisTurn) return;
|
|
19616
|
+
if (room.awaitingClarify || room.awaitingContinue) return;
|
|
19617
|
+
const recent = listRecentMessages(roomId, 30);
|
|
19618
|
+
const directorAlreadySpoke = recent.some((m) => {
|
|
19619
|
+
if (m.authorKind !== "agent" || m.roundNum !== state.roundNum) return false;
|
|
19620
|
+
if (m.meta?.kind) return false;
|
|
19621
|
+
const a = m.authorId ? getAgent(m.authorId) : null;
|
|
19622
|
+
return a?.roleKind === "director";
|
|
19623
|
+
});
|
|
19624
|
+
const candidates = state.queue.map((q) => getAgent(q.agentId)).filter((a) => a !== null);
|
|
19625
|
+
let pickedAgentId = null;
|
|
19626
|
+
if (directorAlreadySpoke && candidates.length >= 2) {
|
|
19627
|
+
try {
|
|
19628
|
+
const pick = await withTimeout(
|
|
19629
|
+
pickNextSpeaker({
|
|
19630
|
+
candidates,
|
|
19631
|
+
history: recent,
|
|
19632
|
+
room: { subject: room.subject ?? null },
|
|
19633
|
+
mode: "lens-gap"
|
|
19634
|
+
}),
|
|
19635
|
+
15e3,
|
|
19636
|
+
"prewarm-picker"
|
|
19637
|
+
);
|
|
19638
|
+
pickedAgentId = pick.agentId;
|
|
19639
|
+
} catch (e) {
|
|
19640
|
+
process.stderr.write(`[pre-warm picker] ${e instanceof Error ? e.message : String(e)}
|
|
19641
|
+
`);
|
|
19642
|
+
}
|
|
19643
|
+
}
|
|
19644
|
+
if (state.preWarmed) return;
|
|
19645
|
+
const live = getRoom(roomId);
|
|
19646
|
+
if (!live || live.status !== "live") return;
|
|
19647
|
+
if (state.queue.length < 2) return;
|
|
19648
|
+
if (pickedAgentId) {
|
|
19649
|
+
const idx = state.queue.findIndex((q) => q.agentId === pickedAgentId);
|
|
19650
|
+
if (idx > 1) {
|
|
19651
|
+
const [picked] = state.queue.splice(idx, 1);
|
|
19652
|
+
state.queue.splice(1, 0, picked);
|
|
19653
|
+
emitQueueUpdate(roomId, state);
|
|
19654
|
+
}
|
|
19655
|
+
}
|
|
19656
|
+
const nextEntry = state.queue[1];
|
|
19657
|
+
if (!nextEntry) return;
|
|
19658
|
+
const nextSpeaker = getAgent(nextEntry.agentId);
|
|
19659
|
+
if (!nextSpeaker) return;
|
|
19660
|
+
const ac = new AbortController();
|
|
19661
|
+
const sentinel = `pending:${nextSpeaker.id}`;
|
|
19662
|
+
state.inflight.set(sentinel, ac);
|
|
19663
|
+
rlog(roomId, "prewarm-start", {
|
|
19664
|
+
agent: nextSpeaker.name,
|
|
19665
|
+
agentId: nextSpeaker.id,
|
|
19666
|
+
pickedByHaiku: !!pickedAgentId,
|
|
19667
|
+
queueHead: state.queue[0]?.agentId
|
|
19668
|
+
});
|
|
19669
|
+
const preWarmed = {
|
|
19670
|
+
agentId: nextEntry.agentId,
|
|
19671
|
+
messageId: "",
|
|
19672
|
+
promise: Promise.resolve(null),
|
|
19673
|
+
// backfilled below
|
|
19674
|
+
abortController: ac
|
|
19675
|
+
};
|
|
19676
|
+
preWarmed.promise = streamSpeakerTurn({
|
|
19677
|
+
roomId,
|
|
19678
|
+
speaker: nextSpeaker,
|
|
19679
|
+
roundNum: state.roundNum,
|
|
19680
|
+
signal: ac.signal,
|
|
19681
|
+
preWarmed: true,
|
|
19682
|
+
onPlaceholder: (info) => {
|
|
19683
|
+
preWarmed.messageId = info.messageId;
|
|
19684
|
+
if (state.inflight.has(sentinel)) {
|
|
19685
|
+
state.inflight.delete(sentinel);
|
|
19686
|
+
state.inflight.set(info.messageId, ac);
|
|
19687
|
+
}
|
|
19688
|
+
}
|
|
19689
|
+
// Chain trigger lives in pumpQueue's consume point, NOT here.
|
|
19690
|
+
// Rationale: B's `message-final` fires while B is still occupying
|
|
19691
|
+
// `state.preWarmed`. A nested schedulePreWarm() call from inside
|
|
19692
|
+
// B's pre-warm stream would hit the `if (state.preWarmed) return`
|
|
19693
|
+
// guard at the top of runPickerThenPrewarm and bail — C never
|
|
19694
|
+
// gets pre-warmed, depth-1 collapses to "first pair only". The
|
|
19695
|
+
// correct hook is the moment pumpQueue clears preWarmed (consume
|
|
19696
|
+
// path); at that instant the slot is free, the queue head has
|
|
19697
|
+
// advanced, and the next pre-warm has the right context.
|
|
19698
|
+
// onMessageFinal intentionally omitted.
|
|
19699
|
+
});
|
|
19700
|
+
state.preWarmed = preWarmed;
|
|
19701
|
+
}
|
|
19906
19702
|
async function pumpQueue(roomId) {
|
|
19907
19703
|
const state = ensureState(roomId);
|
|
19908
19704
|
if (state.processing) {
|
|
@@ -19923,7 +19719,8 @@ async function pumpQueue(roomId) {
|
|
|
19923
19719
|
emitQueueUpdate(roomId, state);
|
|
19924
19720
|
break;
|
|
19925
19721
|
}
|
|
19926
|
-
|
|
19722
|
+
const preWarmedHit = !!(state.preWarmed && state.queue[0] && state.preWarmed.agentId === state.queue[0].agentId);
|
|
19723
|
+
if (!preWarmedHit && state.queue.length >= 2) {
|
|
19927
19724
|
const recent = listRecentMessages(roomId, 30);
|
|
19928
19725
|
const round = state.roundNum;
|
|
19929
19726
|
let isReactive = false;
|
|
@@ -19990,13 +19787,37 @@ async function pumpQueue(roomId) {
|
|
|
19990
19787
|
} catch {
|
|
19991
19788
|
}
|
|
19992
19789
|
}
|
|
19993
|
-
const
|
|
19994
|
-
|
|
19995
|
-
|
|
19996
|
-
|
|
19997
|
-
|
|
19998
|
-
|
|
19999
|
-
|
|
19790
|
+
const fallbackPick = {
|
|
19791
|
+
agentId: null,
|
|
19792
|
+
rationale: "",
|
|
19793
|
+
intervention: null
|
|
19794
|
+
};
|
|
19795
|
+
let pick = fallbackPick;
|
|
19796
|
+
try {
|
|
19797
|
+
pick = await withTimeout(
|
|
19798
|
+
pickNextSpeaker({
|
|
19799
|
+
candidates: pickerCandidates,
|
|
19800
|
+
history: recent,
|
|
19801
|
+
room: pickRoom ?? void 0,
|
|
19802
|
+
mode: useDissentMode ? "dissent-gap" : "lens-gap",
|
|
19803
|
+
convergentTerms: useDissentMode ? convergentTerms : void 0
|
|
19804
|
+
}),
|
|
19805
|
+
15e3,
|
|
19806
|
+
"speaker-picker"
|
|
19807
|
+
);
|
|
19808
|
+
} catch (e) {
|
|
19809
|
+
if (e instanceof TimeoutError) {
|
|
19810
|
+
process.stderr.write(`[picker] timeout \u2014 falling back to round-robin
|
|
19811
|
+
`);
|
|
19812
|
+
emitAutoSkipped(roomId, "picker", "picker-timeout");
|
|
19813
|
+
} else {
|
|
19814
|
+
process.stderr.write(
|
|
19815
|
+
`[picker] error: ${e instanceof Error ? e.message : String(e)}
|
|
19816
|
+
`
|
|
19817
|
+
);
|
|
19818
|
+
}
|
|
19819
|
+
pick = fallbackPick;
|
|
19820
|
+
}
|
|
20000
19821
|
if (useDissentMode && convergentTerms.length > 0) {
|
|
20001
19822
|
const lastBreaker = state.lastFrameBreakerAgentId;
|
|
20002
19823
|
const chosenId = pick.agentId ?? state.queue[0]?.agentId;
|
|
@@ -20091,23 +19912,57 @@ async function pumpQueue(roomId) {
|
|
|
20091
19912
|
emitQueueUpdate(roomId, state);
|
|
20092
19913
|
continue;
|
|
20093
19914
|
}
|
|
20094
|
-
|
|
20095
|
-
|
|
19915
|
+
let ac;
|
|
19916
|
+
let streamPromise;
|
|
19917
|
+
if (preWarmedHit && state.preWarmed) {
|
|
19918
|
+
const justConsumed = state.preWarmed;
|
|
19919
|
+
ac = state.preWarmed.abortController;
|
|
19920
|
+
streamPromise = state.preWarmed.promise;
|
|
19921
|
+
state.preWarmed = null;
|
|
19922
|
+
rlog(roomId, "speaker-prewarm-consumed", {
|
|
19923
|
+
agent: speaker.name,
|
|
19924
|
+
agentId: speaker.id
|
|
19925
|
+
});
|
|
19926
|
+
schedulePreWarm(roomId, justConsumed.messageId);
|
|
19927
|
+
} else {
|
|
19928
|
+
rlog(roomId, "speaker-fresh-path", {
|
|
19929
|
+
agent: speaker.name,
|
|
19930
|
+
agentId: speaker.id,
|
|
19931
|
+
hasPrewarm: !!state.preWarmed,
|
|
19932
|
+
prewarmAgent: state.preWarmed?.agentId ?? null,
|
|
19933
|
+
queueHead: state.queue[0]?.agentId,
|
|
19934
|
+
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."
|
|
19935
|
+
});
|
|
19936
|
+
ac = new AbortController();
|
|
19937
|
+
const sentinel = `pending:${speaker.id}`;
|
|
19938
|
+
state.inflight.set(sentinel, ac);
|
|
19939
|
+
streamPromise = streamSpeakerTurn({
|
|
19940
|
+
roomId,
|
|
19941
|
+
speaker,
|
|
19942
|
+
roundNum: state.roundNum,
|
|
19943
|
+
signal: ac.signal,
|
|
19944
|
+
onPlaceholder: (info) => {
|
|
19945
|
+
if (state.inflight.has(sentinel)) {
|
|
19946
|
+
state.inflight.delete(sentinel);
|
|
19947
|
+
state.inflight.set(info.messageId, ac);
|
|
19948
|
+
}
|
|
19949
|
+
},
|
|
19950
|
+
onMessageFinal: (info) => {
|
|
19951
|
+
schedulePreWarm(roomId, info.messageId);
|
|
19952
|
+
}
|
|
19953
|
+
});
|
|
19954
|
+
}
|
|
20096
19955
|
const turnStart = Date.now();
|
|
20097
19956
|
rlog(roomId, "speaker-start", {
|
|
20098
19957
|
agent: speaker.name,
|
|
20099
19958
|
agentId: speaker.id,
|
|
20100
19959
|
modelV: speaker.modelV,
|
|
20101
19960
|
round: state.roundNum,
|
|
20102
|
-
position: `${state.speakersThisTurn + 1}/${state.maxSpeakersThisTurn}
|
|
19961
|
+
position: `${state.speakersThisTurn + 1}/${state.maxSpeakersThisTurn}`,
|
|
19962
|
+
preWarmed: preWarmedHit
|
|
20103
19963
|
});
|
|
20104
19964
|
try {
|
|
20105
|
-
const messageId = await
|
|
20106
|
-
roomId,
|
|
20107
|
-
speaker,
|
|
20108
|
-
roundNum: state.roundNum,
|
|
20109
|
-
signal: ac.signal
|
|
20110
|
-
});
|
|
19965
|
+
const messageId = await streamPromise;
|
|
20111
19966
|
if (messageId && getRoom(roomId)?.deliveryMode === "voice") {
|
|
20112
19967
|
await waitForVoicePlayback(roomId, messageId);
|
|
20113
19968
|
}
|
|
@@ -20129,7 +19984,11 @@ async function pumpQueue(roomId) {
|
|
|
20129
19984
|
process.stderr.write(`[orchestrator] stream error: ${msg}
|
|
20130
19985
|
`);
|
|
20131
19986
|
} finally {
|
|
20132
|
-
|
|
19987
|
+
const keysToDel = [];
|
|
19988
|
+
for (const [key, val] of state.inflight) {
|
|
19989
|
+
if (val === ac) keysToDel.push(key);
|
|
19990
|
+
}
|
|
19991
|
+
for (const key of keysToDel) state.inflight.delete(key);
|
|
20133
19992
|
}
|
|
20134
19993
|
if (state.queue[0] !== entry) {
|
|
20135
19994
|
continue;
|
|
@@ -20228,15 +20087,26 @@ async function pumpQueue(roomId) {
|
|
|
20228
20087
|
// same path the chat round-prompt button takes).
|
|
20229
20088
|
room.voteTrigger !== "manual") {
|
|
20230
20089
|
const wrappedRound = state.roundNum;
|
|
20090
|
+
emitChairPending(roomId, "vote-summary");
|
|
20231
20091
|
let recommendation;
|
|
20232
20092
|
try {
|
|
20233
20093
|
const recent = listRecentMessages(roomId, 30);
|
|
20234
|
-
const wrap = await
|
|
20094
|
+
const wrap = await withTimeout(
|
|
20095
|
+
pickRoundWrap({ history: recent, roundNum: wrappedRound, room }),
|
|
20096
|
+
15e3,
|
|
20097
|
+
"pickRoundWrap"
|
|
20098
|
+
);
|
|
20235
20099
|
recommendation = { kind: wrap.recommendation, rationale: wrap.rationale };
|
|
20236
20100
|
} catch (e) {
|
|
20237
|
-
|
|
20238
|
-
|
|
20239
|
-
|
|
20101
|
+
if (e instanceof TimeoutError) {
|
|
20102
|
+
process.stderr.write(`[round-wrap] timeout \u2014 using neutral prompt
|
|
20103
|
+
`);
|
|
20104
|
+
emitAutoSkipped(roomId, "picker", "pickRoundWrap-timeout");
|
|
20105
|
+
} else {
|
|
20106
|
+
rlog(roomId, "round-wrap-error", {
|
|
20107
|
+
error: e instanceof Error ? e.message : String(e)
|
|
20108
|
+
});
|
|
20109
|
+
}
|
|
20240
20110
|
}
|
|
20241
20111
|
const roomAgain = getRoom(roomId);
|
|
20242
20112
|
const stateNow = ensureState(roomId);
|
|
@@ -20261,7 +20131,7 @@ async function pumpQueue(roomId) {
|
|
|
20261
20131
|
}
|
|
20262
20132
|
}
|
|
20263
20133
|
async function streamSpeakerTurn(args) {
|
|
20264
|
-
const { roomId, speaker, roundNum, signal } = args;
|
|
20134
|
+
const { roomId, speaker, roundNum, signal, preWarmed = false, onPlaceholder, onMessageFinal } = args;
|
|
20265
20135
|
const room = getRoom(roomId);
|
|
20266
20136
|
if (!room) return null;
|
|
20267
20137
|
const memberRows = listRoomMembers(roomId);
|
|
@@ -20437,6 +20307,9 @@ async function streamSpeakerTurn(args) {
|
|
|
20437
20307
|
speakerStatus: "streaming",
|
|
20438
20308
|
streaming: true
|
|
20439
20309
|
};
|
|
20310
|
+
if (preWarmed) {
|
|
20311
|
+
placeholderMeta.preWarmed = true;
|
|
20312
|
+
}
|
|
20440
20313
|
if (activeSkills.length > 0) {
|
|
20441
20314
|
placeholderMeta.skillsUsed = activeSkills.map((s) => s.slug);
|
|
20442
20315
|
if (pickerReason) placeholderMeta.skillsReason = pickerReason;
|
|
@@ -20468,6 +20341,14 @@ async function streamSpeakerTurn(args) {
|
|
|
20468
20341
|
roundNum: placeholder.roundNum,
|
|
20469
20342
|
createdAt: placeholder.createdAt
|
|
20470
20343
|
});
|
|
20344
|
+
if (onPlaceholder) {
|
|
20345
|
+
try {
|
|
20346
|
+
onPlaceholder({ messageId: placeholder.id });
|
|
20347
|
+
} catch (e) {
|
|
20348
|
+
process.stderr.write(`[onPlaceholder] ${e instanceof Error ? e.message : String(e)}
|
|
20349
|
+
`);
|
|
20350
|
+
}
|
|
20351
|
+
}
|
|
20471
20352
|
let buf = "";
|
|
20472
20353
|
let finishReason;
|
|
20473
20354
|
let errored = false;
|
|
@@ -20494,32 +20375,90 @@ async function streamSpeakerTurn(args) {
|
|
|
20494
20375
|
if (!voiceProfile) return;
|
|
20495
20376
|
process.stderr.write(`[tts] emitVoiceText called: provider=${voiceProfile.provider} voiceId=${voiceProfile.voiceId} textLen=${text.length} text="${text.slice(0, 50)}"
|
|
20496
20377
|
`);
|
|
20497
|
-
|
|
20498
|
-
|
|
20499
|
-
|
|
20500
|
-
|
|
20501
|
-
|
|
20378
|
+
const MAX_ATTEMPTS = 2;
|
|
20379
|
+
const TIMEOUT_MS = 3e4;
|
|
20380
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
20381
|
+
if (signal.aborted) return;
|
|
20382
|
+
const timeoutCtrl = new AbortController();
|
|
20383
|
+
const timer = setTimeout(() => timeoutCtrl.abort(), TIMEOUT_MS);
|
|
20384
|
+
const onOuterAbort = () => timeoutCtrl.abort();
|
|
20385
|
+
signal.addEventListener("abort", onOuterAbort);
|
|
20386
|
+
let chunkCount = 0;
|
|
20387
|
+
let failure = null;
|
|
20388
|
+
try {
|
|
20389
|
+
for await (const chunk of synthesizeSpeechStream(text, voiceProfile, timeoutCtrl.signal)) {
|
|
20390
|
+
if (signal.aborted) break;
|
|
20391
|
+
chunkCount++;
|
|
20392
|
+
roomBus.emit(roomId, {
|
|
20393
|
+
type: "voice-chunk",
|
|
20394
|
+
messageId: placeholder.id,
|
|
20395
|
+
seq: voiceSeq++,
|
|
20396
|
+
text: chunk.text,
|
|
20397
|
+
provider: chunk.provider,
|
|
20398
|
+
model: chunk.model,
|
|
20399
|
+
voiceId: chunk.voiceId,
|
|
20400
|
+
...chunk.mimeType ? { mimeType: chunk.mimeType } : {},
|
|
20401
|
+
...chunk.audioBase64 ? { audioBase64: chunk.audioBase64 } : {}
|
|
20402
|
+
});
|
|
20403
|
+
}
|
|
20404
|
+
} catch (e) {
|
|
20405
|
+
failure = e instanceof Error ? e : new Error(String(e));
|
|
20406
|
+
} finally {
|
|
20407
|
+
clearTimeout(timer);
|
|
20408
|
+
signal.removeEventListener("abort", onOuterAbort);
|
|
20409
|
+
}
|
|
20410
|
+
if (signal.aborted) {
|
|
20411
|
+
process.stderr.write(`[tts] outer abort during attempt ${attempt}, giving up
|
|
20412
|
+
`);
|
|
20413
|
+
return;
|
|
20414
|
+
}
|
|
20415
|
+
if (!failure) {
|
|
20416
|
+
process.stderr.write(`[tts] emitVoiceText done (attempt ${attempt}/${MAX_ATTEMPTS}): ${chunkCount} chunks emitted
|
|
20417
|
+
`);
|
|
20418
|
+
return;
|
|
20419
|
+
}
|
|
20420
|
+
const billing = tryExtractTtsBillingError(failure);
|
|
20421
|
+
if (billing) {
|
|
20502
20422
|
roomBus.emit(roomId, {
|
|
20503
|
-
type: "voice-
|
|
20423
|
+
type: "voice-error",
|
|
20504
20424
|
messageId: placeholder.id,
|
|
20505
|
-
|
|
20506
|
-
|
|
20507
|
-
|
|
20508
|
-
|
|
20509
|
-
voiceId: chunk.voiceId,
|
|
20510
|
-
...chunk.mimeType ? { mimeType: chunk.mimeType } : {},
|
|
20511
|
-
...chunk.audioBase64 ? { audioBase64: chunk.audioBase64 } : {}
|
|
20425
|
+
code: billing.code,
|
|
20426
|
+
provider: billing.provider,
|
|
20427
|
+
message: billing.message,
|
|
20428
|
+
upgradeUrl: billing.upgradeUrl
|
|
20512
20429
|
});
|
|
20430
|
+
process.stderr.write(
|
|
20431
|
+
`[tts] BILLING-ERROR room=${roomId} agent=${speaker.name} provider=${voiceProfile.provider} \xB7 ${billing.message}
|
|
20432
|
+
`
|
|
20433
|
+
);
|
|
20434
|
+
return;
|
|
20513
20435
|
}
|
|
20514
|
-
|
|
20515
|
-
`);
|
|
20516
|
-
} catch (e) {
|
|
20436
|
+
const willRetry = attempt < MAX_ATTEMPTS && chunkCount === 0;
|
|
20517
20437
|
process.stderr.write(
|
|
20518
|
-
`[tts] ERROR room=${roomId} agent=${speaker.name} provider=${voiceProfile.provider} voiceId=${voiceProfile.voiceId} \xB7 ${
|
|
20519
|
-
`
|
|
20438
|
+
`[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")
|
|
20520
20439
|
);
|
|
20521
|
-
|
|
20522
|
-
|
|
20440
|
+
if (!willRetry) return;
|
|
20441
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
20442
|
+
}
|
|
20443
|
+
}
|
|
20444
|
+
const turnCtrl = new AbortController();
|
|
20445
|
+
const onRoomAbort = () => turnCtrl.abort();
|
|
20446
|
+
if (signal.aborted) turnCtrl.abort();
|
|
20447
|
+
else signal.addEventListener("abort", onRoomAbort);
|
|
20448
|
+
let hardCapTimedOut = false;
|
|
20449
|
+
let firstTokenTimedOut = false;
|
|
20450
|
+
const hardCapTimer = setTimeout(() => {
|
|
20451
|
+
if (!turnCtrl.signal.aborted) {
|
|
20452
|
+
hardCapTimedOut = true;
|
|
20453
|
+
turnCtrl.abort();
|
|
20454
|
+
}
|
|
20455
|
+
}, 12e4);
|
|
20456
|
+
let firstTokenTimer = setTimeout(() => {
|
|
20457
|
+
if (buf.length === 0 && !turnCtrl.signal.aborted) {
|
|
20458
|
+
firstTokenTimedOut = true;
|
|
20459
|
+
turnCtrl.abort();
|
|
20460
|
+
}
|
|
20461
|
+
}, 6e4);
|
|
20523
20462
|
try {
|
|
20524
20463
|
for await (const chunk of callLLMStream({
|
|
20525
20464
|
modelV: speaker.modelV,
|
|
@@ -20538,10 +20477,14 @@ async function streamSpeakerTurn(args) {
|
|
|
20538
20477
|
// the next move is to cap reasoning explicitly via providerOptions
|
|
20539
20478
|
// (`reasoning.max_tokens`) instead of just enlarging the total cap.
|
|
20540
20479
|
maxTokens: 4e3,
|
|
20541
|
-
signal
|
|
20480
|
+
signal: turnCtrl.signal
|
|
20542
20481
|
})) {
|
|
20543
20482
|
if (signal.aborted) break;
|
|
20544
20483
|
if (chunk.type === "text") {
|
|
20484
|
+
if (firstTokenTimer) {
|
|
20485
|
+
clearTimeout(firstTokenTimer);
|
|
20486
|
+
firstTokenTimer = null;
|
|
20487
|
+
}
|
|
20545
20488
|
buf += chunk.delta;
|
|
20546
20489
|
updateMessageBody(placeholder.id, buf, {
|
|
20547
20490
|
...placeholderMeta,
|
|
@@ -20614,7 +20557,14 @@ async function streamSpeakerTurn(args) {
|
|
|
20614
20557
|
}
|
|
20615
20558
|
} catch (e) {
|
|
20616
20559
|
errored = true;
|
|
20617
|
-
|
|
20560
|
+
let msg = e instanceof Error ? e.message : String(e);
|
|
20561
|
+
if (firstTokenTimedOut) {
|
|
20562
|
+
msg = `LLM did not produce any token within 60s \xB7 auto-skipped`;
|
|
20563
|
+
emitAutoSkipped(roomId, "llm", "llm-first-token-timeout", placeholder.id);
|
|
20564
|
+
} else if (hardCapTimedOut) {
|
|
20565
|
+
msg = `LLM stream exceeded 120s hard cap \xB7 auto-skipped`;
|
|
20566
|
+
emitAutoSkipped(roomId, "llm", "llm-timeout", placeholder.id);
|
|
20567
|
+
}
|
|
20618
20568
|
process.stderr.write(
|
|
20619
20569
|
`[stream-throw] room=${roomId} agent=${speaker.name} modelV=${speaker.modelV} \xB7 ${msg}
|
|
20620
20570
|
`
|
|
@@ -20630,7 +20580,16 @@ async function streamSpeakerTurn(args) {
|
|
|
20630
20580
|
messageId: placeholder.id,
|
|
20631
20581
|
message: msg
|
|
20632
20582
|
});
|
|
20633
|
-
|
|
20583
|
+
markVoicePlaybackDone(roomId, placeholder.id);
|
|
20584
|
+
if (!firstTokenTimedOut && !hardCapTimedOut) throw e;
|
|
20585
|
+
} finally {
|
|
20586
|
+
clearTimeout(hardCapTimer);
|
|
20587
|
+
if (firstTokenTimer) {
|
|
20588
|
+
clearTimeout(firstTokenTimer);
|
|
20589
|
+
firstTokenTimer = null;
|
|
20590
|
+
}
|
|
20591
|
+
signal.removeEventListener("abort", onRoomAbort);
|
|
20592
|
+
finalizeStreamingMessage(placeholder.id, "turn-cleanup");
|
|
20634
20593
|
}
|
|
20635
20594
|
if (signal.aborted) {
|
|
20636
20595
|
finishReason = finishReason ?? "aborted";
|
|
@@ -20668,6 +20627,14 @@ async function streamSpeakerTurn(args) {
|
|
|
20668
20627
|
messageId: placeholder.id,
|
|
20669
20628
|
finishReason
|
|
20670
20629
|
});
|
|
20630
|
+
if (onMessageFinal) {
|
|
20631
|
+
try {
|
|
20632
|
+
onMessageFinal({ messageId: placeholder.id });
|
|
20633
|
+
} catch (e) {
|
|
20634
|
+
process.stderr.write(`[onMessageFinal] ${e instanceof Error ? e.message : String(e)}
|
|
20635
|
+
`);
|
|
20636
|
+
}
|
|
20637
|
+
}
|
|
20671
20638
|
if (buf.trim().length >= 40) {
|
|
20672
20639
|
void (async () => {
|
|
20673
20640
|
try {
|
|
@@ -21164,6 +21131,17 @@ async function streamChairMessage(args) {
|
|
|
21164
21131
|
});
|
|
21165
21132
|
}
|
|
21166
21133
|
} catch (e) {
|
|
21134
|
+
const billing = tryExtractTtsBillingError(e);
|
|
21135
|
+
if (billing) {
|
|
21136
|
+
roomBus.emit(roomId, {
|
|
21137
|
+
type: "voice-error",
|
|
21138
|
+
messageId: placeholder.id,
|
|
21139
|
+
code: billing.code,
|
|
21140
|
+
provider: billing.provider,
|
|
21141
|
+
message: billing.message,
|
|
21142
|
+
upgradeUrl: billing.upgradeUrl
|
|
21143
|
+
});
|
|
21144
|
+
}
|
|
21167
21145
|
process.stderr.write(`[tts-chair] ${e instanceof Error ? e.message : String(e)}
|
|
21168
21146
|
`);
|
|
21169
21147
|
}
|
|
@@ -21339,13 +21317,34 @@ async function runChairClarify(roomId) {
|
|
|
21339
21317
|
}
|
|
21340
21318
|
}
|
|
21341
21319
|
if (turnNumber === 1) {
|
|
21342
|
-
|
|
21343
|
-
|
|
21320
|
+
emitChairPending(roomId, "clarify-deciding");
|
|
21321
|
+
let decision = null;
|
|
21322
|
+
try {
|
|
21323
|
+
decision = await withTimeout(
|
|
21324
|
+
pickChairClarifyDecision({ history }),
|
|
21325
|
+
15e3,
|
|
21326
|
+
"chair-clarify-decision"
|
|
21327
|
+
);
|
|
21328
|
+
} catch (e) {
|
|
21329
|
+
if (e instanceof TimeoutError) {
|
|
21330
|
+
process.stderr.write(`[chair-clarify] decision timeout \u2014 skipping clarify
|
|
21331
|
+
`);
|
|
21332
|
+
emitAutoSkipped(roomId, "clarify", "clarify-timeout");
|
|
21333
|
+
decision = { shouldAsk: false, rationale: "timeout" };
|
|
21334
|
+
} else {
|
|
21335
|
+
process.stderr.write(
|
|
21336
|
+
`[chair-clarify] decision error: ${e instanceof Error ? e.message : String(e)}
|
|
21337
|
+
`
|
|
21338
|
+
);
|
|
21339
|
+
decision = { shouldAsk: false, rationale: "error" };
|
|
21340
|
+
}
|
|
21341
|
+
}
|
|
21342
|
+
if (!decision || !decision.shouldAsk) {
|
|
21344
21343
|
setAwaitingClarify(roomId, false);
|
|
21345
21344
|
roomBus.emit(roomId, {
|
|
21346
21345
|
type: "config-event",
|
|
21347
21346
|
kind: "clarify-ready",
|
|
21348
|
-
payload: { skipped: true, rationale: decision
|
|
21347
|
+
payload: { skipped: true, rationale: decision?.rationale },
|
|
21349
21348
|
createdAt: Date.now()
|
|
21350
21349
|
});
|
|
21351
21350
|
return { asked: false, ready: true, exhausted: false };
|
|
@@ -21555,6 +21554,17 @@ async function emitChairAnnouncementVoice(roomId, messageId, body) {
|
|
|
21555
21554
|
roomBus.emit(roomId, { type: "voice-final", messageId });
|
|
21556
21555
|
await waitForVoicePlayback(roomId, messageId, 6e4);
|
|
21557
21556
|
} catch (e) {
|
|
21557
|
+
const billing = tryExtractTtsBillingError(e);
|
|
21558
|
+
if (billing) {
|
|
21559
|
+
roomBus.emit(roomId, {
|
|
21560
|
+
type: "voice-error",
|
|
21561
|
+
messageId,
|
|
21562
|
+
code: billing.code,
|
|
21563
|
+
provider: billing.provider,
|
|
21564
|
+
message: billing.message,
|
|
21565
|
+
upgradeUrl: billing.upgradeUrl
|
|
21566
|
+
});
|
|
21567
|
+
}
|
|
21558
21568
|
process.stderr.write(`[tts-chair-announce] ${e instanceof Error ? e.message : String(e)}
|
|
21559
21569
|
`);
|
|
21560
21570
|
}
|
|
@@ -22462,42 +22472,12 @@ function roomsRouter() {
|
|
|
22462
22472
|
payload: { mode, intensity, briefStyle, deliveryMode, members: members.map((m) => m.agentId), autoPick },
|
|
22463
22473
|
actorKind: "user"
|
|
22464
22474
|
});
|
|
22465
|
-
let seedContext = null;
|
|
22466
|
-
if (b.seedContext && typeof b.seedContext === "object") {
|
|
22467
|
-
const raw = b.seedContext;
|
|
22468
|
-
const topicRecId = typeof raw.topicRecId === "string" && raw.topicRecId.trim().length > 0 ? raw.topicRecId.trim().slice(0, 64) : void 0;
|
|
22469
|
-
const rationale = typeof raw.rationale === "string" && raw.rationale.trim().length > 0 ? raw.rationale.trim().slice(0, 400) : void 0;
|
|
22470
|
-
const rawSnippets = Array.isArray(raw.snippets) ? raw.snippets : [];
|
|
22471
|
-
const snippets = rawSnippets.filter(
|
|
22472
|
-
(s) => !!s && typeof s === "object" && typeof s.title === "string" && typeof s.url === "string" && typeof s.description === "string"
|
|
22473
|
-
).slice(0, 12).map((s) => ({
|
|
22474
|
-
title: s.title.slice(0, 200),
|
|
22475
|
-
url: s.url.slice(0, 600),
|
|
22476
|
-
description: s.description.slice(0, 600)
|
|
22477
|
-
}));
|
|
22478
|
-
if (topicRecId || rationale || snippets.length > 0) {
|
|
22479
|
-
seedContext = {
|
|
22480
|
-
...topicRecId ? { topicRecId } : {},
|
|
22481
|
-
...rationale ? { rationale } : {},
|
|
22482
|
-
...snippets.length > 0 ? { snippets } : {}
|
|
22483
|
-
};
|
|
22484
|
-
}
|
|
22485
|
-
}
|
|
22486
22475
|
const opening = insertMessage({
|
|
22487
22476
|
roomId: room.id,
|
|
22488
22477
|
authorKind: "user",
|
|
22489
22478
|
body: subject,
|
|
22490
|
-
roundNum: 1
|
|
22491
|
-
meta: seedContext ? { seedContext } : void 0
|
|
22479
|
+
roundNum: 1
|
|
22492
22480
|
});
|
|
22493
|
-
if (seedContext?.topicRecId) {
|
|
22494
|
-
try {
|
|
22495
|
-
markTopicRecOpened(seedContext.topicRecId, room.id);
|
|
22496
|
-
} catch (e) {
|
|
22497
|
-
process.stderr.write(`[rooms] topic-rec link failed: ${e instanceof Error ? e.message : String(e)}
|
|
22498
|
-
`);
|
|
22499
|
-
}
|
|
22500
|
-
}
|
|
22501
22481
|
roomBus.emit(room.id, {
|
|
22502
22482
|
type: "message-appended",
|
|
22503
22483
|
messageId: opening.id,
|
|
@@ -22629,13 +22609,19 @@ function roomsRouter() {
|
|
|
22629
22609
|
r.get("/:id/stream", (c) => {
|
|
22630
22610
|
const id = c.req.param("id");
|
|
22631
22611
|
if (!getRoom(id)) return c.json({ error: "not found" }, 404);
|
|
22632
|
-
|
|
22612
|
+
const lastIdHeader = c.req.header("last-event-id");
|
|
22613
|
+
const sinceId = lastIdHeader ? Number.parseInt(lastIdHeader, 10) : NaN;
|
|
22614
|
+
return streamSSE2(c, async (s) => {
|
|
22633
22615
|
await s.writeSSE({ event: "hello", data: JSON.stringify({ roomId: id, ts: Date.now() }) });
|
|
22634
22616
|
const queue = [];
|
|
22635
22617
|
let resolveWaiter = null;
|
|
22636
22618
|
let closed = false;
|
|
22637
|
-
|
|
22638
|
-
|
|
22619
|
+
if (Number.isFinite(sinceId) && sinceId > 0) {
|
|
22620
|
+
const missed = roomBus.replay(id, sinceId);
|
|
22621
|
+
for (const m of missed) queue.push({ id: m.id, event: m.event });
|
|
22622
|
+
}
|
|
22623
|
+
const off = roomBus.subscribeWithId(id, (eventId, event) => {
|
|
22624
|
+
queue.push({ id: eventId, event });
|
|
22639
22625
|
if (resolveWaiter) {
|
|
22640
22626
|
resolveWaiter();
|
|
22641
22627
|
resolveWaiter = null;
|
|
@@ -22656,8 +22642,12 @@ function roomsRouter() {
|
|
|
22656
22642
|
});
|
|
22657
22643
|
continue;
|
|
22658
22644
|
}
|
|
22659
|
-
const event = queue.shift();
|
|
22660
|
-
await s.writeSSE({
|
|
22645
|
+
const { id: eventId, event } = queue.shift();
|
|
22646
|
+
await s.writeSSE({
|
|
22647
|
+
id: String(eventId),
|
|
22648
|
+
event: event.type,
|
|
22649
|
+
data: JSON.stringify(event)
|
|
22650
|
+
});
|
|
22661
22651
|
}
|
|
22662
22652
|
});
|
|
22663
22653
|
});
|
|
@@ -22753,6 +22743,12 @@ function roomsRouter() {
|
|
|
22753
22743
|
if (!getRoom(id)) return c.json({ error: "not found" }, 404);
|
|
22754
22744
|
return c.json({ ok: markVoicePlaybackDone(id, messageId) });
|
|
22755
22745
|
});
|
|
22746
|
+
r.post("/:id/messages/:messageId/voice-progress", (c) => {
|
|
22747
|
+
const id = c.req.param("id");
|
|
22748
|
+
const messageId = c.req.param("messageId");
|
|
22749
|
+
if (!getRoom(id)) return c.json({ error: "not found" }, 404);
|
|
22750
|
+
return c.json({ ok: bumpVoicePlaybackHeartbeat(id, messageId) });
|
|
22751
|
+
});
|
|
22756
22752
|
r.post("/:id/pause", async (c) => {
|
|
22757
22753
|
const id = c.req.param("id");
|
|
22758
22754
|
const room = getRoom(id);
|
|
@@ -23559,13 +23555,22 @@ function voicesRouter() {
|
|
|
23559
23555
|
ttsCacheSet(key, out);
|
|
23560
23556
|
return c.json(out);
|
|
23561
23557
|
} catch (e) {
|
|
23558
|
+
const tagged = e ?? {};
|
|
23562
23559
|
const msg = ttsErrorMessage(e, profile.provider);
|
|
23563
23560
|
const isNoKey = /401|403|api[\s-]?key|unauthor/i.test(msg);
|
|
23564
|
-
|
|
23561
|
+
const payload = {
|
|
23565
23562
|
error: msg,
|
|
23566
|
-
|
|
23567
|
-
|
|
23568
|
-
|
|
23563
|
+
provider: typeof tagged.provider === "string" ? tagged.provider : profile.provider
|
|
23564
|
+
};
|
|
23565
|
+
if (typeof tagged.code === "string") {
|
|
23566
|
+
payload.code = tagged.code;
|
|
23567
|
+
} else {
|
|
23568
|
+
payload.code = isNoKey ? "no-key" : "tts-error";
|
|
23569
|
+
}
|
|
23570
|
+
if (typeof tagged.upgradeUrl === "string") {
|
|
23571
|
+
payload.upgradeUrl = tagged.upgradeUrl;
|
|
23572
|
+
}
|
|
23573
|
+
return c.json(payload, 502);
|
|
23569
23574
|
}
|
|
23570
23575
|
});
|
|
23571
23576
|
return r;
|
|
@@ -23575,7 +23580,7 @@ function voicesRouter() {
|
|
|
23575
23580
|
init_paths();
|
|
23576
23581
|
|
|
23577
23582
|
// src/version.ts
|
|
23578
|
-
var VERSION = "0.1.
|
|
23583
|
+
var VERSION = "0.1.25";
|
|
23579
23584
|
|
|
23580
23585
|
// src/server.ts
|
|
23581
23586
|
function createApp() {
|
|
@@ -23618,8 +23623,8 @@ Build the package or check that public/ is bundled alongside dist/.`
|
|
|
23618
23623
|
app.route("/api/prefs", prefsRouter());
|
|
23619
23624
|
app.route("/api/agents", agentsRouter());
|
|
23620
23625
|
app.route("/api/keys", keysRouter());
|
|
23626
|
+
app.route("/api/credentials", credentialsRouter());
|
|
23621
23627
|
app.route("/api/models", modelsRouter());
|
|
23622
|
-
app.route("/api/topic-recs", topicRecsRouter());
|
|
23623
23628
|
app.route("/api/rooms", roomsRouter());
|
|
23624
23629
|
app.route("/api/briefs", briefsRouter());
|
|
23625
23630
|
app.route("/api/notes", notesRouter());
|
|
@@ -23698,6 +23703,7 @@ async function bootApp(opts = {}) {
|
|
|
23698
23703
|
process.stderr.write(`[boot] orphan cleanup failed: ${errMsg(e)}
|
|
23699
23704
|
`);
|
|
23700
23705
|
}
|
|
23706
|
+
startRuntimeOrphanSweep();
|
|
23701
23707
|
try {
|
|
23702
23708
|
const fixed = recoverStuckClarifyRooms();
|
|
23703
23709
|
if (fixed > 0) {
|
|
@@ -23716,16 +23722,6 @@ async function bootApp(opts = {}) {
|
|
|
23716
23722
|
}
|
|
23717
23723
|
} catch (e) {
|
|
23718
23724
|
process.stderr.write(`[boot] persona-job recovery failed: ${errMsg(e)}
|
|
23719
|
-
`);
|
|
23720
|
-
}
|
|
23721
|
-
try {
|
|
23722
|
-
const failed = markRunningTopicRecJobsFailed();
|
|
23723
|
-
if (failed > 0) {
|
|
23724
|
-
process.stderr.write(`[boot] marked ${failed} topic-rec job(s) failed (server restarted mid-build)
|
|
23725
|
-
`);
|
|
23726
|
-
}
|
|
23727
|
-
} catch (e) {
|
|
23728
|
-
process.stderr.write(`[boot] topic-rec recovery failed: ${errMsg(e)}
|
|
23729
23725
|
`);
|
|
23730
23726
|
}
|
|
23731
23727
|
void (async () => {
|
|
@@ -23756,6 +23752,7 @@ var shuttingDown = false;
|
|
|
23756
23752
|
async function shutdownApp(server) {
|
|
23757
23753
|
if (shuttingDown) return;
|
|
23758
23754
|
shuttingDown = true;
|
|
23755
|
+
stopRuntimeOrphanSweep();
|
|
23759
23756
|
try {
|
|
23760
23757
|
await server?.close();
|
|
23761
23758
|
} catch (e) {
|
|
@@ -23767,6 +23764,35 @@ async function shutdownApp(server) {
|
|
|
23767
23764
|
console.error(" ! error closing db", e);
|
|
23768
23765
|
}
|
|
23769
23766
|
}
|
|
23767
|
+
var RUNTIME_SWEEP_INTERVAL_MS = 6e4;
|
|
23768
|
+
var RUNTIME_SWEEP_MAX_AGE_MS = 5 * 6e4;
|
|
23769
|
+
var runtimeSweepTimer = null;
|
|
23770
|
+
function startRuntimeOrphanSweep() {
|
|
23771
|
+
if (runtimeSweepTimer) return;
|
|
23772
|
+
runtimeSweepTimer = setInterval(() => {
|
|
23773
|
+
try {
|
|
23774
|
+
const r = cleanupOrphanedStreams({
|
|
23775
|
+
maxAgeMs: RUNTIME_SWEEP_MAX_AGE_MS,
|
|
23776
|
+
reason: "runtime-sweep \xB7 5min stuck"
|
|
23777
|
+
});
|
|
23778
|
+
if (r.fixed + r.deleted > 0) {
|
|
23779
|
+
process.stderr.write(
|
|
23780
|
+
`[runtime-sweep] finalised ${r.fixed} stuck stream(s), dropped ${r.deleted} empty placeholder(s)
|
|
23781
|
+
`
|
|
23782
|
+
);
|
|
23783
|
+
}
|
|
23784
|
+
} catch (e) {
|
|
23785
|
+
process.stderr.write(`[runtime-sweep] failed: ${errMsg(e)}
|
|
23786
|
+
`);
|
|
23787
|
+
}
|
|
23788
|
+
}, RUNTIME_SWEEP_INTERVAL_MS);
|
|
23789
|
+
}
|
|
23790
|
+
function stopRuntimeOrphanSweep() {
|
|
23791
|
+
if (runtimeSweepTimer) {
|
|
23792
|
+
clearInterval(runtimeSweepTimer);
|
|
23793
|
+
runtimeSweepTimer = null;
|
|
23794
|
+
}
|
|
23795
|
+
}
|
|
23770
23796
|
function errMsg(e) {
|
|
23771
23797
|
return e instanceof Error ? e.message : String(e);
|
|
23772
23798
|
}
|