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/server.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
|
}
|
|
@@ -2625,11 +2688,158 @@ import { createOpenAI } from "@ai-sdk/openai";
|
|
|
2625
2688
|
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
|
2626
2689
|
import { APICallError, streamText } from "ai";
|
|
2627
2690
|
|
|
2628
|
-
// src/storage/
|
|
2629
|
-
init_db();
|
|
2691
|
+
// src/storage/credentials.ts
|
|
2630
2692
|
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "crypto";
|
|
2631
2693
|
import { userInfo } from "os";
|
|
2632
2694
|
|
|
2695
|
+
// src/ai/providers.ts
|
|
2696
|
+
var MULTI_MODEL_LLM_PROVIDERS = [
|
|
2697
|
+
"openrouter",
|
|
2698
|
+
"bai"
|
|
2699
|
+
];
|
|
2700
|
+
var SINGLE_MODEL_LLM_PROVIDERS = [
|
|
2701
|
+
"anthropic",
|
|
2702
|
+
"openai",
|
|
2703
|
+
"google",
|
|
2704
|
+
"xai"
|
|
2705
|
+
];
|
|
2706
|
+
var ALL_LLM_PROVIDERS = [
|
|
2707
|
+
...MULTI_MODEL_LLM_PROVIDERS,
|
|
2708
|
+
...SINGLE_MODEL_LLM_PROVIDERS
|
|
2709
|
+
];
|
|
2710
|
+
var LLM_PROVIDER_PRIORITY = [
|
|
2711
|
+
"openrouter",
|
|
2712
|
+
"bai",
|
|
2713
|
+
"anthropic",
|
|
2714
|
+
"openai",
|
|
2715
|
+
"google",
|
|
2716
|
+
"xai"
|
|
2717
|
+
];
|
|
2718
|
+
function isMultiModelProvider(p) {
|
|
2719
|
+
return p === "openrouter" || p === "bai";
|
|
2720
|
+
}
|
|
2721
|
+
function isLlmProvider(p) {
|
|
2722
|
+
return ALL_LLM_PROVIDERS.indexOf(p) >= 0;
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
// src/storage/credentials.ts
|
|
2726
|
+
init_db();
|
|
2727
|
+
var SALT = "boardroom.v1.salt";
|
|
2728
|
+
var ALGO = "aes-256-gcm";
|
|
2729
|
+
var _key = null;
|
|
2730
|
+
function deriveKey() {
|
|
2731
|
+
if (_key) return _key;
|
|
2732
|
+
const username = userInfo().username || "boardroom-default";
|
|
2733
|
+
_key = scryptSync(username, SALT, 32);
|
|
2734
|
+
return _key;
|
|
2735
|
+
}
|
|
2736
|
+
function encrypt(plain) {
|
|
2737
|
+
const iv = randomBytes(12);
|
|
2738
|
+
const cipher = createCipheriv(ALGO, deriveKey(), iv);
|
|
2739
|
+
const ct = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
|
|
2740
|
+
const tag = cipher.getAuthTag();
|
|
2741
|
+
return Buffer.concat([iv, tag, ct]);
|
|
2742
|
+
}
|
|
2743
|
+
function decrypt(blob) {
|
|
2744
|
+
const iv = blob.subarray(0, 12);
|
|
2745
|
+
const tag = blob.subarray(12, 28);
|
|
2746
|
+
const ct = blob.subarray(28);
|
|
2747
|
+
const decipher = createDecipheriv(ALGO, deriveKey(), iv);
|
|
2748
|
+
decipher.setAuthTag(tag);
|
|
2749
|
+
return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8");
|
|
2750
|
+
}
|
|
2751
|
+
function maskKey(plain) {
|
|
2752
|
+
const trimmed = plain.trim();
|
|
2753
|
+
if (!trimmed) return "";
|
|
2754
|
+
const n = trimmed.length;
|
|
2755
|
+
if (n <= 4) return "\u2022".repeat(n);
|
|
2756
|
+
if (n <= 12) return `${trimmed.slice(0, 2)}${"\u2022".repeat(n - 4)}${trimmed.slice(-2)}`;
|
|
2757
|
+
return `${trimmed.slice(0, 4)}${"\u2022".repeat(n - 8)}${trimmed.slice(-4)}`;
|
|
2758
|
+
}
|
|
2759
|
+
function rowToMeta(row) {
|
|
2760
|
+
if (!isLlmProvider(row.provider)) return null;
|
|
2761
|
+
let preview = "";
|
|
2762
|
+
try {
|
|
2763
|
+
preview = maskKey(decrypt(row.key_blob));
|
|
2764
|
+
} catch {
|
|
2765
|
+
preview = "";
|
|
2766
|
+
}
|
|
2767
|
+
return {
|
|
2768
|
+
id: row.id,
|
|
2769
|
+
provider: row.provider,
|
|
2770
|
+
label: row.label,
|
|
2771
|
+
preview,
|
|
2772
|
+
createdAt: row.created_at,
|
|
2773
|
+
updatedAt: row.updated_at
|
|
2774
|
+
};
|
|
2775
|
+
}
|
|
2776
|
+
function listLlmCredentials() {
|
|
2777
|
+
const rows = getDb().prepare("SELECT id, provider, label, key_blob, created_at, updated_at FROM llm_credentials ORDER BY created_at ASC").all();
|
|
2778
|
+
return rows.map(rowToMeta).filter((m) => m !== null);
|
|
2779
|
+
}
|
|
2780
|
+
function getLlmCredentialMeta(id) {
|
|
2781
|
+
const row = getDb().prepare("SELECT id, provider, label, key_blob, created_at, updated_at FROM llm_credentials WHERE id = ?").get(id);
|
|
2782
|
+
if (!row) return null;
|
|
2783
|
+
return rowToMeta(row);
|
|
2784
|
+
}
|
|
2785
|
+
function getLlmCredentialKey(id) {
|
|
2786
|
+
const row = getDb().prepare("SELECT key_blob FROM llm_credentials WHERE id = ?").get(id);
|
|
2787
|
+
if (!row) return null;
|
|
2788
|
+
try {
|
|
2789
|
+
return decrypt(row.key_blob);
|
|
2790
|
+
} catch {
|
|
2791
|
+
return null;
|
|
2792
|
+
}
|
|
2793
|
+
}
|
|
2794
|
+
function resolveFreeLabel(provider, suggested) {
|
|
2795
|
+
const base = suggested && suggested.trim() || providerDisplayName(provider);
|
|
2796
|
+
const existing = new Set(
|
|
2797
|
+
getDb().prepare("SELECT label FROM llm_credentials").all().map((r) => r.label)
|
|
2798
|
+
);
|
|
2799
|
+
if (!existing.has(base)) return base;
|
|
2800
|
+
for (let n = 2; n < 1e3; n++) {
|
|
2801
|
+
const candidate = `${base} ${n}`;
|
|
2802
|
+
if (!existing.has(candidate)) return candidate;
|
|
2803
|
+
}
|
|
2804
|
+
return `${base} ${Date.now()}`;
|
|
2805
|
+
}
|
|
2806
|
+
function providerDisplayName(provider) {
|
|
2807
|
+
switch (provider) {
|
|
2808
|
+
case "openrouter":
|
|
2809
|
+
return "OpenRouter";
|
|
2810
|
+
case "bai":
|
|
2811
|
+
return "B.AI";
|
|
2812
|
+
case "anthropic":
|
|
2813
|
+
return "Claude";
|
|
2814
|
+
case "openai":
|
|
2815
|
+
return "ChatGPT";
|
|
2816
|
+
case "google":
|
|
2817
|
+
return "Gemini";
|
|
2818
|
+
case "xai":
|
|
2819
|
+
return "Grok";
|
|
2820
|
+
}
|
|
2821
|
+
}
|
|
2822
|
+
function createLlmCredential(provider, label, plain) {
|
|
2823
|
+
const trimmed = plain.trim();
|
|
2824
|
+
if (!trimmed) return null;
|
|
2825
|
+
if (!ALL_LLM_PROVIDERS.includes(provider)) return null;
|
|
2826
|
+
const resolvedLabel = resolveFreeLabel(provider, label);
|
|
2827
|
+
const id = randomBytes(8).toString("hex");
|
|
2828
|
+
const blob = encrypt(trimmed);
|
|
2829
|
+
const now = Date.now();
|
|
2830
|
+
getDb().prepare(
|
|
2831
|
+
`INSERT INTO llm_credentials (id, provider, label, key_blob, created_at, updated_at)
|
|
2832
|
+
VALUES (?, ?, ?, ?, ?, ?)`
|
|
2833
|
+
).run(id, provider, resolvedLabel, blob, now, now);
|
|
2834
|
+
return getLlmCredentialMeta(id);
|
|
2835
|
+
}
|
|
2836
|
+
function deleteLlmCredential(id) {
|
|
2837
|
+
const meta = getLlmCredentialMeta(id);
|
|
2838
|
+
if (!meta) return null;
|
|
2839
|
+
getDb().prepare("DELETE FROM llm_credentials WHERE id = ?").run(id);
|
|
2840
|
+
return meta.provider;
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2633
2843
|
// src/storage/prefs.ts
|
|
2634
2844
|
init_db();
|
|
2635
2845
|
function normalizeWebSearchProviderPref(raw) {
|
|
@@ -2639,6 +2849,7 @@ function normalizeMinimaxRegion(raw) {
|
|
|
2639
2849
|
return raw === "intl" ? "intl" : "cn";
|
|
2640
2850
|
}
|
|
2641
2851
|
function mapRow3(row) {
|
|
2852
|
+
const raw = row.active_llm_provider;
|
|
2642
2853
|
return {
|
|
2643
2854
|
name: row.name,
|
|
2644
2855
|
intro: row.intro,
|
|
@@ -2646,6 +2857,8 @@ function mapRow3(row) {
|
|
|
2646
2857
|
defaultModelV: row.default_model_v,
|
|
2647
2858
|
webSearchProvider: normalizeWebSearchProviderPref(row.web_search_provider),
|
|
2648
2859
|
minimaxRegion: normalizeMinimaxRegion(row.minimax_region),
|
|
2860
|
+
activeLlmProvider: raw && isLlmProvider(raw) ? raw : null,
|
|
2861
|
+
activeLlmCredentialId: row.active_llm_credential_id,
|
|
2649
2862
|
createdAt: row.created_at,
|
|
2650
2863
|
updatedAt: row.updated_at
|
|
2651
2864
|
};
|
|
@@ -2655,6 +2868,8 @@ function getPrefs() {
|
|
|
2655
2868
|
`SELECT name, intro, avatar_seed, default_model_v,
|
|
2656
2869
|
COALESCE(web_search_provider, 'brave') AS web_search_provider,
|
|
2657
2870
|
COALESCE(minimax_region, 'cn') AS minimax_region,
|
|
2871
|
+
active_llm_provider,
|
|
2872
|
+
active_llm_credential_id,
|
|
2658
2873
|
created_at, updated_at FROM prefs WHERE id = 1`
|
|
2659
2874
|
).get();
|
|
2660
2875
|
if (!row) {
|
|
@@ -2689,6 +2904,14 @@ function updatePrefs(patch) {
|
|
|
2689
2904
|
fields.push("minimax_region = ?");
|
|
2690
2905
|
values.push(patch.minimaxRegion === "intl" ? "intl" : "cn");
|
|
2691
2906
|
}
|
|
2907
|
+
if (patch.activeLlmProvider !== void 0) {
|
|
2908
|
+
fields.push("active_llm_provider = ?");
|
|
2909
|
+
values.push(patch.activeLlmProvider);
|
|
2910
|
+
}
|
|
2911
|
+
if (patch.activeLlmCredentialId !== void 0) {
|
|
2912
|
+
fields.push("active_llm_credential_id = ?");
|
|
2913
|
+
values.push(patch.activeLlmCredentialId);
|
|
2914
|
+
}
|
|
2692
2915
|
if (fields.length === 0) return getPrefs();
|
|
2693
2916
|
fields.push("updated_at = ?");
|
|
2694
2917
|
values.push(Date.now());
|
|
@@ -2696,118 +2919,6 @@ function updatePrefs(patch) {
|
|
|
2696
2919
|
return getPrefs();
|
|
2697
2920
|
}
|
|
2698
2921
|
|
|
2699
|
-
// src/storage/keys.ts
|
|
2700
|
-
var SALT = "boardroom.v1.salt";
|
|
2701
|
-
var ALGO = "aes-256-gcm";
|
|
2702
|
-
var _key = null;
|
|
2703
|
-
function deriveKey() {
|
|
2704
|
-
if (_key) return _key;
|
|
2705
|
-
const username = userInfo().username || "boardroom-default";
|
|
2706
|
-
_key = scryptSync(username, SALT, 32);
|
|
2707
|
-
return _key;
|
|
2708
|
-
}
|
|
2709
|
-
function encrypt(plain) {
|
|
2710
|
-
const iv = randomBytes(12);
|
|
2711
|
-
const cipher = createCipheriv(ALGO, deriveKey(), iv);
|
|
2712
|
-
const ct = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
|
|
2713
|
-
const tag = cipher.getAuthTag();
|
|
2714
|
-
return Buffer.concat([iv, tag, ct]);
|
|
2715
|
-
}
|
|
2716
|
-
function decrypt(blob) {
|
|
2717
|
-
const iv = blob.subarray(0, 12);
|
|
2718
|
-
const tag = blob.subarray(12, 28);
|
|
2719
|
-
const ct = blob.subarray(28);
|
|
2720
|
-
const decipher = createDecipheriv(ALGO, deriveKey(), iv);
|
|
2721
|
-
decipher.setAuthTag(tag);
|
|
2722
|
-
return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8");
|
|
2723
|
-
}
|
|
2724
|
-
function hasBraveKey() {
|
|
2725
|
-
const k = getKey("brave");
|
|
2726
|
-
return typeof k === "string" && k.length > 0;
|
|
2727
|
-
}
|
|
2728
|
-
function hasTavilyKey() {
|
|
2729
|
-
const k = getKey("tavily");
|
|
2730
|
-
return typeof k === "string" && k.length > 0;
|
|
2731
|
-
}
|
|
2732
|
-
function hasWebSearchKey() {
|
|
2733
|
-
return hasBraveKey() || hasTavilyKey();
|
|
2734
|
-
}
|
|
2735
|
-
function resolveWebSearchBackend(preference) {
|
|
2736
|
-
const b = hasBraveKey();
|
|
2737
|
-
const t = hasTavilyKey();
|
|
2738
|
-
if (!b && !t) return null;
|
|
2739
|
-
if (b && !t) return "brave";
|
|
2740
|
-
if (!b && t) return "tavily";
|
|
2741
|
-
if (preference === "tavily" && t) return "tavily";
|
|
2742
|
-
if (preference === "brave" && b) return "brave";
|
|
2743
|
-
return b ? "brave" : "tavily";
|
|
2744
|
-
}
|
|
2745
|
-
function getActiveWebSearchCredentials() {
|
|
2746
|
-
const prefRaw = getPrefs().webSearchProvider;
|
|
2747
|
-
const preference = prefRaw === "tavily" ? "tavily" : "brave";
|
|
2748
|
-
const backend = resolveWebSearchBackend(preference);
|
|
2749
|
-
if (!backend) return null;
|
|
2750
|
-
const apiKey = getKey(backend);
|
|
2751
|
-
return apiKey ? { backend, apiKey } : null;
|
|
2752
|
-
}
|
|
2753
|
-
function maskKey(plain) {
|
|
2754
|
-
const trimmed = plain.trim();
|
|
2755
|
-
if (!trimmed) return "";
|
|
2756
|
-
const n = trimmed.length;
|
|
2757
|
-
if (n <= 4) return "\u2022".repeat(n);
|
|
2758
|
-
if (n <= 12) {
|
|
2759
|
-
return `${trimmed.slice(0, 2)}${"\u2022".repeat(n - 4)}${trimmed.slice(-2)}`;
|
|
2760
|
-
}
|
|
2761
|
-
return `${trimmed.slice(0, 4)}${"\u2022".repeat(n - 8)}${trimmed.slice(-4)}`;
|
|
2762
|
-
}
|
|
2763
|
-
function listKeyMeta() {
|
|
2764
|
-
const rows = getDb().prepare("SELECT provider, key_blob, updated_at FROM provider_keys").all();
|
|
2765
|
-
const map = /* @__PURE__ */ new Map();
|
|
2766
|
-
for (const r of rows) {
|
|
2767
|
-
let preview = null;
|
|
2768
|
-
if (r.key_blob.length > 0) {
|
|
2769
|
-
try {
|
|
2770
|
-
preview = maskKey(decrypt(r.key_blob));
|
|
2771
|
-
} catch {
|
|
2772
|
-
preview = null;
|
|
2773
|
-
}
|
|
2774
|
-
}
|
|
2775
|
-
map.set(r.provider, {
|
|
2776
|
-
provider: r.provider,
|
|
2777
|
-
configured: r.key_blob.length > 0,
|
|
2778
|
-
updatedAt: r.updated_at,
|
|
2779
|
-
preview
|
|
2780
|
-
});
|
|
2781
|
-
}
|
|
2782
|
-
return Array.from(map.values());
|
|
2783
|
-
}
|
|
2784
|
-
function getKey(provider) {
|
|
2785
|
-
const row = getDb().prepare("SELECT key_blob FROM provider_keys WHERE provider = ?").get(provider);
|
|
2786
|
-
if (!row) return null;
|
|
2787
|
-
try {
|
|
2788
|
-
return decrypt(row.key_blob);
|
|
2789
|
-
} catch {
|
|
2790
|
-
return null;
|
|
2791
|
-
}
|
|
2792
|
-
}
|
|
2793
|
-
function setKey(provider, plain) {
|
|
2794
|
-
const trimmed = plain.trim();
|
|
2795
|
-
if (!trimmed) {
|
|
2796
|
-
deleteKey(provider);
|
|
2797
|
-
return;
|
|
2798
|
-
}
|
|
2799
|
-
const blob = encrypt(trimmed);
|
|
2800
|
-
const now = Date.now();
|
|
2801
|
-
getDb().prepare(
|
|
2802
|
-
`INSERT INTO provider_keys (provider, key_blob, created_at, updated_at)
|
|
2803
|
-
VALUES (?, ?, ?, ?)
|
|
2804
|
-
ON CONFLICT(provider) DO UPDATE SET key_blob = excluded.key_blob, updated_at = excluded.updated_at`
|
|
2805
|
-
).run(provider, blob, now, now);
|
|
2806
|
-
}
|
|
2807
|
-
function deleteKey(provider) {
|
|
2808
|
-
getDb().prepare("DELETE FROM provider_keys WHERE provider = ?").run(provider);
|
|
2809
|
-
}
|
|
2810
|
-
|
|
2811
2922
|
// src/ai/adapter.ts
|
|
2812
2923
|
var NoKeyError = class extends Error {
|
|
2813
2924
|
constructor(provider) {
|
|
@@ -2948,84 +3059,34 @@ function formatStreamError(e) {
|
|
|
2948
3059
|
}
|
|
2949
3060
|
return String(e);
|
|
2950
3061
|
}
|
|
2951
|
-
function resolveModel(modelV,
|
|
3062
|
+
function resolveModel(modelV, _carrier, _excludeCarriers) {
|
|
3063
|
+
void _carrier;
|
|
3064
|
+
void _excludeCarriers;
|
|
2952
3065
|
const meta = getModel(modelV);
|
|
2953
|
-
const
|
|
2954
|
-
|
|
2955
|
-
const
|
|
2956
|
-
const
|
|
2957
|
-
if (
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
if (carrier === "bai" && baiKey && meta.baiId) {
|
|
2963
|
-
process.stderr.write(`[adapter] modelV=${modelV} \u2192 bai:${meta.baiId} (pinned)
|
|
2964
|
-
`);
|
|
2965
|
-
return baiResolved(meta, baiKey);
|
|
2966
|
-
}
|
|
2967
|
-
if (carrier && carrier !== "openrouter" && carrier !== "bai" && carrier === meta.provider) {
|
|
2968
|
-
const pinnedKey = !skip(carrier) ? getKey(carrier) : void 0;
|
|
2969
|
-
if (pinnedKey) {
|
|
2970
|
-
process.stderr.write(`[adapter] modelV=${modelV} \u2192 direct:${meta.provider}/${meta.directApiId} (pinned)
|
|
2971
|
-
`);
|
|
2972
|
-
return directResolved(meta, pinnedKey);
|
|
2973
|
-
}
|
|
2974
|
-
}
|
|
2975
|
-
if (carrier && !excludeCarriers?.size) {
|
|
2976
|
-
process.stderr.write(
|
|
2977
|
-
`[adapter] modelV=${modelV} pinned carrier=${carrier} unreachable; falling back to default routing
|
|
2978
|
-
`
|
|
2979
|
-
);
|
|
2980
|
-
}
|
|
2981
|
-
if (meta.viaUniversalOnly && orKey) {
|
|
2982
|
-
process.stderr.write(`[adapter] modelV=${modelV} \u2192 openrouter:${meta.openrouterId} (preferred)
|
|
2983
|
-
`);
|
|
2984
|
-
return openRouterResolved(meta, orKey);
|
|
2985
|
-
}
|
|
2986
|
-
if (directKey && !meta.viaUniversalOnly) {
|
|
2987
|
-
process.stderr.write(`[adapter] modelV=${modelV} \u2192 direct:${meta.provider}/${meta.directApiId}
|
|
3066
|
+
const credId = getPrefs().activeLlmCredentialId;
|
|
3067
|
+
if (!credId) throw new NoKeyError(meta.provider);
|
|
3068
|
+
const credMeta = getLlmCredentialMeta(credId);
|
|
3069
|
+
const key = getLlmCredentialKey(credId);
|
|
3070
|
+
if (!credMeta || !key) throw new NoKeyError(meta.provider);
|
|
3071
|
+
const p = credMeta.provider;
|
|
3072
|
+
if (p === "openrouter") {
|
|
3073
|
+
if (!meta.openrouterId) throw new NoKeyError(meta.provider);
|
|
3074
|
+
process.stderr.write(`[adapter] modelV=${modelV} \u2192 openrouter:${meta.openrouterId} (cred:${credMeta.label})
|
|
2988
3075
|
`);
|
|
2989
|
-
return
|
|
3076
|
+
return openRouterResolved(meta, key);
|
|
2990
3077
|
}
|
|
2991
|
-
if (
|
|
2992
|
-
|
|
3078
|
+
if (p === "bai") {
|
|
3079
|
+
if (!meta.baiId) throw new NoKeyError(meta.provider);
|
|
3080
|
+
process.stderr.write(`[adapter] modelV=${modelV} \u2192 bai:${meta.baiId} (cred:${credMeta.label})
|
|
2993
3081
|
`);
|
|
2994
|
-
return baiResolved(meta,
|
|
3082
|
+
return baiResolved(meta, key);
|
|
2995
3083
|
}
|
|
2996
|
-
if (
|
|
2997
|
-
|
|
2998
|
-
`);
|
|
2999
|
-
return openRouterResolved(meta, orKey);
|
|
3084
|
+
if (meta.provider !== p || meta.viaUniversalOnly) {
|
|
3085
|
+
throw new NoKeyError(meta.provider);
|
|
3000
3086
|
}
|
|
3001
|
-
|
|
3002
|
-
process.stderr.write(`[adapter] modelV=${modelV} \u2192 direct:${meta.provider}/${meta.directApiId} (viaUniversalOnly fallback \xB7 no OR / B.AI key)
|
|
3087
|
+
process.stderr.write(`[adapter] modelV=${modelV} \u2192 direct:${meta.provider}/${meta.directApiId} (cred:${credMeta.label})
|
|
3003
3088
|
`);
|
|
3004
|
-
|
|
3005
|
-
}
|
|
3006
|
-
throw new NoKeyError(meta.provider);
|
|
3007
|
-
}
|
|
3008
|
-
function isCarrierAccessDenied(message) {
|
|
3009
|
-
if (!message) return false;
|
|
3010
|
-
const m = message.toLowerCase();
|
|
3011
|
-
if (/\b40[23]\b/.test(m) && /(access|deposit|payment|paid|premium|subscri|quota|balance|fund)/.test(m)) return true;
|
|
3012
|
-
if (/access[_\s-]?denied/.test(m)) return true;
|
|
3013
|
-
if (/deposit\s+required/.test(m)) return true;
|
|
3014
|
-
if (/paid[_\s-]?plan[_\s-]?required/.test(m)) return true;
|
|
3015
|
-
if (/premium[_\s-]?model/.test(m)) return true;
|
|
3016
|
-
if (/insufficient[_\s-]?(?:quota|balance|fund)/.test(m)) return true;
|
|
3017
|
-
if (/quota[_\s-]?exceeded/.test(m)) return true;
|
|
3018
|
-
if (/payment[_\s-]?required/.test(m)) return true;
|
|
3019
|
-
if (/billing/.test(m) && /(disabled|inactive|required|invalid|missing)/.test(m)) return true;
|
|
3020
|
-
if (/model[_\s-]?not[_\s-]?found/.test(m)) return true;
|
|
3021
|
-
if (/no\s+available\s+channel/.test(m)) return true;
|
|
3022
|
-
if (/no\s+endpoints?\s+found/.test(m)) return true;
|
|
3023
|
-
if (/invalid\s+model/.test(m)) return true;
|
|
3024
|
-
if (/model.*(unavailable|not\s+(?:supported|available))/.test(m)) return true;
|
|
3025
|
-
if (/prohibited.*(?:terms?\s+of\s+service|provider)/.test(m)) return true;
|
|
3026
|
-
if (/provider.*terms?\s+of\s+service/.test(m)) return true;
|
|
3027
|
-
if (/violates?\s+(?:our|the)?\s*(?:terms|policy|content)/.test(m)) return true;
|
|
3028
|
-
return false;
|
|
3089
|
+
return directResolved(meta, key);
|
|
3029
3090
|
}
|
|
3030
3091
|
function directResolved(meta, apiKey) {
|
|
3031
3092
|
switch (meta.provider) {
|
|
@@ -3157,8 +3218,6 @@ async function* callLLMStream(req) {
|
|
|
3157
3218
|
let attempt = 0;
|
|
3158
3219
|
let lastTransientMessage = "";
|
|
3159
3220
|
let yieldedText = false;
|
|
3160
|
-
const triedCarriers = /* @__PURE__ */ new Set();
|
|
3161
|
-
triedCarriers.add(resolved.carrier);
|
|
3162
3221
|
while (attempt < RETRY_MAX_ATTEMPTS) {
|
|
3163
3222
|
attempt++;
|
|
3164
3223
|
if (req.signal?.aborted) {
|
|
@@ -3200,21 +3259,6 @@ async function* callLLMStream(req) {
|
|
|
3200
3259
|
retriableErrorMessage = msg;
|
|
3201
3260
|
break;
|
|
3202
3261
|
}
|
|
3203
|
-
if (!yieldedText && isCarrierAccessDenied(msg)) {
|
|
3204
|
-
try {
|
|
3205
|
-
const next = resolveModel(req.modelV, null, triedCarriers);
|
|
3206
|
-
triedCarriers.add(next.carrier);
|
|
3207
|
-
process.stderr.write(
|
|
3208
|
-
`[adapter] modelV=${req.modelV} carrier=${resolved.carrier} rejected (access denied); retrying via ${next.carrier}
|
|
3209
|
-
`
|
|
3210
|
-
);
|
|
3211
|
-
resolved = next;
|
|
3212
|
-
attempt = 0;
|
|
3213
|
-
retriableErrorMessage = msg;
|
|
3214
|
-
break;
|
|
3215
|
-
} catch {
|
|
3216
|
-
}
|
|
3217
|
-
}
|
|
3218
3262
|
sawError = true;
|
|
3219
3263
|
yield { type: "error", message: msg };
|
|
3220
3264
|
}
|
|
@@ -3257,20 +3301,6 @@ async function* callLLMStream(req) {
|
|
|
3257
3301
|
lastTransientMessage = msg;
|
|
3258
3302
|
continue;
|
|
3259
3303
|
}
|
|
3260
|
-
if (!yieldedText && isCarrierAccessDenied(msg)) {
|
|
3261
|
-
try {
|
|
3262
|
-
const next = resolveModel(req.modelV, null, triedCarriers);
|
|
3263
|
-
triedCarriers.add(next.carrier);
|
|
3264
|
-
process.stderr.write(
|
|
3265
|
-
`[adapter] modelV=${req.modelV} carrier=${resolved.carrier} rejected (access denied / threw); retrying via ${next.carrier}
|
|
3266
|
-
`
|
|
3267
|
-
);
|
|
3268
|
-
resolved = next;
|
|
3269
|
-
attempt = 0;
|
|
3270
|
-
continue;
|
|
3271
|
-
} catch {
|
|
3272
|
-
}
|
|
3273
|
-
}
|
|
3274
3304
|
yield { type: "error", message: msg };
|
|
3275
3305
|
return;
|
|
3276
3306
|
}
|
|
@@ -3307,68 +3337,147 @@ async function callLLMWithUsage(req) {
|
|
|
3307
3337
|
return { text: buf, usage };
|
|
3308
3338
|
}
|
|
3309
3339
|
|
|
3310
|
-
// src/storage/
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3340
|
+
// src/storage/keys.ts
|
|
3341
|
+
init_db();
|
|
3342
|
+
import { createCipheriv as createCipheriv2, createDecipheriv as createDecipheriv2, randomBytes as randomBytes2, scryptSync as scryptSync2 } from "crypto";
|
|
3343
|
+
import { userInfo as userInfo2 } from "os";
|
|
3344
|
+
var SALT2 = "boardroom.v1.salt";
|
|
3345
|
+
var ALGO2 = "aes-256-gcm";
|
|
3346
|
+
var _key2 = null;
|
|
3347
|
+
function deriveKey2() {
|
|
3348
|
+
if (_key2) return _key2;
|
|
3349
|
+
const username = userInfo2().username || "boardroom-default";
|
|
3350
|
+
_key2 = scryptSync2(username, SALT2, 32);
|
|
3351
|
+
return _key2;
|
|
3352
|
+
}
|
|
3353
|
+
function encrypt2(plain) {
|
|
3354
|
+
const iv = randomBytes2(12);
|
|
3355
|
+
const cipher = createCipheriv2(ALGO2, deriveKey2(), iv);
|
|
3356
|
+
const ct = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
|
|
3357
|
+
const tag = cipher.getAuthTag();
|
|
3358
|
+
return Buffer.concat([iv, tag, ct]);
|
|
3359
|
+
}
|
|
3360
|
+
function decrypt2(blob) {
|
|
3361
|
+
const iv = blob.subarray(0, 12);
|
|
3362
|
+
const tag = blob.subarray(12, 28);
|
|
3363
|
+
const ct = blob.subarray(28);
|
|
3364
|
+
const decipher = createDecipheriv2(ALGO2, deriveKey2(), iv);
|
|
3365
|
+
decipher.setAuthTag(tag);
|
|
3366
|
+
return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8");
|
|
3367
|
+
}
|
|
3368
|
+
function hasBraveKey() {
|
|
3369
|
+
const k = getKey("brave");
|
|
3370
|
+
return typeof k === "string" && k.length > 0;
|
|
3371
|
+
}
|
|
3372
|
+
function hasTavilyKey() {
|
|
3373
|
+
const k = getKey("tavily");
|
|
3374
|
+
return typeof k === "string" && k.length > 0;
|
|
3375
|
+
}
|
|
3376
|
+
function hasWebSearchKey() {
|
|
3377
|
+
return hasBraveKey() || hasTavilyKey();
|
|
3378
|
+
}
|
|
3379
|
+
function resolveWebSearchBackend(preference) {
|
|
3380
|
+
const b = hasBraveKey();
|
|
3381
|
+
const t = hasTavilyKey();
|
|
3382
|
+
if (!b && !t) return null;
|
|
3383
|
+
if (b && !t) return "brave";
|
|
3384
|
+
if (!b && t) return "tavily";
|
|
3385
|
+
if (preference === "tavily" && t) return "tavily";
|
|
3386
|
+
if (preference === "brave" && b) return "brave";
|
|
3387
|
+
return b ? "brave" : "tavily";
|
|
3388
|
+
}
|
|
3389
|
+
function getActiveWebSearchCredentials() {
|
|
3390
|
+
const prefRaw = getPrefs().webSearchProvider;
|
|
3391
|
+
const preference = prefRaw === "tavily" ? "tavily" : "brave";
|
|
3392
|
+
const backend = resolveWebSearchBackend(preference);
|
|
3393
|
+
if (!backend) return null;
|
|
3394
|
+
const apiKey = getKey(backend);
|
|
3395
|
+
return apiKey ? { backend, apiKey } : null;
|
|
3396
|
+
}
|
|
3397
|
+
function maskKey2(plain) {
|
|
3398
|
+
const trimmed = plain.trim();
|
|
3399
|
+
if (!trimmed) return "";
|
|
3400
|
+
const n = trimmed.length;
|
|
3401
|
+
if (n <= 4) return "\u2022".repeat(n);
|
|
3402
|
+
if (n <= 12) {
|
|
3403
|
+
return `${trimmed.slice(0, 2)}${"\u2022".repeat(n - 4)}${trimmed.slice(-2)}`;
|
|
3404
|
+
}
|
|
3405
|
+
return `${trimmed.slice(0, 4)}${"\u2022".repeat(n - 8)}${trimmed.slice(-4)}`;
|
|
3406
|
+
}
|
|
3407
|
+
function listKeyMeta() {
|
|
3408
|
+
const rows = getDb().prepare("SELECT provider, key_blob, updated_at FROM provider_keys").all();
|
|
3409
|
+
const map = /* @__PURE__ */ new Map();
|
|
3410
|
+
for (const r of rows) {
|
|
3411
|
+
let preview = null;
|
|
3412
|
+
if (r.key_blob.length > 0) {
|
|
3413
|
+
try {
|
|
3414
|
+
preview = maskKey2(decrypt2(r.key_blob));
|
|
3415
|
+
} catch {
|
|
3416
|
+
preview = null;
|
|
3417
|
+
}
|
|
3418
|
+
}
|
|
3419
|
+
map.set(r.provider, {
|
|
3420
|
+
provider: r.provider,
|
|
3421
|
+
configured: r.key_blob.length > 0,
|
|
3422
|
+
updatedAt: r.updated_at,
|
|
3423
|
+
preview
|
|
3424
|
+
});
|
|
3425
|
+
}
|
|
3426
|
+
return Array.from(map.values());
|
|
3427
|
+
}
|
|
3428
|
+
function getKey(provider) {
|
|
3429
|
+
const row = getDb().prepare("SELECT key_blob FROM provider_keys WHERE provider = ?").get(provider);
|
|
3430
|
+
if (!row) return null;
|
|
3431
|
+
try {
|
|
3432
|
+
return decrypt2(row.key_blob);
|
|
3433
|
+
} catch {
|
|
3434
|
+
return null;
|
|
3435
|
+
}
|
|
3436
|
+
}
|
|
3437
|
+
function setKey(provider, plain) {
|
|
3438
|
+
const trimmed = plain.trim();
|
|
3439
|
+
if (!trimmed) {
|
|
3440
|
+
deleteKey(provider);
|
|
3441
|
+
return;
|
|
3442
|
+
}
|
|
3443
|
+
const blob = encrypt2(trimmed);
|
|
3444
|
+
const now = Date.now();
|
|
3445
|
+
getDb().prepare(
|
|
3446
|
+
`INSERT INTO provider_keys (provider, key_blob, created_at, updated_at)
|
|
3447
|
+
VALUES (?, ?, ?, ?)
|
|
3448
|
+
ON CONFLICT(provider) DO UPDATE SET key_blob = excluded.key_blob, updated_at = excluded.updated_at`
|
|
3449
|
+
).run(provider, blob, now, now);
|
|
3450
|
+
}
|
|
3451
|
+
function deleteKey(provider) {
|
|
3452
|
+
getDb().prepare("DELETE FROM provider_keys WHERE provider = ?").run(provider);
|
|
3453
|
+
}
|
|
3454
|
+
|
|
3455
|
+
// src/storage/reconcile-models.ts
|
|
3456
|
+
var PRIMARY_BY_CARRIER = {
|
|
3457
|
+
openrouter: "opus-4-6-fast",
|
|
3458
|
+
bai: "haiku-4-5",
|
|
3314
3459
|
anthropic: "haiku-4-5",
|
|
3315
3460
|
openai: "gpt-5-4-mini",
|
|
3316
3461
|
google: "gemini-3-1-flash"
|
|
3317
|
-
// xai · no primary (no LLM modelV in registry as of 2026-05-17).
|
|
3318
|
-
// adapter / availability layer skip xai when this key is absent.
|
|
3462
|
+
// xai · no primary (no LLM modelV in registry as of 2026-05-17).
|
|
3319
3463
|
};
|
|
3320
|
-
var CARRIER_PRIORITY = ["openrouter", "bai", "anthropic", "openai", "google", "xai"];
|
|
3321
3464
|
function reachableModelVs() {
|
|
3322
3465
|
const out = /* @__PURE__ */ new Set();
|
|
3323
|
-
const
|
|
3324
|
-
|
|
3466
|
+
const p = getProviderKeyState().activeLlmProvider;
|
|
3467
|
+
if (!p) return out;
|
|
3325
3468
|
for (const [v, meta] of Object.entries(MODELS)) {
|
|
3326
|
-
if (
|
|
3327
|
-
out.add(v);
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
out.add(v);
|
|
3332
|
-
continue;
|
|
3333
|
-
}
|
|
3334
|
-
if (!meta.viaUniversalOnly && hasDirectKey(meta.provider)) {
|
|
3335
|
-
out.add(v);
|
|
3336
|
-
continue;
|
|
3337
|
-
}
|
|
3338
|
-
if (meta.viaUniversalOnly && hasDirectKey(meta.provider)) {
|
|
3339
|
-
out.add(v);
|
|
3469
|
+
if (p === "openrouter") {
|
|
3470
|
+
if (meta.openrouterId) out.add(v);
|
|
3471
|
+
} else if (p === "bai") {
|
|
3472
|
+
if (meta.baiId) out.add(v);
|
|
3473
|
+
} else {
|
|
3474
|
+
if (meta.provider === p && !meta.viaUniversalOnly) out.add(v);
|
|
3340
3475
|
}
|
|
3341
3476
|
}
|
|
3342
3477
|
return out;
|
|
3343
3478
|
}
|
|
3344
|
-
function hasDirectKey(provider) {
|
|
3345
|
-
switch (provider) {
|
|
3346
|
-
case "anthropic":
|
|
3347
|
-
case "openai":
|
|
3348
|
-
case "google":
|
|
3349
|
-
case "xai":
|
|
3350
|
-
return !!getKey(provider);
|
|
3351
|
-
default:
|
|
3352
|
-
return false;
|
|
3353
|
-
}
|
|
3354
|
-
}
|
|
3355
3479
|
function activeCarrier() {
|
|
3356
|
-
|
|
3357
|
-
if (prefs.defaultModelV) {
|
|
3358
|
-
const meta = MODELS[prefs.defaultModelV];
|
|
3359
|
-
if (meta) {
|
|
3360
|
-
if (meta.viaUniversalOnly && getKey("openrouter")) return "openrouter";
|
|
3361
|
-
if (hasDirectKey(meta.provider)) return meta.provider;
|
|
3362
|
-
if (getKey("bai") && meta.baiId) return "bai";
|
|
3363
|
-
if (getKey("openrouter")) return "openrouter";
|
|
3364
|
-
}
|
|
3365
|
-
}
|
|
3366
|
-
for (const c of CARRIER_PRIORITY) {
|
|
3367
|
-
if (c === "openrouter" && getKey("openrouter")) return "openrouter";
|
|
3368
|
-
if (c === "bai" && getKey("bai")) return "bai";
|
|
3369
|
-
if (c !== "openrouter" && c !== "bai" && hasDirectKey(c)) return c;
|
|
3370
|
-
}
|
|
3371
|
-
return null;
|
|
3480
|
+
return getProviderKeyState().activeLlmProvider;
|
|
3372
3481
|
}
|
|
3373
3482
|
function reconcileAgentModels(opts = {}) {
|
|
3374
3483
|
const reachable = reachableModelVs();
|
|
@@ -3377,16 +3486,9 @@ function reconcileAgentModels(opts = {}) {
|
|
|
3377
3486
|
const forcePrimary = opts.forcePrimary === true;
|
|
3378
3487
|
const switched = [];
|
|
3379
3488
|
const cleared = [];
|
|
3380
|
-
const orReachable = !!getKey("openrouter");
|
|
3381
|
-
const baiReachable = !!getKey("bai");
|
|
3382
|
-
function carrierKeyReachable(c) {
|
|
3383
|
-
if (c === "openrouter") return orReachable;
|
|
3384
|
-
if (c === "bai") return baiReachable;
|
|
3385
|
-
return hasDirectKey(c);
|
|
3386
|
-
}
|
|
3387
3489
|
for (const agent of listAllAgents()) {
|
|
3388
3490
|
const v = (agent.modelV || "").trim();
|
|
3389
|
-
if (agent.carrierPref
|
|
3491
|
+
if (agent.carrierPref) {
|
|
3390
3492
|
updateAgent(agent.id, { carrierPref: null });
|
|
3391
3493
|
}
|
|
3392
3494
|
if (!forcePrimary && v && reachable.has(v)) continue;
|
|
@@ -3402,6 +3504,7 @@ function reconcileAgentModels(opts = {}) {
|
|
|
3402
3504
|
cleared.push(agent.id);
|
|
3403
3505
|
}
|
|
3404
3506
|
}
|
|
3507
|
+
void isMultiModelProvider;
|
|
3405
3508
|
const prefs = getPrefs();
|
|
3406
3509
|
const currentReachable = !!prefs.defaultModelV && reachable.has(prefs.defaultModelV);
|
|
3407
3510
|
const shouldBump = forcePrimary || !prefs.defaultModelV || !currentReachable;
|
|
@@ -3415,31 +3518,36 @@ function reconcileAgentModels(opts = {}) {
|
|
|
3415
3518
|
|
|
3416
3519
|
// src/ai/availability.ts
|
|
3417
3520
|
function getProviderKeyState() {
|
|
3418
|
-
const
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3521
|
+
const credId = getPrefs().activeLlmCredentialId;
|
|
3522
|
+
if (credId) {
|
|
3523
|
+
const meta = getLlmCredentialMeta(credId);
|
|
3524
|
+
if (meta) return { activeLlmProvider: meta.provider, hasAnyLlmKey: true };
|
|
3525
|
+
}
|
|
3526
|
+
return { activeLlmProvider: null, hasAnyLlmKey: false };
|
|
3527
|
+
}
|
|
3528
|
+
function routeFor(p) {
|
|
3529
|
+
if (!p) return null;
|
|
3530
|
+
if (p === "openrouter") return "openrouter";
|
|
3531
|
+
if (p === "bai") return "bai";
|
|
3532
|
+
return "direct";
|
|
3429
3533
|
}
|
|
3430
3534
|
function availabilityFor(meta, keys) {
|
|
3431
|
-
const
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3535
|
+
const p = keys.activeLlmProvider;
|
|
3536
|
+
let reachable = false;
|
|
3537
|
+
if (p === "openrouter") {
|
|
3538
|
+
reachable = !!meta.openrouterId;
|
|
3539
|
+
} else if (p === "bai") {
|
|
3540
|
+
reachable = !!meta.baiId;
|
|
3541
|
+
} else if (p) {
|
|
3542
|
+
reachable = meta.provider === p && !meta.viaUniversalOnly;
|
|
3543
|
+
}
|
|
3435
3544
|
return {
|
|
3436
3545
|
modelV: meta.v,
|
|
3437
3546
|
displayName: meta.displayName,
|
|
3438
3547
|
provider: meta.provider,
|
|
3439
3548
|
deck: meta.deck,
|
|
3440
|
-
routes: { direct: directReachable, openrouter: orReachable, bai: baiReachable },
|
|
3441
3549
|
reachable,
|
|
3442
|
-
preferredRoute:
|
|
3550
|
+
preferredRoute: reachable ? routeFor(p) : null
|
|
3443
3551
|
};
|
|
3444
3552
|
}
|
|
3445
3553
|
function modelAvailability() {
|
|
@@ -3450,8 +3558,7 @@ function reachableModels() {
|
|
|
3450
3558
|
return modelAvailability().filter((m) => m.reachable);
|
|
3451
3559
|
}
|
|
3452
3560
|
function hasAnyModelKey() {
|
|
3453
|
-
|
|
3454
|
-
return keys.hasOpenRouter || keys.hasBai || keys.directProviders.size > 0;
|
|
3561
|
+
return getProviderKeyState().hasAnyLlmKey;
|
|
3455
3562
|
}
|
|
3456
3563
|
var PROVIDER_FLAGSHIP = {
|
|
3457
3564
|
anthropic: "opus-4-7",
|
|
@@ -3563,36 +3670,15 @@ function effectiveDefaultModel() {
|
|
|
3563
3670
|
return fresh;
|
|
3564
3671
|
}
|
|
3565
3672
|
function defaultModelFor(keys = getProviderKeyState()) {
|
|
3566
|
-
const
|
|
3567
|
-
if (
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
if (keys.hasOpenRouter) {
|
|
3576
|
-
const fast = reachable.find((m) => m.modelV === "opus-4-6-fast");
|
|
3577
|
-
if (fast) return fast.modelV;
|
|
3578
|
-
const opus = reachable.find((m) => m.modelV === "opus-4-7");
|
|
3579
|
-
if (opus) return opus.modelV;
|
|
3580
|
-
}
|
|
3581
|
-
if (keys.hasBai) {
|
|
3582
|
-
const fast = reachable.find((m) => m.modelV === "haiku-4-5");
|
|
3583
|
-
if (fast) return fast.modelV;
|
|
3584
|
-
const opus = reachable.find((m) => m.modelV === "opus-4-7");
|
|
3585
|
-
if (opus) return opus.modelV;
|
|
3586
|
-
}
|
|
3587
|
-
for (const provider of keys.directProviders) {
|
|
3588
|
-
const fast = PROVIDER_FAST[provider];
|
|
3589
|
-
if (fast && reachable.find((m) => m.modelV === fast)) return fast;
|
|
3590
|
-
}
|
|
3591
|
-
for (const provider of keys.directProviders) {
|
|
3592
|
-
const flagship = PROVIDER_FLAGSHIP[provider];
|
|
3593
|
-
if (flagship && reachable.find((m) => m.modelV === flagship)) return flagship;
|
|
3594
|
-
}
|
|
3595
|
-
return reachable[0].modelV;
|
|
3673
|
+
const p = keys.activeLlmProvider;
|
|
3674
|
+
if (!p) return null;
|
|
3675
|
+
const reachable = new Set(reachableModels().map((m) => m.modelV));
|
|
3676
|
+
if (reachable.size === 0) return null;
|
|
3677
|
+
const fast = PROVIDER_FAST[p];
|
|
3678
|
+
if (fast && reachable.has(fast)) return fast;
|
|
3679
|
+
const flagship = PROVIDER_FLAGSHIP[p];
|
|
3680
|
+
if (flagship && reachable.has(flagship)) return flagship;
|
|
3681
|
+
return reachableModels()[0]?.modelV ?? null;
|
|
3596
3682
|
}
|
|
3597
3683
|
var CHEAP_BY_CARRIER = {
|
|
3598
3684
|
openrouter: "haiku-4-5",
|
|
@@ -3613,11 +3699,6 @@ var UTILITY_PREFERENCE = [
|
|
|
3613
3699
|
];
|
|
3614
3700
|
function utilityModelFor(fallback = null) {
|
|
3615
3701
|
const reachable = new Set(reachableModels().map((m) => m.modelV));
|
|
3616
|
-
const prefs = getPrefs();
|
|
3617
|
-
const userDefault = prefs.defaultModelV;
|
|
3618
|
-
if (userDefault && UTILITY_PREFERENCE.includes(userDefault) && reachable.has(userDefault)) {
|
|
3619
|
-
return userDefault;
|
|
3620
|
-
}
|
|
3621
3702
|
const carrier = activeCarrier();
|
|
3622
3703
|
if (carrier) {
|
|
3623
3704
|
const preferred = CHEAP_BY_CARRIER[carrier];
|
|
@@ -5117,6 +5198,97 @@ function getPartialPersona(jobId) {
|
|
|
5117
5198
|
};
|
|
5118
5199
|
}
|
|
5119
5200
|
|
|
5201
|
+
// src/orchestrator/celebrity-seed.ts
|
|
5202
|
+
var SLUG_RE = /^[a-z][a-z0-9-]{1,40}$/;
|
|
5203
|
+
function parseSeed(raw) {
|
|
5204
|
+
let s = raw.trim();
|
|
5205
|
+
if (s.startsWith("```")) {
|
|
5206
|
+
s = s.replace(/^```[a-zA-Z]*\s*/, "").replace(/```\s*$/, "").trim();
|
|
5207
|
+
}
|
|
5208
|
+
let j;
|
|
5209
|
+
try {
|
|
5210
|
+
j = JSON.parse(s);
|
|
5211
|
+
} catch {
|
|
5212
|
+
return null;
|
|
5213
|
+
}
|
|
5214
|
+
if (!j || typeof j !== "object") return null;
|
|
5215
|
+
const o = j;
|
|
5216
|
+
const id = typeof o.id === "string" ? o.id.trim().toLowerCase() : "";
|
|
5217
|
+
const name = typeof o.name === "string" ? o.name.trim() : "";
|
|
5218
|
+
const roleTag = typeof o.roleTag === "string" ? o.roleTag.trim().toLowerCase() : "";
|
|
5219
|
+
const description = typeof o.description === "string" ? o.description.trim() : "";
|
|
5220
|
+
const introRaw = o.intro && typeof o.intro === "object" ? o.intro : {};
|
|
5221
|
+
const introEn = typeof introRaw.en === "string" ? introRaw.en.trim() : "";
|
|
5222
|
+
const introZh = typeof introRaw.zh === "string" ? introRaw.zh.trim() : "";
|
|
5223
|
+
if (!SLUG_RE.test(id)) return null;
|
|
5224
|
+
if (name.length < 2 || name.length > 80) return null;
|
|
5225
|
+
if (roleTag.length < 2 || roleTag.length > 24) return null;
|
|
5226
|
+
if (description.length < 60 || description.length > 1200) return null;
|
|
5227
|
+
if (introEn.length < 8 || introEn.length > 200) return null;
|
|
5228
|
+
if (introZh.length < 4 || introZh.length > 200) return null;
|
|
5229
|
+
return {
|
|
5230
|
+
id,
|
|
5231
|
+
name,
|
|
5232
|
+
roleTag,
|
|
5233
|
+
intro: { en: introEn, zh: introZh },
|
|
5234
|
+
description
|
|
5235
|
+
};
|
|
5236
|
+
}
|
|
5237
|
+
function buildPrompt(opts) {
|
|
5238
|
+
const excludeBlock = opts.excludeIds.length === 0 ? "(no exclusions)" : opts.excludeIds.slice(0, 200).map((id) => `\xB7 ${id}`).join("\n");
|
|
5239
|
+
const novelty = opts.emphasizeNovelty ? "CRITICAL: do NOT repeat any id from the exclusion list. Re-read it before answering. Pick a different person." : "";
|
|
5240
|
+
return [
|
|
5241
|
+
"You are inventing ONE famous-figure preset card for an app that lets users 'hire' historical or contemporary thinkers as AI directors.",
|
|
5242
|
+
"",
|
|
5243
|
+
"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).",
|
|
5244
|
+
"",
|
|
5245
|
+
"Output STRICT JSON with exactly these fields, nothing else (no prose before or after, no code fences):",
|
|
5246
|
+
"{",
|
|
5247
|
+
` "id": "kebab-case-slug", // lowercase, 2-40 chars, letters/digits/hyphen, starts with a letter`,
|
|
5248
|
+
` "name": "Display Name", // verbatim \xB7 keep their real name; CJK names stay CJK`,
|
|
5249
|
+
` "roleTag": "founder", // one short English noun \xB7 examples: founder | philosopher | physicist | essayist | investor | architect | mathematician | poet | director | dissident`,
|
|
5250
|
+
` "intro": {`,
|
|
5251
|
+
` "en": "one-line tagline \xB7 8 to 30 words \xB7 captures the lens this person brings",`,
|
|
5252
|
+
` "zh": "\u4E2D\u6587 8 \u5230 30 \u5B57 \xB7 \u4E0E en \u540C\u4E3B\u65E8"`,
|
|
5253
|
+
` },`,
|
|
5254
|
+
` "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."`,
|
|
5255
|
+
"}",
|
|
5256
|
+
"",
|
|
5257
|
+
"Avoid these ids (already in the pool):",
|
|
5258
|
+
excludeBlock,
|
|
5259
|
+
"",
|
|
5260
|
+
novelty
|
|
5261
|
+
].filter(Boolean).join("\n");
|
|
5262
|
+
}
|
|
5263
|
+
async function generateCelebritySeed(opts) {
|
|
5264
|
+
const modelV = utilityModelFor();
|
|
5265
|
+
if (!modelV) throw new Error("no utility model available");
|
|
5266
|
+
const excludeSet = new Set(opts.excludeIds);
|
|
5267
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
5268
|
+
const prompt = buildPrompt({
|
|
5269
|
+
excludeIds: opts.excludeIds,
|
|
5270
|
+
emphasizeNovelty: attempt > 0
|
|
5271
|
+
});
|
|
5272
|
+
let raw;
|
|
5273
|
+
try {
|
|
5274
|
+
raw = await callLLM({
|
|
5275
|
+
modelV,
|
|
5276
|
+
messages: [{ role: "user", content: prompt }],
|
|
5277
|
+
temperature: 0.85,
|
|
5278
|
+
maxTokens: 900
|
|
5279
|
+
});
|
|
5280
|
+
} catch (e) {
|
|
5281
|
+
if (attempt === 1) throw e;
|
|
5282
|
+
continue;
|
|
5283
|
+
}
|
|
5284
|
+
const parsed = parseSeed(raw);
|
|
5285
|
+
if (!parsed) continue;
|
|
5286
|
+
if (excludeSet.has(parsed.id)) continue;
|
|
5287
|
+
return parsed;
|
|
5288
|
+
}
|
|
5289
|
+
throw new Error("celebrity-seed \xB7 model failed to produce a valid novel entry");
|
|
5290
|
+
}
|
|
5291
|
+
|
|
5120
5292
|
// src/ai/prompts/persona-render.ts
|
|
5121
5293
|
var INSTRUCTION_MAX = 6e3;
|
|
5122
5294
|
function synthesizePersonaInstruction(spec, meta) {
|
|
@@ -5361,12 +5533,12 @@ function renderRules(lines, rules) {
|
|
|
5361
5533
|
init_db();
|
|
5362
5534
|
|
|
5363
5535
|
// src/utils/id.ts
|
|
5364
|
-
import { randomBytes as
|
|
5536
|
+
import { randomBytes as randomBytes3 } from "crypto";
|
|
5365
5537
|
var ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
|
|
5366
5538
|
var ALPHABET_LEN = ALPHABET.length;
|
|
5367
5539
|
var MASK = (1 << 5) - 1;
|
|
5368
5540
|
function newId(len = 12) {
|
|
5369
|
-
const bytes =
|
|
5541
|
+
const bytes = randomBytes3(len);
|
|
5370
5542
|
let out = "";
|
|
5371
5543
|
for (let i = 0; i < len; i++) {
|
|
5372
5544
|
out += ALPHABET[bytes[i] & MASK];
|
|
@@ -5737,7 +5909,7 @@ var TIPS_MAX_COUNT = 8;
|
|
|
5737
5909
|
var BODY_MAX_BYTES = 32 * 1024;
|
|
5738
5910
|
var ABILITY_MIN = -3;
|
|
5739
5911
|
var ABILITY_MAX = 3;
|
|
5740
|
-
var
|
|
5912
|
+
var SLUG_RE2 = /^[a-z0-9][a-z0-9-]*$/;
|
|
5741
5913
|
function slugifyName(name) {
|
|
5742
5914
|
return name.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, SLUG_MAX);
|
|
5743
5915
|
}
|
|
@@ -5790,7 +5962,7 @@ function parseSkillMd(src) {
|
|
|
5790
5962
|
}
|
|
5791
5963
|
slug = raw.slug.trim();
|
|
5792
5964
|
if (slug.length > SLUG_MAX) return err(`\`slug\` too long (max ${SLUG_MAX})`);
|
|
5793
|
-
if (!
|
|
5965
|
+
if (!SLUG_RE2.test(slug)) {
|
|
5794
5966
|
return err("`slug` must be lowercase letters, digits, and hyphens (start with letter/digit)");
|
|
5795
5967
|
}
|
|
5796
5968
|
}
|
|
@@ -6857,6 +7029,23 @@ function agentsRouter() {
|
|
|
6857
7029
|
const jobId = startPersonaBuild({ description, locale });
|
|
6858
7030
|
return c.json({ jobId });
|
|
6859
7031
|
});
|
|
7032
|
+
r.post("/celebrity-seed", async (c) => {
|
|
7033
|
+
let body;
|
|
7034
|
+
try {
|
|
7035
|
+
body = await c.req.json();
|
|
7036
|
+
} catch {
|
|
7037
|
+
body = {};
|
|
7038
|
+
}
|
|
7039
|
+
const b = body ?? {};
|
|
7040
|
+
const excludeIds = Array.isArray(b.excludeIds) ? b.excludeIds.filter((x) => typeof x === "string").slice(0, 200) : [];
|
|
7041
|
+
try {
|
|
7042
|
+
const seed = await generateCelebritySeed({ excludeIds });
|
|
7043
|
+
return c.json({ seed });
|
|
7044
|
+
} catch (e) {
|
|
7045
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
7046
|
+
return c.json({ error: msg }, 502);
|
|
7047
|
+
}
|
|
7048
|
+
});
|
|
6860
7049
|
r.get("/generate-persona/:jobId/stream", (c) => {
|
|
6861
7050
|
const jobId = c.req.param("jobId");
|
|
6862
7051
|
const job = getPersonaJob(jobId);
|
|
@@ -12273,6 +12462,26 @@ function searchMessages(query, limit = 200) {
|
|
|
12273
12462
|
createdAt: r.created_at
|
|
12274
12463
|
}));
|
|
12275
12464
|
}
|
|
12465
|
+
function finalizeStreamingMessage(messageId, reason) {
|
|
12466
|
+
const db = getDb();
|
|
12467
|
+
const before = db.prepare(
|
|
12468
|
+
`SELECT json_extract(meta_json, '$.streaming') AS streaming
|
|
12469
|
+
FROM messages WHERE id = ?`
|
|
12470
|
+
).get(messageId);
|
|
12471
|
+
if (!before) return false;
|
|
12472
|
+
if (before.streaming !== 1) return false;
|
|
12473
|
+
db.prepare(
|
|
12474
|
+
`UPDATE messages
|
|
12475
|
+
SET meta_json = json_set(
|
|
12476
|
+
COALESCE(meta_json, '{}'),
|
|
12477
|
+
'$.streaming', 0,
|
|
12478
|
+
'$.speakerStatus', 'final',
|
|
12479
|
+
'$.error', ?
|
|
12480
|
+
)
|
|
12481
|
+
WHERE id = ?`
|
|
12482
|
+
).run(String(reason || "finalized"), messageId);
|
|
12483
|
+
return true;
|
|
12484
|
+
}
|
|
12276
12485
|
|
|
12277
12486
|
// src/storage/rooms.ts
|
|
12278
12487
|
init_db();
|
|
@@ -12801,8 +13010,17 @@ function estimateTokens(text) {
|
|
|
12801
13010
|
|
|
12802
13011
|
// src/orchestrator/stream.ts
|
|
12803
13012
|
import { EventEmitter as EventEmitter2 } from "events";
|
|
13013
|
+
var MAX_BUFFER_PER_ROOM = 100;
|
|
13014
|
+
var BUFFER_TTL_MS = 5 * 60 * 1e3;
|
|
12804
13015
|
var RoomBus = class {
|
|
12805
13016
|
emitters = /* @__PURE__ */ new Map();
|
|
13017
|
+
buffers = /* @__PURE__ */ new Map();
|
|
13018
|
+
/** Monotonic event id (global across rooms · simpler than per-room
|
|
13019
|
+
* counters and equivalent for SSE's Last-Event-ID use). Survives
|
|
13020
|
+
* the lifetime of the process; reset on restart, which is fine ·
|
|
13021
|
+
* EventSource treats a smaller id from the server as "fresh
|
|
13022
|
+
* stream" and starts over. */
|
|
13023
|
+
nextId = 1;
|
|
12806
13024
|
get(roomId) {
|
|
12807
13025
|
let e = this.emitters.get(roomId);
|
|
12808
13026
|
if (!e) {
|
|
@@ -12812,15 +13030,48 @@ var RoomBus = class {
|
|
|
12812
13030
|
}
|
|
12813
13031
|
return e;
|
|
12814
13032
|
}
|
|
12815
|
-
|
|
12816
|
-
this.get(roomId)
|
|
13033
|
+
buffer(roomId) {
|
|
13034
|
+
let b = this.buffers.get(roomId);
|
|
13035
|
+
if (!b) {
|
|
13036
|
+
b = [];
|
|
13037
|
+
this.buffers.set(roomId, b);
|
|
13038
|
+
}
|
|
13039
|
+
return b;
|
|
12817
13040
|
}
|
|
12818
|
-
|
|
13041
|
+
emit(roomId, event) {
|
|
13042
|
+
const id = this.nextId++;
|
|
13043
|
+
const ts = Date.now();
|
|
13044
|
+
const buf = this.buffer(roomId);
|
|
13045
|
+
buf.push({ id, event, ts });
|
|
13046
|
+
while (buf.length > MAX_BUFFER_PER_ROOM) buf.shift();
|
|
13047
|
+
while (buf.length && ts - buf[0].ts > BUFFER_TTL_MS) buf.shift();
|
|
13048
|
+
this.get(roomId).emit("event", id, event);
|
|
13049
|
+
}
|
|
13050
|
+
/** Legacy subscribe · returns events without ids. Kept for callers
|
|
13051
|
+
* that don't care about replay (none currently, but the surface
|
|
13052
|
+
* stays compatible). */
|
|
12819
13053
|
subscribe(roomId, listener) {
|
|
13054
|
+
return this.subscribeWithId(roomId, (_id, event) => listener(event));
|
|
13055
|
+
}
|
|
13056
|
+
/** Subscribe with monotonic event id · the SSE route uses this so
|
|
13057
|
+
* every event written to the client carries an `id: N` line. The
|
|
13058
|
+
* client's EventSource then sends `Last-Event-ID: N` on auto-
|
|
13059
|
+
* reconnect, which the route maps back to replay() below. */
|
|
13060
|
+
subscribeWithId(roomId, listener) {
|
|
12820
13061
|
const e = this.get(roomId);
|
|
12821
13062
|
e.on("event", listener);
|
|
12822
13063
|
return () => e.off("event", listener);
|
|
12823
13064
|
}
|
|
13065
|
+
/** Return cached events with id > sinceId in emit order. Used by
|
|
13066
|
+
* the SSE route on reconnect to replay the gap before subscribing
|
|
13067
|
+
* fresh. Returns [] when there is no cache OR the gap is older
|
|
13068
|
+
* than the buffer's retained window (caller treats as "no replay
|
|
13069
|
+
* possible · client may have missed events permanently"). */
|
|
13070
|
+
replay(roomId, sinceId) {
|
|
13071
|
+
const buf = this.buffers.get(roomId);
|
|
13072
|
+
if (!buf || buf.length === 0) return [];
|
|
13073
|
+
return buf.filter((e) => e.id > sinceId);
|
|
13074
|
+
}
|
|
12824
13075
|
/** Drop all listeners for a room (e.g. when it's deleted). */
|
|
12825
13076
|
drop(roomId) {
|
|
12826
13077
|
const e = this.emitters.get(roomId);
|
|
@@ -12828,6 +13079,7 @@ var RoomBus = class {
|
|
|
12828
13079
|
e.removeAllListeners();
|
|
12829
13080
|
this.emitters.delete(roomId);
|
|
12830
13081
|
}
|
|
13082
|
+
this.buffers.delete(roomId);
|
|
12831
13083
|
}
|
|
12832
13084
|
};
|
|
12833
13085
|
var roomBus = new RoomBus();
|
|
@@ -14403,8 +14655,144 @@ function briefsRouter() {
|
|
|
14403
14655
|
return r;
|
|
14404
14656
|
}
|
|
14405
14657
|
|
|
14406
|
-
// src/routes/
|
|
14658
|
+
// src/routes/credentials.ts
|
|
14407
14659
|
import { Hono as Hono4 } from "hono";
|
|
14660
|
+
function payloadFor(meta, activeId) {
|
|
14661
|
+
return {
|
|
14662
|
+
id: meta.id,
|
|
14663
|
+
provider: meta.provider,
|
|
14664
|
+
label: meta.label,
|
|
14665
|
+
preview: meta.preview,
|
|
14666
|
+
createdAt: meta.createdAt,
|
|
14667
|
+
updatedAt: meta.updatedAt,
|
|
14668
|
+
isActive: meta.id === activeId
|
|
14669
|
+
};
|
|
14670
|
+
}
|
|
14671
|
+
function pickNextActiveId(removedProvider) {
|
|
14672
|
+
const all = listLlmCredentials();
|
|
14673
|
+
if (all.length === 0) return null;
|
|
14674
|
+
if (removedProvider) {
|
|
14675
|
+
const sameProvider = all.filter((c) => c.provider === removedProvider);
|
|
14676
|
+
if (sameProvider.length > 0) {
|
|
14677
|
+
sameProvider.sort((a, b) => a.createdAt - b.createdAt);
|
|
14678
|
+
return sameProvider[0].id;
|
|
14679
|
+
}
|
|
14680
|
+
}
|
|
14681
|
+
const sorted = all.slice().sort((a, b) => {
|
|
14682
|
+
const ai = LLM_PROVIDER_PRIORITY.indexOf(a.provider);
|
|
14683
|
+
const bi = LLM_PROVIDER_PRIORITY.indexOf(b.provider);
|
|
14684
|
+
if (ai !== bi) return ai - bi;
|
|
14685
|
+
return a.createdAt - b.createdAt;
|
|
14686
|
+
});
|
|
14687
|
+
return sorted[0]?.id ?? null;
|
|
14688
|
+
}
|
|
14689
|
+
function credentialsRouter() {
|
|
14690
|
+
const r = new Hono4();
|
|
14691
|
+
r.get("/", (c) => {
|
|
14692
|
+
const activeId = getPrefs().activeLlmCredentialId;
|
|
14693
|
+
const items = listLlmCredentials().map((m) => payloadFor(m, activeId));
|
|
14694
|
+
return c.json({
|
|
14695
|
+
credentials: items,
|
|
14696
|
+
activeId
|
|
14697
|
+
});
|
|
14698
|
+
});
|
|
14699
|
+
r.put("/active", async (c) => {
|
|
14700
|
+
let body;
|
|
14701
|
+
try {
|
|
14702
|
+
body = await c.req.json();
|
|
14703
|
+
} catch {
|
|
14704
|
+
return c.json({ error: "invalid JSON body" }, 400);
|
|
14705
|
+
}
|
|
14706
|
+
const rawId = body?.id;
|
|
14707
|
+
let nextId;
|
|
14708
|
+
if (rawId === null || rawId === void 0) {
|
|
14709
|
+
nextId = null;
|
|
14710
|
+
} else if (typeof rawId === "string") {
|
|
14711
|
+
nextId = rawId;
|
|
14712
|
+
} else {
|
|
14713
|
+
return c.json({ error: "id must be a string or null" }, 400);
|
|
14714
|
+
}
|
|
14715
|
+
if (nextId) {
|
|
14716
|
+
const meta = getLlmCredentialMeta(nextId);
|
|
14717
|
+
if (!meta) return c.json({ error: "credential not found" }, 404);
|
|
14718
|
+
updatePrefs({ activeLlmCredentialId: nextId });
|
|
14719
|
+
const flagship = PRIMARY_BY_CARRIER[meta.provider];
|
|
14720
|
+
if (flagship) updatePrefs({ defaultModelV: flagship });
|
|
14721
|
+
} else {
|
|
14722
|
+
updatePrefs({ activeLlmCredentialId: null });
|
|
14723
|
+
}
|
|
14724
|
+
try {
|
|
14725
|
+
reconcileAgentModels({ forcePrimary: true });
|
|
14726
|
+
} catch (e) {
|
|
14727
|
+
process.stderr.write(`[credentials.active] reconcile failed: ${e instanceof Error ? e.message : String(e)}
|
|
14728
|
+
`);
|
|
14729
|
+
}
|
|
14730
|
+
return c.json({ activeId: nextId });
|
|
14731
|
+
});
|
|
14732
|
+
r.post("/", async (c) => {
|
|
14733
|
+
let body;
|
|
14734
|
+
try {
|
|
14735
|
+
body = await c.req.json();
|
|
14736
|
+
} catch {
|
|
14737
|
+
return c.json({ error: "invalid JSON body" }, 400);
|
|
14738
|
+
}
|
|
14739
|
+
const provider = body?.provider;
|
|
14740
|
+
const labelRaw = body?.label;
|
|
14741
|
+
const key = body?.key;
|
|
14742
|
+
if (typeof provider !== "string" || !isLlmProvider(provider)) {
|
|
14743
|
+
return c.json({ error: "provider must be a known LLM slug" }, 400);
|
|
14744
|
+
}
|
|
14745
|
+
if (typeof key !== "string" || key.trim().length === 0) {
|
|
14746
|
+
return c.json({ error: "key must be a non-empty string" }, 400);
|
|
14747
|
+
}
|
|
14748
|
+
const label = typeof labelRaw === "string" ? labelRaw : null;
|
|
14749
|
+
const meta = createLlmCredential(provider, label, key);
|
|
14750
|
+
if (!meta) return c.json({ error: "failed to create credential" }, 500);
|
|
14751
|
+
updatePrefs({ activeLlmCredentialId: meta.id });
|
|
14752
|
+
const flagship = PRIMARY_BY_CARRIER[provider];
|
|
14753
|
+
if (flagship) updatePrefs({ defaultModelV: flagship });
|
|
14754
|
+
try {
|
|
14755
|
+
reconcileAgentModels({ forcePrimary: true });
|
|
14756
|
+
} catch (e) {
|
|
14757
|
+
process.stderr.write(`[credentials.post] reconcile failed: ${e instanceof Error ? e.message : String(e)}
|
|
14758
|
+
`);
|
|
14759
|
+
}
|
|
14760
|
+
const activeId = getPrefs().activeLlmCredentialId;
|
|
14761
|
+
return c.json(payloadFor(meta, activeId), 201);
|
|
14762
|
+
});
|
|
14763
|
+
r.delete("/:id", (c) => {
|
|
14764
|
+
const id = c.req.param("id");
|
|
14765
|
+
const meta = getLlmCredentialMeta(id);
|
|
14766
|
+
if (!meta) return c.json({ error: "credential not found" }, 404);
|
|
14767
|
+
const prefs = getPrefs();
|
|
14768
|
+
const wasActive = prefs.activeLlmCredentialId === id;
|
|
14769
|
+
const removedProvider = deleteLlmCredential(id);
|
|
14770
|
+
if (wasActive) {
|
|
14771
|
+
const nextId = pickNextActiveId(removedProvider);
|
|
14772
|
+
updatePrefs({ activeLlmCredentialId: nextId });
|
|
14773
|
+
if (nextId) {
|
|
14774
|
+
const nextMeta = getLlmCredentialMeta(nextId);
|
|
14775
|
+
if (nextMeta) {
|
|
14776
|
+
const flagship = PRIMARY_BY_CARRIER[nextMeta.provider];
|
|
14777
|
+
if (flagship) updatePrefs({ defaultModelV: flagship });
|
|
14778
|
+
}
|
|
14779
|
+
} else {
|
|
14780
|
+
updatePrefs({ defaultModelV: null });
|
|
14781
|
+
}
|
|
14782
|
+
}
|
|
14783
|
+
try {
|
|
14784
|
+
reconcileAgentModels(wasActive ? { forcePrimary: true } : void 0);
|
|
14785
|
+
} catch (e) {
|
|
14786
|
+
process.stderr.write(`[credentials.delete] reconcile failed: ${e instanceof Error ? e.message : String(e)}
|
|
14787
|
+
`);
|
|
14788
|
+
}
|
|
14789
|
+
return c.json({ id, deleted: true, activeId: getPrefs().activeLlmCredentialId });
|
|
14790
|
+
});
|
|
14791
|
+
return r;
|
|
14792
|
+
}
|
|
14793
|
+
|
|
14794
|
+
// src/routes/keys.ts
|
|
14795
|
+
import { Hono as Hono5 } from "hono";
|
|
14408
14796
|
|
|
14409
14797
|
// src/voice/registry.ts
|
|
14410
14798
|
function minimaxBaseUrl() {
|
|
@@ -14642,27 +15030,16 @@ function defaultVoiceForProvider(provider) {
|
|
|
14642
15030
|
|
|
14643
15031
|
// src/routes/keys.ts
|
|
14644
15032
|
var PROVIDERS = /* @__PURE__ */ new Set([
|
|
14645
|
-
|
|
14646
|
-
|
|
14647
|
-
|
|
14648
|
-
|
|
14649
|
-
"google",
|
|
14650
|
-
"xai",
|
|
15033
|
+
...ALL_LLM_PROVIDERS,
|
|
15034
|
+
// `deepseek` lives in the storage Provider union for type-compat
|
|
15035
|
+
// with the model registry; no @ai-sdk client routes through it, so
|
|
15036
|
+
// the row is accepted but unused.
|
|
14651
15037
|
"deepseek",
|
|
14652
15038
|
"minimax",
|
|
14653
15039
|
"elevenlabs",
|
|
14654
15040
|
"brave",
|
|
14655
15041
|
"tavily"
|
|
14656
15042
|
]);
|
|
14657
|
-
var LLM_PROVIDERS = /* @__PURE__ */ new Set([
|
|
14658
|
-
"openrouter",
|
|
14659
|
-
"bai",
|
|
14660
|
-
"anthropic",
|
|
14661
|
-
"openai",
|
|
14662
|
-
"google",
|
|
14663
|
-
"xai",
|
|
14664
|
-
"deepseek"
|
|
14665
|
-
]);
|
|
14666
15043
|
function isProvider(s) {
|
|
14667
15044
|
return PROVIDERS.has(s);
|
|
14668
15045
|
}
|
|
@@ -14700,7 +15077,7 @@ function autoAssignVoicesOnFirstKey(provider) {
|
|
|
14700
15077
|
}
|
|
14701
15078
|
}
|
|
14702
15079
|
function keysRouter() {
|
|
14703
|
-
const r = new
|
|
15080
|
+
const r = new Hono5();
|
|
14704
15081
|
r.get("/", (c) => {
|
|
14705
15082
|
const meta = listKeyMeta();
|
|
14706
15083
|
const map = /* @__PURE__ */ new Map();
|
|
@@ -14708,11 +15085,22 @@ function keysRouter() {
|
|
|
14708
15085
|
const out = Array.from(PROVIDERS).map(
|
|
14709
15086
|
(p) => map.get(p) ?? { provider: p, configured: false, updatedAt: null, preview: null }
|
|
14710
15087
|
);
|
|
14711
|
-
return c.json({
|
|
15088
|
+
return c.json({
|
|
15089
|
+
keys: out,
|
|
15090
|
+
classification: {
|
|
15091
|
+
multiModel: [...MULTI_MODEL_LLM_PROVIDERS],
|
|
15092
|
+
singleModel: [...SINGLE_MODEL_LLM_PROVIDERS],
|
|
15093
|
+
voice: ["minimax", "elevenlabs"],
|
|
15094
|
+
skill: ["brave", "tavily"]
|
|
15095
|
+
}
|
|
15096
|
+
});
|
|
14712
15097
|
});
|
|
14713
15098
|
r.put("/:provider", async (c) => {
|
|
14714
15099
|
const provider = c.req.param("provider");
|
|
14715
15100
|
if (!isProvider(provider)) return c.json({ error: "unknown provider" }, 400);
|
|
15101
|
+
if (isLlmProvider(provider)) {
|
|
15102
|
+
return c.json({ error: "LLM providers use POST /api/credentials" }, 410);
|
|
15103
|
+
}
|
|
14716
15104
|
let body;
|
|
14717
15105
|
try {
|
|
14718
15106
|
body = await c.req.json();
|
|
@@ -14721,30 +15109,9 @@ function keysRouter() {
|
|
|
14721
15109
|
}
|
|
14722
15110
|
const key = body?.key;
|
|
14723
15111
|
if (typeof key !== "string") return c.json({ error: "body must contain { key: string }" }, 400);
|
|
14724
|
-
const makeDefault = body?.makeDefault === true;
|
|
14725
15112
|
const hadAnyVoiceKeyBefore = !!getKey("minimax") || !!getKey("elevenlabs");
|
|
14726
15113
|
setKey(provider, key);
|
|
14727
|
-
if (provider
|
|
14728
|
-
const willForce = makeDefault && key.trim().length > 0;
|
|
14729
|
-
if (willForce) {
|
|
14730
|
-
const flagship = PRIMARY_BY_CARRIER[provider];
|
|
14731
|
-
if (flagship) {
|
|
14732
|
-
try {
|
|
14733
|
-
updatePrefs({ defaultModelV: flagship });
|
|
14734
|
-
} catch (e) {
|
|
14735
|
-
process.stderr.write(`[keys.put] updatePrefs failed: ${e instanceof Error ? e.message : String(e)}
|
|
14736
|
-
`);
|
|
14737
|
-
}
|
|
14738
|
-
}
|
|
14739
|
-
}
|
|
14740
|
-
try {
|
|
14741
|
-
reconcileAgentModels({ forcePrimary: willForce });
|
|
14742
|
-
} catch (e) {
|
|
14743
|
-
process.stderr.write(`[keys.put] reconcile failed: ${e instanceof Error ? e.message : String(e)}
|
|
14744
|
-
`);
|
|
14745
|
-
}
|
|
14746
|
-
}
|
|
14747
|
-
if ((provider === "minimax" || provider === "elevenlabs") && key.trim().length > 0 && !hadAnyVoiceKeyBefore) {
|
|
15114
|
+
if ((provider === "minimax" || provider === "elevenlabs") && key.trim().length > 0 && !hadAnyVoiceKeyBefore) {
|
|
14748
15115
|
try {
|
|
14749
15116
|
autoAssignVoicesOnFirstKey(provider);
|
|
14750
15117
|
} catch (e) {
|
|
@@ -14760,42 +15127,23 @@ function keysRouter() {
|
|
|
14760
15127
|
r.delete("/:provider", (c) => {
|
|
14761
15128
|
const provider = c.req.param("provider");
|
|
14762
15129
|
if (!isProvider(provider)) return c.json({ error: "unknown provider" }, 400);
|
|
14763
|
-
if (
|
|
14764
|
-
|
|
14765
|
-
const targetConfigured = !!allMeta.find((m) => m.provider === provider && m.configured);
|
|
14766
|
-
const configuredLlmCount = allMeta.filter(
|
|
14767
|
-
(m) => LLM_PROVIDERS.has(m.provider) && m.configured
|
|
14768
|
-
).length;
|
|
14769
|
-
if (targetConfigured && configuredLlmCount <= 1) {
|
|
14770
|
-
return c.json(
|
|
14771
|
-
{
|
|
14772
|
-
error: "Can't remove your only LLM key \u2014 add another LLM provider first, then remove this one."
|
|
14773
|
-
},
|
|
14774
|
-
409
|
|
14775
|
-
);
|
|
14776
|
-
}
|
|
15130
|
+
if (isLlmProvider(provider)) {
|
|
15131
|
+
return c.json({ error: "LLM providers use DELETE /api/credentials/:id" }, 410);
|
|
14777
15132
|
}
|
|
14778
15133
|
deleteKey(provider);
|
|
14779
|
-
if (provider !== "brave" && provider !== "tavily" && provider !== "minimax" && provider !== "elevenlabs") {
|
|
14780
|
-
try {
|
|
14781
|
-
reconcileAgentModels();
|
|
14782
|
-
} catch (e) {
|
|
14783
|
-
process.stderr.write(`[keys.delete] reconcile failed: ${e instanceof Error ? e.message : String(e)}
|
|
14784
|
-
`);
|
|
14785
|
-
}
|
|
14786
|
-
}
|
|
14787
15134
|
return c.json({ provider, configured: false, updatedAt: null, preview: null });
|
|
14788
15135
|
});
|
|
14789
15136
|
return r;
|
|
14790
15137
|
}
|
|
14791
15138
|
|
|
14792
15139
|
// src/routes/models.ts
|
|
14793
|
-
import { Hono as
|
|
15140
|
+
import { Hono as Hono6 } from "hono";
|
|
14794
15141
|
function modelsRouter() {
|
|
14795
|
-
const r = new
|
|
15142
|
+
const r = new Hono6();
|
|
14796
15143
|
r.get("/", (c) => {
|
|
14797
15144
|
const all = modelAvailability();
|
|
14798
15145
|
const reachable = all.filter((m) => m.reachable);
|
|
15146
|
+
const active = getProviderKeyState().activeLlmProvider;
|
|
14799
15147
|
return c.json({
|
|
14800
15148
|
/** Whether any LLM provider key is configured. False → frontend
|
|
14801
15149
|
* redirects the user to the API Key settings before letting
|
|
@@ -14811,760 +15159,35 @@ function modelsRouter() {
|
|
|
14811
15159
|
/** Global default model · what new agents inherit and what
|
|
14812
15160
|
* stale-modelV agents fall back to. NULL when no key is
|
|
14813
15161
|
* configured yet. */
|
|
14814
|
-
defaultModelV: effectiveDefaultModel(),
|
|
14815
|
-
/** Cheap utility model used by background tasks (skill picker,
|
|
14816
|
-
* director auto-pick, agent-spec gen, ability analyzer,
|
|
14817
|
-
* convening speech). NULL when no key is configured. */
|
|
14818
|
-
utilityModelV: utilityModelFor(),
|
|
14819
|
-
/** Provider summary · so the frontend can show "you have OR +
|
|
14820
|
-
* OpenAI direct" at a glance without iterating models. */
|
|
14821
|
-
providers: collectProviderSummary(all)
|
|
14822
|
-
|
|
14823
|
-
|
|
14824
|
-
|
|
14825
|
-
}
|
|
14826
|
-
|
|
14827
|
-
|
|
14828
|
-
|
|
14829
|
-
const cur = map.get(m.provider) ?? { reachable: 0, total: 0 };
|
|
14830
|
-
cur.total++;
|
|
14831
|
-
if (m.reachable) cur.reachable++;
|
|
14832
|
-
map.set(m.provider, cur);
|
|
14833
|
-
}
|
|
14834
|
-
return Array.from(map.entries()).map(([provider, v]) => ({ provider, ...v }));
|
|
14835
|
-
}
|
|
14836
|
-
|
|
14837
|
-
// src/routes/topic-recs.ts
|
|
14838
|
-
import { Hono as Hono6 } from "hono";
|
|
14839
|
-
import { streamSSE as streamSSE2 } from "hono/streaming";
|
|
14840
|
-
|
|
14841
|
-
// src/orchestrator/topic-recommender.ts
|
|
14842
|
-
import { randomUUID as randomUUID2 } from "crypto";
|
|
14843
|
-
|
|
14844
|
-
// src/storage/topic-recs.ts
|
|
14845
|
-
init_db();
|
|
14846
|
-
function createTopicRecBatch(input) {
|
|
14847
|
-
const now = Date.now();
|
|
14848
|
-
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);
|
|
14849
|
-
return { id: input.id, hasWeb: input.hasWeb, keywords: input.keywords, createdAt: now };
|
|
14850
|
-
}
|
|
14851
|
-
function mapRec(r) {
|
|
14852
|
-
let seedContext = null;
|
|
14853
|
-
if (r.seed_context_json) {
|
|
14854
|
-
try {
|
|
14855
|
-
const parsed = JSON.parse(r.seed_context_json);
|
|
14856
|
-
if (Array.isArray(parsed)) {
|
|
14857
|
-
seedContext = parsed.filter(
|
|
14858
|
-
(s) => s && typeof s.title === "string" && typeof s.url === "string" && typeof s.description === "string"
|
|
14859
|
-
);
|
|
14860
|
-
}
|
|
14861
|
-
} catch {
|
|
14862
|
-
}
|
|
14863
|
-
}
|
|
14864
|
-
return {
|
|
14865
|
-
id: r.id,
|
|
14866
|
-
batchId: r.batch_id,
|
|
14867
|
-
subject: r.subject,
|
|
14868
|
-
rationale: r.rationale,
|
|
14869
|
-
source: r.source === "web" ? "web" : "memory",
|
|
14870
|
-
tag: typeof r.tag === "string" && r.tag.trim().length > 0 ? r.tag.trim() : null,
|
|
14871
|
-
seedContext,
|
|
14872
|
-
createdAt: r.created_at,
|
|
14873
|
-
openedRoomId: r.opened_room_id
|
|
14874
|
-
};
|
|
14875
|
-
}
|
|
14876
|
-
var REC_COLS = "id, batch_id, subject, rationale, source, tag, seed_context_json, created_at, opened_room_id";
|
|
14877
|
-
function insertTopicRec(input) {
|
|
14878
|
-
const now = Date.now();
|
|
14879
|
-
getDb().prepare(
|
|
14880
|
-
`INSERT INTO topic_recs
|
|
14881
|
-
(id, batch_id, subject, rationale, source, tag, seed_context_json, created_at, opened_room_id)
|
|
14882
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)`
|
|
14883
|
-
).run(
|
|
14884
|
-
input.id,
|
|
14885
|
-
input.batchId,
|
|
14886
|
-
input.subject,
|
|
14887
|
-
input.rationale,
|
|
14888
|
-
input.source,
|
|
14889
|
-
input.tag,
|
|
14890
|
-
input.seedContext ? JSON.stringify(input.seedContext) : null,
|
|
14891
|
-
now
|
|
14892
|
-
);
|
|
14893
|
-
return {
|
|
14894
|
-
id: input.id,
|
|
14895
|
-
batchId: input.batchId,
|
|
14896
|
-
subject: input.subject,
|
|
14897
|
-
rationale: input.rationale,
|
|
14898
|
-
source: input.source,
|
|
14899
|
-
tag: input.tag,
|
|
14900
|
-
seedContext: input.seedContext,
|
|
14901
|
-
createdAt: now,
|
|
14902
|
-
openedRoomId: null
|
|
14903
|
-
};
|
|
14904
|
-
}
|
|
14905
|
-
function getTopicRec(id) {
|
|
14906
|
-
const row = getDb().prepare(`SELECT ${REC_COLS} FROM topic_recs WHERE id = ?`).get(id);
|
|
14907
|
-
return row ? mapRec(row) : null;
|
|
14908
|
-
}
|
|
14909
|
-
function listTopicRecs(opts) {
|
|
14910
|
-
const limit = Math.max(1, Math.min(100, opts.limit));
|
|
14911
|
-
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 ?`);
|
|
14912
|
-
const rows = opts.cursor === null ? stmt.all(limit) : stmt.all(opts.cursor, limit);
|
|
14913
|
-
const items = rows.map(mapRec);
|
|
14914
|
-
const nextCursor = items.length === limit ? items[items.length - 1].createdAt : null;
|
|
14915
|
-
return { items, nextCursor };
|
|
14916
|
-
}
|
|
14917
|
-
function markTopicRecOpened(recId, roomId) {
|
|
14918
|
-
getDb().prepare("UPDATE topic_recs SET opened_room_id = ? WHERE id = ?").run(roomId, recId);
|
|
14919
|
-
}
|
|
14920
|
-
function clearAllTopicRecs() {
|
|
14921
|
-
const r = getDb().prepare("DELETE FROM topic_recs").run();
|
|
14922
|
-
return r.changes;
|
|
14923
|
-
}
|
|
14924
|
-
function mapJob(r) {
|
|
14925
|
-
const status = ["running", "done", "failed", "aborted"].includes(r.status) ? r.status : "failed";
|
|
14926
|
-
return {
|
|
14927
|
-
id: r.id,
|
|
14928
|
-
status,
|
|
14929
|
-
currentPhase: r.current_phase,
|
|
14930
|
-
progressPct: r.progress_pct,
|
|
14931
|
-
batchId: r.batch_id,
|
|
14932
|
-
error: r.error,
|
|
14933
|
-
startedAt: r.started_at,
|
|
14934
|
-
updatedAt: r.updated_at
|
|
14935
|
-
};
|
|
14936
|
-
}
|
|
14937
|
-
var JOB_COLS = "id, status, current_phase, progress_pct, batch_id, error, started_at, updated_at";
|
|
14938
|
-
function createTopicRecJob(id) {
|
|
14939
|
-
const now = Date.now();
|
|
14940
|
-
getDb().prepare(
|
|
14941
|
-
`INSERT INTO topic_rec_jobs (id, status, current_phase, progress_pct, batch_id, error, started_at, updated_at)
|
|
14942
|
-
VALUES (?, 'running', 0, 0, NULL, NULL, ?, ?)`
|
|
14943
|
-
).run(id, now, now);
|
|
14944
|
-
return getTopicRecJob(id);
|
|
14945
|
-
}
|
|
14946
|
-
function getTopicRecJob(id) {
|
|
14947
|
-
const row = getDb().prepare(`SELECT ${JOB_COLS} FROM topic_rec_jobs WHERE id = ?`).get(id);
|
|
14948
|
-
return row ? mapJob(row) : null;
|
|
14949
|
-
}
|
|
14950
|
-
function updateTopicRecJob(id, patch) {
|
|
14951
|
-
const fields = [];
|
|
14952
|
-
const values = [];
|
|
14953
|
-
if (patch.status !== void 0) {
|
|
14954
|
-
fields.push("status = ?");
|
|
14955
|
-
values.push(patch.status);
|
|
14956
|
-
}
|
|
14957
|
-
if (typeof patch.currentPhase === "number") {
|
|
14958
|
-
fields.push("current_phase = ?");
|
|
14959
|
-
values.push(patch.currentPhase);
|
|
14960
|
-
}
|
|
14961
|
-
if (typeof patch.progressPct === "number") {
|
|
14962
|
-
fields.push("progress_pct = ?");
|
|
14963
|
-
values.push(Math.max(0, Math.min(100, Math.round(patch.progressPct))));
|
|
14964
|
-
}
|
|
14965
|
-
if (patch.batchId !== void 0) {
|
|
14966
|
-
fields.push("batch_id = ?");
|
|
14967
|
-
values.push(patch.batchId);
|
|
14968
|
-
}
|
|
14969
|
-
if (patch.error !== void 0) {
|
|
14970
|
-
fields.push("error = ?");
|
|
14971
|
-
values.push(patch.error);
|
|
14972
|
-
}
|
|
14973
|
-
if (fields.length === 0) return getTopicRecJob(id);
|
|
14974
|
-
fields.push("updated_at = ?");
|
|
14975
|
-
values.push(Date.now());
|
|
14976
|
-
values.push(id);
|
|
14977
|
-
getDb().prepare(`UPDATE topic_rec_jobs SET ${fields.join(", ")} WHERE id = ?`).run(...values);
|
|
14978
|
-
return getTopicRecJob(id);
|
|
14979
|
-
}
|
|
14980
|
-
|
|
14981
|
-
// src/orchestrator/topic-stream.ts
|
|
14982
|
-
import { EventEmitter as EventEmitter3 } from "events";
|
|
14983
|
-
var TopicRecBus = class {
|
|
14984
|
-
emitters = /* @__PURE__ */ new Map();
|
|
14985
|
-
get(jobId) {
|
|
14986
|
-
let e = this.emitters.get(jobId);
|
|
14987
|
-
if (!e) {
|
|
14988
|
-
e = new EventEmitter3();
|
|
14989
|
-
e.setMaxListeners(16);
|
|
14990
|
-
this.emitters.set(jobId, e);
|
|
14991
|
-
}
|
|
14992
|
-
return e;
|
|
14993
|
-
}
|
|
14994
|
-
emit(jobId, event) {
|
|
14995
|
-
this.get(jobId).emit("event", event);
|
|
14996
|
-
}
|
|
14997
|
-
subscribe(jobId, listener) {
|
|
14998
|
-
const e = this.get(jobId);
|
|
14999
|
-
e.on("event", listener);
|
|
15000
|
-
return () => e.off("event", listener);
|
|
15001
|
-
}
|
|
15002
|
-
/** Free the EventEmitter for a job. Call on terminal events
|
|
15003
|
-
* (`topic-final`, `topic-error`, `topic-aborted`) so the Map
|
|
15004
|
-
* doesn't grow unbounded across many runs in one process. */
|
|
15005
|
-
drop(jobId) {
|
|
15006
|
-
const e = this.emitters.get(jobId);
|
|
15007
|
-
if (e) {
|
|
15008
|
-
e.removeAllListeners();
|
|
15009
|
-
this.emitters.delete(jobId);
|
|
15010
|
-
}
|
|
15011
|
-
}
|
|
15012
|
-
};
|
|
15013
|
-
var topicRecBus = new TopicRecBus();
|
|
15014
|
-
|
|
15015
|
-
// src/orchestrator/topic-recommender.ts
|
|
15016
|
-
var LLM_CALL_TIMEOUT_MS2 = 6e4;
|
|
15017
|
-
var PIPELINE_WALL_CLOCK_MS = 12e4;
|
|
15018
|
-
var SEARCH_PARALLEL_CHUNK = 3;
|
|
15019
|
-
var SEARCH_CHUNK_GAP_MS = 1e3;
|
|
15020
|
-
var SEARCH_RESULTS_PER_QUERY = 5;
|
|
15021
|
-
var inFlightJobs2 = /* @__PURE__ */ new Map();
|
|
15022
|
-
function signalWithTimeout3(parent, timeoutMs) {
|
|
15023
|
-
const controller = new AbortController();
|
|
15024
|
-
const onParentAbort = () => controller.abort();
|
|
15025
|
-
if (parent?.aborted) controller.abort();
|
|
15026
|
-
else parent?.addEventListener("abort", onParentAbort, { once: true });
|
|
15027
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
15028
|
-
return {
|
|
15029
|
-
signal: controller.signal,
|
|
15030
|
-
cleanup: () => {
|
|
15031
|
-
clearTimeout(timer);
|
|
15032
|
-
parent?.removeEventListener("abort", onParentAbort);
|
|
15033
|
-
}
|
|
15034
|
-
};
|
|
15035
|
-
}
|
|
15036
|
-
function extractJson5(raw) {
|
|
15037
|
-
const fence = /```(?:json)?\s*([\s\S]*?)```/i.exec(raw);
|
|
15038
|
-
const candidate = fence ? fence[1] : raw;
|
|
15039
|
-
if (!candidate) return null;
|
|
15040
|
-
const start = candidate.indexOf("{");
|
|
15041
|
-
if (start === -1) return null;
|
|
15042
|
-
let depth = 0;
|
|
15043
|
-
let end = -1;
|
|
15044
|
-
for (let i = start; i < candidate.length; i++) {
|
|
15045
|
-
if (candidate[i] === "{") depth++;
|
|
15046
|
-
else if (candidate[i] === "}") {
|
|
15047
|
-
depth--;
|
|
15048
|
-
if (depth === 0) {
|
|
15049
|
-
end = i;
|
|
15050
|
-
break;
|
|
15051
|
-
}
|
|
15052
|
-
}
|
|
15053
|
-
}
|
|
15054
|
-
if (end === -1) return null;
|
|
15055
|
-
try {
|
|
15056
|
-
return JSON.parse(candidate.slice(start, end + 1));
|
|
15057
|
-
} catch {
|
|
15058
|
-
return null;
|
|
15059
|
-
}
|
|
15060
|
-
}
|
|
15061
|
-
async function callPhaseLLM2(state, modelV, messages, opts) {
|
|
15062
|
-
if (!isModelV(modelV)) return null;
|
|
15063
|
-
const t = signalWithTimeout3(state.controller.signal, LLM_CALL_TIMEOUT_MS2);
|
|
15064
|
-
try {
|
|
15065
|
-
const r = await callLLMWithUsage({
|
|
15066
|
-
modelV,
|
|
15067
|
-
messages,
|
|
15068
|
-
temperature: opts.temperature,
|
|
15069
|
-
maxTokens: opts.maxTokens,
|
|
15070
|
-
signal: t.signal
|
|
15071
|
-
});
|
|
15072
|
-
return r.text;
|
|
15073
|
-
} catch (e) {
|
|
15074
|
-
process.stderr.write(
|
|
15075
|
-
`[topic-recommender] ${modelV} failed: ${e instanceof Error ? e.message : String(e)}
|
|
15076
|
-
`
|
|
15077
|
-
);
|
|
15078
|
-
return null;
|
|
15079
|
-
} finally {
|
|
15080
|
-
t.cleanup();
|
|
15081
|
-
}
|
|
15082
|
-
}
|
|
15083
|
-
function sleepWithSignal2(ms, signal) {
|
|
15084
|
-
return new Promise((resolve2) => {
|
|
15085
|
-
if (signal.aborted) return resolve2();
|
|
15086
|
-
const t = setTimeout(() => {
|
|
15087
|
-
signal.removeEventListener("abort", onAbort);
|
|
15088
|
-
resolve2();
|
|
15089
|
-
}, ms);
|
|
15090
|
-
function onAbort() {
|
|
15091
|
-
clearTimeout(t);
|
|
15092
|
-
resolve2();
|
|
15093
|
-
}
|
|
15094
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
15095
|
-
});
|
|
15096
|
-
}
|
|
15097
|
-
function startTopicRecommend() {
|
|
15098
|
-
const jobId = randomUUID2();
|
|
15099
|
-
createTopicRecJob(jobId);
|
|
15100
|
-
const state = {
|
|
15101
|
-
id: jobId,
|
|
15102
|
-
startedAt: Date.now(),
|
|
15103
|
-
controller: new AbortController()
|
|
15104
|
-
};
|
|
15105
|
-
inFlightJobs2.set(jobId, state);
|
|
15106
|
-
const wallClock = setTimeout(() => {
|
|
15107
|
-
if (inFlightJobs2.has(jobId)) state.controller.abort();
|
|
15108
|
-
}, PIPELINE_WALL_CLOCK_MS);
|
|
15109
|
-
void runPipeline3(state).finally(() => {
|
|
15110
|
-
clearTimeout(wallClock);
|
|
15111
|
-
inFlightJobs2.delete(jobId);
|
|
15112
|
-
});
|
|
15113
|
-
return jobId;
|
|
15114
|
-
}
|
|
15115
|
-
function abortTopicRecommend(jobId) {
|
|
15116
|
-
const state = inFlightJobs2.get(jobId);
|
|
15117
|
-
if (!state) return false;
|
|
15118
|
-
try {
|
|
15119
|
-
state.controller.abort();
|
|
15120
|
-
} catch {
|
|
15121
|
-
}
|
|
15122
|
-
return true;
|
|
15123
|
-
}
|
|
15124
|
-
function isTopicRecJobRunning(jobId) {
|
|
15125
|
-
return inFlightJobs2.has(jobId);
|
|
15126
|
-
}
|
|
15127
|
-
async function runPipeline3(state) {
|
|
15128
|
-
const phaseLabels = [
|
|
15129
|
-
"Reading your boardroom history",
|
|
15130
|
-
"Distilling interests",
|
|
15131
|
-
"Scanning trending topics",
|
|
15132
|
-
"Synthesising recommendations"
|
|
15133
|
-
];
|
|
15134
|
-
const emitPhaseStart = (phase) => {
|
|
15135
|
-
topicRecBus.emit(state.id, {
|
|
15136
|
-
type: "topic-phase-start",
|
|
15137
|
-
phase,
|
|
15138
|
-
label: phaseLabels[phase - 1] ?? `Phase ${phase}`
|
|
15139
|
-
});
|
|
15140
|
-
};
|
|
15141
|
-
const emitPhaseProgress = (phase, detail, pct) => {
|
|
15142
|
-
topicRecBus.emit(state.id, {
|
|
15143
|
-
type: "topic-phase-progress",
|
|
15144
|
-
phase,
|
|
15145
|
-
detail,
|
|
15146
|
-
progressPct: Math.max(0, Math.min(100, Math.round(pct)))
|
|
15147
|
-
});
|
|
15148
|
-
updateTopicRecJob(state.id, { currentPhase: phase, progressPct: pct });
|
|
15149
|
-
};
|
|
15150
|
-
const emitPhaseEnd = (phase, pct) => {
|
|
15151
|
-
topicRecBus.emit(state.id, {
|
|
15152
|
-
type: "topic-phase-end",
|
|
15153
|
-
phase,
|
|
15154
|
-
progressPct: Math.max(0, Math.min(100, Math.round(pct)))
|
|
15155
|
-
});
|
|
15156
|
-
updateTopicRecJob(state.id, { currentPhase: phase, progressPct: pct });
|
|
15157
|
-
};
|
|
15158
|
-
const fail = (message) => {
|
|
15159
|
-
updateTopicRecJob(state.id, { status: "failed", error: message });
|
|
15160
|
-
topicRecBus.emit(state.id, { type: "topic-error", message });
|
|
15161
|
-
topicRecBus.drop(state.id);
|
|
15162
|
-
};
|
|
15163
|
-
const cancel = () => {
|
|
15164
|
-
updateTopicRecJob(state.id, { status: "aborted" });
|
|
15165
|
-
topicRecBus.emit(state.id, { type: "topic-aborted" });
|
|
15166
|
-
topicRecBus.drop(state.id);
|
|
15167
|
-
};
|
|
15168
|
-
try {
|
|
15169
|
-
emitPhaseStart(1);
|
|
15170
|
-
const chair = getChairAgent();
|
|
15171
|
-
if (!chair) {
|
|
15172
|
-
fail("chair agent missing \u2014 set up onboarding first");
|
|
15173
|
-
return;
|
|
15174
|
-
}
|
|
15175
|
-
const memories = memoriesForContext(chair.id, 50);
|
|
15176
|
-
emitPhaseProgress(1, `read ${memories.length} memories`, 8);
|
|
15177
|
-
emitPhaseEnd(1, 10);
|
|
15178
|
-
if (state.controller.signal.aborted) {
|
|
15179
|
-
cancel();
|
|
15180
|
-
return;
|
|
15181
|
-
}
|
|
15182
|
-
emitPhaseStart(2);
|
|
15183
|
-
const modelV = utilityModelFor();
|
|
15184
|
-
if (!modelV) {
|
|
15185
|
-
fail("no LLM provider configured \u2014 add an API key first");
|
|
15186
|
-
return;
|
|
15187
|
-
}
|
|
15188
|
-
const keywords = await distilKeywords(state, modelV, memories);
|
|
15189
|
-
if (state.controller.signal.aborted) {
|
|
15190
|
-
cancel();
|
|
15191
|
-
return;
|
|
15192
|
-
}
|
|
15193
|
-
if (keywords.length === 0) {
|
|
15194
|
-
fail("couldn't distil any keywords from the chair's memory yet \u2014 try again after a couple of rooms");
|
|
15195
|
-
return;
|
|
15196
|
-
}
|
|
15197
|
-
emitPhaseProgress(2, `picked ${keywords.length} keywords`, 25);
|
|
15198
|
-
emitPhaseEnd(2, 30);
|
|
15199
|
-
const hasWeb = hasWebSearchKey();
|
|
15200
|
-
let snippetsByKeyword = /* @__PURE__ */ new Map();
|
|
15201
|
-
if (hasWeb) {
|
|
15202
|
-
emitPhaseStart(3);
|
|
15203
|
-
snippetsByKeyword = await runWebSweep(state, keywords, (kw, snippets, idx) => {
|
|
15204
|
-
emitPhaseProgress(
|
|
15205
|
-
3,
|
|
15206
|
-
`scanned "${kw}" (${snippets.length} hits) \xB7 ${idx}/${keywords.length}`,
|
|
15207
|
-
30 + Math.round(idx / keywords.length * 40)
|
|
15208
|
-
);
|
|
15209
|
-
topicRecBus.emit(state.id, {
|
|
15210
|
-
type: "topic-search-round",
|
|
15211
|
-
keyword: kw,
|
|
15212
|
-
query: `${kw} site:x.com`,
|
|
15213
|
-
resultsCount: snippets.length,
|
|
15214
|
-
snippets
|
|
15215
|
-
});
|
|
15216
|
-
});
|
|
15217
|
-
if (state.controller.signal.aborted) {
|
|
15218
|
-
cancel();
|
|
15219
|
-
return;
|
|
15220
|
-
}
|
|
15221
|
-
emitPhaseEnd(3, 70);
|
|
15222
|
-
} else {
|
|
15223
|
-
emitPhaseProgress(3, "no web-search key \u2014 skipping", 70);
|
|
15224
|
-
}
|
|
15225
|
-
emitPhaseStart(4);
|
|
15226
|
-
const batchId = randomUUID2();
|
|
15227
|
-
createTopicRecBatch({ id: batchId, hasWeb, keywords });
|
|
15228
|
-
updateTopicRecJob(state.id, { batchId });
|
|
15229
|
-
const synth = await synthesiseTopics(state, modelV, {
|
|
15230
|
-
memories,
|
|
15231
|
-
keywords,
|
|
15232
|
-
snippetsByKeyword,
|
|
15233
|
-
hasWeb
|
|
15234
|
-
});
|
|
15235
|
-
if (state.controller.signal.aborted) {
|
|
15236
|
-
cancel();
|
|
15237
|
-
return;
|
|
15238
|
-
}
|
|
15239
|
-
if (synth.length === 0) {
|
|
15240
|
-
fail("synthesis returned no topics \u2014 try again or refine your boardroom history first");
|
|
15241
|
-
return;
|
|
15242
|
-
}
|
|
15243
|
-
clearAllTopicRecs();
|
|
15244
|
-
let inserted = 0;
|
|
15245
|
-
for (const t of synth) {
|
|
15246
|
-
const rec = insertTopicRec({
|
|
15247
|
-
id: randomUUID2(),
|
|
15248
|
-
batchId,
|
|
15249
|
-
subject: t.subject,
|
|
15250
|
-
rationale: t.rationale,
|
|
15251
|
-
source: t.source,
|
|
15252
|
-
tag: t.tag,
|
|
15253
|
-
seedContext: t.seedContext
|
|
15254
|
-
});
|
|
15255
|
-
inserted++;
|
|
15256
|
-
topicRecBus.emit(state.id, { type: "topic-rec", rec });
|
|
15257
|
-
emitPhaseProgress(
|
|
15258
|
-
4,
|
|
15259
|
-
`synthesised ${inserted}/${synth.length}`,
|
|
15260
|
-
70 + Math.round(inserted / synth.length * 28)
|
|
15261
|
-
);
|
|
15262
|
-
}
|
|
15263
|
-
emitPhaseEnd(4, 100);
|
|
15264
|
-
updateTopicRecJob(state.id, {
|
|
15265
|
-
status: "done",
|
|
15266
|
-
progressPct: 100,
|
|
15267
|
-
currentPhase: 4
|
|
15268
|
-
});
|
|
15269
|
-
topicRecBus.emit(state.id, {
|
|
15270
|
-
type: "topic-final",
|
|
15271
|
-
batchId,
|
|
15272
|
-
totalRecs: inserted,
|
|
15273
|
-
hasWeb
|
|
15274
|
-
});
|
|
15275
|
-
topicRecBus.drop(state.id);
|
|
15276
|
-
} catch (e) {
|
|
15277
|
-
if (state.controller.signal.aborted) {
|
|
15278
|
-
cancel();
|
|
15279
|
-
return;
|
|
15280
|
-
}
|
|
15281
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
15282
|
-
process.stderr.write(`[topic-recommender] pipeline crashed: ${msg}
|
|
15283
|
-
`);
|
|
15284
|
-
fail(msg);
|
|
15285
|
-
}
|
|
15286
|
-
}
|
|
15287
|
-
async function distilKeywords(state, modelV, memories) {
|
|
15288
|
-
if (memories.length === 0) return [];
|
|
15289
|
-
const memoryLines = memories.slice(0, 60).map((m, i) => {
|
|
15290
|
-
const tier = m.tier === "long" ? "STABLE" : "fresh";
|
|
15291
|
-
const prov = m.provenanceRooms > 1 ? ` \xB7 \xD7${m.provenanceRooms} rooms` : "";
|
|
15292
|
-
const recency = Math.max(0, Math.round((Date.now() - m.createdAt) / 864e5));
|
|
15293
|
-
return `${i + 1}. [${tier}${prov} \xB7 ${recency}d ago \xB7 ${m.kind}] ${m.content}`;
|
|
15294
|
-
}).join("\n");
|
|
15295
|
-
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.`;
|
|
15296
|
-
const user = `# Chair's memory about the user (newest first within each tier)
|
|
15297
|
-
${memoryLines}
|
|
15298
|
-
|
|
15299
|
-
Return up to 10 keywords as JSON.`;
|
|
15300
|
-
const raw = await callPhaseLLM2(state, modelV, [
|
|
15301
|
-
{ role: "system", content: system },
|
|
15302
|
-
{ role: "user", content: user }
|
|
15303
|
-
], { temperature: 0.3, maxTokens: 600 });
|
|
15304
|
-
if (!raw) return [];
|
|
15305
|
-
const parsed = extractJson5(raw);
|
|
15306
|
-
if (!parsed || !Array.isArray(parsed.keywords)) return [];
|
|
15307
|
-
return parsed.keywords.filter((k) => typeof k === "string").map((k) => k.trim()).filter((k) => k.length > 0).slice(0, 10);
|
|
15308
|
-
}
|
|
15309
|
-
async function runWebSweep(state, keywords, onKeywordDone) {
|
|
15310
|
-
const out = /* @__PURE__ */ new Map();
|
|
15311
|
-
const creds = getActiveWebSearchCredentials();
|
|
15312
|
-
if (!creds) return out;
|
|
15313
|
-
let doneCount = 0;
|
|
15314
|
-
for (let i = 0; i < keywords.length; i += SEARCH_PARALLEL_CHUNK) {
|
|
15315
|
-
if (state.controller.signal.aborted) break;
|
|
15316
|
-
const chunk = keywords.slice(i, i + SEARCH_PARALLEL_CHUNK);
|
|
15317
|
-
const settled = await Promise.allSettled(
|
|
15318
|
-
chunk.map((kw) => fetchKeywordSnippets(creds.backend, creds.apiKey, kw))
|
|
15319
|
-
);
|
|
15320
|
-
settled.forEach((res, j) => {
|
|
15321
|
-
const kw = chunk[j];
|
|
15322
|
-
const snippets = res.status === "fulfilled" ? res.value : [];
|
|
15323
|
-
out.set(kw, snippets);
|
|
15324
|
-
doneCount++;
|
|
15325
|
-
onKeywordDone(kw, snippets, doneCount);
|
|
15326
|
-
});
|
|
15327
|
-
if (i + SEARCH_PARALLEL_CHUNK < keywords.length) {
|
|
15328
|
-
await sleepWithSignal2(SEARCH_CHUNK_GAP_MS, state.controller.signal);
|
|
15329
|
-
}
|
|
15330
|
-
}
|
|
15331
|
-
return out;
|
|
15332
|
-
}
|
|
15333
|
-
async function fetchKeywordSnippets(backend, apiKey, keyword) {
|
|
15334
|
-
const xQuery = `${keyword} site:x.com`;
|
|
15335
|
-
const xResults = await runWebSearch(backend, apiKey, xQuery, {
|
|
15336
|
-
count: SEARCH_RESULTS_PER_QUERY
|
|
15337
|
-
});
|
|
15338
|
-
if (xResults && xResults.length > 0) {
|
|
15339
|
-
return xResults.map(toSnippet);
|
|
15340
|
-
}
|
|
15341
|
-
const generic = await runWebSearch(backend, apiKey, keyword, {
|
|
15342
|
-
count: SEARCH_RESULTS_PER_QUERY
|
|
15343
|
-
});
|
|
15344
|
-
return (generic ?? []).map(toSnippet);
|
|
15345
|
-
}
|
|
15346
|
-
function toSnippet(r) {
|
|
15347
|
-
return {
|
|
15348
|
-
title: r.title || "(untitled)",
|
|
15349
|
-
url: r.url,
|
|
15350
|
-
description: r.description || ""
|
|
15351
|
-
};
|
|
15352
|
-
}
|
|
15353
|
-
async function synthesiseTopics(state, modelV, opts) {
|
|
15354
|
-
const { memories, keywords, snippetsByKeyword, hasWeb } = opts;
|
|
15355
|
-
const flatSnippets = [];
|
|
15356
|
-
if (hasWeb) {
|
|
15357
|
-
for (const kw of keywords) {
|
|
15358
|
-
for (const s of snippetsByKeyword.get(kw) ?? []) {
|
|
15359
|
-
flatSnippets.push({ ...s, keyword: kw });
|
|
15360
|
-
}
|
|
15361
|
-
}
|
|
15362
|
-
}
|
|
15363
|
-
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");
|
|
15364
|
-
const snippetBlock = flatSnippets.length === 0 ? "(no web snippets \u2014 synthesise from memory only)" : flatSnippets.map(
|
|
15365
|
-
(s, i) => `S${i + 1}. [keyword: ${s.keyword}] ${s.title}
|
|
15366
|
-
${s.description}
|
|
15367
|
-
${s.url}`
|
|
15368
|
-
).join("\n\n");
|
|
15369
|
-
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>] } ] }';
|
|
15370
|
-
const user = `# Keywords distilled from chair memory
|
|
15371
|
-
${keywords.map((k, i) => `K${i + 1}. ${k}`).join("\n")}
|
|
15372
|
-
|
|
15373
|
-
# Memory excerpts
|
|
15374
|
-
${memorySummary}
|
|
15375
|
-
|
|
15376
|
-
# Web snippets ${hasWeb ? "(use these to ground at least some recs as source=web)" : "(none \u2014 synthesise from memory only)"}
|
|
15377
|
-
${snippetBlock}
|
|
15378
|
-
|
|
15379
|
-
Return EXACTLY 5 topics as JSON, each with a different tag, spanning different dimensions.`;
|
|
15380
|
-
const raw = await callPhaseLLM2(state, modelV, [
|
|
15381
|
-
{ role: "system", content: system },
|
|
15382
|
-
{ role: "user", content: user }
|
|
15383
|
-
], { temperature: 0.6, maxTokens: 2e3 });
|
|
15384
|
-
if (!raw) return [];
|
|
15385
|
-
const parsed = extractJson5(raw);
|
|
15386
|
-
if (!parsed || !Array.isArray(parsed.topics)) return [];
|
|
15387
|
-
const out = [];
|
|
15388
|
-
for (const t of parsed.topics) {
|
|
15389
|
-
const subject = typeof t.subject === "string" ? t.subject.trim() : "";
|
|
15390
|
-
const rationale = typeof t.rationale === "string" ? t.rationale.trim() : "";
|
|
15391
|
-
if (!subject || !rationale) continue;
|
|
15392
|
-
let tag = null;
|
|
15393
|
-
const TAG_BLOCKLIST = /* @__PURE__ */ new Set([
|
|
15394
|
-
"web",
|
|
15395
|
-
"memory",
|
|
15396
|
-
"general",
|
|
15397
|
-
"misc",
|
|
15398
|
-
"other",
|
|
15399
|
-
"topic",
|
|
15400
|
-
"recommendation",
|
|
15401
|
-
"recommendations",
|
|
15402
|
-
"rec",
|
|
15403
|
-
"category",
|
|
15404
|
-
"n/a",
|
|
15405
|
-
"na",
|
|
15406
|
-
"none"
|
|
15407
|
-
]);
|
|
15408
|
-
if (typeof t.tag === "string") {
|
|
15409
|
-
const cleaned = t.tag.trim().replace(/^\/\/\s*/, "").toLowerCase().replace(/[^a-z0-9 -]/g, "").replace(/\s+/g, " ").trim().slice(0, 28);
|
|
15410
|
-
if (cleaned.length > 0 && !TAG_BLOCKLIST.has(cleaned)) {
|
|
15411
|
-
tag = cleaned;
|
|
15412
|
-
}
|
|
15413
|
-
}
|
|
15414
|
-
if (!tag) {
|
|
15415
|
-
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));
|
|
15416
|
-
if (words.length > 0) {
|
|
15417
|
-
tag = words.slice(0, 2).join(" ").slice(0, 28);
|
|
15418
|
-
} else {
|
|
15419
|
-
tag = "topic";
|
|
15420
|
-
}
|
|
15421
|
-
}
|
|
15422
|
-
let source = t.source === "web" ? "web" : "memory";
|
|
15423
|
-
let seedContext = null;
|
|
15424
|
-
if (source === "web" && hasWeb && Array.isArray(t.snippetRefs)) {
|
|
15425
|
-
const refs = t.snippetRefs.map((r) => {
|
|
15426
|
-
if (typeof r === "number") return r;
|
|
15427
|
-
if (typeof r === "string") {
|
|
15428
|
-
const m = r.match(/^S?(\d+)$/i);
|
|
15429
|
-
return m ? Number(m[1]) : NaN;
|
|
15430
|
-
}
|
|
15431
|
-
return NaN;
|
|
15432
|
-
}).filter((n) => Number.isInteger(n) && n > 0 && n <= flatSnippets.length);
|
|
15433
|
-
const seen = /* @__PURE__ */ new Set();
|
|
15434
|
-
const cited = [];
|
|
15435
|
-
for (const ref of refs) {
|
|
15436
|
-
const snip = flatSnippets[ref - 1];
|
|
15437
|
-
if (!snip || seen.has(snip.url)) continue;
|
|
15438
|
-
seen.add(snip.url);
|
|
15439
|
-
cited.push({ title: snip.title, url: snip.url, description: snip.description });
|
|
15440
|
-
}
|
|
15441
|
-
if (cited.length > 0) {
|
|
15442
|
-
seedContext = cited;
|
|
15443
|
-
} else {
|
|
15444
|
-
source = "memory";
|
|
15445
|
-
}
|
|
15446
|
-
} else if (source === "web") {
|
|
15447
|
-
source = "memory";
|
|
15448
|
-
}
|
|
15449
|
-
out.push({ subject, rationale, source, tag, seedContext });
|
|
15450
|
-
if (out.length >= 6) break;
|
|
15451
|
-
}
|
|
15452
|
-
return out;
|
|
15453
|
-
}
|
|
15454
|
-
|
|
15455
|
-
// src/routes/topic-recs.ts
|
|
15456
|
-
function topicRecsRouter() {
|
|
15457
|
-
const r = new Hono6();
|
|
15458
|
-
r.post("/", (c) => {
|
|
15459
|
-
if (!hasAnyModelKey()) {
|
|
15460
|
-
return c.json({ error: "configure an LLM provider key first" }, 400);
|
|
15461
|
-
}
|
|
15462
|
-
const jobId = startTopicRecommend();
|
|
15463
|
-
return c.json({ jobId });
|
|
15464
|
-
});
|
|
15465
|
-
r.get("/jobs/:id/stream", (c) => {
|
|
15466
|
-
const jobId = c.req.param("id");
|
|
15467
|
-
const job = getTopicRecJob(jobId);
|
|
15468
|
-
if (!job) return c.json({ error: "job not found" }, 404);
|
|
15469
|
-
return streamSSE2(c, async (s) => {
|
|
15470
|
-
await s.writeSSE({
|
|
15471
|
-
event: "hello",
|
|
15472
|
-
data: JSON.stringify({
|
|
15473
|
-
jobId,
|
|
15474
|
-
status: job.status,
|
|
15475
|
-
currentPhase: job.currentPhase,
|
|
15476
|
-
progressPct: job.progressPct,
|
|
15477
|
-
batchId: job.batchId,
|
|
15478
|
-
error: job.error
|
|
15479
|
-
})
|
|
15480
|
-
});
|
|
15481
|
-
if (!isTopicRecJobRunning(jobId)) {
|
|
15482
|
-
if (job.status === "done") {
|
|
15483
|
-
await s.writeSSE({
|
|
15484
|
-
event: "topic-final",
|
|
15485
|
-
data: JSON.stringify({
|
|
15486
|
-
type: "topic-final",
|
|
15487
|
-
batchId: job.batchId,
|
|
15488
|
-
totalRecs: null,
|
|
15489
|
-
hasWeb: null
|
|
15490
|
-
})
|
|
15491
|
-
});
|
|
15492
|
-
} else if (job.status === "aborted") {
|
|
15493
|
-
await s.writeSSE({
|
|
15494
|
-
event: "topic-aborted",
|
|
15495
|
-
data: JSON.stringify({ type: "topic-aborted" })
|
|
15496
|
-
});
|
|
15497
|
-
} else if (job.status === "failed") {
|
|
15498
|
-
await s.writeSSE({
|
|
15499
|
-
event: "topic-error",
|
|
15500
|
-
data: JSON.stringify({
|
|
15501
|
-
type: "topic-error",
|
|
15502
|
-
message: job.error || "generation failed"
|
|
15503
|
-
})
|
|
15504
|
-
});
|
|
15505
|
-
}
|
|
15506
|
-
return;
|
|
15507
|
-
}
|
|
15508
|
-
const queue = [];
|
|
15509
|
-
let resolveWaiter = null;
|
|
15510
|
-
let closed = false;
|
|
15511
|
-
const off = topicRecBus.subscribe(jobId, (event) => {
|
|
15512
|
-
queue.push(event);
|
|
15513
|
-
if (resolveWaiter) {
|
|
15514
|
-
resolveWaiter();
|
|
15515
|
-
resolveWaiter = null;
|
|
15516
|
-
}
|
|
15517
|
-
});
|
|
15518
|
-
s.onAbort(() => {
|
|
15519
|
-
closed = true;
|
|
15520
|
-
off();
|
|
15521
|
-
if (resolveWaiter) {
|
|
15522
|
-
resolveWaiter();
|
|
15523
|
-
resolveWaiter = null;
|
|
15524
|
-
}
|
|
15525
|
-
});
|
|
15526
|
-
while (!closed) {
|
|
15527
|
-
if (queue.length === 0) {
|
|
15528
|
-
await new Promise((resolve2) => {
|
|
15529
|
-
resolveWaiter = resolve2;
|
|
15530
|
-
});
|
|
15531
|
-
continue;
|
|
15532
|
-
}
|
|
15533
|
-
const event = queue.shift();
|
|
15534
|
-
await s.writeSSE({ event: event.type, data: JSON.stringify(event) });
|
|
15535
|
-
if (event.type === "topic-final" || event.type === "topic-error" || event.type === "topic-aborted") {
|
|
15536
|
-
closed = true;
|
|
15537
|
-
off();
|
|
15538
|
-
}
|
|
15539
|
-
}
|
|
15162
|
+
defaultModelV: effectiveDefaultModel(),
|
|
15163
|
+
/** Cheap utility model used by background tasks (skill picker,
|
|
15164
|
+
* director auto-pick, agent-spec gen, ability analyzer,
|
|
15165
|
+
* convening speech). NULL when no key is configured. */
|
|
15166
|
+
utilityModelV: utilityModelFor(),
|
|
15167
|
+
/** Provider summary · so the frontend can show "you have OR +
|
|
15168
|
+
* OpenAI direct" at a glance without iterating models. */
|
|
15169
|
+
providers: collectProviderSummary(all),
|
|
15170
|
+
/** The user's single active LLM provider (under the single-
|
|
15171
|
+
* active-LLM-provider invariant). Null when none configured.
|
|
15172
|
+
* Frontend pickers use this to decide grouping strategy:
|
|
15173
|
+
* multi-model → group by family with "via {provider}" caption,
|
|
15174
|
+
* single-model → flat list. */
|
|
15175
|
+
activeLlmProvider: active,
|
|
15176
|
+
activeLlmClassification: active ? isMultiModelProvider(active) ? "multi-model" : "single-model" : null
|
|
15540
15177
|
});
|
|
15541
15178
|
});
|
|
15542
|
-
r.post("/jobs/:id/abort", (c) => {
|
|
15543
|
-
const jobId = c.req.param("id");
|
|
15544
|
-
const ok = abortTopicRecommend(jobId);
|
|
15545
|
-
if (!ok) {
|
|
15546
|
-
const job = getTopicRecJob(jobId);
|
|
15547
|
-
if (!job) return c.json({ error: "job not found" }, 404);
|
|
15548
|
-
return c.json({ ok: true, status: job.status });
|
|
15549
|
-
}
|
|
15550
|
-
return c.json({ ok: true });
|
|
15551
|
-
});
|
|
15552
|
-
r.get("/", (c) => {
|
|
15553
|
-
const cursorRaw = c.req.query("cursor");
|
|
15554
|
-
const limitRaw = c.req.query("limit");
|
|
15555
|
-
const cursor = cursorRaw && /^\d+$/.test(cursorRaw) ? Number(cursorRaw) : null;
|
|
15556
|
-
const limit = limitRaw && /^\d+$/.test(limitRaw) ? Math.max(1, Math.min(100, Number(limitRaw))) : 20;
|
|
15557
|
-
const { items, nextCursor } = listTopicRecs({ cursor, limit });
|
|
15558
|
-
return c.json({ items, nextCursor });
|
|
15559
|
-
});
|
|
15560
|
-
r.get("/:id", (c) => {
|
|
15561
|
-
const id = c.req.param("id");
|
|
15562
|
-
const rec = getTopicRec(id);
|
|
15563
|
-
if (!rec) return c.json({ error: "not found" }, 404);
|
|
15564
|
-
return c.json(rec);
|
|
15565
|
-
});
|
|
15566
15179
|
return r;
|
|
15567
15180
|
}
|
|
15181
|
+
function collectProviderSummary(models) {
|
|
15182
|
+
const map = /* @__PURE__ */ new Map();
|
|
15183
|
+
for (const m of models) {
|
|
15184
|
+
const cur = map.get(m.provider) ?? { reachable: 0, total: 0 };
|
|
15185
|
+
cur.total++;
|
|
15186
|
+
if (m.reachable) cur.reachable++;
|
|
15187
|
+
map.set(m.provider, cur);
|
|
15188
|
+
}
|
|
15189
|
+
return Array.from(map.entries()).map(([provider, v]) => ({ provider, ...v }));
|
|
15190
|
+
}
|
|
15568
15191
|
|
|
15569
15192
|
// src/routes/notes.ts
|
|
15570
15193
|
import { Hono as Hono7 } from "hono";
|
|
@@ -15776,7 +15399,7 @@ function prefsRouter() {
|
|
|
15776
15399
|
|
|
15777
15400
|
// src/routes/rooms.ts
|
|
15778
15401
|
import { Hono as Hono9 } from "hono";
|
|
15779
|
-
import { streamSSE as
|
|
15402
|
+
import { streamSSE as streamSSE2 } from "hono/streaming";
|
|
15780
15403
|
|
|
15781
15404
|
// src/storage/key_points.ts
|
|
15782
15405
|
init_db();
|
|
@@ -15894,7 +15517,7 @@ Does the chair need to ask a clarifying question before opening the room?`
|
|
|
15894
15517
|
}
|
|
15895
15518
|
return { shouldAsk: true, rationale: "" };
|
|
15896
15519
|
}
|
|
15897
|
-
const parsed =
|
|
15520
|
+
const parsed = extractJson5(raw);
|
|
15898
15521
|
if (!parsed || typeof parsed !== "object") {
|
|
15899
15522
|
return { shouldAsk: true, rationale: "" };
|
|
15900
15523
|
}
|
|
@@ -15983,7 +15606,7 @@ async function pickRoundWrap(opts) {
|
|
|
15983
15606
|
}
|
|
15984
15607
|
return { recommendation: "continue", rationale: "" };
|
|
15985
15608
|
}
|
|
15986
|
-
const parsed =
|
|
15609
|
+
const parsed = extractJson5(raw);
|
|
15987
15610
|
if (!parsed || typeof parsed !== "object") {
|
|
15988
15611
|
return { recommendation: "continue", rationale: "" };
|
|
15989
15612
|
}
|
|
@@ -16150,7 +15773,7 @@ ${extras.join("\n")}` : baseRow;
|
|
|
16150
15773
|
}
|
|
16151
15774
|
return { agentId: null, rationale: "", intervention: null };
|
|
16152
15775
|
}
|
|
16153
|
-
const parsed =
|
|
15776
|
+
const parsed = extractJson5(raw);
|
|
16154
15777
|
if (!parsed || typeof parsed !== "object") {
|
|
16155
15778
|
return { agentId: null, rationale: "", intervention: null };
|
|
16156
15779
|
}
|
|
@@ -16272,7 +15895,7 @@ async function pickChairWebSearch(opts) {
|
|
|
16272
15895
|
}
|
|
16273
15896
|
return null;
|
|
16274
15897
|
}
|
|
16275
|
-
const parsed =
|
|
15898
|
+
const parsed = extractJson5(raw);
|
|
16276
15899
|
if (!parsed || typeof parsed !== "object") return null;
|
|
16277
15900
|
const ws = parsed;
|
|
16278
15901
|
if (typeof ws.query !== "string") return null;
|
|
@@ -16297,7 +15920,7 @@ function buildSkillsIndex(skills) {
|
|
|
16297
15920
|
function loadSkillBody(skill) {
|
|
16298
15921
|
return skill.bodyMd;
|
|
16299
15922
|
}
|
|
16300
|
-
function
|
|
15923
|
+
function extractJson5(text) {
|
|
16301
15924
|
if (!text) return null;
|
|
16302
15925
|
let s = text.trim();
|
|
16303
15926
|
s = s.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/i, "").trim();
|
|
@@ -16411,7 +16034,7 @@ Which skills apply, and does this turn need web search?`
|
|
|
16411
16034
|
continue;
|
|
16412
16035
|
}
|
|
16413
16036
|
}
|
|
16414
|
-
const parsed =
|
|
16037
|
+
const parsed = extractJson5(raw);
|
|
16415
16038
|
if (!parsed || typeof parsed !== "object") {
|
|
16416
16039
|
return { used: [], reason: "", webSearchQuery: null };
|
|
16417
16040
|
}
|
|
@@ -16800,22 +16423,22 @@ var TONE_GUIDANCE = {
|
|
|
16800
16423
|
"",
|
|
16801
16424
|
"## Each turn MUST",
|
|
16802
16425
|
" (1) Name the lens you're auditing from this turn.",
|
|
16803
|
-
" (2) Surface 2\u20133 specific flaws,
|
|
16804
|
-
"
|
|
16805
|
-
"
|
|
16806
|
-
|
|
16426
|
+
" (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.",
|
|
16427
|
+
" How bad \xB7 `blocker` (ship is unsafe) / `major` (fix before commit) / `minor` (nice-to-fix)",
|
|
16428
|
+
" How likely \xB7 `likely` (>50%) / `plausible` (10-50%) / `edge` (<10%)",
|
|
16429
|
+
` 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.`,
|
|
16807
16430
|
' (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.',
|
|
16808
|
-
" (4) **Strength-preservation** \xB7 every
|
|
16431
|
+
" (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.",
|
|
16809
16432
|
"",
|
|
16810
|
-
`At least one
|
|
16433
|
+
`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.`,
|
|
16811
16434
|
"",
|
|
16812
16435
|
"## Forbidden",
|
|
16813
16436
|
" \xB7 Redesigning or reframing the work. You audit as-is. The fix-direction pointer is ONE sentence; never a rewrite.",
|
|
16814
16437
|
' \xB7 Vague "feels off" / "not quite right" without a mechanism.',
|
|
16815
16438
|
" \xB7 Praise-only turns; attacking the author rather than the work.",
|
|
16816
|
-
" \xB7 Cherry-picking edge cases \u2014 naming a 1% failure mode as
|
|
16439
|
+
" \xB7 Cherry-picking edge cases \u2014 naming a 1% failure mode as a blocker without anchoring it to its likelihood AND its consequence asymmetry.",
|
|
16817
16440
|
" \xB7 Repeating another director's critique under a different label.",
|
|
16818
|
-
|
|
16441
|
+
' \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.',
|
|
16819
16442
|
"",
|
|
16820
16443
|
"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."
|
|
16821
16444
|
].join("\n")
|
|
@@ -17326,10 +16949,8 @@ function buildChairClarifyMessages(opts) {
|
|
|
17326
16949
|
``,
|
|
17327
16950
|
`Output: either <ack + blank line + READY> OR the 2-part question block (in the user's language).`
|
|
17328
16951
|
].join("\n");
|
|
17329
|
-
const seedSystem = buildSeedContextSystem(opts.history);
|
|
17330
16952
|
return [
|
|
17331
16953
|
buildChairSystem(opts, isFirstTurn ? firstTurnTask : followUpTask),
|
|
17332
|
-
...seedSystem ? [seedSystem] : [],
|
|
17333
16954
|
...renderHistoryForChair(opts.history, opts.cast, opts.prefs),
|
|
17334
16955
|
{
|
|
17335
16956
|
role: "user",
|
|
@@ -17337,39 +16958,6 @@ function buildChairClarifyMessages(opts) {
|
|
|
17337
16958
|
}
|
|
17338
16959
|
];
|
|
17339
16960
|
}
|
|
17340
|
-
function buildSeedContextSystem(history) {
|
|
17341
|
-
for (let i = 0; i < history.length; i++) {
|
|
17342
|
-
const m = history[i];
|
|
17343
|
-
if (m.authorKind !== "user") continue;
|
|
17344
|
-
const meta = m.meta;
|
|
17345
|
-
const rationale = typeof meta?.seedContext?.rationale === "string" ? meta.seedContext.rationale.trim() : "";
|
|
17346
|
-
const rawSnippets = meta?.seedContext?.snippets;
|
|
17347
|
-
const snippets = Array.isArray(rawSnippets) ? rawSnippets : [];
|
|
17348
|
-
const snippetLines = [];
|
|
17349
|
-
for (const s of snippets) {
|
|
17350
|
-
if (!s || typeof s !== "object") continue;
|
|
17351
|
-
const title = typeof s.title === "string" ? s.title.trim() : "";
|
|
17352
|
-
const url = typeof s.url === "string" ? s.url.trim() : "";
|
|
17353
|
-
const desc = typeof s.description === "string" ? s.description.trim() : "";
|
|
17354
|
-
if (!title && !url && !desc) continue;
|
|
17355
|
-
snippetLines.push(`\xB7 ${title || "(untitled)"} \u2014 ${url || "(no url)"}
|
|
17356
|
-
${desc.slice(0, 360)}`);
|
|
17357
|
-
}
|
|
17358
|
-
if (!rationale && snippetLines.length === 0) continue;
|
|
17359
|
-
const blocks = [
|
|
17360
|
-
`\u2500\u2500\u2500 BACKGROUND MATERIAL \xB7 pre-attached by the user \u2500\u2500\u2500`,
|
|
17361
|
-
`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.`
|
|
17362
|
-
];
|
|
17363
|
-
if (rationale) {
|
|
17364
|
-
blocks.push(``, `Why this topic was recommended (hidden from the user \u2014 your reasoning context):`, `\xB7 ${rationale}`);
|
|
17365
|
-
}
|
|
17366
|
-
if (snippetLines.length > 0) {
|
|
17367
|
-
blocks.push(``, `Source snippets the recommendation was grounded in:`, ...snippetLines);
|
|
17368
|
-
}
|
|
17369
|
-
return { role: "system", content: blocks.join("\n") };
|
|
17370
|
-
}
|
|
17371
|
-
return null;
|
|
17372
|
-
}
|
|
17373
16961
|
function buildChairConveningMessages(opts) {
|
|
17374
16962
|
const subject = opts.room.subject;
|
|
17375
16963
|
const directorList = opts.picksWithReasons.map((p, i) => {
|
|
@@ -17437,14 +17025,15 @@ function buildChairRoundEndMessages(opts) {
|
|
|
17437
17025
|
``,
|
|
17438
17026
|
`\u2500\u2500\u2500 CRITIQUE-MODE POINT SELECTION \u2500\u2500\u2500`,
|
|
17439
17027
|
`Override the default "what got said" rule with severity-aware curation. Pick points that maximise audit value:`,
|
|
17440
|
-
` \xB7 Prefer 1
|
|
17028
|
+
` \xB7 Prefer 1 likely blocker over 3 edge-case minors.`,
|
|
17441
17029
|
` \xB7 Surface the dimension NO director attacked this round (the lens-coverage gap).`,
|
|
17442
|
-
` \xB7 If the room only produced
|
|
17030
|
+
` \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.`,
|
|
17443
17031
|
`Use these prompts to test what should rise to a key point:`,
|
|
17444
17032
|
` \xB7 Which flaw is fatal, and which is fixable?`,
|
|
17445
17033
|
` \xB7 What sounds plausible now but probably won't survive execution?`,
|
|
17446
17034
|
` \xB7 Which lens is conspicuously absent from this round's critique?`,
|
|
17447
|
-
` \xB7 What would a competitor / regulator / power user attack that didn't get raised
|
|
17035
|
+
` \xB7 What would a competitor / regulator / power user attack that didn't get raised?`,
|
|
17036
|
+
`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").`
|
|
17448
17037
|
].join("\n") : "";
|
|
17449
17038
|
const task = [
|
|
17450
17039
|
`\u2500\u2500\u2500 YOUR TASK \xB7 CLOSE THIS ROUND \u2500\u2500\u2500`,
|
|
@@ -18362,6 +17951,37 @@ Rules:
|
|
|
18362
17951
|
return branch.id;
|
|
18363
17952
|
}
|
|
18364
17953
|
|
|
17954
|
+
// src/orchestrator/timeouts.ts
|
|
17955
|
+
var TimeoutError = class extends Error {
|
|
17956
|
+
constructor(ms, label) {
|
|
17957
|
+
super(`timeout after ${ms}ms${label ? ` \xB7 ${label}` : ""}`);
|
|
17958
|
+
this.name = "TimeoutError";
|
|
17959
|
+
}
|
|
17960
|
+
};
|
|
17961
|
+
function withTimeout(promise, ms, label) {
|
|
17962
|
+
let timer = null;
|
|
17963
|
+
const timeout = new Promise((_, reject) => {
|
|
17964
|
+
timer = setTimeout(() => reject(new TimeoutError(ms, label)), ms);
|
|
17965
|
+
});
|
|
17966
|
+
return Promise.race([promise, timeout]).finally(() => {
|
|
17967
|
+
if (timer) clearTimeout(timer);
|
|
17968
|
+
});
|
|
17969
|
+
}
|
|
17970
|
+
|
|
17971
|
+
// src/orchestrator/auto-skip.ts
|
|
17972
|
+
function emitAutoSkipped(roomId, phase, reason, messageId) {
|
|
17973
|
+
roomBus.emit(roomId, {
|
|
17974
|
+
type: "config-event",
|
|
17975
|
+
kind: "auto-skipped",
|
|
17976
|
+
payload: {
|
|
17977
|
+
phase,
|
|
17978
|
+
reason,
|
|
17979
|
+
...messageId ? { messageId } : {}
|
|
17980
|
+
},
|
|
17981
|
+
createdAt: Date.now()
|
|
17982
|
+
});
|
|
17983
|
+
}
|
|
17984
|
+
|
|
18365
17985
|
// src/voice/sentence-splitter.ts
|
|
18366
17986
|
var END_RE = /[。!?!?;;::\n]|[.](?=\s|$)/;
|
|
18367
17987
|
var SentenceChunker = class {
|
|
@@ -18422,6 +18042,39 @@ function minimaxBaseUrl2() {
|
|
|
18422
18042
|
}
|
|
18423
18043
|
var ELEVENLABS_API = "https://api.elevenlabs.io/v1";
|
|
18424
18044
|
var OPENAI_API = "https://api.openai.com/v1";
|
|
18045
|
+
function makeMiniMaxBalanceError() {
|
|
18046
|
+
const err2 = new Error(
|
|
18047
|
+
"Your MiniMax account balance is insufficient for TTS. Top up your account in the MiniMax console and try again."
|
|
18048
|
+
);
|
|
18049
|
+
err2.code = "paid-plan-required";
|
|
18050
|
+
err2.provider = "minimax";
|
|
18051
|
+
err2.upgradeUrl = getPrefs().minimaxRegion === "intl" ? "https://platform.minimax.io/user-center/billing/overview" : "https://platform.minimaxi.com/user-center/payment";
|
|
18052
|
+
return err2;
|
|
18053
|
+
}
|
|
18054
|
+
function makeElevenLabsBillingError(message) {
|
|
18055
|
+
const err2 = new Error(message);
|
|
18056
|
+
err2.code = "paid-plan-required";
|
|
18057
|
+
err2.provider = "elevenlabs";
|
|
18058
|
+
err2.upgradeUrl = "https://elevenlabs.io/pricing";
|
|
18059
|
+
return err2;
|
|
18060
|
+
}
|
|
18061
|
+
function isElevenLabsCreditError(errText) {
|
|
18062
|
+
return /quota_exceeded|insufficient[ _-]?(?:credit|quota|balance|fund)|out\s+of\s+credits?|voice_limit_reached|余额不足/i.test(errText);
|
|
18063
|
+
}
|
|
18064
|
+
function tryExtractTtsBillingError(err2) {
|
|
18065
|
+
if (!err2 || typeof err2 !== "object") return null;
|
|
18066
|
+
const tagged = err2;
|
|
18067
|
+
if (tagged.code !== "paid-plan-required") return null;
|
|
18068
|
+
if (typeof tagged.provider !== "string") return null;
|
|
18069
|
+
const out = err2;
|
|
18070
|
+
if (typeof tagged.upgradeUrl !== "string") {
|
|
18071
|
+
out.upgradeUrl = "";
|
|
18072
|
+
}
|
|
18073
|
+
if (typeof tagged.message !== "string") {
|
|
18074
|
+
out.message = "Voice synthesis requires a paid plan.";
|
|
18075
|
+
}
|
|
18076
|
+
return out;
|
|
18077
|
+
}
|
|
18425
18078
|
function cleanForSpeech(md) {
|
|
18426
18079
|
if (!md) return "";
|
|
18427
18080
|
let out = md;
|
|
@@ -18521,11 +18174,17 @@ async function* synthesizeSpeechStream(text, profile, signal) {
|
|
|
18521
18174
|
});
|
|
18522
18175
|
if (!res.ok) {
|
|
18523
18176
|
const errText = await res.text();
|
|
18177
|
+
if (res.status === 402 || /"status_code"\s*:\s*1008|insufficient[ _-]?(?:balance|quota|credit|fund)|余额不足|余额[^a-zA-Z]?(?:不足|不够)/i.test(errText)) {
|
|
18178
|
+
throw makeMiniMaxBalanceError();
|
|
18179
|
+
}
|
|
18524
18180
|
throw new Error(`MiniMax TTS stream HTTP ${res.status}: ${errText}`);
|
|
18525
18181
|
}
|
|
18526
18182
|
const contentType = res.headers.get("content-type") || "";
|
|
18527
18183
|
if (!contentType.includes("text/event-stream")) {
|
|
18528
18184
|
const errBody = await res.text();
|
|
18185
|
+
if (/"status_code"\s*:\s*1008|insufficient[ _-]?(?:balance|quota|credit|fund)|余额不足|余额[^a-zA-Z]?(?:不足|不够)/i.test(errBody)) {
|
|
18186
|
+
throw makeMiniMaxBalanceError();
|
|
18187
|
+
}
|
|
18529
18188
|
throw new Error(`MiniMax TTS: expected event-stream but got ${contentType}: ${errBody.slice(0, 200)}`);
|
|
18530
18189
|
}
|
|
18531
18190
|
const body = res.body;
|
|
@@ -18632,13 +18291,7 @@ async function synthesizeMiniMax(text, profile, signal) {
|
|
|
18632
18291
|
if (!res.ok) {
|
|
18633
18292
|
const errText = await res.text();
|
|
18634
18293
|
if (res.status === 402 || /insufficient[ _-]?(?:balance|quota|credit|fund)|余额不足|余额[^a-zA-Z]?(?:不足|不够)/i.test(errText)) {
|
|
18635
|
-
|
|
18636
|
-
"Your MiniMax account balance is insufficient for TTS. Top up your account in the MiniMax console and try again."
|
|
18637
|
-
);
|
|
18638
|
-
err2.code = "paid-plan-required";
|
|
18639
|
-
err2.provider = "minimax";
|
|
18640
|
-
err2.upgradeUrl = getPrefs().minimaxRegion === "intl" ? "https://platform.minimax.io/user-center/billing/overview" : "https://platform.minimaxi.com/user-center/payment";
|
|
18641
|
-
throw err2;
|
|
18294
|
+
throw makeMiniMaxBalanceError();
|
|
18642
18295
|
}
|
|
18643
18296
|
throw new Error(`MiniMax TTS HTTP ${res.status}: ${errText}`);
|
|
18644
18297
|
}
|
|
@@ -18646,13 +18299,7 @@ async function synthesizeMiniMax(text, profile, signal) {
|
|
|
18646
18299
|
const status = json.base_resp?.status_code ?? 0;
|
|
18647
18300
|
const statusMsg = json.base_resp?.status_msg || "";
|
|
18648
18301
|
if (status !== 0 && (status === 1008 || /insufficient[ _-]?(?:balance|quota|credit|fund)|余额不足|余额[^a-zA-Z]?(?:不足|不够)/i.test(statusMsg))) {
|
|
18649
|
-
|
|
18650
|
-
"Your MiniMax account balance is insufficient for TTS. Top up your account in the MiniMax console and try again."
|
|
18651
|
-
);
|
|
18652
|
-
err2.code = "paid-plan-required";
|
|
18653
|
-
err2.provider = "minimax";
|
|
18654
|
-
err2.upgradeUrl = getPrefs().minimaxRegion === "intl" ? "https://platform.minimax.io/user-center/billing/overview" : "https://platform.minimaxi.com/user-center/payment";
|
|
18655
|
-
throw err2;
|
|
18302
|
+
throw makeMiniMaxBalanceError();
|
|
18656
18303
|
}
|
|
18657
18304
|
const hex = json.data?.audio ?? json.audio ?? "";
|
|
18658
18305
|
if (!hex) {
|
|
@@ -18735,13 +18382,14 @@ async function synthesizeElevenLabs(text, profile, signal) {
|
|
|
18735
18382
|
if (!res.ok) {
|
|
18736
18383
|
const errText = await res.text();
|
|
18737
18384
|
if (res.status === 402 && /paid_plan_required|library voices/i.test(errText)) {
|
|
18738
|
-
|
|
18385
|
+
throw makeElevenLabsBillingError(
|
|
18739
18386
|
"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."
|
|
18740
18387
|
);
|
|
18741
|
-
|
|
18742
|
-
|
|
18743
|
-
|
|
18744
|
-
|
|
18388
|
+
}
|
|
18389
|
+
if (isElevenLabsCreditError(errText)) {
|
|
18390
|
+
throw makeElevenLabsBillingError(
|
|
18391
|
+
"Your ElevenLabs account is out of credits. Top up your ElevenLabs plan and try again."
|
|
18392
|
+
);
|
|
18745
18393
|
}
|
|
18746
18394
|
throw new Error(`ElevenLabs TTS HTTP ${res.status}: ${errText.slice(0, 400)}`);
|
|
18747
18395
|
}
|
|
@@ -18780,13 +18428,14 @@ async function* synthesizeElevenLabsStream(text, profile, signal) {
|
|
|
18780
18428
|
if (!res.ok) {
|
|
18781
18429
|
const errText = await res.text();
|
|
18782
18430
|
if (res.status === 402 && /paid_plan_required|library voices/i.test(errText)) {
|
|
18783
|
-
|
|
18431
|
+
throw makeElevenLabsBillingError(
|
|
18784
18432
|
"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."
|
|
18785
18433
|
);
|
|
18786
|
-
|
|
18787
|
-
|
|
18788
|
-
|
|
18789
|
-
|
|
18434
|
+
}
|
|
18435
|
+
if (isElevenLabsCreditError(errText)) {
|
|
18436
|
+
throw makeElevenLabsBillingError(
|
|
18437
|
+
"Your ElevenLabs account is out of credits. Top up your ElevenLabs plan and try again."
|
|
18438
|
+
);
|
|
18790
18439
|
}
|
|
18791
18440
|
throw new Error(`ElevenLabs TTS stream HTTP ${res.status}: ${errText.slice(0, 400)}`);
|
|
18792
18441
|
}
|
|
@@ -18845,7 +18494,8 @@ function ensureState(roomId) {
|
|
|
18845
18494
|
if (!s) {
|
|
18846
18495
|
s = {
|
|
18847
18496
|
queue: [],
|
|
18848
|
-
inflight:
|
|
18497
|
+
inflight: /* @__PURE__ */ new Map(),
|
|
18498
|
+
preWarmed: null,
|
|
18849
18499
|
processing: false,
|
|
18850
18500
|
roundNum: 1,
|
|
18851
18501
|
speakersThisTurn: 0,
|
|
@@ -18859,7 +18509,8 @@ function ensureState(roomId) {
|
|
|
18859
18509
|
pendingFrameBreakerRole: null,
|
|
18860
18510
|
lastFrameBreakerAgentId: null,
|
|
18861
18511
|
billingHaltedThisTurn: false,
|
|
18862
|
-
voiceWaiters: /* @__PURE__ */ new Map()
|
|
18512
|
+
voiceWaiters: /* @__PURE__ */ new Map(),
|
|
18513
|
+
voicePredone: /* @__PURE__ */ new Set()
|
|
18863
18514
|
};
|
|
18864
18515
|
_state.set(roomId, s);
|
|
18865
18516
|
}
|
|
@@ -18867,29 +18518,59 @@ function ensureState(roomId) {
|
|
|
18867
18518
|
}
|
|
18868
18519
|
function markVoicePlaybackDone(roomId, messageId) {
|
|
18869
18520
|
const s = _state.get(roomId);
|
|
18870
|
-
|
|
18871
|
-
|
|
18872
|
-
|
|
18873
|
-
|
|
18521
|
+
if (!s) return false;
|
|
18522
|
+
const waiter = s.voiceWaiters.get(messageId);
|
|
18523
|
+
if (waiter) {
|
|
18524
|
+
s.voiceWaiters.delete(messageId);
|
|
18525
|
+
waiter.resolve();
|
|
18526
|
+
return true;
|
|
18527
|
+
}
|
|
18528
|
+
s.voicePredone.add(messageId);
|
|
18529
|
+
return false;
|
|
18530
|
+
}
|
|
18531
|
+
function bumpVoicePlaybackHeartbeat(roomId, messageId) {
|
|
18532
|
+
const s = _state.get(roomId);
|
|
18533
|
+
if (!s) return false;
|
|
18534
|
+
const waiter = s.voiceWaiters.get(messageId);
|
|
18535
|
+
if (!waiter) return false;
|
|
18536
|
+
waiter.bump();
|
|
18874
18537
|
return true;
|
|
18875
18538
|
}
|
|
18876
|
-
function waitForVoicePlayback(roomId, messageId, timeoutMs =
|
|
18539
|
+
function waitForVoicePlayback(roomId, messageId, timeoutMs = 6e4) {
|
|
18877
18540
|
const s = ensureState(roomId);
|
|
18541
|
+
if (s.voicePredone.has(messageId)) {
|
|
18542
|
+
s.voicePredone.delete(messageId);
|
|
18543
|
+
return Promise.resolve();
|
|
18544
|
+
}
|
|
18878
18545
|
return new Promise((resolve2) => {
|
|
18879
|
-
|
|
18880
|
-
|
|
18881
|
-
|
|
18882
|
-
|
|
18883
|
-
|
|
18884
|
-
|
|
18885
|
-
|
|
18546
|
+
let timer;
|
|
18547
|
+
const arm = () => {
|
|
18548
|
+
timer = setTimeout(() => {
|
|
18549
|
+
s.voiceWaiters.delete(messageId);
|
|
18550
|
+
process.stderr.write(
|
|
18551
|
+
`[voice-wait] no-heartbeat fallback fired for msg=${messageId.slice(0, 8)} after ${timeoutMs}ms
|
|
18552
|
+
`
|
|
18553
|
+
);
|
|
18554
|
+
resolve2();
|
|
18555
|
+
}, timeoutMs);
|
|
18556
|
+
};
|
|
18557
|
+
arm();
|
|
18558
|
+
s.voiceWaiters.set(messageId, {
|
|
18559
|
+
resolve: () => {
|
|
18560
|
+
clearTimeout(timer);
|
|
18561
|
+
resolve2();
|
|
18562
|
+
},
|
|
18563
|
+
bump: () => {
|
|
18564
|
+
clearTimeout(timer);
|
|
18565
|
+
arm();
|
|
18566
|
+
}
|
|
18886
18567
|
});
|
|
18887
18568
|
});
|
|
18888
18569
|
}
|
|
18889
18570
|
function isRoomSpeaking(roomId) {
|
|
18890
18571
|
const s = _state.get(roomId);
|
|
18891
18572
|
if (!s) return false;
|
|
18892
|
-
return s.inflight
|
|
18573
|
+
return s.inflight.size > 0;
|
|
18893
18574
|
}
|
|
18894
18575
|
function injectSpeakers(roomId, agentIds) {
|
|
18895
18576
|
if (agentIds.length === 0) return;
|
|
@@ -18926,7 +18607,7 @@ function injectSpeakers(roomId, agentIds) {
|
|
|
18926
18607
|
function requestSoftPause(roomId) {
|
|
18927
18608
|
const s = ensureState(roomId);
|
|
18928
18609
|
s.pauseAfterCurrent = true;
|
|
18929
|
-
rlog(roomId, "soft-pause-requested", { remaining: s.queue.length, speaking: s.inflight
|
|
18610
|
+
rlog(roomId, "soft-pause-requested", { remaining: s.queue.length, speaking: s.inflight.size });
|
|
18930
18611
|
}
|
|
18931
18612
|
function setPendingUserAfterCurrent(roomId, payload) {
|
|
18932
18613
|
const s = ensureState(roomId);
|
|
@@ -18935,7 +18616,7 @@ function setPendingUserAfterCurrent(roomId, payload) {
|
|
|
18935
18616
|
function requestRoundEndAfterCurrent(roomId) {
|
|
18936
18617
|
const s = ensureState(roomId);
|
|
18937
18618
|
s.pendingRoundEnd = true;
|
|
18938
|
-
rlog(roomId, "round-end-deferred", { remaining: s.queue.length, speaking: s.inflight
|
|
18619
|
+
rlog(roomId, "round-end-deferred", { remaining: s.queue.length, speaking: s.inflight.size });
|
|
18939
18620
|
}
|
|
18940
18621
|
async function chairInterrupt(roomId) {
|
|
18941
18622
|
const state = ensureState(roomId);
|
|
@@ -18944,23 +18625,30 @@ async function chairInterrupt(roomId) {
|
|
|
18944
18625
|
status: "queued"
|
|
18945
18626
|
}));
|
|
18946
18627
|
let interruptedAgentId = null;
|
|
18947
|
-
if (state.inflight) {
|
|
18628
|
+
if (state.inflight.size > 0) {
|
|
18948
18629
|
interruptedAgentId = state.queue[0]?.agentId ?? null;
|
|
18949
|
-
state.inflight.abort();
|
|
18950
|
-
state.inflight
|
|
18951
|
-
|
|
18952
|
-
|
|
18953
|
-
|
|
18954
|
-
|
|
18955
|
-
|
|
18956
|
-
|
|
18957
|
-
|
|
18958
|
-
|
|
18959
|
-
|
|
18960
|
-
|
|
18961
|
-
|
|
18962
|
-
|
|
18963
|
-
|
|
18630
|
+
for (const ac of state.inflight.values()) ac.abort();
|
|
18631
|
+
state.inflight.clear();
|
|
18632
|
+
}
|
|
18633
|
+
if (state.preWarmed) {
|
|
18634
|
+
try {
|
|
18635
|
+
state.preWarmed.abortController.abort();
|
|
18636
|
+
} catch (_) {
|
|
18637
|
+
}
|
|
18638
|
+
state.preWarmed = null;
|
|
18639
|
+
}
|
|
18640
|
+
if (interruptedAgentId) {
|
|
18641
|
+
const recent = listRecentMessages(roomId, 8);
|
|
18642
|
+
for (let i = recent.length - 1; i >= 0; i--) {
|
|
18643
|
+
const m = recent[i];
|
|
18644
|
+
if (m.authorKind === "agent" && m.authorId === interruptedAgentId && m.meta && m.meta.streaming === true) {
|
|
18645
|
+
deleteMessage(m.id);
|
|
18646
|
+
roomBus.emit(roomId, {
|
|
18647
|
+
type: "message-removed",
|
|
18648
|
+
messageId: m.id,
|
|
18649
|
+
reason: "chair-interrupt"
|
|
18650
|
+
});
|
|
18651
|
+
break;
|
|
18964
18652
|
}
|
|
18965
18653
|
}
|
|
18966
18654
|
}
|
|
@@ -19000,15 +18688,21 @@ function abortRoom(roomId) {
|
|
|
19000
18688
|
maxSpeakersThisTurn: s.maxSpeakersThisTurn
|
|
19001
18689
|
};
|
|
19002
18690
|
s.queue = [];
|
|
19003
|
-
const wasSpeaking = s.inflight
|
|
19004
|
-
|
|
19005
|
-
|
|
19006
|
-
|
|
18691
|
+
const wasSpeaking = s.inflight.size > 0;
|
|
18692
|
+
for (const ac of s.inflight.values()) ac.abort();
|
|
18693
|
+
s.inflight.clear();
|
|
18694
|
+
if (s.preWarmed) {
|
|
18695
|
+
try {
|
|
18696
|
+
s.preWarmed.abortController.abort();
|
|
18697
|
+
} catch (_) {
|
|
18698
|
+
}
|
|
18699
|
+
s.preWarmed = null;
|
|
19007
18700
|
}
|
|
19008
|
-
for (const [
|
|
19009
|
-
waiter();
|
|
18701
|
+
for (const [, waiter] of s.voiceWaiters) {
|
|
18702
|
+
waiter.resolve();
|
|
19010
18703
|
}
|
|
19011
18704
|
s.voiceWaiters.clear();
|
|
18705
|
+
s.voicePredone.clear();
|
|
19012
18706
|
rlog(roomId, "abort", {
|
|
19013
18707
|
snapshot: remaining.length,
|
|
19014
18708
|
round: s.roundNum,
|
|
@@ -19100,9 +18794,17 @@ function tickRoom(roomId, opts) {
|
|
|
19100
18794
|
}
|
|
19101
18795
|
if (plan.length === 0) return;
|
|
19102
18796
|
const state = ensureState(roomId);
|
|
19103
|
-
|
|
18797
|
+
for (const ac of state.inflight.values()) ac.abort();
|
|
18798
|
+
state.inflight.clear();
|
|
18799
|
+
if (state.preWarmed) {
|
|
18800
|
+
try {
|
|
18801
|
+
state.preWarmed.abortController.abort();
|
|
18802
|
+
} catch (_) {
|
|
18803
|
+
}
|
|
18804
|
+
state.preWarmed = null;
|
|
18805
|
+
}
|
|
19104
18806
|
for (const [, waiter] of state.voiceWaiters) {
|
|
19105
|
-
waiter();
|
|
18807
|
+
waiter.resolve();
|
|
19106
18808
|
}
|
|
19107
18809
|
state.voiceWaiters.clear();
|
|
19108
18810
|
state.queue = plan.map((a) => ({ agentId: a.id, status: "queued" }));
|
|
@@ -19130,6 +18832,106 @@ function tickRoom(roomId, opts) {
|
|
|
19130
18832
|
void pumpQueue(roomId);
|
|
19131
18833
|
}
|
|
19132
18834
|
}
|
|
18835
|
+
function schedulePreWarm(roomId, currentMessageId) {
|
|
18836
|
+
void runPickerThenPrewarm(roomId, currentMessageId).catch((e) => {
|
|
18837
|
+
process.stderr.write(`[pre-warm] failed: ${e instanceof Error ? e.message : String(e)}
|
|
18838
|
+
`);
|
|
18839
|
+
});
|
|
18840
|
+
}
|
|
18841
|
+
async function runPickerThenPrewarm(roomId, _currentMessageId) {
|
|
18842
|
+
const state = ensureState(roomId);
|
|
18843
|
+
if (state.preWarmed) return;
|
|
18844
|
+
if (state.queue.length < 2) return;
|
|
18845
|
+
const room = getRoom(roomId);
|
|
18846
|
+
if (!room || room.status !== "live") return;
|
|
18847
|
+
if (room.deliveryMode !== "voice") return;
|
|
18848
|
+
if (state.pendingRoundEnd || state.pauseAfterCurrent || state.billingHaltedThisTurn) return;
|
|
18849
|
+
if (room.awaitingClarify || room.awaitingContinue) return;
|
|
18850
|
+
const recent = listRecentMessages(roomId, 30);
|
|
18851
|
+
const directorAlreadySpoke = recent.some((m) => {
|
|
18852
|
+
if (m.authorKind !== "agent" || m.roundNum !== state.roundNum) return false;
|
|
18853
|
+
if (m.meta?.kind) return false;
|
|
18854
|
+
const a = m.authorId ? getAgent(m.authorId) : null;
|
|
18855
|
+
return a?.roleKind === "director";
|
|
18856
|
+
});
|
|
18857
|
+
const candidates = state.queue.map((q) => getAgent(q.agentId)).filter((a) => a !== null);
|
|
18858
|
+
let pickedAgentId = null;
|
|
18859
|
+
if (directorAlreadySpoke && candidates.length >= 2) {
|
|
18860
|
+
try {
|
|
18861
|
+
const pick = await withTimeout(
|
|
18862
|
+
pickNextSpeaker({
|
|
18863
|
+
candidates,
|
|
18864
|
+
history: recent,
|
|
18865
|
+
room: { subject: room.subject ?? null },
|
|
18866
|
+
mode: "lens-gap"
|
|
18867
|
+
}),
|
|
18868
|
+
15e3,
|
|
18869
|
+
"prewarm-picker"
|
|
18870
|
+
);
|
|
18871
|
+
pickedAgentId = pick.agentId;
|
|
18872
|
+
} catch (e) {
|
|
18873
|
+
process.stderr.write(`[pre-warm picker] ${e instanceof Error ? e.message : String(e)}
|
|
18874
|
+
`);
|
|
18875
|
+
}
|
|
18876
|
+
}
|
|
18877
|
+
if (state.preWarmed) return;
|
|
18878
|
+
const live = getRoom(roomId);
|
|
18879
|
+
if (!live || live.status !== "live") return;
|
|
18880
|
+
if (state.queue.length < 2) return;
|
|
18881
|
+
if (pickedAgentId) {
|
|
18882
|
+
const idx = state.queue.findIndex((q) => q.agentId === pickedAgentId);
|
|
18883
|
+
if (idx > 1) {
|
|
18884
|
+
const [picked] = state.queue.splice(idx, 1);
|
|
18885
|
+
state.queue.splice(1, 0, picked);
|
|
18886
|
+
emitQueueUpdate(roomId, state);
|
|
18887
|
+
}
|
|
18888
|
+
}
|
|
18889
|
+
const nextEntry = state.queue[1];
|
|
18890
|
+
if (!nextEntry) return;
|
|
18891
|
+
const nextSpeaker = getAgent(nextEntry.agentId);
|
|
18892
|
+
if (!nextSpeaker) return;
|
|
18893
|
+
const ac = new AbortController();
|
|
18894
|
+
const sentinel = `pending:${nextSpeaker.id}`;
|
|
18895
|
+
state.inflight.set(sentinel, ac);
|
|
18896
|
+
rlog(roomId, "prewarm-start", {
|
|
18897
|
+
agent: nextSpeaker.name,
|
|
18898
|
+
agentId: nextSpeaker.id,
|
|
18899
|
+
pickedByHaiku: !!pickedAgentId,
|
|
18900
|
+
queueHead: state.queue[0]?.agentId
|
|
18901
|
+
});
|
|
18902
|
+
const preWarmed = {
|
|
18903
|
+
agentId: nextEntry.agentId,
|
|
18904
|
+
messageId: "",
|
|
18905
|
+
promise: Promise.resolve(null),
|
|
18906
|
+
// backfilled below
|
|
18907
|
+
abortController: ac
|
|
18908
|
+
};
|
|
18909
|
+
preWarmed.promise = streamSpeakerTurn({
|
|
18910
|
+
roomId,
|
|
18911
|
+
speaker: nextSpeaker,
|
|
18912
|
+
roundNum: state.roundNum,
|
|
18913
|
+
signal: ac.signal,
|
|
18914
|
+
preWarmed: true,
|
|
18915
|
+
onPlaceholder: (info) => {
|
|
18916
|
+
preWarmed.messageId = info.messageId;
|
|
18917
|
+
if (state.inflight.has(sentinel)) {
|
|
18918
|
+
state.inflight.delete(sentinel);
|
|
18919
|
+
state.inflight.set(info.messageId, ac);
|
|
18920
|
+
}
|
|
18921
|
+
}
|
|
18922
|
+
// Chain trigger lives in pumpQueue's consume point, NOT here.
|
|
18923
|
+
// Rationale: B's `message-final` fires while B is still occupying
|
|
18924
|
+
// `state.preWarmed`. A nested schedulePreWarm() call from inside
|
|
18925
|
+
// B's pre-warm stream would hit the `if (state.preWarmed) return`
|
|
18926
|
+
// guard at the top of runPickerThenPrewarm and bail — C never
|
|
18927
|
+
// gets pre-warmed, depth-1 collapses to "first pair only". The
|
|
18928
|
+
// correct hook is the moment pumpQueue clears preWarmed (consume
|
|
18929
|
+
// path); at that instant the slot is free, the queue head has
|
|
18930
|
+
// advanced, and the next pre-warm has the right context.
|
|
18931
|
+
// onMessageFinal intentionally omitted.
|
|
18932
|
+
});
|
|
18933
|
+
state.preWarmed = preWarmed;
|
|
18934
|
+
}
|
|
19133
18935
|
async function pumpQueue(roomId) {
|
|
19134
18936
|
const state = ensureState(roomId);
|
|
19135
18937
|
if (state.processing) {
|
|
@@ -19150,7 +18952,8 @@ async function pumpQueue(roomId) {
|
|
|
19150
18952
|
emitQueueUpdate(roomId, state);
|
|
19151
18953
|
break;
|
|
19152
18954
|
}
|
|
19153
|
-
|
|
18955
|
+
const preWarmedHit = !!(state.preWarmed && state.queue[0] && state.preWarmed.agentId === state.queue[0].agentId);
|
|
18956
|
+
if (!preWarmedHit && state.queue.length >= 2) {
|
|
19154
18957
|
const recent = listRecentMessages(roomId, 30);
|
|
19155
18958
|
const round = state.roundNum;
|
|
19156
18959
|
let isReactive = false;
|
|
@@ -19217,13 +19020,37 @@ async function pumpQueue(roomId) {
|
|
|
19217
19020
|
} catch {
|
|
19218
19021
|
}
|
|
19219
19022
|
}
|
|
19220
|
-
const
|
|
19221
|
-
|
|
19222
|
-
|
|
19223
|
-
|
|
19224
|
-
|
|
19225
|
-
|
|
19226
|
-
|
|
19023
|
+
const fallbackPick = {
|
|
19024
|
+
agentId: null,
|
|
19025
|
+
rationale: "",
|
|
19026
|
+
intervention: null
|
|
19027
|
+
};
|
|
19028
|
+
let pick = fallbackPick;
|
|
19029
|
+
try {
|
|
19030
|
+
pick = await withTimeout(
|
|
19031
|
+
pickNextSpeaker({
|
|
19032
|
+
candidates: pickerCandidates,
|
|
19033
|
+
history: recent,
|
|
19034
|
+
room: pickRoom ?? void 0,
|
|
19035
|
+
mode: useDissentMode ? "dissent-gap" : "lens-gap",
|
|
19036
|
+
convergentTerms: useDissentMode ? convergentTerms : void 0
|
|
19037
|
+
}),
|
|
19038
|
+
15e3,
|
|
19039
|
+
"speaker-picker"
|
|
19040
|
+
);
|
|
19041
|
+
} catch (e) {
|
|
19042
|
+
if (e instanceof TimeoutError) {
|
|
19043
|
+
process.stderr.write(`[picker] timeout \u2014 falling back to round-robin
|
|
19044
|
+
`);
|
|
19045
|
+
emitAutoSkipped(roomId, "picker", "picker-timeout");
|
|
19046
|
+
} else {
|
|
19047
|
+
process.stderr.write(
|
|
19048
|
+
`[picker] error: ${e instanceof Error ? e.message : String(e)}
|
|
19049
|
+
`
|
|
19050
|
+
);
|
|
19051
|
+
}
|
|
19052
|
+
pick = fallbackPick;
|
|
19053
|
+
}
|
|
19227
19054
|
if (useDissentMode && convergentTerms.length > 0) {
|
|
19228
19055
|
const lastBreaker = state.lastFrameBreakerAgentId;
|
|
19229
19056
|
const chosenId = pick.agentId ?? state.queue[0]?.agentId;
|
|
@@ -19318,23 +19145,57 @@ async function pumpQueue(roomId) {
|
|
|
19318
19145
|
emitQueueUpdate(roomId, state);
|
|
19319
19146
|
continue;
|
|
19320
19147
|
}
|
|
19321
|
-
|
|
19322
|
-
|
|
19148
|
+
let ac;
|
|
19149
|
+
let streamPromise;
|
|
19150
|
+
if (preWarmedHit && state.preWarmed) {
|
|
19151
|
+
const justConsumed = state.preWarmed;
|
|
19152
|
+
ac = state.preWarmed.abortController;
|
|
19153
|
+
streamPromise = state.preWarmed.promise;
|
|
19154
|
+
state.preWarmed = null;
|
|
19155
|
+
rlog(roomId, "speaker-prewarm-consumed", {
|
|
19156
|
+
agent: speaker.name,
|
|
19157
|
+
agentId: speaker.id
|
|
19158
|
+
});
|
|
19159
|
+
schedulePreWarm(roomId, justConsumed.messageId);
|
|
19160
|
+
} else {
|
|
19161
|
+
rlog(roomId, "speaker-fresh-path", {
|
|
19162
|
+
agent: speaker.name,
|
|
19163
|
+
agentId: speaker.id,
|
|
19164
|
+
hasPrewarm: !!state.preWarmed,
|
|
19165
|
+
prewarmAgent: state.preWarmed?.agentId ?? null,
|
|
19166
|
+
queueHead: state.queue[0]?.agentId,
|
|
19167
|
+
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."
|
|
19168
|
+
});
|
|
19169
|
+
ac = new AbortController();
|
|
19170
|
+
const sentinel = `pending:${speaker.id}`;
|
|
19171
|
+
state.inflight.set(sentinel, ac);
|
|
19172
|
+
streamPromise = streamSpeakerTurn({
|
|
19173
|
+
roomId,
|
|
19174
|
+
speaker,
|
|
19175
|
+
roundNum: state.roundNum,
|
|
19176
|
+
signal: ac.signal,
|
|
19177
|
+
onPlaceholder: (info) => {
|
|
19178
|
+
if (state.inflight.has(sentinel)) {
|
|
19179
|
+
state.inflight.delete(sentinel);
|
|
19180
|
+
state.inflight.set(info.messageId, ac);
|
|
19181
|
+
}
|
|
19182
|
+
},
|
|
19183
|
+
onMessageFinal: (info) => {
|
|
19184
|
+
schedulePreWarm(roomId, info.messageId);
|
|
19185
|
+
}
|
|
19186
|
+
});
|
|
19187
|
+
}
|
|
19323
19188
|
const turnStart = Date.now();
|
|
19324
19189
|
rlog(roomId, "speaker-start", {
|
|
19325
19190
|
agent: speaker.name,
|
|
19326
19191
|
agentId: speaker.id,
|
|
19327
19192
|
modelV: speaker.modelV,
|
|
19328
19193
|
round: state.roundNum,
|
|
19329
|
-
position: `${state.speakersThisTurn + 1}/${state.maxSpeakersThisTurn}
|
|
19194
|
+
position: `${state.speakersThisTurn + 1}/${state.maxSpeakersThisTurn}`,
|
|
19195
|
+
preWarmed: preWarmedHit
|
|
19330
19196
|
});
|
|
19331
19197
|
try {
|
|
19332
|
-
const messageId = await
|
|
19333
|
-
roomId,
|
|
19334
|
-
speaker,
|
|
19335
|
-
roundNum: state.roundNum,
|
|
19336
|
-
signal: ac.signal
|
|
19337
|
-
});
|
|
19198
|
+
const messageId = await streamPromise;
|
|
19338
19199
|
if (messageId && getRoom(roomId)?.deliveryMode === "voice") {
|
|
19339
19200
|
await waitForVoicePlayback(roomId, messageId);
|
|
19340
19201
|
}
|
|
@@ -19356,7 +19217,11 @@ async function pumpQueue(roomId) {
|
|
|
19356
19217
|
process.stderr.write(`[orchestrator] stream error: ${msg}
|
|
19357
19218
|
`);
|
|
19358
19219
|
} finally {
|
|
19359
|
-
|
|
19220
|
+
const keysToDel = [];
|
|
19221
|
+
for (const [key, val] of state.inflight) {
|
|
19222
|
+
if (val === ac) keysToDel.push(key);
|
|
19223
|
+
}
|
|
19224
|
+
for (const key of keysToDel) state.inflight.delete(key);
|
|
19360
19225
|
}
|
|
19361
19226
|
if (state.queue[0] !== entry) {
|
|
19362
19227
|
continue;
|
|
@@ -19455,15 +19320,26 @@ async function pumpQueue(roomId) {
|
|
|
19455
19320
|
// same path the chat round-prompt button takes).
|
|
19456
19321
|
room.voteTrigger !== "manual") {
|
|
19457
19322
|
const wrappedRound = state.roundNum;
|
|
19323
|
+
emitChairPending(roomId, "vote-summary");
|
|
19458
19324
|
let recommendation;
|
|
19459
19325
|
try {
|
|
19460
19326
|
const recent = listRecentMessages(roomId, 30);
|
|
19461
|
-
const wrap = await
|
|
19327
|
+
const wrap = await withTimeout(
|
|
19328
|
+
pickRoundWrap({ history: recent, roundNum: wrappedRound, room }),
|
|
19329
|
+
15e3,
|
|
19330
|
+
"pickRoundWrap"
|
|
19331
|
+
);
|
|
19462
19332
|
recommendation = { kind: wrap.recommendation, rationale: wrap.rationale };
|
|
19463
19333
|
} catch (e) {
|
|
19464
|
-
|
|
19465
|
-
|
|
19466
|
-
|
|
19334
|
+
if (e instanceof TimeoutError) {
|
|
19335
|
+
process.stderr.write(`[round-wrap] timeout \u2014 using neutral prompt
|
|
19336
|
+
`);
|
|
19337
|
+
emitAutoSkipped(roomId, "picker", "pickRoundWrap-timeout");
|
|
19338
|
+
} else {
|
|
19339
|
+
rlog(roomId, "round-wrap-error", {
|
|
19340
|
+
error: e instanceof Error ? e.message : String(e)
|
|
19341
|
+
});
|
|
19342
|
+
}
|
|
19467
19343
|
}
|
|
19468
19344
|
const roomAgain = getRoom(roomId);
|
|
19469
19345
|
const stateNow = ensureState(roomId);
|
|
@@ -19488,7 +19364,7 @@ async function pumpQueue(roomId) {
|
|
|
19488
19364
|
}
|
|
19489
19365
|
}
|
|
19490
19366
|
async function streamSpeakerTurn(args) {
|
|
19491
|
-
const { roomId, speaker, roundNum, signal } = args;
|
|
19367
|
+
const { roomId, speaker, roundNum, signal, preWarmed = false, onPlaceholder, onMessageFinal } = args;
|
|
19492
19368
|
const room = getRoom(roomId);
|
|
19493
19369
|
if (!room) return null;
|
|
19494
19370
|
const memberRows = listRoomMembers(roomId);
|
|
@@ -19664,6 +19540,9 @@ async function streamSpeakerTurn(args) {
|
|
|
19664
19540
|
speakerStatus: "streaming",
|
|
19665
19541
|
streaming: true
|
|
19666
19542
|
};
|
|
19543
|
+
if (preWarmed) {
|
|
19544
|
+
placeholderMeta.preWarmed = true;
|
|
19545
|
+
}
|
|
19667
19546
|
if (activeSkills.length > 0) {
|
|
19668
19547
|
placeholderMeta.skillsUsed = activeSkills.map((s) => s.slug);
|
|
19669
19548
|
if (pickerReason) placeholderMeta.skillsReason = pickerReason;
|
|
@@ -19695,6 +19574,14 @@ async function streamSpeakerTurn(args) {
|
|
|
19695
19574
|
roundNum: placeholder.roundNum,
|
|
19696
19575
|
createdAt: placeholder.createdAt
|
|
19697
19576
|
});
|
|
19577
|
+
if (onPlaceholder) {
|
|
19578
|
+
try {
|
|
19579
|
+
onPlaceholder({ messageId: placeholder.id });
|
|
19580
|
+
} catch (e) {
|
|
19581
|
+
process.stderr.write(`[onPlaceholder] ${e instanceof Error ? e.message : String(e)}
|
|
19582
|
+
`);
|
|
19583
|
+
}
|
|
19584
|
+
}
|
|
19698
19585
|
let buf = "";
|
|
19699
19586
|
let finishReason;
|
|
19700
19587
|
let errored = false;
|
|
@@ -19721,32 +19608,90 @@ async function streamSpeakerTurn(args) {
|
|
|
19721
19608
|
if (!voiceProfile) return;
|
|
19722
19609
|
process.stderr.write(`[tts] emitVoiceText called: provider=${voiceProfile.provider} voiceId=${voiceProfile.voiceId} textLen=${text.length} text="${text.slice(0, 50)}"
|
|
19723
19610
|
`);
|
|
19724
|
-
|
|
19725
|
-
|
|
19726
|
-
|
|
19727
|
-
|
|
19728
|
-
|
|
19611
|
+
const MAX_ATTEMPTS = 2;
|
|
19612
|
+
const TIMEOUT_MS = 3e4;
|
|
19613
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
19614
|
+
if (signal.aborted) return;
|
|
19615
|
+
const timeoutCtrl = new AbortController();
|
|
19616
|
+
const timer = setTimeout(() => timeoutCtrl.abort(), TIMEOUT_MS);
|
|
19617
|
+
const onOuterAbort = () => timeoutCtrl.abort();
|
|
19618
|
+
signal.addEventListener("abort", onOuterAbort);
|
|
19619
|
+
let chunkCount = 0;
|
|
19620
|
+
let failure = null;
|
|
19621
|
+
try {
|
|
19622
|
+
for await (const chunk of synthesizeSpeechStream(text, voiceProfile, timeoutCtrl.signal)) {
|
|
19623
|
+
if (signal.aborted) break;
|
|
19624
|
+
chunkCount++;
|
|
19625
|
+
roomBus.emit(roomId, {
|
|
19626
|
+
type: "voice-chunk",
|
|
19627
|
+
messageId: placeholder.id,
|
|
19628
|
+
seq: voiceSeq++,
|
|
19629
|
+
text: chunk.text,
|
|
19630
|
+
provider: chunk.provider,
|
|
19631
|
+
model: chunk.model,
|
|
19632
|
+
voiceId: chunk.voiceId,
|
|
19633
|
+
...chunk.mimeType ? { mimeType: chunk.mimeType } : {},
|
|
19634
|
+
...chunk.audioBase64 ? { audioBase64: chunk.audioBase64 } : {}
|
|
19635
|
+
});
|
|
19636
|
+
}
|
|
19637
|
+
} catch (e) {
|
|
19638
|
+
failure = e instanceof Error ? e : new Error(String(e));
|
|
19639
|
+
} finally {
|
|
19640
|
+
clearTimeout(timer);
|
|
19641
|
+
signal.removeEventListener("abort", onOuterAbort);
|
|
19642
|
+
}
|
|
19643
|
+
if (signal.aborted) {
|
|
19644
|
+
process.stderr.write(`[tts] outer abort during attempt ${attempt}, giving up
|
|
19645
|
+
`);
|
|
19646
|
+
return;
|
|
19647
|
+
}
|
|
19648
|
+
if (!failure) {
|
|
19649
|
+
process.stderr.write(`[tts] emitVoiceText done (attempt ${attempt}/${MAX_ATTEMPTS}): ${chunkCount} chunks emitted
|
|
19650
|
+
`);
|
|
19651
|
+
return;
|
|
19652
|
+
}
|
|
19653
|
+
const billing = tryExtractTtsBillingError(failure);
|
|
19654
|
+
if (billing) {
|
|
19729
19655
|
roomBus.emit(roomId, {
|
|
19730
|
-
type: "voice-
|
|
19656
|
+
type: "voice-error",
|
|
19731
19657
|
messageId: placeholder.id,
|
|
19732
|
-
|
|
19733
|
-
|
|
19734
|
-
|
|
19735
|
-
|
|
19736
|
-
voiceId: chunk.voiceId,
|
|
19737
|
-
...chunk.mimeType ? { mimeType: chunk.mimeType } : {},
|
|
19738
|
-
...chunk.audioBase64 ? { audioBase64: chunk.audioBase64 } : {}
|
|
19658
|
+
code: billing.code,
|
|
19659
|
+
provider: billing.provider,
|
|
19660
|
+
message: billing.message,
|
|
19661
|
+
upgradeUrl: billing.upgradeUrl
|
|
19739
19662
|
});
|
|
19663
|
+
process.stderr.write(
|
|
19664
|
+
`[tts] BILLING-ERROR room=${roomId} agent=${speaker.name} provider=${voiceProfile.provider} \xB7 ${billing.message}
|
|
19665
|
+
`
|
|
19666
|
+
);
|
|
19667
|
+
return;
|
|
19740
19668
|
}
|
|
19741
|
-
|
|
19742
|
-
`);
|
|
19743
|
-
} catch (e) {
|
|
19669
|
+
const willRetry = attempt < MAX_ATTEMPTS && chunkCount === 0;
|
|
19744
19670
|
process.stderr.write(
|
|
19745
|
-
`[tts] ERROR room=${roomId} agent=${speaker.name} provider=${voiceProfile.provider} voiceId=${voiceProfile.voiceId} \xB7 ${
|
|
19746
|
-
`
|
|
19671
|
+
`[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")
|
|
19747
19672
|
);
|
|
19748
|
-
|
|
19749
|
-
|
|
19673
|
+
if (!willRetry) return;
|
|
19674
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
19675
|
+
}
|
|
19676
|
+
}
|
|
19677
|
+
const turnCtrl = new AbortController();
|
|
19678
|
+
const onRoomAbort = () => turnCtrl.abort();
|
|
19679
|
+
if (signal.aborted) turnCtrl.abort();
|
|
19680
|
+
else signal.addEventListener("abort", onRoomAbort);
|
|
19681
|
+
let hardCapTimedOut = false;
|
|
19682
|
+
let firstTokenTimedOut = false;
|
|
19683
|
+
const hardCapTimer = setTimeout(() => {
|
|
19684
|
+
if (!turnCtrl.signal.aborted) {
|
|
19685
|
+
hardCapTimedOut = true;
|
|
19686
|
+
turnCtrl.abort();
|
|
19687
|
+
}
|
|
19688
|
+
}, 12e4);
|
|
19689
|
+
let firstTokenTimer = setTimeout(() => {
|
|
19690
|
+
if (buf.length === 0 && !turnCtrl.signal.aborted) {
|
|
19691
|
+
firstTokenTimedOut = true;
|
|
19692
|
+
turnCtrl.abort();
|
|
19693
|
+
}
|
|
19694
|
+
}, 6e4);
|
|
19750
19695
|
try {
|
|
19751
19696
|
for await (const chunk of callLLMStream({
|
|
19752
19697
|
modelV: speaker.modelV,
|
|
@@ -19765,10 +19710,14 @@ async function streamSpeakerTurn(args) {
|
|
|
19765
19710
|
// the next move is to cap reasoning explicitly via providerOptions
|
|
19766
19711
|
// (`reasoning.max_tokens`) instead of just enlarging the total cap.
|
|
19767
19712
|
maxTokens: 4e3,
|
|
19768
|
-
signal
|
|
19713
|
+
signal: turnCtrl.signal
|
|
19769
19714
|
})) {
|
|
19770
19715
|
if (signal.aborted) break;
|
|
19771
19716
|
if (chunk.type === "text") {
|
|
19717
|
+
if (firstTokenTimer) {
|
|
19718
|
+
clearTimeout(firstTokenTimer);
|
|
19719
|
+
firstTokenTimer = null;
|
|
19720
|
+
}
|
|
19772
19721
|
buf += chunk.delta;
|
|
19773
19722
|
updateMessageBody(placeholder.id, buf, {
|
|
19774
19723
|
...placeholderMeta,
|
|
@@ -19841,7 +19790,14 @@ async function streamSpeakerTurn(args) {
|
|
|
19841
19790
|
}
|
|
19842
19791
|
} catch (e) {
|
|
19843
19792
|
errored = true;
|
|
19844
|
-
|
|
19793
|
+
let msg = e instanceof Error ? e.message : String(e);
|
|
19794
|
+
if (firstTokenTimedOut) {
|
|
19795
|
+
msg = `LLM did not produce any token within 60s \xB7 auto-skipped`;
|
|
19796
|
+
emitAutoSkipped(roomId, "llm", "llm-first-token-timeout", placeholder.id);
|
|
19797
|
+
} else if (hardCapTimedOut) {
|
|
19798
|
+
msg = `LLM stream exceeded 120s hard cap \xB7 auto-skipped`;
|
|
19799
|
+
emitAutoSkipped(roomId, "llm", "llm-timeout", placeholder.id);
|
|
19800
|
+
}
|
|
19845
19801
|
process.stderr.write(
|
|
19846
19802
|
`[stream-throw] room=${roomId} agent=${speaker.name} modelV=${speaker.modelV} \xB7 ${msg}
|
|
19847
19803
|
`
|
|
@@ -19857,7 +19813,16 @@ async function streamSpeakerTurn(args) {
|
|
|
19857
19813
|
messageId: placeholder.id,
|
|
19858
19814
|
message: msg
|
|
19859
19815
|
});
|
|
19860
|
-
|
|
19816
|
+
markVoicePlaybackDone(roomId, placeholder.id);
|
|
19817
|
+
if (!firstTokenTimedOut && !hardCapTimedOut) throw e;
|
|
19818
|
+
} finally {
|
|
19819
|
+
clearTimeout(hardCapTimer);
|
|
19820
|
+
if (firstTokenTimer) {
|
|
19821
|
+
clearTimeout(firstTokenTimer);
|
|
19822
|
+
firstTokenTimer = null;
|
|
19823
|
+
}
|
|
19824
|
+
signal.removeEventListener("abort", onRoomAbort);
|
|
19825
|
+
finalizeStreamingMessage(placeholder.id, "turn-cleanup");
|
|
19861
19826
|
}
|
|
19862
19827
|
if (signal.aborted) {
|
|
19863
19828
|
finishReason = finishReason ?? "aborted";
|
|
@@ -19895,6 +19860,14 @@ async function streamSpeakerTurn(args) {
|
|
|
19895
19860
|
messageId: placeholder.id,
|
|
19896
19861
|
finishReason
|
|
19897
19862
|
});
|
|
19863
|
+
if (onMessageFinal) {
|
|
19864
|
+
try {
|
|
19865
|
+
onMessageFinal({ messageId: placeholder.id });
|
|
19866
|
+
} catch (e) {
|
|
19867
|
+
process.stderr.write(`[onMessageFinal] ${e instanceof Error ? e.message : String(e)}
|
|
19868
|
+
`);
|
|
19869
|
+
}
|
|
19870
|
+
}
|
|
19898
19871
|
if (buf.trim().length >= 40) {
|
|
19899
19872
|
void (async () => {
|
|
19900
19873
|
try {
|
|
@@ -20391,6 +20364,17 @@ async function streamChairMessage(args) {
|
|
|
20391
20364
|
});
|
|
20392
20365
|
}
|
|
20393
20366
|
} catch (e) {
|
|
20367
|
+
const billing = tryExtractTtsBillingError(e);
|
|
20368
|
+
if (billing) {
|
|
20369
|
+
roomBus.emit(roomId, {
|
|
20370
|
+
type: "voice-error",
|
|
20371
|
+
messageId: placeholder.id,
|
|
20372
|
+
code: billing.code,
|
|
20373
|
+
provider: billing.provider,
|
|
20374
|
+
message: billing.message,
|
|
20375
|
+
upgradeUrl: billing.upgradeUrl
|
|
20376
|
+
});
|
|
20377
|
+
}
|
|
20394
20378
|
process.stderr.write(`[tts-chair] ${e instanceof Error ? e.message : String(e)}
|
|
20395
20379
|
`);
|
|
20396
20380
|
}
|
|
@@ -20566,13 +20550,34 @@ async function runChairClarify(roomId) {
|
|
|
20566
20550
|
}
|
|
20567
20551
|
}
|
|
20568
20552
|
if (turnNumber === 1) {
|
|
20569
|
-
|
|
20570
|
-
|
|
20553
|
+
emitChairPending(roomId, "clarify-deciding");
|
|
20554
|
+
let decision = null;
|
|
20555
|
+
try {
|
|
20556
|
+
decision = await withTimeout(
|
|
20557
|
+
pickChairClarifyDecision({ history }),
|
|
20558
|
+
15e3,
|
|
20559
|
+
"chair-clarify-decision"
|
|
20560
|
+
);
|
|
20561
|
+
} catch (e) {
|
|
20562
|
+
if (e instanceof TimeoutError) {
|
|
20563
|
+
process.stderr.write(`[chair-clarify] decision timeout \u2014 skipping clarify
|
|
20564
|
+
`);
|
|
20565
|
+
emitAutoSkipped(roomId, "clarify", "clarify-timeout");
|
|
20566
|
+
decision = { shouldAsk: false, rationale: "timeout" };
|
|
20567
|
+
} else {
|
|
20568
|
+
process.stderr.write(
|
|
20569
|
+
`[chair-clarify] decision error: ${e instanceof Error ? e.message : String(e)}
|
|
20570
|
+
`
|
|
20571
|
+
);
|
|
20572
|
+
decision = { shouldAsk: false, rationale: "error" };
|
|
20573
|
+
}
|
|
20574
|
+
}
|
|
20575
|
+
if (!decision || !decision.shouldAsk) {
|
|
20571
20576
|
setAwaitingClarify(roomId, false);
|
|
20572
20577
|
roomBus.emit(roomId, {
|
|
20573
20578
|
type: "config-event",
|
|
20574
20579
|
kind: "clarify-ready",
|
|
20575
|
-
payload: { skipped: true, rationale: decision
|
|
20580
|
+
payload: { skipped: true, rationale: decision?.rationale },
|
|
20576
20581
|
createdAt: Date.now()
|
|
20577
20582
|
});
|
|
20578
20583
|
return { asked: false, ready: true, exhausted: false };
|
|
@@ -20782,6 +20787,17 @@ async function emitChairAnnouncementVoice(roomId, messageId, body) {
|
|
|
20782
20787
|
roomBus.emit(roomId, { type: "voice-final", messageId });
|
|
20783
20788
|
await waitForVoicePlayback(roomId, messageId, 6e4);
|
|
20784
20789
|
} catch (e) {
|
|
20790
|
+
const billing = tryExtractTtsBillingError(e);
|
|
20791
|
+
if (billing) {
|
|
20792
|
+
roomBus.emit(roomId, {
|
|
20793
|
+
type: "voice-error",
|
|
20794
|
+
messageId,
|
|
20795
|
+
code: billing.code,
|
|
20796
|
+
provider: billing.provider,
|
|
20797
|
+
message: billing.message,
|
|
20798
|
+
upgradeUrl: billing.upgradeUrl
|
|
20799
|
+
});
|
|
20800
|
+
}
|
|
20785
20801
|
process.stderr.write(`[tts-chair-announce] ${e instanceof Error ? e.message : String(e)}
|
|
20786
20802
|
`);
|
|
20787
20803
|
}
|
|
@@ -21689,42 +21705,12 @@ function roomsRouter() {
|
|
|
21689
21705
|
payload: { mode, intensity, briefStyle, deliveryMode, members: members.map((m) => m.agentId), autoPick },
|
|
21690
21706
|
actorKind: "user"
|
|
21691
21707
|
});
|
|
21692
|
-
let seedContext = null;
|
|
21693
|
-
if (b.seedContext && typeof b.seedContext === "object") {
|
|
21694
|
-
const raw = b.seedContext;
|
|
21695
|
-
const topicRecId = typeof raw.topicRecId === "string" && raw.topicRecId.trim().length > 0 ? raw.topicRecId.trim().slice(0, 64) : void 0;
|
|
21696
|
-
const rationale = typeof raw.rationale === "string" && raw.rationale.trim().length > 0 ? raw.rationale.trim().slice(0, 400) : void 0;
|
|
21697
|
-
const rawSnippets = Array.isArray(raw.snippets) ? raw.snippets : [];
|
|
21698
|
-
const snippets = rawSnippets.filter(
|
|
21699
|
-
(s) => !!s && typeof s === "object" && typeof s.title === "string" && typeof s.url === "string" && typeof s.description === "string"
|
|
21700
|
-
).slice(0, 12).map((s) => ({
|
|
21701
|
-
title: s.title.slice(0, 200),
|
|
21702
|
-
url: s.url.slice(0, 600),
|
|
21703
|
-
description: s.description.slice(0, 600)
|
|
21704
|
-
}));
|
|
21705
|
-
if (topicRecId || rationale || snippets.length > 0) {
|
|
21706
|
-
seedContext = {
|
|
21707
|
-
...topicRecId ? { topicRecId } : {},
|
|
21708
|
-
...rationale ? { rationale } : {},
|
|
21709
|
-
...snippets.length > 0 ? { snippets } : {}
|
|
21710
|
-
};
|
|
21711
|
-
}
|
|
21712
|
-
}
|
|
21713
21708
|
const opening = insertMessage({
|
|
21714
21709
|
roomId: room.id,
|
|
21715
21710
|
authorKind: "user",
|
|
21716
21711
|
body: subject,
|
|
21717
|
-
roundNum: 1
|
|
21718
|
-
meta: seedContext ? { seedContext } : void 0
|
|
21712
|
+
roundNum: 1
|
|
21719
21713
|
});
|
|
21720
|
-
if (seedContext?.topicRecId) {
|
|
21721
|
-
try {
|
|
21722
|
-
markTopicRecOpened(seedContext.topicRecId, room.id);
|
|
21723
|
-
} catch (e) {
|
|
21724
|
-
process.stderr.write(`[rooms] topic-rec link failed: ${e instanceof Error ? e.message : String(e)}
|
|
21725
|
-
`);
|
|
21726
|
-
}
|
|
21727
|
-
}
|
|
21728
21714
|
roomBus.emit(room.id, {
|
|
21729
21715
|
type: "message-appended",
|
|
21730
21716
|
messageId: opening.id,
|
|
@@ -21856,13 +21842,19 @@ function roomsRouter() {
|
|
|
21856
21842
|
r.get("/:id/stream", (c) => {
|
|
21857
21843
|
const id = c.req.param("id");
|
|
21858
21844
|
if (!getRoom(id)) return c.json({ error: "not found" }, 404);
|
|
21859
|
-
|
|
21845
|
+
const lastIdHeader = c.req.header("last-event-id");
|
|
21846
|
+
const sinceId = lastIdHeader ? Number.parseInt(lastIdHeader, 10) : NaN;
|
|
21847
|
+
return streamSSE2(c, async (s) => {
|
|
21860
21848
|
await s.writeSSE({ event: "hello", data: JSON.stringify({ roomId: id, ts: Date.now() }) });
|
|
21861
21849
|
const queue = [];
|
|
21862
21850
|
let resolveWaiter = null;
|
|
21863
21851
|
let closed = false;
|
|
21864
|
-
|
|
21865
|
-
|
|
21852
|
+
if (Number.isFinite(sinceId) && sinceId > 0) {
|
|
21853
|
+
const missed = roomBus.replay(id, sinceId);
|
|
21854
|
+
for (const m of missed) queue.push({ id: m.id, event: m.event });
|
|
21855
|
+
}
|
|
21856
|
+
const off = roomBus.subscribeWithId(id, (eventId, event) => {
|
|
21857
|
+
queue.push({ id: eventId, event });
|
|
21866
21858
|
if (resolveWaiter) {
|
|
21867
21859
|
resolveWaiter();
|
|
21868
21860
|
resolveWaiter = null;
|
|
@@ -21883,8 +21875,12 @@ function roomsRouter() {
|
|
|
21883
21875
|
});
|
|
21884
21876
|
continue;
|
|
21885
21877
|
}
|
|
21886
|
-
const event = queue.shift();
|
|
21887
|
-
await s.writeSSE({
|
|
21878
|
+
const { id: eventId, event } = queue.shift();
|
|
21879
|
+
await s.writeSSE({
|
|
21880
|
+
id: String(eventId),
|
|
21881
|
+
event: event.type,
|
|
21882
|
+
data: JSON.stringify(event)
|
|
21883
|
+
});
|
|
21888
21884
|
}
|
|
21889
21885
|
});
|
|
21890
21886
|
});
|
|
@@ -21980,6 +21976,12 @@ function roomsRouter() {
|
|
|
21980
21976
|
if (!getRoom(id)) return c.json({ error: "not found" }, 404);
|
|
21981
21977
|
return c.json({ ok: markVoicePlaybackDone(id, messageId) });
|
|
21982
21978
|
});
|
|
21979
|
+
r.post("/:id/messages/:messageId/voice-progress", (c) => {
|
|
21980
|
+
const id = c.req.param("id");
|
|
21981
|
+
const messageId = c.req.param("messageId");
|
|
21982
|
+
if (!getRoom(id)) return c.json({ error: "not found" }, 404);
|
|
21983
|
+
return c.json({ ok: bumpVoicePlaybackHeartbeat(id, messageId) });
|
|
21984
|
+
});
|
|
21983
21985
|
r.post("/:id/pause", async (c) => {
|
|
21984
21986
|
const id = c.req.param("id");
|
|
21985
21987
|
const room = getRoom(id);
|
|
@@ -22786,13 +22788,22 @@ function voicesRouter() {
|
|
|
22786
22788
|
ttsCacheSet(key, out);
|
|
22787
22789
|
return c.json(out);
|
|
22788
22790
|
} catch (e) {
|
|
22791
|
+
const tagged = e ?? {};
|
|
22789
22792
|
const msg = ttsErrorMessage(e, profile.provider);
|
|
22790
22793
|
const isNoKey = /401|403|api[\s-]?key|unauthor/i.test(msg);
|
|
22791
|
-
|
|
22794
|
+
const payload = {
|
|
22792
22795
|
error: msg,
|
|
22793
|
-
|
|
22794
|
-
|
|
22795
|
-
|
|
22796
|
+
provider: typeof tagged.provider === "string" ? tagged.provider : profile.provider
|
|
22797
|
+
};
|
|
22798
|
+
if (typeof tagged.code === "string") {
|
|
22799
|
+
payload.code = tagged.code;
|
|
22800
|
+
} else {
|
|
22801
|
+
payload.code = isNoKey ? "no-key" : "tts-error";
|
|
22802
|
+
}
|
|
22803
|
+
if (typeof tagged.upgradeUrl === "string") {
|
|
22804
|
+
payload.upgradeUrl = tagged.upgradeUrl;
|
|
22805
|
+
}
|
|
22806
|
+
return c.json(payload, 502);
|
|
22796
22807
|
}
|
|
22797
22808
|
});
|
|
22798
22809
|
return r;
|
|
@@ -22802,7 +22813,7 @@ function voicesRouter() {
|
|
|
22802
22813
|
init_paths();
|
|
22803
22814
|
|
|
22804
22815
|
// src/version.ts
|
|
22805
|
-
var VERSION = "0.1.
|
|
22816
|
+
var VERSION = "0.1.25";
|
|
22806
22817
|
|
|
22807
22818
|
// src/server.ts
|
|
22808
22819
|
function createApp() {
|
|
@@ -22845,8 +22856,8 @@ Build the package or check that public/ is bundled alongside dist/.`
|
|
|
22845
22856
|
app.route("/api/prefs", prefsRouter());
|
|
22846
22857
|
app.route("/api/agents", agentsRouter());
|
|
22847
22858
|
app.route("/api/keys", keysRouter());
|
|
22859
|
+
app.route("/api/credentials", credentialsRouter());
|
|
22848
22860
|
app.route("/api/models", modelsRouter());
|
|
22849
|
-
app.route("/api/topic-recs", topicRecsRouter());
|
|
22850
22861
|
app.route("/api/rooms", roomsRouter());
|
|
22851
22862
|
app.route("/api/briefs", briefsRouter());
|
|
22852
22863
|
app.route("/api/notes", notesRouter());
|