privateboard 0.1.36 → 0.1.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/boot.js CHANGED
@@ -888,6 +888,30 @@ var init_room_name_auto = __esm({
888
888
  }
889
889
  });
890
890
 
891
+ // src/storage/migrations/053_room_threads.sql
892
+ var room_threads_default;
893
+ var init_room_threads = __esm({
894
+ "src/storage/migrations/053_room_threads.sql"() {
895
+ room_threads_default = "-- 053_room_threads.sql \xB7 Private 1:1 threads with a single director.\n--\n-- A \"thread\" is a lightweight room spawned from a live room (the\n-- parent) for the user to pull one director aside without exposing\n-- the conversation to the other directors. The thread reuses the\n-- regular `rooms` / `messages` / `room_members` plumbing \u2014 only two\n-- discriminators tell it apart:\n--\n-- room_kind \xB7 'main' (default \xB7 all existing rows) or 'thread'\n-- thread_director_id \xB7 the single director the thread is with;\n-- NULL on main rooms\n--\n-- parent_room_id (added in 020) is reused to point at the parent\n-- main room. The follow-up-room feature also uses parent_room_id,\n-- so callers must disambiguate via room_kind (follow-up = 'main'\n-- with parent_room_id set; thread = 'thread' with both set).\n--\n-- No FK constraints \u2014 main room deletion handled at storage layer\n-- so a future hard-delete path can cascade thread rooms explicitly.\n\nALTER TABLE rooms ADD COLUMN room_kind TEXT NOT NULL DEFAULT 'main';\nALTER TABLE rooms ADD COLUMN thread_director_id TEXT NULL;\n\n-- Index the parent \u2192 child relationship for the common dock-bar /\n-- \"list threads in this room\" query. Filter to threads only so the\n-- much larger main-room population doesn't dominate the index.\nCREATE INDEX IF NOT EXISTS idx_rooms_parent_thread\n ON rooms (parent_room_id, room_kind)\n WHERE room_kind = 'thread';\n";
896
+ }
897
+ });
898
+
899
+ // src/storage/migrations/054_voice_clone_jobs.sql
900
+ var voice_clone_jobs_default;
901
+ var init_voice_clone_jobs = __esm({
902
+ "src/storage/migrations/054_voice_clone_jobs.sql"() {
903
+ voice_clone_jobs_default = "-- 054_voice_clone_jobs.sql \xB7 Persist voice-clone jobs across a process restart.\n--\n-- A clone job spans 5-60 s (YouTube fetch \u2192 upload \u2192 clone) and the\n-- user can minimize the modal into a bottom-right pill. If the\n-- process is killed mid-job (Ctrl+C, hard restart) the row would\n-- otherwise be left in `running` forever; boot-time recovery (see\n-- src/boot.ts) flips stale `running` rows to `failed` so the UI can\n-- surface \"last clone was interrupted\" instead of silently losing it.\n--\n-- Fields:\n-- id uuid \xB7 primary key, also exposed to client as jobId\n-- agent_id director receiving the cloned voice\n-- provider 'minimax' | 'elevenlabs' \xB7 resolved from active credential at start\n-- source_kind 'file' | 'youtube'\n-- source_ref absolute filesystem path (file) or YouTube URL (youtube)\n-- label user-supplied display label for the new voice; nullable\n-- status 'queued' | 'running' | 'done' | 'failed' | 'cancelled'\n-- current_stage 'fetch' | 'upload' | 'clone' \xB7 which 3rd of the pipeline\n-- pct 0-100 overall progress (each stage covers ~33 pp)\n-- voice_id provider-issued voice id when status='done'; NULL otherwise\n-- error_code short token like 'yt_age_gated' / 'provider_quota'\n-- error_message human-readable detail for the modal\n-- created_at epoch millis\n-- updated_at epoch millis \xB7 refreshed on every progress write\n\nCREATE TABLE IF NOT EXISTS clone_jobs (\n id TEXT PRIMARY KEY,\n agent_id TEXT NOT NULL,\n provider TEXT NOT NULL,\n source_kind TEXT NOT NULL,\n source_ref TEXT NOT NULL,\n label TEXT,\n status TEXT NOT NULL DEFAULT 'queued',\n current_stage TEXT NOT NULL DEFAULT 'fetch',\n pct INTEGER NOT NULL DEFAULT 0,\n voice_id TEXT,\n error_code TEXT,\n error_message TEXT,\n created_at INTEGER NOT NULL,\n updated_at INTEGER NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS idx_clone_jobs_status_updated\n ON clone_jobs (status, updated_at DESC);\n\nCREATE INDEX IF NOT EXISTS idx_clone_jobs_agent_running\n ON clone_jobs (agent_id, status)\n WHERE status IN ('queued', 'running');\n";
904
+ }
905
+ });
906
+
907
+ // src/storage/migrations/055_voice_labels.sql
908
+ var voice_labels_default;
909
+ var init_voice_labels = __esm({
910
+ "src/storage/migrations/055_voice_labels.sql"() {
911
+ voice_labels_default = "-- 055_voice_labels.sql \xB7 Persist user-supplied friendly names for\n-- provider voice_ids that don't have a name field of their own.\n--\n-- MiniMax's `voice_clone` API has no `name` parameter \u2014 the voice_id\n-- IS the dashboard label \u2014 so when the user typed \"Chloe\" in the\n-- clone modal, that string never reaches MiniMax. Previously we\n-- mirrored the label to localStorage, which dies the moment the\n-- user clears site data or moves to another machine. This table is\n-- the durable record. `listVoicesPage` merges it into the catalogue\n-- response so the picker / trigger / message-author labels stay\n-- friendly across reloads, devices, and DB exports.\n--\n-- Fields:\n-- voice_id TEXT PRIMARY KEY \xB7 provider-issued voice id\n-- provider TEXT NOT NULL \xB7 'minimax' | 'elevenlabs'\n-- label TEXT NOT NULL \xB7 user-typed name from the clone modal\n-- created_at INTEGER NOT NULL \xB7 epoch ms\n-- updated_at INTEGER NOT NULL \xB7 epoch ms \xB7 refreshed on rename\n\nCREATE TABLE IF NOT EXISTS voice_labels (\n voice_id TEXT PRIMARY KEY,\n provider TEXT NOT NULL,\n label TEXT NOT NULL,\n created_at INTEGER NOT NULL,\n updated_at INTEGER NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS voice_labels_provider_idx\n ON voice_labels(provider);\n";
912
+ }
913
+ });
914
+
891
915
  // src/storage/db.ts
892
916
  var db_exports = {};
893
917
  __export(db_exports, {
@@ -1014,6 +1038,9 @@ var init_db = __esm({
1014
1038
  init_agent_provider_buckets();
1015
1039
  init_search_credentials();
1016
1040
  init_room_name_auto();
1041
+ init_room_threads();
1042
+ init_voice_clone_jobs();
1043
+ init_voice_labels();
1017
1044
  MIGRATIONS = [
1018
1045
  { name: "001_init.sql", sql: init_default },
1019
1046
  { name: "002_default_opus.sql", sql: default_opus_default },
@@ -1066,7 +1093,10 @@ var init_db = __esm({
1066
1093
  { name: "049_voice_credentials.sql", sql: voice_credentials_default },
1067
1094
  { name: "050_agent_provider_buckets.sql", sql: agent_provider_buckets_default },
1068
1095
  { name: "051_search_credentials.sql", sql: search_credentials_default },
1069
- { name: "052_room_name_auto.sql", sql: room_name_auto_default }
1096
+ { name: "052_room_name_auto.sql", sql: room_name_auto_default },
1097
+ { name: "053_room_threads.sql", sql: room_threads_default },
1098
+ { name: "054_voice_clone_jobs.sql", sql: voice_clone_jobs_default },
1099
+ { name: "055_voice_labels.sql", sql: voice_labels_default }
1070
1100
  ];
1071
1101
  _db = null;
1072
1102
  }
@@ -2605,8 +2635,8 @@ function runSeed() {
2605
2635
  // src/server.ts
2606
2636
  import { serve } from "@hono/node-server";
2607
2637
  import { serveStatic } from "@hono/node-server/serve-static";
2608
- import { Hono as Hono15 } from "hono";
2609
- import { existsSync as existsSync2 } from "fs";
2638
+ import { Hono as Hono17 } from "hono";
2639
+ import { existsSync as existsSync3 } from "fs";
2610
2640
 
2611
2641
  // src/routes/agents.ts
2612
2642
  import { Hono } from "hono";
@@ -14212,7 +14242,7 @@ function cleanupOrphanedStreams(opts = {}) {
14212
14242
 
14213
14243
  // src/storage/rooms.ts
14214
14244
  init_db();
14215
- var ROOM_COLS = "id, number, name, subject, mode, intensity, delivery_mode, vote_trigger, status, brief_style, awaiting_continue, awaiting_clarify, created_at, paused_at, adjourned_at, incognito, parent_room_id, parent_brief_id, name_auto";
14245
+ var ROOM_COLS = "id, number, name, subject, mode, intensity, delivery_mode, vote_trigger, status, brief_style, awaiting_continue, awaiting_clarify, created_at, paused_at, adjourned_at, incognito, parent_room_id, parent_brief_id, name_auto, room_kind, thread_director_id";
14216
14246
  function mapRow8(row) {
14217
14247
  return {
14218
14248
  id: row.id,
@@ -14233,7 +14263,9 @@ function mapRow8(row) {
14233
14263
  incognito: row.incognito === 1,
14234
14264
  parentRoomId: row.parent_room_id,
14235
14265
  parentBriefId: row.parent_brief_id,
14236
- nameAuto: row.name_auto === 1
14266
+ nameAuto: row.name_auto === 1,
14267
+ kind: row.room_kind === "thread" ? "thread" : "main",
14268
+ threadDirectorId: row.thread_director_id
14237
14269
  };
14238
14270
  }
14239
14271
  function mapMember(row) {
@@ -14245,7 +14277,9 @@ function mapMember(row) {
14245
14277
  };
14246
14278
  }
14247
14279
  function listRooms() {
14248
- const rows = getDb().prepare(`SELECT ${ROOM_COLS} FROM rooms ORDER BY created_at DESC`).all();
14280
+ const rows = getDb().prepare(
14281
+ `SELECT ${ROOM_COLS} FROM rooms WHERE room_kind = 'main' ORDER BY created_at DESC`
14282
+ ).all();
14249
14283
  return rows.map(mapRow8);
14250
14284
  }
14251
14285
  function getRoom(id) {
@@ -14265,11 +14299,65 @@ function listAllRoomMembers(roomId) {
14265
14299
  return rows.map(mapMember);
14266
14300
  }
14267
14301
  function listFollowUpRooms(parentRoomId) {
14268
- const rows = getDb().prepare(`SELECT ${ROOM_COLS} FROM rooms WHERE parent_room_id = ? ORDER BY created_at DESC`).all(parentRoomId);
14302
+ const rows = getDb().prepare(
14303
+ `SELECT ${ROOM_COLS} FROM rooms WHERE parent_room_id = ? AND room_kind = 'main' ORDER BY created_at DESC`
14304
+ ).all(parentRoomId);
14305
+ return rows.map(mapRow8);
14306
+ }
14307
+ function listThreadsForRoom(parentRoomId, opts = {}) {
14308
+ const params = [parentRoomId];
14309
+ let sql = `SELECT ${ROOM_COLS} FROM rooms WHERE parent_room_id = ? AND room_kind = 'thread'`;
14310
+ if (opts.directorId) {
14311
+ sql += ` AND thread_director_id = ?`;
14312
+ params.push(opts.directorId);
14313
+ }
14314
+ sql += ` ORDER BY created_at DESC`;
14315
+ const rows = getDb().prepare(sql).all(...params);
14269
14316
  return rows.map(mapRow8);
14270
14317
  }
14318
+ function createThread(parentRoomId, directorId) {
14319
+ const parent = getRoom(parentRoomId);
14320
+ if (!parent) throw new Error(`createThread \xB7 parent room ${parentRoomId} not found`);
14321
+ if (parent.kind !== "main") {
14322
+ throw new Error(`createThread \xB7 parent room ${parentRoomId} is a ${parent.kind}; threads can only spawn from main rooms`);
14323
+ }
14324
+ const parentMembers = listRoomMembers(parentRoomId);
14325
+ const isMember = parentMembers.some((m) => m.agentId === directorId);
14326
+ if (!isMember) {
14327
+ throw new Error(`createThread \xB7 director ${directorId} is not a member of parent room ${parentRoomId}`);
14328
+ }
14329
+ const db = getDb();
14330
+ const id = newId();
14331
+ const number = nextRoomNumber();
14332
+ const now = Date.now();
14333
+ const subject = parent.subject;
14334
+ const name = subject.slice(0, 60);
14335
+ const mode = parent.mode;
14336
+ const intensity = parent.intensity;
14337
+ const deliveryMode = "text";
14338
+ const voteTrigger = "manual";
14339
+ const insertRoom = db.prepare(
14340
+ `INSERT INTO rooms (
14341
+ id, number, name, subject, mode, intensity, delivery_mode, vote_trigger,
14342
+ brief_style, status, created_at,
14343
+ parent_room_id, parent_brief_id, name_auto, room_kind, thread_director_id
14344
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 'live', ?, ?, NULL, 1, 'thread', ?)`
14345
+ );
14346
+ const insertMember = db.prepare(
14347
+ "INSERT INTO room_members (room_id, agent_id, position, joined_at) VALUES (?, ?, ?, ?)"
14348
+ );
14349
+ const tx = db.transaction(() => {
14350
+ insertRoom.run(id, number, name, subject, mode, intensity, deliveryMode, voteTrigger, now, parentRoomId, directorId);
14351
+ insertMember.run(id, directorId, 0, now);
14352
+ });
14353
+ tx();
14354
+ return {
14355
+ room: getRoom(id),
14356
+ members: listRoomMembers(id)
14357
+ };
14358
+ }
14271
14359
  function recentDirectorAppearances(windowSize) {
14272
- const rooms = getDb().prepare("SELECT id FROM rooms ORDER BY created_at DESC LIMIT ?").all(Math.max(1, Math.floor(windowSize)));
14360
+ const rooms = getDb().prepare("SELECT id FROM rooms WHERE room_kind = 'main' ORDER BY created_at DESC LIMIT ?").all(Math.max(1, Math.floor(windowSize)));
14273
14361
  const counts = /* @__PURE__ */ new Map();
14274
14362
  if (rooms.length === 0) return counts;
14275
14363
  const placeholders = rooms.map(() => "?").join(",");
@@ -14340,6 +14428,18 @@ function setRoomNameFromAuto(roomId, name) {
14340
14428
  const r = getDb().prepare("UPDATE rooms SET name = ? WHERE id = ? AND name_auto = 1").run(trimmed, roomId);
14341
14429
  return r.changes > 0;
14342
14430
  }
14431
+ function forceRoomAutoName(roomId, name) {
14432
+ const trimmed = name.trim();
14433
+ if (!trimmed) return false;
14434
+ const r = getDb().prepare("UPDATE rooms SET name = ?, name_auto = 1 WHERE id = ?").run(trimmed, roomId);
14435
+ return r.changes > 0;
14436
+ }
14437
+ function setRoomSubject(roomId, next) {
14438
+ const trimmed = next.trim();
14439
+ if (!trimmed) return false;
14440
+ const r = getDb().prepare("UPDATE rooms SET subject = ? WHERE id = ?").run(trimmed, roomId);
14441
+ return r.changes > 0;
14442
+ }
14343
14443
  function addRoomMember(roomId, agentId) {
14344
14444
  const db = getDb();
14345
14445
  const existing = db.prepare("SELECT agent_id, position, joined_at, removed_at FROM room_members WHERE room_id = ? AND agent_id = ?").get(roomId, agentId);
@@ -14956,6 +15056,52 @@ function getActiveVoiceKeyPlaintext() {
14956
15056
  return getVoiceCredentialKey(active.id);
14957
15057
  }
14958
15058
 
15059
+ // src/storage/voice-labels.ts
15060
+ init_db();
15061
+ function rowToLabel(r) {
15062
+ return {
15063
+ voiceId: r.voice_id,
15064
+ provider: r.provider,
15065
+ label: r.label,
15066
+ createdAt: r.created_at,
15067
+ updatedAt: r.updated_at
15068
+ };
15069
+ }
15070
+ function setVoiceLabel(input) {
15071
+ const now = Date.now();
15072
+ const id = (input.voiceId || "").trim();
15073
+ const label = (input.label || "").trim();
15074
+ if (!id || !label) return;
15075
+ getDb().prepare(
15076
+ `INSERT INTO voice_labels (voice_id, provider, label, created_at, updated_at)
15077
+ VALUES (?, ?, ?, ?, ?)
15078
+ ON CONFLICT(voice_id) DO UPDATE SET
15079
+ provider = excluded.provider,
15080
+ label = excluded.label,
15081
+ updated_at = excluded.updated_at`
15082
+ ).run(id, input.provider, label, now, now);
15083
+ }
15084
+ function getVoiceLabelMap(voiceIds) {
15085
+ const out = /* @__PURE__ */ new Map();
15086
+ if (voiceIds.length === 0) return out;
15087
+ const CHUNK = 500;
15088
+ for (let i = 0; i < voiceIds.length; i += CHUNK) {
15089
+ const slice = voiceIds.slice(i, i + CHUNK);
15090
+ const placeholders = slice.map(() => "?").join(",");
15091
+ const rows = getDb().prepare(`SELECT voice_id, label FROM voice_labels WHERE voice_id IN (${placeholders})`).all(...slice);
15092
+ for (const r of rows) out.set(r.voice_id, r.label);
15093
+ }
15094
+ return out;
15095
+ }
15096
+ function listVoiceLabels() {
15097
+ const rows = getDb().prepare(`SELECT * FROM voice_labels ORDER BY updated_at DESC`).all();
15098
+ return rows.map(rowToLabel);
15099
+ }
15100
+ function deleteVoiceLabel(voiceId) {
15101
+ const r = getDb().prepare(`DELETE FROM voice_labels WHERE voice_id = ?`).run(voiceId);
15102
+ return r.changes > 0;
15103
+ }
15104
+
14959
15105
  // src/voice/registry.ts
14960
15106
  function minimaxBaseUrl() {
14961
15107
  const region = getPrefs().minimaxRegion;
@@ -15091,6 +15237,7 @@ async function fetchAllElevenLabsV2Voices(apiKey) {
15091
15237
  }
15092
15238
  const json = await res.json();
15093
15239
  const rows = elevenLabsV2VoiceRows(json.voices);
15240
+ rows.sort((a, b) => elevenLabsCategoryRank(a.category) - elevenLabsCategoryRank(b.category));
15094
15241
  for (const r of rows) {
15095
15242
  out.push({
15096
15243
  provider: "elevenlabs",
@@ -15125,6 +15272,11 @@ async function fetchAllElevenLabsV2Voices(apiKey) {
15125
15272
  );
15126
15273
  return { voices: out, error: lastError };
15127
15274
  }
15275
+ function elevenLabsCategoryRank(category) {
15276
+ if (category === "cloned" || category === "professional") return 0;
15277
+ if (category === "generated") return 2;
15278
+ return 1;
15279
+ }
15128
15280
  function elevenLabsV2VoiceRows(raw) {
15129
15281
  if (!Array.isArray(raw)) return [];
15130
15282
  const out = [];
@@ -15180,8 +15332,8 @@ async function fetchAllMiniMaxVoices(apiKey) {
15180
15332
  }
15181
15333
  const json = await res.json();
15182
15334
  const rows = [
15183
- ...voiceRows(json.system_voice, "system"),
15184
15335
  ...voiceRows(json.voice_cloning, "clone"),
15336
+ ...voiceRows(json.system_voice, "system"),
15185
15337
  ...voiceRows(json.voice_generation, "generated")
15186
15338
  ];
15187
15339
  if (rows.length === 0) {
@@ -15245,7 +15397,7 @@ async function listVoicesPage(cursorStr, pageSize) {
15245
15397
  if (activeProvider === "elevenlabs") {
15246
15398
  const { voices: all, error } = await getElevenLabsVoicesCached(activeKey);
15247
15399
  const offset = cursor && cursor.src === "el" ? cursor.offset ?? 0 : 0;
15248
- const slice = all.slice(offset, offset + size);
15400
+ const slice = mergeCustomLabels(all.slice(offset, offset + size));
15249
15401
  const next = offset + slice.length;
15250
15402
  const hasMore = next < all.length;
15251
15403
  const nextCursor = hasMore ? encodeCursor({ src: "el", offset: next }) : null;
@@ -15266,7 +15418,7 @@ async function listVoicesPage(cursorStr, pageSize) {
15266
15418
  if (activeProvider === "minimax") {
15267
15419
  const all = await getMiniMaxVoicesCached(activeKey);
15268
15420
  const offset = cursor && cursor.src === "mm" ? cursor.offset ?? 0 : 0;
15269
- const slice = all.slice(offset, offset + size);
15421
+ const slice = mergeCustomLabels(all.slice(offset, offset + size));
15270
15422
  const next = offset + slice.length;
15271
15423
  const hasMore = next < all.length;
15272
15424
  const nextCursor = hasMore ? encodeCursor({ src: "mm", offset: next }) : null;
@@ -15282,6 +15434,22 @@ async function listVoicesPage(cursorStr, pageSize) {
15282
15434
  configured: true
15283
15435
  };
15284
15436
  }
15437
+ function mergeCustomLabels(voices) {
15438
+ const ids = voices.map((v) => v.voiceId).filter((id) => !!id);
15439
+ if (ids.length === 0) return voices;
15440
+ const labelMap = getVoiceLabelMap(ids);
15441
+ if (labelMap.size === 0) return voices;
15442
+ return voices.map((v) => {
15443
+ const custom = v.voiceId ? labelMap.get(v.voiceId) : void 0;
15444
+ if (!custom) return v;
15445
+ if (v.label && v.label !== v.voiceId) return v;
15446
+ return { ...v, label: custom };
15447
+ });
15448
+ }
15449
+ function invalidateVoicesCache() {
15450
+ miniMaxCache.clear();
15451
+ elevenLabsCache.clear();
15452
+ }
15285
15453
  async function listAvailableVoices() {
15286
15454
  const voices = [];
15287
15455
  let cursor = null;
@@ -19043,7 +19211,9 @@ var TONE_GUIDANCE = {
19043
19211
  "1\u20132 \u53E5\u3002\u7ED9\u8FD9\u4E2A idea \u4E00\u4E2A**\u66F4\u6709\u4F20\u64AD\u529B\u7684\u8BF4\u6CD5**\u2014\u2014\u4E00\u53E5 slogan\u3001\u4E00\u4E2A\u65B0\u540D\u5B57\u3001\u4E00\u4E2A\u5BF9\u5916\u8BB2\u5F97\u6E05\u695A\u7684\u5B9A\u4F4D\u3001\u4E00\u4E2A\u8BA9\u4EBA\u8BB0\u4F4F\u7684\u6BD4\u55BB\u3002",
19044
19212
  "",
19045
19213
  "\u3010\u4E00\u4E2A\u5177\u4F53\u505A\u6CD5\u3011",
19046
- "1\u20133 \u53E5\u3002\u7ED9\u4E00\u4E2A**\u6700\u5C0F\u53EF\u6267\u884C**\u7684\u5177\u4F53\u52A8\u4F5C / \u5B9E\u9A8C / \u7B2C\u4E00\u6B65\u5F62\u6001\u3002**\u4E0B\u5468\u5C31\u80FD\u505A\u7684\u4E8B**\uFF0C\u4E0D\u662F\u5B8F\u5927\u84DD\u56FE\u3002",
19214
+ "1\u20133 \u53E5\u3002\u7ED9\u4E00\u4E2A**\u6700\u5C0F\u53EF\u6267\u884C**\u7684\u5177\u4F53\u52A8\u4F5C / \u5B9E\u9A8C / \u7B2C\u4E00\u6B65\u5F62\u6001\u2014\u2014\u53EF\u4EE5\u662F\u4E00\u4E2A\u539F\u578B\u3001\u4E00\u901A\u7535\u8BDD\u3001\u4E00\u6B21\u5C0F\u6D4B\u3001\u4E00\u4EFD\u8349\u6848\u3001\u4E00\u4E2A\u80FD\u8DD1\u901A\u7684\u6700\u5C0F\u95ED\u73AF\u3002\u91CD\u70B9\u662F\u300C\u80FD\u7ACB\u523B\u52A8\u624B\u3001\u89C4\u6A21\u53EF\u63A7\u300D\uFF0C\u4E0D\u662F\u5B8F\u5927\u84DD\u56FE\u3002",
19215
+ " \xB7 **\u4E0D\u8981\u7528\u6A21\u677F\u8154**\u5199\u300C\u4E0B\u5468\u5C31\u80FD\u505A\u7684\u4E8B\u300D\u300C\u8FD9\u5468\u5C31\u80FD\u52A8\u624B\u300D\u300C\u660E\u5929\u5C31\u80FD\u5F00\u59CB\u300D\u8FD9\u4E00\u7C7B**\u6B7B\u677F\u65F6\u95F4\u8868\u8FBE**\u2014\u2014\u4EFB\u4F55 director \u4E00\u65E6\u673A\u68B0\u590D\u8BFB\u300C\u4E0B\u5468\u5C31\u80FD\u505A\u300D\u300C\u4E0B\u5468\u53EF\u4EE5\u2026\u300D/\u300Cnext week we can\u2026\u300D/\u300Cby next week\u2026\u300D\uFF0C\u6574\u6BB5\u90FD\u4F1A\u88AB\u89C6\u4E3A\u6A21\u677F\u586B\u5145\u800C\u975E\u771F\u6B63\u8D21\u732E\u3002\u8FD9\u79CD phrasing **\u6574\u8F6E\u91CC\u6700\u591A\u51FA\u73B0\u4E00\u6B21**\uFF0C\u4E0D\u8981\u6BCF\u4E2A director \u90FD\u91CD\u590D\u3002",
19216
+ " \xB7 \u8868\u8FBE\u300C\u6700\u5C0F\u53EF\u6267\u884C\u300D\u7528\u5404\u81EA\u7684\u8BDD\uFF1A\u4F8B\u5982\u300C\u4E00\u4E2A\u4E0B\u5348\u5C31\u80FD\u62FC\u51FA\u539F\u578B\u300D\u300C\u5148\u627E 3 \u4E2A\u76EE\u6807\u7528\u6237\u804A\u4E00\u804A\u300D\u300C\u62FF\u73B0\u6210\u6570\u636E\u5148\u8DD1\u4E00\u7248\u300D\u300C\u5199\u4E00\u9875 brief \u53D1\u7ED9 X\u300D\u300C\u5728 X \u5E73\u53F0\u4E0A\u6302\u4E2A\u843D\u5730\u9875\u6D4B\u70B9\u51FB\u300D\u300C\u5148\u505A\u5185\u6D4B\u7248\u7ED9\u5C0F\u8303\u56F4\u7528\u6237\u300D\u2014\u2014\u4F60\u7684\u89D2\u8272\u80CC\u666F\u51B3\u5B9A\u4F60\u600E\u4E48\u63CF\u8FF0\u8FD9\u4E2A\u6700\u5C0F\u52A8\u4F5C\uFF0C\u800C\u4E0D\u662F\u5957\u65F6\u95F4\u8BCD\u3002",
19047
19217
  "",
19048
19218
  "\u3010\u6211\u8865\u5145\u7684\u65B0\u65B9\u5411\u3011",
19049
19219
  "1\u20133 \u53E5\u3002\u4ECE\u4F60\u72EC\u7279\u7684\u89D2\u8272\u89C6\u89D2\uFF0C\u5F00\u4E00\u4E2A**\u623F\u95F4\u91CC\u8FD8\u6CA1\u4EBA\u8BB2\u8FC7\u7684\u65B9\u5411**\u3002\u53EF\u4EE5\u662F\u90BB\u8FD1\u9886\u57DF\u7684\u7C7B\u6BD4\u3001\u672A\u88AB\u6CE8\u610F\u7684\u7528\u6237\u573A\u666F\u3001\u8DE8\u5B66\u79D1\u7684\u8FDE\u63A5\u3001\u534A\u6210\u54C1\u5F0F\u7684\u300C\u5982\u679C\u2026\u4F1A\u600E\u6837\u300D\u3002\u8FD9\u91CC\u662F\u4F60 contrarian DNA \u7684\u552F\u4E00\u51FA\u53E3\u2014\u2014\u628A\u5B83\u7528\u5728\u300C\u5F00\u522B\u4EBA\u6CA1\u5F00\u8FC7\u7684\u65B9\u5411\u300D\u4E0A\uFF0C\u4E0D\u662F\u300C\u6307\u51FA\u522B\u4EBA\u7684\u76F2\u70B9\u300D\u3002",
@@ -19345,6 +19515,15 @@ Name: ${prefs.name}
19345
19515
  interestLines.push(``);
19346
19516
  }
19347
19517
  }
19518
+ const threadModeBlock = room.kind === "thread" ? [
19519
+ ``,
19520
+ `\u2500\u2500\u2500 PRIVATE ASIDE \xB7 1:1 WITH THE USER \u2500\u2500\u2500`,
19521
+ `This is a private thread the user pulled you into from the main boardroom (room "${room.subject}"). The transcript below shows BOTH (a) the main room conversation up to the moment the user opened this thread, AND (b) this thread's own messages \u2014 chronologically merged so you have full context.`,
19522
+ `Crucially: the other directors are NOT here. They cannot see this conversation. Anything you say below is between you and the user. The chair is not moderating; there is no round-robin; there will be no brief.`,
19523
+ `Your posture \xB7 drop the "speak into the room" framing. You're having a candid 1:1 \u2014 be more personal, more specific, willing to commit to a view, willing to say what you wouldn't put on the record. Stay yourself (your lens, your discipline) but you don't have to "represent your seat" \u2014 just talk with this person.`,
19524
+ `Do not address other directors by name as if they're listening (they aren't). You CAN reference what they said in the main room (it's part of your context) \u2014 "Socrates earlier framed it as X, but between you and me, I think the sharper question is \u2026".`,
19525
+ `No \`@handle\` tokens in prose \u2014 the same handle-vs-name rule applies (use NAME if you reference someone, never the raw handle).`
19526
+ ].join("\n") : "";
19348
19527
  const system = {
19349
19528
  role: "system",
19350
19529
  content: [
@@ -19355,6 +19534,7 @@ Name: ${prefs.name}
19355
19534
  `Other directors at the table:`,
19356
19535
  ` \xB7 ${others_summary}`,
19357
19536
  youSection,
19537
+ ...threadModeBlock ? [threadModeBlock] : [],
19358
19538
  ...memoryBlock ? [memoryBlock] : [],
19359
19539
  ...interestLines,
19360
19540
  ...priorContext && priorContext.trim() ? [priorContext] : [],
@@ -19377,8 +19557,14 @@ Name: ${prefs.name}
19377
19557
  `\u2500\u2500\u2500 INTENSITY \xB7 ${intensity.toUpperCase()} \u2500\u2500\u2500`,
19378
19558
  intensityLine,
19379
19559
  ``,
19380
- `\u2500\u2500\u2500 ROUND MODE \xB7 ${opening ? "OPENING (PARALLEL)" : "REACTIVE"} \u2500\u2500\u2500`,
19381
- opening ? OPENING_BLOCK : REACTIVE_BLOCK,
19560
+ // Round-mode block is only meaningful in main rooms (opening
19561
+ // parallel sweep vs reactive build-on). Threads are a continuous
19562
+ // 1:1 with no rounds, no peers — skip this block entirely so the
19563
+ // model isn't told to "engage other directors" who aren't here.
19564
+ ...room.kind === "thread" ? [] : [
19565
+ `\u2500\u2500\u2500 ROUND MODE \xB7 ${opening ? "OPENING (PARALLEL)" : "REACTIVE"} \u2500\u2500\u2500`,
19566
+ opening ? OPENING_BLOCK : REACTIVE_BLOCK
19567
+ ],
19382
19568
  ...chairBriefBlock ? [chairBriefBlock] : [],
19383
19569
  ...activeSkillsBlock ? ["", activeSkillsBlock] : [],
19384
19570
  ...sharedMaterials && sharedMaterials.trim() ? ["", sharedMaterials] : [],
@@ -20261,6 +20447,15 @@ function extractProviderHint(message) {
20261
20447
  // src/orchestrator/context.ts
20262
20448
  function buildDirectorContext(roomId) {
20263
20449
  const room = getRoom(roomId);
20450
+ if (room && room.kind === "thread" && room.parentRoomId) {
20451
+ const threadOwn = listMessages(roomId);
20452
+ const parentSnapshot = listMessages(room.parentRoomId).filter((m) => m.createdAt < room.createdAt);
20453
+ const merged = [...parentSnapshot, ...threadOwn].sort(
20454
+ (a, b) => a.createdAt - b.createdAt
20455
+ );
20456
+ const currentRound2 = merged.length > 0 ? Math.max(...merged.map((m) => m.roundNum ?? 0), 0) : 0;
20457
+ return { historyMessages: merged, summaryPreamble: "", currentRound: currentRound2 };
20458
+ }
20264
20459
  const allMessages = listMessages(roomId);
20265
20460
  if (allMessages.length === 0) {
20266
20461
  return { historyMessages: [], summaryPreamble: "", currentRound: 0 };
@@ -20820,7 +21015,8 @@ function ensureState(roomId) {
20820
21015
  lastFrameBreakerAgentId: null,
20821
21016
  billingHaltedThisTurn: false,
20822
21017
  voiceWaiters: /* @__PURE__ */ new Map(),
20823
- voicePredone: /* @__PURE__ */ new Set()
21018
+ voicePredone: /* @__PURE__ */ new Set(),
21019
+ activeMessageId: null
20824
21020
  };
20825
21021
  _state.set(roomId, s);
20826
21022
  }
@@ -20947,6 +21143,7 @@ async function chairInterrupt(roomId) {
20947
21143
  }
20948
21144
  state.preWarmed = null;
20949
21145
  }
21146
+ state.activeMessageId = null;
20950
21147
  if (interruptedAgentId) {
20951
21148
  const recent = listRecentMessages(roomId, 8);
20952
21149
  for (let i = recent.length - 1; i >= 0; i--) {
@@ -21082,7 +21279,8 @@ function emitQueueUpdate(roomId, s) {
21082
21279
  round: {
21083
21280
  spoken: s.speakersThisTurn,
21084
21281
  total: s.maxSpeakersThisTurn
21085
- }
21282
+ },
21283
+ activeMessageId: s.activeMessageId
21086
21284
  };
21087
21285
  roomBus.emit(roomId, update);
21088
21286
  }
@@ -21113,6 +21311,7 @@ function tickRoom(roomId, opts) {
21113
21311
  }
21114
21312
  state.preWarmed = null;
21115
21313
  }
21314
+ state.activeMessageId = null;
21116
21315
  for (const [, waiter] of state.voiceWaiters) {
21117
21316
  waiter.resolve();
21118
21317
  }
@@ -21129,7 +21328,7 @@ function tickRoom(roomId, opts) {
21129
21328
  state.maxSpeakersThisTurn = plan.length;
21130
21329
  emitQueueUpdate(roomId, state);
21131
21330
  const tickKind = opts.kind ?? "user";
21132
- if (!opts.forceSpeakerId && tickKind !== "force") {
21331
+ if (!opts.forceSpeakerId && tickKind !== "force" && room.kind !== "thread") {
21133
21332
  announceRoundOpen(roomId, opts.roundNum, tickKind === "user");
21134
21333
  }
21135
21334
  rlog(roomId, "tick", {
@@ -21228,6 +21427,21 @@ async function runPickerThenPrewarm(roomId, _currentMessageId) {
21228
21427
  state.inflight.delete(sentinel);
21229
21428
  state.inflight.set(info.messageId, ac);
21230
21429
  }
21430
+ if (state.preWarmed !== preWarmed && state.activeMessageId === null) {
21431
+ state.activeMessageId = info.messageId;
21432
+ const m = getMessage(info.messageId);
21433
+ if (m) {
21434
+ const newMeta = { ...m.meta || {}, preWarmed: false };
21435
+ updateMessageBody(info.messageId, m.body, newMeta);
21436
+ roomBus.emit(roomId, {
21437
+ type: "message-updated",
21438
+ messageId: info.messageId,
21439
+ body: m.body,
21440
+ meta: newMeta
21441
+ });
21442
+ }
21443
+ emitQueueUpdate(roomId, state);
21444
+ }
21231
21445
  }
21232
21446
  // Chain trigger lives in pumpQueue's consume point, NOT here.
21233
21447
  // Rationale: B's `message-final` fires while B is still occupying
@@ -21462,9 +21676,25 @@ async function pumpQueue(roomId) {
21462
21676
  ac = state.preWarmed.abortController;
21463
21677
  streamPromise = state.preWarmed.promise;
21464
21678
  state.preWarmed = null;
21679
+ if (justConsumed.messageId) {
21680
+ state.activeMessageId = justConsumed.messageId;
21681
+ const m = getMessage(justConsumed.messageId);
21682
+ if (m) {
21683
+ const newMeta = { ...m.meta || {}, preWarmed: false };
21684
+ updateMessageBody(justConsumed.messageId, m.body, newMeta);
21685
+ roomBus.emit(roomId, {
21686
+ type: "message-updated",
21687
+ messageId: justConsumed.messageId,
21688
+ body: m.body,
21689
+ meta: newMeta
21690
+ });
21691
+ }
21692
+ emitQueueUpdate(roomId, state);
21693
+ }
21465
21694
  rlog(roomId, "speaker-prewarm-consumed", {
21466
21695
  agent: speaker.name,
21467
- agentId: speaker.id
21696
+ agentId: speaker.id,
21697
+ messageId: justConsumed.messageId || "(pending)"
21468
21698
  });
21469
21699
  schedulePreWarm(roomId, justConsumed.messageId);
21470
21700
  } else {
@@ -21489,6 +21719,8 @@ async function pumpQueue(roomId) {
21489
21719
  state.inflight.delete(sentinel);
21490
21720
  state.inflight.set(info.messageId, ac);
21491
21721
  }
21722
+ state.activeMessageId = info.messageId;
21723
+ emitQueueUpdate(roomId, state);
21492
21724
  },
21493
21725
  onMessageFinal: (info) => {
21494
21726
  schedulePreWarm(roomId, info.messageId);
@@ -21532,6 +21764,10 @@ async function pumpQueue(roomId) {
21532
21764
  if (val === ac) keysToDel.push(key);
21533
21765
  }
21534
21766
  for (const key of keysToDel) state.inflight.delete(key);
21767
+ if (state.activeMessageId) {
21768
+ state.activeMessageId = null;
21769
+ emitQueueUpdate(roomId, state);
21770
+ }
21535
21771
  }
21536
21772
  if (state.queue[0] !== entry) {
21537
21773
  continue;
@@ -21624,6 +21860,9 @@ async function pumpQueue(roomId) {
21624
21860
  });
21625
21861
  if (reachedCap) {
21626
21862
  const room = getRoom(roomId);
21863
+ if (room && room.kind === "thread") {
21864
+ return;
21865
+ }
21627
21866
  if (room && room.status === "live" && !room.awaitingContinue && !room.awaitingClarify && room.voteTrigger === "manual") {
21628
21867
  const nextRound = nextUserRoundNum(roomId);
21629
21868
  rlog(roomId, "manual-auto-continue", {
@@ -23923,17 +24162,44 @@ var REJECT_PHRASES = /* @__PURE__ */ new Set([
23923
24162
  ]);
23924
24163
  async function generateRoomTitle(roomId) {
23925
24164
  const room = getRoom(roomId);
23926
- if (!room) return { kind: "skipped", reason: "no-room" };
23927
- if (!room.nameAuto) return { kind: "skipped", reason: "user-named" };
24165
+ if (!room) {
24166
+ process.stderr.write(`[room-title] room=${roomId} skip=no-room
24167
+ `);
24168
+ return { kind: "skipped", reason: "no-room" };
24169
+ }
24170
+ if (!room.nameAuto) {
24171
+ process.stderr.write(`[room-title] room=${roomId} kind=${room.kind} skip=user-named
24172
+ `);
24173
+ return { kind: "skipped", reason: "user-named" };
24174
+ }
23928
24175
  const subject = room.subject.trim();
23929
- if (!subject) return { kind: "skipped", reason: "no-subject" };
24176
+ if (!subject) {
24177
+ process.stderr.write(`[room-title] room=${roomId} kind=${room.kind} skip=no-subject
24178
+ `);
24179
+ return { kind: "skipped", reason: "no-subject" };
24180
+ }
23930
24181
  const fallbackName = room.subject.slice(0, 60);
23931
24182
  if (room.name !== fallbackName) {
24183
+ process.stderr.write(
24184
+ `[room-title] room=${roomId} kind=${room.kind} skip=already-renamed name="${room.name.slice(0, 30)}" fallback="${fallbackName.slice(0, 30)}"
24185
+ `
24186
+ );
23932
24187
  return { kind: "skipped", reason: "already-renamed", detail: room.name.slice(0, 60) };
23933
24188
  }
23934
- const modelV = utilityModelFor();
23935
- if (!modelV) return { kind: "skipped", reason: "no-model" };
23936
- const prompt = `You are titling a conversation for a sidebar entry, the way ChatGPT does it. Read the user's opening question and write the title that another reader would expect to see for THIS conversation \u2014 specific enough to distinguish it from any neighbouring entry in the same domain.
24189
+ const r = await distillTitle(subject, `room=${roomId} kind=${room.kind}`);
24190
+ if (!r.ok) return { kind: "skipped", reason: r.reason, detail: r.detail };
24191
+ const updated = setRoomNameFromAuto(roomId, r.phrase);
24192
+ if (!updated) return { kind: "skipped", reason: "race-after-rename" };
24193
+ roomBus.emit(roomId, {
24194
+ type: "config-event",
24195
+ kind: "settings-changed",
24196
+ payload: { changes: { name: { from: room.name, to: r.phrase } } },
24197
+ createdAt: Date.now()
24198
+ });
24199
+ return { kind: "ok", before: room.name, after: r.phrase };
24200
+ }
24201
+ function buildTitlePrompt(text) {
24202
+ return `You are titling a conversation for a sidebar entry, the way ChatGPT does it. Read the user's opening question and write the title that another reader would expect to see for THIS conversation \u2014 specific enough to distinguish it from any neighbouring entry in the same domain.
23937
24203
 
23938
24204
  How to write a representative title:
23939
24205
  1. Identify the CORE SUBJECT or TASK (the noun, the deliverable, the decision being made).
@@ -23968,47 +24234,95 @@ Input: I want to redesign our onboarding email sequence \u2014 currently 5 email
23968
24234
  Output: Onboarding email redesign
23969
24235
 
23970
24236
  --- User's opening question ---
23971
- ${subject}
24237
+ ${text}
23972
24238
 
23973
24239
  --- Title ---
23974
24240
  `;
24241
+ }
24242
+ async function distillTitle(text, ctx) {
24243
+ const modelV = utilityModelFor();
24244
+ if (!modelV) {
24245
+ process.stderr.write(`[room-title] ${ctx} skip=no-model
24246
+ `);
24247
+ return { ok: false, reason: "no-model" };
24248
+ }
24249
+ process.stderr.write(`[room-title] ${ctx} model=${modelV} input="${text.slice(0, 40)}\u2026" \xB7 calling LLM
24250
+ `);
23975
24251
  let raw = "";
23976
24252
  try {
23977
24253
  raw = await callLLM({
23978
24254
  modelV,
23979
24255
  carrier: null,
23980
- messages: [{ role: "user", content: prompt }],
23981
- // Low but not zero · 0.2 was deterministic-ish but kept locking
23982
- // onto a generic first-noun pick. 0.4 lets the model trade off
23983
- // alternatives without wandering into creative territory.
24256
+ messages: [{ role: "user", content: buildTitlePrompt(text) }],
24257
+ // Low but not zero · 0.2 kept locking onto a generic first-noun
24258
+ // pick; 0.4 lets the model trade off alternatives without
24259
+ // wandering into creative territory.
23984
24260
  temperature: 0.4,
23985
- // 40 was tight enough that a model thinking briefly before
23986
- // answering would get cut off mid-title; 80 fits the title plus
23987
- // a small margin without inviting paragraphs.
24261
+ // 40 truncated mid-title for models that think briefly first;
24262
+ // 80 fits the title plus margin without inviting paragraphs.
23988
24263
  maxTokens: 80
23989
24264
  });
23990
24265
  } catch (e) {
23991
24266
  const detail = e instanceof Error ? e.message : String(e);
23992
- process.stderr.write(`[room-title] LLM call failed for ${roomId}: ${detail}
24267
+ process.stderr.write(`[room-title] ${ctx} LLM call failed: ${detail}
23993
24268
  `);
23994
- return { kind: "skipped", reason: "llm-error", detail };
24269
+ return { ok: false, reason: "llm-error", detail };
23995
24270
  }
23996
24271
  if (!raw.trim()) {
23997
- return { kind: "skipped", reason: "empty-output", detail: `model=${modelV}` };
24272
+ process.stderr.write(`[room-title] ${ctx} skip=empty-output model=${modelV}
24273
+ `);
24274
+ return { ok: false, reason: "empty-output", detail: `model=${modelV}` };
23998
24275
  }
23999
24276
  const phrase = sanitiseTitle(raw);
24000
24277
  if (!phrase) {
24001
- return { kind: "skipped", reason: "rejected-generic", detail: raw.trim().slice(0, 80) };
24278
+ process.stderr.write(`[room-title] ${ctx} skip=rejected-generic raw="${raw.trim().slice(0, 80)}"
24279
+ `);
24280
+ return { ok: false, reason: "rejected-generic", detail: raw.trim().slice(0, 80) };
24281
+ }
24282
+ process.stderr.write(`[room-title] ${ctx} llm_raw="${raw.trim().slice(0, 60)}" phrase="${phrase}"
24283
+ `);
24284
+ return { ok: true, phrase };
24285
+ }
24286
+ function threadSeedText(body) {
24287
+ return body.replace(/^\s*[—–-]\s*@.*$/gm, "").replace(/^\s*>\s?/gm, "").replace(/\n{2,}/g, "\n").trim();
24288
+ }
24289
+ async function generateThreadTitle(threadId) {
24290
+ const room = getRoom(threadId);
24291
+ if (!room) {
24292
+ process.stderr.write(`[thread-title] thread=${threadId} skip=no-room
24293
+ `);
24294
+ return { kind: "skipped", reason: "no-room" };
24295
+ }
24296
+ if (room.kind !== "thread") {
24297
+ return { kind: "skipped", reason: "not-thread" };
24002
24298
  }
24003
- const updated = setRoomNameFromAuto(roomId, phrase);
24299
+ const firstUser = listMessages(threadId).find((m) => m.authorKind === "user");
24300
+ if (!firstUser || !firstUser.body.trim()) {
24301
+ return { kind: "skipped", reason: "no-message" };
24302
+ }
24303
+ const seed = threadSeedText(firstUser.body);
24304
+ if (!seed) {
24305
+ return { kind: "skipped", reason: "no-subject" };
24306
+ }
24307
+ const name = (room.name || "").trim();
24308
+ const isPlaceholder = /^thread:/.test(name);
24309
+ const isRawTruncation = name === room.subject.slice(0, 60) || name === firstUser.body.slice(0, 60);
24310
+ if (!isPlaceholder && !isRawTruncation) {
24311
+ return { kind: "skipped", reason: "already-renamed", detail: name.slice(0, 60) };
24312
+ }
24313
+ const r = await distillTitle(seed, `thread=${threadId}`);
24314
+ if (!r.ok) return { kind: "skipped", reason: r.reason, detail: r.detail };
24315
+ const updated = forceRoomAutoName(threadId, r.phrase);
24004
24316
  if (!updated) return { kind: "skipped", reason: "race-after-rename" };
24005
- roomBus.emit(roomId, {
24317
+ roomBus.emit(threadId, {
24006
24318
  type: "config-event",
24007
24319
  kind: "settings-changed",
24008
- payload: { changes: { name: { from: room.name, to: phrase } } },
24320
+ payload: { changes: { name: { from: name, to: r.phrase } } },
24009
24321
  createdAt: Date.now()
24010
24322
  });
24011
- return { kind: "ok", before: room.name, after: phrase };
24323
+ process.stderr.write(`[thread-title] OK thread=${threadId} "${name.slice(0, 30)}" \u2192 "${r.phrase}"
24324
+ `);
24325
+ return { kind: "ok", before: name, after: r.phrase };
24012
24326
  }
24013
24327
  function sanitiseTitle(raw) {
24014
24328
  let s = raw.trim();
@@ -24403,6 +24717,16 @@ function roomsRouter() {
24403
24717
  return c.json({ deferred: true });
24404
24718
  }
24405
24719
  const roundNum = nextUserRoundNum(id);
24720
+ let triggerThreadTitle = false;
24721
+ if (room.kind === "thread") {
24722
+ const priorMsgs = listMessages(id);
24723
+ const priorUser = priorMsgs.some((m) => m.authorKind === "user");
24724
+ if (!priorUser) {
24725
+ setRoomSubject(id, text);
24726
+ setRoomNameFromAuto(id, text.slice(0, 60));
24727
+ triggerThreadTitle = true;
24728
+ }
24729
+ }
24406
24730
  const msg = insertMessage({
24407
24731
  roomId: id,
24408
24732
  authorKind: "user",
@@ -24411,6 +24735,32 @@ function roomsRouter() {
24411
24735
  meta: mentions.length ? { mentions } : {},
24412
24736
  roundNum
24413
24737
  });
24738
+ if (triggerThreadTitle) {
24739
+ const before = getRoom(id);
24740
+ process.stderr.write(
24741
+ `[thread-title] firing for thread=${id} subject="${(before?.subject ?? "").slice(0, 40)}" name="${before?.name ?? ""}" nameAuto=${before?.nameAuto}
24742
+ `
24743
+ );
24744
+ generateThreadTitle(id).then((result) => {
24745
+ if (result.kind === "ok") {
24746
+ process.stderr.write(
24747
+ `[thread-title] OK thread=${id} "${result.before.slice(0, 40)}" \u2192 "${result.after}"
24748
+ `
24749
+ );
24750
+ } else {
24751
+ const tail = result.detail ? ` detail="${result.detail.slice(0, 100)}"` : "";
24752
+ process.stderr.write(
24753
+ `[thread-title] SKIP thread=${id} reason=${result.reason}${tail}
24754
+ `
24755
+ );
24756
+ }
24757
+ }).catch((e) => {
24758
+ process.stderr.write(
24759
+ `[thread-title] THROW thread=${id} ${e instanceof Error ? e.message : String(e)}
24760
+ `
24761
+ );
24762
+ });
24763
+ }
24414
24764
  roomBus.emit(id, {
24415
24765
  type: "message-appended",
24416
24766
  messageId: msg.id,
@@ -24448,7 +24798,7 @@ function roomsRouter() {
24448
24798
  return c.json(msg);
24449
24799
  }
24450
24800
  const chair = getChairAgent();
24451
- const chairMentioned = !!chair && (mentions.includes(chair.id) || /(?:^|\s)@chair\b/i.test(text));
24801
+ const chairMentioned = !!chair && room.kind !== "thread" && (mentions.includes(chair.id) || /(?:^|\s)@chair\b/i.test(text));
24452
24802
  if (chairMentioned) {
24453
24803
  void chairInterrupt(id).catch((e) => {
24454
24804
  process.stderr.write(
@@ -24467,6 +24817,62 @@ function roomsRouter() {
24467
24817
  abortRoom(id);
24468
24818
  return c.json({ ok: true });
24469
24819
  });
24820
+ r.post("/:id/threads", async (c) => {
24821
+ const parentId = c.req.param("id");
24822
+ const parent = getRoom(parentId);
24823
+ if (!parent) return c.json({ error: "parent room not found" }, 404);
24824
+ if (parent.kind !== "main") {
24825
+ return c.json({ error: "threads can only spawn from main rooms" }, 400);
24826
+ }
24827
+ let body;
24828
+ try {
24829
+ body = await c.req.json();
24830
+ } catch {
24831
+ return c.json({ error: "invalid JSON body" }, 400);
24832
+ }
24833
+ const b = body ?? {};
24834
+ const directorId = typeof b.directorId === "string" ? b.directorId.trim() : "";
24835
+ if (!directorId) return c.json({ error: "directorId is required" }, 400);
24836
+ const agent = getAgent(directorId);
24837
+ if (!agent) return c.json({ error: "director not found" }, 404);
24838
+ if (agent.roleKind === "moderator") {
24839
+ return c.json({ error: "cannot open a thread with the chair" }, 400);
24840
+ }
24841
+ try {
24842
+ const existing = listThreadsForRoom(parentId, { directorId });
24843
+ if (existing.length > 0) {
24844
+ const newest = existing[0];
24845
+ const members = listRoomMembers(newest.id);
24846
+ return c.json({ room: newest, members });
24847
+ }
24848
+ const result = createThread(parentId, directorId);
24849
+ return c.json(result);
24850
+ } catch (e) {
24851
+ const msg = e instanceof Error ? e.message : String(e);
24852
+ return c.json({ error: msg }, 400);
24853
+ }
24854
+ });
24855
+ r.get("/:id/threads", (c) => {
24856
+ const parentId = c.req.param("id");
24857
+ if (!getRoom(parentId)) return c.json({ error: "not found" }, 404);
24858
+ const directorId = c.req.query("directorId");
24859
+ const threads = listThreadsForRoom(
24860
+ parentId,
24861
+ directorId ? { directorId } : {}
24862
+ );
24863
+ const enriched = threads.map((t) => {
24864
+ const msgs = listMessages(t.id);
24865
+ const messageCount = msgs.filter(
24866
+ (m) => !(m.meta?.streaming === true)
24867
+ ).length;
24868
+ return { ...t, messageCount };
24869
+ });
24870
+ for (const t of enriched) {
24871
+ if (t.messageCount > 0) void generateThreadTitle(t.id).catch(() => {
24872
+ });
24873
+ }
24874
+ return c.json({ threads: enriched });
24875
+ });
24470
24876
  r.post("/:id/messages/:messageId/voice-done", (c) => {
24471
24877
  const id = c.req.param("id");
24472
24878
  const messageId = c.req.param("messageId");
@@ -25274,8 +25680,650 @@ function usageRouter() {
25274
25680
  return r;
25275
25681
  }
25276
25682
 
25277
- // src/routes/voice-credentials.ts
25683
+ // src/routes/voice-clone.ts
25278
25684
  import { Hono as Hono13 } from "hono";
25685
+ import { streamSSE as streamSSE3 } from "hono/streaming";
25686
+ import { randomBytes as randomBytes9 } from "crypto";
25687
+ import { mkdirSync as mkdirSync2, writeFileSync, statSync as statSync2, rmSync, existsSync as existsSync2 } from "fs";
25688
+ import { tmpdir } from "os";
25689
+ import { join as join4 } from "path";
25690
+
25691
+ // src/storage/clone-jobs.ts
25692
+ init_db();
25693
+ import { randomBytes as randomBytes7 } from "crypto";
25694
+ function rowToJob(r) {
25695
+ return {
25696
+ id: r.id,
25697
+ agentId: r.agent_id,
25698
+ provider: r.provider,
25699
+ sourceKind: r.source_kind,
25700
+ sourceRef: r.source_ref,
25701
+ label: r.label,
25702
+ status: r.status,
25703
+ currentStage: r.current_stage,
25704
+ pct: r.pct,
25705
+ voiceId: r.voice_id,
25706
+ errorCode: r.error_code,
25707
+ errorMessage: r.error_message,
25708
+ createdAt: r.created_at,
25709
+ updatedAt: r.updated_at
25710
+ };
25711
+ }
25712
+ function createCloneJob(input) {
25713
+ const id = randomBytes7(8).toString("hex");
25714
+ const now = Date.now();
25715
+ getDb().prepare(
25716
+ `INSERT INTO clone_jobs (id, agent_id, provider, source_kind, source_ref, label,
25717
+ status, current_stage, pct, created_at, updated_at)
25718
+ VALUES (?, ?, ?, ?, ?, ?, 'queued', 'fetch', 0, ?, ?)`
25719
+ ).run(id, input.agentId, input.provider, input.sourceKind, input.sourceRef, input.label ?? null, now, now);
25720
+ const row = getDb().prepare(`SELECT * FROM clone_jobs WHERE id = ?`).get(id);
25721
+ return rowToJob(row);
25722
+ }
25723
+ function getCloneJob(id) {
25724
+ const row = getDb().prepare(`SELECT * FROM clone_jobs WHERE id = ?`).get(id);
25725
+ return row ? rowToJob(row) : null;
25726
+ }
25727
+ function findActiveJobForAgent(agentId) {
25728
+ const row = getDb().prepare(`SELECT * FROM clone_jobs WHERE agent_id = ? AND status IN ('queued', 'running') ORDER BY created_at DESC LIMIT 1`).get(agentId);
25729
+ return row ? rowToJob(row) : null;
25730
+ }
25731
+ function findAnyActiveJob() {
25732
+ const row = getDb().prepare(`SELECT * FROM clone_jobs WHERE status IN ('queued', 'running') ORDER BY created_at DESC LIMIT 1`).get();
25733
+ return row ? rowToJob(row) : null;
25734
+ }
25735
+ function updateCloneJobProgress(id, patch) {
25736
+ const cur = getCloneJob(id);
25737
+ if (!cur) return null;
25738
+ const next = {
25739
+ status: patch.status ?? cur.status,
25740
+ currentStage: patch.currentStage ?? cur.currentStage,
25741
+ pct: patch.pct ?? cur.pct,
25742
+ voiceId: patch.voiceId !== void 0 ? patch.voiceId : cur.voiceId,
25743
+ errorCode: patch.errorCode !== void 0 ? patch.errorCode : cur.errorCode,
25744
+ errorMessage: patch.errorMessage !== void 0 ? patch.errorMessage : cur.errorMessage
25745
+ };
25746
+ getDb().prepare(
25747
+ `UPDATE clone_jobs SET status=?, current_stage=?, pct=?, voice_id=?, error_code=?, error_message=?, updated_at=?
25748
+ WHERE id=?`
25749
+ ).run(
25750
+ next.status,
25751
+ next.currentStage,
25752
+ next.pct,
25753
+ next.voiceId,
25754
+ next.errorCode,
25755
+ next.errorMessage,
25756
+ Date.now(),
25757
+ id
25758
+ );
25759
+ return getCloneJob(id);
25760
+ }
25761
+ function recoverStuckCloneJobs() {
25762
+ const r = getDb().prepare(
25763
+ `UPDATE clone_jobs
25764
+ SET status = 'failed',
25765
+ error_code = COALESCE(error_code, 'interrupted'),
25766
+ error_message = COALESCE(error_message, 'Process restarted while clone was in progress.'),
25767
+ updated_at = ?
25768
+ WHERE status IN ('queued', 'running')`
25769
+ ).run(Date.now());
25770
+ return r.changes;
25771
+ }
25772
+
25773
+ // src/voice/clone.ts
25774
+ import { readFileSync, statSync } from "fs";
25775
+ import { basename } from "path";
25776
+ import { randomBytes as randomBytes8 } from "crypto";
25777
+ var CloneError = class extends Error {
25778
+ code;
25779
+ detail;
25780
+ constructor(code, message, detail = "") {
25781
+ super(message);
25782
+ this.name = "CloneError";
25783
+ this.code = code;
25784
+ this.detail = detail;
25785
+ }
25786
+ };
25787
+ var MAX_AUDIO_BYTES = 20 * 1024 * 1024;
25788
+ var MIN_AUDIO_BYTES = 32 * 1024;
25789
+ function extractMiniMaxGroupId(jwt) {
25790
+ const parts = jwt.split(".");
25791
+ if (parts.length !== 3) return null;
25792
+ try {
25793
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
25794
+ const candidates = ["GroupID", "group_id", "groupId", "g"];
25795
+ for (const k of candidates) {
25796
+ const v = payload[k];
25797
+ if (typeof v === "string" && v.trim()) return v.trim();
25798
+ }
25799
+ } catch {
25800
+ }
25801
+ return null;
25802
+ }
25803
+ async function cloneFromAudio(input) {
25804
+ validateAudioFile(input.audioPath);
25805
+ if (input.provider === "minimax") return cloneMiniMax(input);
25806
+ if (input.provider === "elevenlabs") return cloneElevenLabs(input);
25807
+ throw new CloneError("provider_unknown", `Unsupported provider ${String(input.provider)}`);
25808
+ }
25809
+ function validateAudioFile(path) {
25810
+ let size;
25811
+ try {
25812
+ size = statSync(path).size;
25813
+ } catch (e) {
25814
+ throw new CloneError("audio_unreadable", "Could not read audio file", String(e));
25815
+ }
25816
+ if (size < MIN_AUDIO_BYTES) throw new CloneError("audio_too_short", "Audio file is too small to clone from");
25817
+ if (size > MAX_AUDIO_BYTES) throw new CloneError("audio_too_large", "Audio file exceeds 20MB");
25818
+ }
25819
+ async function cloneMiniMax(input) {
25820
+ const groupId = input.miniMaxGroupId && input.miniMaxGroupId.trim() || extractMiniMaxGroupId(input.apiKey);
25821
+ if (!groupId) {
25822
+ throw new CloneError(
25823
+ "missing_group_id",
25824
+ 'MiniMax needs a Group ID for voice cloning. Paste it into the "MiniMax Group ID" field on the clone dialog, or re-paste a JWT-format key that already carries the ID.'
25825
+ );
25826
+ }
25827
+ const baseUrl = input.miniMaxBaseUrl || "https://api.minimaxi.com";
25828
+ input.onProgress?.(0, "upload");
25829
+ const fileBuf = readFileSync(input.audioPath);
25830
+ const fileName = basename(input.audioPath);
25831
+ const upRes = await streamMultipartUpload({
25832
+ url: `${baseUrl}/v1/files/upload?GroupId=${encodeURIComponent(groupId)}`,
25833
+ headers: { "authorization": `Bearer ${input.apiKey}` },
25834
+ fields: { purpose: "voice_clone" },
25835
+ files: [{ fieldName: "file", bytes: fileBuf, mime: mimeForName(fileName), fileName }],
25836
+ onProgress: (pct) => input.onProgress?.(pct, "upload"),
25837
+ signal: input.signal
25838
+ });
25839
+ if (!upRes.ok) throw await translateMinimaxError(upRes, "upload");
25840
+ const upJson = await upRes.json();
25841
+ const fileId = upJson.file?.file_id;
25842
+ if (!fileId) {
25843
+ const msg = upJson.base_resp?.status_msg || "unknown error";
25844
+ throw new CloneError("provider_unknown", `MiniMax upload returned no file_id: ${msg}`);
25845
+ }
25846
+ input.onProgress?.(100, "upload");
25847
+ input.onProgress?.(0, "clone");
25848
+ const voiceId = buildMiniMaxVoiceId(input.agentId, input.label || null);
25849
+ const cloneRes = await fetch(`${baseUrl}/v1/voice_clone?GroupId=${encodeURIComponent(groupId)}`, {
25850
+ method: "POST",
25851
+ headers: {
25852
+ "authorization": `Bearer ${input.apiKey}`,
25853
+ "content-type": "application/json"
25854
+ },
25855
+ body: JSON.stringify({
25856
+ file_id: fileId,
25857
+ voice_id: voiceId,
25858
+ need_noise_reduction: true,
25859
+ need_volume_normalization: true
25860
+ }),
25861
+ signal: input.signal
25862
+ });
25863
+ if (!cloneRes.ok) throw await translateMinimaxError(cloneRes, "clone");
25864
+ const cloneJson = await cloneRes.json();
25865
+ const status = cloneJson.base_resp?.status_code ?? 0;
25866
+ if (status !== 0) {
25867
+ const msg = cloneJson.base_resp?.status_msg || "unknown error";
25868
+ if (status === 1008 || /insufficient/i.test(msg)) {
25869
+ throw new CloneError("provider_quota", "MiniMax balance is insufficient for voice cloning.", msg);
25870
+ }
25871
+ if (/voice[_ ]id/i.test(msg)) {
25872
+ throw new CloneError("provider_invalid_voice_id", `MiniMax rejected the voice_id: ${msg}`);
25873
+ }
25874
+ throw new CloneError("provider_unknown", `MiniMax voice_clone failed (${status}): ${msg}`);
25875
+ }
25876
+ input.onProgress?.(100, "clone");
25877
+ return { voiceId, label: input.label?.trim() || `Cloned \xB7 ${voiceId}` };
25878
+ }
25879
+ async function translateMinimaxError(res, where) {
25880
+ const text = await res.text().catch(() => "");
25881
+ if (res.status === 401 || res.status === 403) {
25882
+ return new CloneError("provider_auth", "MiniMax rejected the API key. Re-check it in voice settings.", text);
25883
+ }
25884
+ if (res.status === 402 || /insufficient/i.test(text)) {
25885
+ return new CloneError("provider_quota", "MiniMax balance is insufficient for voice cloning.", text);
25886
+ }
25887
+ return new CloneError("provider_unknown", `MiniMax ${where} returned HTTP ${res.status}`, text);
25888
+ }
25889
+ function buildMiniMaxVoiceId(agentId, label) {
25890
+ const ts = Date.now().toString(36);
25891
+ const sanitizedLabel = (label || "").replace(/[^A-Za-z0-9_-]/g, "").slice(0, 16);
25892
+ if (sanitizedLabel && sanitizedLabel.length >= 2) {
25893
+ return `${sanitizedLabel}_${ts}`;
25894
+ }
25895
+ const safeAgent = agentId.replace(/[^A-Za-z0-9]/g, "").slice(0, 8) || "director";
25896
+ return `pb_${safeAgent}_${ts}`;
25897
+ }
25898
+ async function cloneElevenLabs(input) {
25899
+ input.onProgress?.(0, "upload");
25900
+ const fileBuf = readFileSync(input.audioPath);
25901
+ const fileName = basename(input.audioPath);
25902
+ const label = input.label?.trim() || `Cloned \xB7 ${input.agentId.slice(0, 8)}`;
25903
+ const res = await streamMultipartUpload({
25904
+ url: `https://api.elevenlabs.io/v1/voices/add`,
25905
+ headers: { "xi-api-key": input.apiKey },
25906
+ fields: { name: label },
25907
+ files: [{ fieldName: "files", bytes: fileBuf, mime: mimeForName(fileName), fileName }],
25908
+ onProgress: (pct) => input.onProgress?.(pct, "upload"),
25909
+ signal: input.signal
25910
+ });
25911
+ input.onProgress?.(100, "upload");
25912
+ input.onProgress?.(0, "clone");
25913
+ if (!res.ok) {
25914
+ const text = await res.text().catch(() => "");
25915
+ if (res.status === 401) throw new CloneError("provider_auth", "ElevenLabs rejected the API key.", text);
25916
+ if (res.status === 402 || /paid_plan_required|quota_exceeded|insufficient/i.test(text)) {
25917
+ throw new CloneError("provider_quota", "ElevenLabs subscription doesn't allow voice cloning, or you're out of credits.", text);
25918
+ }
25919
+ throw new CloneError("provider_unknown", `ElevenLabs voices/add returned HTTP ${res.status}`, text);
25920
+ }
25921
+ const json = await res.json();
25922
+ const voiceId = json.voice_id;
25923
+ if (!voiceId) throw new CloneError("provider_unknown", "ElevenLabs returned no voice_id");
25924
+ input.onProgress?.(100, "clone");
25925
+ return { voiceId, label };
25926
+ }
25927
+ async function streamMultipartUpload(opts) {
25928
+ const boundary = `----pb-vc-${randomBytes8(8).toString("hex")}`;
25929
+ const CRLF = "\r\n";
25930
+ const enc = (s) => Buffer.from(s, "utf8");
25931
+ const partsBeforeFiles = [];
25932
+ for (const [k, v] of Object.entries(opts.fields)) {
25933
+ partsBeforeFiles.push(enc(`--${boundary}${CRLF}`));
25934
+ partsBeforeFiles.push(enc(`Content-Disposition: form-data; name="${k}"${CRLF}${CRLF}`));
25935
+ partsBeforeFiles.push(enc(`${v}${CRLF}`));
25936
+ }
25937
+ const filePreludes = opts.files.map((f) => enc(
25938
+ `--${boundary}${CRLF}Content-Disposition: form-data; name="${f.fieldName}"; filename="${f.fileName}"${CRLF}Content-Type: ${f.mime}${CRLF}${CRLF}`
25939
+ ));
25940
+ const fileEndings = opts.files.map(() => enc(CRLF));
25941
+ const closing = enc(`--${boundary}--${CRLF}`);
25942
+ let total = 0;
25943
+ for (const b of partsBeforeFiles) total += b.length;
25944
+ for (let i = 0; i < opts.files.length; i++) {
25945
+ total += filePreludes[i].length + opts.files[i].bytes.length + fileEndings[i].length;
25946
+ }
25947
+ total += closing.length;
25948
+ const CHUNK_SIZE = 64 * 1024;
25949
+ let step = { kind: "fixed", idx: 0, list: partsBeforeFiles };
25950
+ let sent = 0;
25951
+ const stream = new ReadableStream({
25952
+ pull(controller) {
25953
+ for (; ; ) {
25954
+ if (step.kind === "done") {
25955
+ controller.close();
25956
+ return;
25957
+ }
25958
+ if (step.kind === "fixed") {
25959
+ if (step.idx >= step.list.length) {
25960
+ if (opts.files.length === 0) step = { kind: "closing" };
25961
+ else {
25962
+ controller.enqueue(filePreludes[0]);
25963
+ sent += filePreludes[0].length;
25964
+ opts.onProgress?.(Math.min(99, sent / total * 100));
25965
+ step = { kind: "fileBody", fileIdx: 0, off: 0 };
25966
+ return;
25967
+ }
25968
+ continue;
25969
+ }
25970
+ const chunk = step.list[step.idx++];
25971
+ controller.enqueue(chunk);
25972
+ sent += chunk.length;
25973
+ opts.onProgress?.(Math.min(99, sent / total * 100));
25974
+ return;
25975
+ }
25976
+ if (step.kind === "fileBody") {
25977
+ const file = opts.files[step.fileIdx];
25978
+ if (step.off >= file.bytes.length) {
25979
+ const ending = fileEndings[step.fileIdx];
25980
+ controller.enqueue(ending);
25981
+ sent += ending.length;
25982
+ opts.onProgress?.(Math.min(99, sent / total * 100));
25983
+ const nextIdx = step.fileIdx + 1;
25984
+ if (nextIdx >= opts.files.length) {
25985
+ step = { kind: "closing" };
25986
+ } else {
25987
+ controller.enqueue(filePreludes[nextIdx]);
25988
+ sent += filePreludes[nextIdx].length;
25989
+ opts.onProgress?.(Math.min(99, sent / total * 100));
25990
+ step = { kind: "fileBody", fileIdx: nextIdx, off: 0 };
25991
+ }
25992
+ return;
25993
+ }
25994
+ const slice = file.bytes.subarray(step.off, step.off + CHUNK_SIZE);
25995
+ controller.enqueue(slice);
25996
+ step.off += slice.length;
25997
+ sent += slice.length;
25998
+ opts.onProgress?.(Math.min(99, sent / total * 100));
25999
+ return;
26000
+ }
26001
+ if (step.kind === "closing") {
26002
+ controller.enqueue(closing);
26003
+ sent += closing.length;
26004
+ opts.onProgress?.(100);
26005
+ step = { kind: "done" };
26006
+ return;
26007
+ }
26008
+ }
26009
+ },
26010
+ cancel() {
26011
+ step = { kind: "done" };
26012
+ }
26013
+ });
26014
+ const fetchInit = {
26015
+ method: "POST",
26016
+ headers: {
26017
+ ...opts.headers,
26018
+ "content-type": `multipart/form-data; boundary=${boundary}`,
26019
+ "content-length": String(total)
26020
+ },
26021
+ body: stream,
26022
+ duplex: "half",
26023
+ signal: opts.signal
26024
+ };
26025
+ return await fetch(opts.url, fetchInit);
26026
+ }
26027
+ function mimeForName(name) {
26028
+ const lower = name.toLowerCase();
26029
+ if (lower.endsWith(".mp3")) return "audio/mpeg";
26030
+ if (lower.endsWith(".m4a")) return "audio/mp4";
26031
+ if (lower.endsWith(".wav")) return "audio/wav";
26032
+ if (lower.endsWith(".webm")) return "audio/webm";
26033
+ if (lower.endsWith(".ogg")) return "audio/ogg";
26034
+ return "application/octet-stream";
26035
+ }
26036
+
26037
+ // src/routes/voice-clone.ts
26038
+ var listeners = /* @__PURE__ */ new Map();
26039
+ function emit(ev) {
26040
+ const set = listeners.get(ev.jobId);
26041
+ if (!set) return;
26042
+ for (const fn of set) {
26043
+ try {
26044
+ fn(ev);
26045
+ } catch {
26046
+ }
26047
+ }
26048
+ }
26049
+ function subscribe(jobId, fn) {
26050
+ let set = listeners.get(jobId);
26051
+ if (!set) {
26052
+ set = /* @__PURE__ */ new Set();
26053
+ listeners.set(jobId, set);
26054
+ }
26055
+ set.add(fn);
26056
+ return () => {
26057
+ set?.delete(fn);
26058
+ if (set?.size === 0) listeners.delete(jobId);
26059
+ };
26060
+ }
26061
+ var aborters = /* @__PURE__ */ new Map();
26062
+ var workerExtras = /* @__PURE__ */ new Map();
26063
+ function overallPct(stage, innerPct) {
26064
+ const stageIdx = stage === "fetch" ? 0 : stage === "upload" ? 1 : 2;
26065
+ return Math.round(stageIdx * (100 / 3) + innerPct / 3);
26066
+ }
26067
+ function pushProgress(jobId, stage, innerPct, message) {
26068
+ const pct = overallPct(stage, innerPct);
26069
+ updateCloneJobProgress(jobId, { status: "running", currentStage: stage, pct });
26070
+ emit({ jobId, stage, pct, status: "running", message, ts: Date.now() });
26071
+ }
26072
+ async function runWorker(job) {
26073
+ const aborter = new AbortController();
26074
+ aborters.set(job.id, aborter);
26075
+ try {
26076
+ const apiKey = getActiveVoiceKeyPlaintext();
26077
+ if (!apiKey) {
26078
+ throw new CloneError("provider_auth", "No active voice credential. Configure one in voice settings first.");
26079
+ }
26080
+ const audioPath = job.sourceRef;
26081
+ pushProgress(job.id, "fetch", 100, "Using uploaded audio");
26082
+ const extras = workerExtras.get(job.id) || {};
26083
+ const { voiceId, label } = await cloneFromAudio({
26084
+ provider: job.provider,
26085
+ apiKey,
26086
+ audioPath,
26087
+ agentId: job.agentId,
26088
+ label: job.label,
26089
+ miniMaxBaseUrl: job.provider === "minimax" ? minimaxBaseUrlFromPref() : void 0,
26090
+ miniMaxGroupId: job.provider === "minimax" && typeof extras.miniMaxGroupId === "string" ? extras.miniMaxGroupId : null,
26091
+ signal: aborter.signal,
26092
+ onProgress: (pct, stage) => {
26093
+ if (aborter.signal.aborted) return;
26094
+ pushProgress(job.id, stage, pct);
26095
+ }
26096
+ });
26097
+ const agent = getAgent(job.agentId);
26098
+ const existing = agent?.voice;
26099
+ const cloneModel = job.provider === "minimax" ? "speech-2.8-hd" : "eleven_multilingual_v2";
26100
+ const updated = updateAgent(job.agentId, {
26101
+ voice: {
26102
+ provider: job.provider,
26103
+ model: cloneModel,
26104
+ voiceId,
26105
+ ...existing?.speed != null ? { speed: existing.speed } : {},
26106
+ ...existing?.pitch != null ? { pitch: existing.pitch } : {},
26107
+ ...existing?.volume != null ? { volume: existing.volume } : {},
26108
+ ...existing?.emotion ? { emotion: existing.emotion } : {}
26109
+ }
26110
+ });
26111
+ if (updated?.voice) writeVoiceBucketEntry(job.agentId, job.provider, updated.voice);
26112
+ if (job.label) setVoiceLabel({ voiceId, provider: job.provider, label: job.label });
26113
+ invalidateVoicesCache();
26114
+ updateCloneJobProgress(job.id, {
26115
+ status: "done",
26116
+ currentStage: "clone",
26117
+ pct: 100,
26118
+ voiceId,
26119
+ errorCode: null,
26120
+ errorMessage: null
26121
+ });
26122
+ emit({
26123
+ jobId: job.id,
26124
+ stage: "clone",
26125
+ pct: 100,
26126
+ status: "done",
26127
+ voiceId,
26128
+ message: label,
26129
+ provider: job.provider,
26130
+ ts: Date.now()
26131
+ });
26132
+ } catch (e) {
26133
+ const { code, message } = normaliseError(e);
26134
+ updateCloneJobProgress(job.id, {
26135
+ status: aborters.has(job.id) ? "failed" : "cancelled",
26136
+ errorCode: code,
26137
+ errorMessage: message
26138
+ });
26139
+ emit({
26140
+ jobId: job.id,
26141
+ stage: getCloneJob(job.id)?.currentStage || "fetch",
26142
+ pct: getCloneJob(job.id)?.pct ?? 0,
26143
+ status: aborters.has(job.id) ? "failed" : "cancelled",
26144
+ errorCode: code,
26145
+ errorMessage: message,
26146
+ ts: Date.now()
26147
+ });
26148
+ } finally {
26149
+ aborters.delete(job.id);
26150
+ workerExtras.delete(job.id);
26151
+ }
26152
+ }
26153
+ function normaliseError(e) {
26154
+ if (e instanceof CloneError) {
26155
+ const detail = e.detail ? `
26156
+ ${e.detail.slice(-360)}` : "";
26157
+ return { code: e.code, message: `${e.message}${detail}` };
26158
+ }
26159
+ if (e instanceof Error && e.name === "AbortError") return { code: "cancelled", message: "Clone was cancelled." };
26160
+ return { code: "unknown", message: e instanceof Error ? e.message : String(e) };
26161
+ }
26162
+ function minimaxBaseUrlFromPref() {
26163
+ try {
26164
+ const region = getPrefs().minimaxRegion;
26165
+ return region === "intl" ? "https://api.minimax.io" : "https://api.minimaxi.com";
26166
+ } catch {
26167
+ return "https://api.minimaxi.com";
26168
+ }
26169
+ }
26170
+ function voiceCloneRouter() {
26171
+ const r = new Hono13();
26172
+ r.post("/upload", async (c) => {
26173
+ const ct = c.req.header("content-type") || "";
26174
+ if (!ct.toLowerCase().startsWith("multipart/form-data")) {
26175
+ return c.json({ error: "expected multipart/form-data" }, 400);
26176
+ }
26177
+ const form = await c.req.formData();
26178
+ const file = form.get("file");
26179
+ if (!(file instanceof File)) {
26180
+ return c.json({ error: "missing file field" }, 400);
26181
+ }
26182
+ const safeName = String(file.name || "source").replace(/[^A-Za-z0-9_.\- ]/g, "_") || "source";
26183
+ const dir = join4(tmpdir(), `pb-voice-clone-${randomBytes9(6).toString("hex")}`);
26184
+ mkdirSync2(dir, { recursive: true });
26185
+ const path = join4(dir, safeName);
26186
+ const buf = Buffer.from(await file.arrayBuffer());
26187
+ writeFileSync(path, buf);
26188
+ return c.json({ filePath: path, size: buf.length, name: safeName });
26189
+ });
26190
+ r.post("/start", async (c) => {
26191
+ const body = await c.req.json();
26192
+ const agentId = body.agentId?.trim();
26193
+ const source = body.source || {};
26194
+ if (!agentId) return c.json({ error: "missing agentId" }, 400);
26195
+ if (!getAgent(agentId)) return c.json({ error: "unknown agent" }, 404);
26196
+ if (findAnyActiveJob()) {
26197
+ return c.json({ error: "another clone job is in progress" }, 409);
26198
+ }
26199
+ if (findActiveJobForAgent(agentId)) {
26200
+ return c.json({ error: "this director already has a clone in progress" }, 409);
26201
+ }
26202
+ if (source.kind !== "file" || !source.filePath) {
26203
+ return c.json({ error: "source must be { kind: 'file', filePath }" }, 400);
26204
+ }
26205
+ if (!existsSync2(source.filePath) || !statSync2(source.filePath).isFile()) {
26206
+ return c.json({ error: "uploaded file is missing" }, 400);
26207
+ }
26208
+ const kind = "file";
26209
+ const ref = source.filePath;
26210
+ const provider = getActiveVoiceProvider();
26211
+ if (provider !== "minimax" && provider !== "elevenlabs") {
26212
+ return c.json({ error: "active voice credential must be minimax or elevenlabs" }, 400);
26213
+ }
26214
+ const label = (body.label || "").trim();
26215
+ if (!label) {
26216
+ return c.json({ error: "label is required" }, 400);
26217
+ }
26218
+ const job = createCloneJob({
26219
+ agentId,
26220
+ provider,
26221
+ sourceKind: kind,
26222
+ sourceRef: ref,
26223
+ label
26224
+ });
26225
+ const extras = {};
26226
+ if (body.miniMaxGroupId) extras.miniMaxGroupId = body.miniMaxGroupId.trim();
26227
+ workerExtras.set(job.id, extras);
26228
+ void runWorker(job);
26229
+ return c.json({ jobId: job.id, status: job.status });
26230
+ });
26231
+ r.get("/active", (c) => {
26232
+ const j = findAnyActiveJob();
26233
+ return c.json({ job: j ?? null });
26234
+ });
26235
+ r.get("/:id", (c) => {
26236
+ const j = getCloneJob(c.req.param("id"));
26237
+ if (!j) return c.json({ error: "not found" }, 404);
26238
+ return c.json({ job: j });
26239
+ });
26240
+ r.get("/:id/stream", async (c) => {
26241
+ const id = c.req.param("id");
26242
+ const initial = getCloneJob(id);
26243
+ if (!initial) return c.json({ error: "not found" }, 404);
26244
+ return streamSSE3(c, async (s) => {
26245
+ await s.writeSSE({
26246
+ event: "snapshot",
26247
+ data: JSON.stringify({
26248
+ jobId: initial.id,
26249
+ stage: initial.currentStage,
26250
+ pct: initial.pct,
26251
+ status: initial.status,
26252
+ voiceId: initial.voiceId,
26253
+ errorCode: initial.errorCode,
26254
+ errorMessage: initial.errorMessage,
26255
+ ts: Date.now()
26256
+ })
26257
+ });
26258
+ if (initial.status === "done" || initial.status === "failed" || initial.status === "cancelled") {
26259
+ await s.writeSSE({ event: "end", data: JSON.stringify({ jobId: id, status: initial.status }) });
26260
+ return;
26261
+ }
26262
+ const queue = [];
26263
+ let wake = null;
26264
+ let closed = false;
26265
+ const off = subscribe(id, (ev) => {
26266
+ queue.push(ev);
26267
+ if (wake) {
26268
+ wake();
26269
+ wake = null;
26270
+ }
26271
+ });
26272
+ s.onAbort(() => {
26273
+ closed = true;
26274
+ off();
26275
+ if (wake) {
26276
+ wake();
26277
+ wake = null;
26278
+ }
26279
+ });
26280
+ while (!closed) {
26281
+ if (queue.length === 0) {
26282
+ await new Promise((res) => {
26283
+ wake = res;
26284
+ });
26285
+ if (closed) break;
26286
+ }
26287
+ const ev = queue.shift();
26288
+ await s.writeSSE({ event: "progress", data: JSON.stringify(ev) });
26289
+ if (ev.status === "done" || ev.status === "failed" || ev.status === "cancelled") {
26290
+ await s.writeSSE({ event: "end", data: JSON.stringify({ jobId: id, status: ev.status }) });
26291
+ break;
26292
+ }
26293
+ }
26294
+ off();
26295
+ });
26296
+ });
26297
+ r.delete("/:id", (c) => {
26298
+ const id = c.req.param("id");
26299
+ const job = getCloneJob(id);
26300
+ if (!job) return c.json({ error: "not found" }, 404);
26301
+ const aborter = aborters.get(id);
26302
+ if (aborter) {
26303
+ aborter.abort();
26304
+ aborters.delete(id);
26305
+ }
26306
+ updateCloneJobProgress(id, {
26307
+ status: "cancelled",
26308
+ errorCode: "cancelled",
26309
+ errorMessage: "Cancelled by user."
26310
+ });
26311
+ emit({
26312
+ jobId: id,
26313
+ stage: job.currentStage,
26314
+ pct: job.pct,
26315
+ status: "cancelled",
26316
+ errorCode: "cancelled",
26317
+ errorMessage: "Cancelled by user.",
26318
+ ts: Date.now()
26319
+ });
26320
+ return c.json({ ok: true });
26321
+ });
26322
+ return r;
26323
+ }
26324
+
26325
+ // src/routes/voice-credentials.ts
26326
+ import { Hono as Hono14 } from "hono";
25279
26327
 
25280
26328
  // src/storage/reconcile-voices.ts
25281
26329
  var MINIMAX_SEED_VOICES = [
@@ -25442,7 +26490,7 @@ function pickNextActiveVoiceId(removedProvider) {
25442
26490
  return sorted[0]?.id ?? null;
25443
26491
  }
25444
26492
  function voiceCredentialsRouter() {
25445
- const r = new Hono13();
26493
+ const r = new Hono14();
25446
26494
  r.get("/", (c) => {
25447
26495
  const activeId = getPrefs().activeVoiceCredentialId;
25448
26496
  const items = listVoiceCredentials().map((m) => payloadFor3(m, activeId));
@@ -25510,8 +26558,9 @@ function voiceCredentialsRouter() {
25510
26558
  const label = typeof labelRaw === "string" ? labelRaw : null;
25511
26559
  const meta = createVoiceCredential(provider, label, key);
25512
26560
  if (!meta) return c.json({ error: "failed to create credential" }, 500);
25513
- const hadActive = !!getPrefs().activeVoiceCredentialId;
25514
- if (!hadActive) {
26561
+ const priorActiveId = getPrefs().activeVoiceCredentialId;
26562
+ const priorActive = priorActiveId ? getVoiceCredentialMeta(priorActiveId) : null;
26563
+ if (!priorActive) {
25515
26564
  updatePrefs({ activeVoiceCredentialId: meta.id });
25516
26565
  try {
25517
26566
  reconcileAgentVoices({ reason: "first-key", priorProvider: null });
@@ -25521,6 +26570,8 @@ function voiceCredentialsRouter() {
25521
26570
  `
25522
26571
  );
25523
26572
  }
26573
+ } else if (priorActive.provider === provider) {
26574
+ updatePrefs({ activeVoiceCredentialId: meta.id });
25524
26575
  }
25525
26576
  const activeId = getPrefs().activeVoiceCredentialId;
25526
26577
  return c.json(payloadFor3(meta, activeId), 201);
@@ -25559,8 +26610,37 @@ function voiceCredentialsRouter() {
25559
26610
  return r;
25560
26611
  }
25561
26612
 
26613
+ // src/routes/voice-labels.ts
26614
+ import { Hono as Hono15 } from "hono";
26615
+ function voiceLabelsRouter() {
26616
+ const r = new Hono15();
26617
+ r.get("/", (c) => {
26618
+ return c.json({ labels: listVoiceLabels() });
26619
+ });
26620
+ r.put("/:voiceId", async (c) => {
26621
+ const voiceId = c.req.param("voiceId");
26622
+ if (!voiceId) return c.json({ error: "missing voiceId" }, 400);
26623
+ const body = await c.req.json();
26624
+ const provider = body.provider === "minimax" || body.provider === "elevenlabs" ? body.provider : null;
26625
+ if (!provider) return c.json({ error: "provider must be minimax or elevenlabs" }, 400);
26626
+ const label = (body.label || "").trim();
26627
+ if (!label) return c.json({ error: "label is required" }, 400);
26628
+ setVoiceLabel({ voiceId, provider, label });
26629
+ invalidateVoicesCache();
26630
+ return c.json({ ok: true, voiceId, provider, label });
26631
+ });
26632
+ r.delete("/:voiceId", (c) => {
26633
+ const voiceId = c.req.param("voiceId");
26634
+ if (!voiceId) return c.json({ error: "missing voiceId" }, 400);
26635
+ const removed = deleteVoiceLabel(voiceId);
26636
+ if (removed) invalidateVoicesCache();
26637
+ return c.json({ ok: true, removed });
26638
+ });
26639
+ return r;
26640
+ }
26641
+
25562
26642
  // src/routes/voices.ts
25563
- import { Hono as Hono14 } from "hono";
26643
+ import { Hono as Hono16 } from "hono";
25564
26644
  function ttsErrorMessage(e, providerLabel) {
25565
26645
  if (!(e instanceof Error)) return String(e);
25566
26646
  const cause = e.cause;
@@ -25605,7 +26685,7 @@ function ttsCacheSet(key, val) {
25605
26685
  }
25606
26686
  }
25607
26687
  function voicesRouter() {
25608
- const r = new Hono14();
26688
+ const r = new Hono16();
25609
26689
  r.get("/", async (c) => {
25610
26690
  const url = new URL(c.req.url);
25611
26691
  const cursor = url.searchParams.get("cursor");
@@ -25772,7 +26852,7 @@ function voicesRouter() {
25772
26852
  init_paths();
25773
26853
 
25774
26854
  // src/version.ts
25775
- var VERSION = "0.1.36";
26855
+ var VERSION = "0.1.38";
25776
26856
 
25777
26857
  // src/utils/render-picker-catalog.ts
25778
26858
  function renderPickerCatalog() {
@@ -25784,9 +26864,9 @@ function renderPickerCatalog() {
25784
26864
 
25785
26865
  // src/server.ts
25786
26866
  function createApp() {
25787
- const app = new Hono15();
26867
+ const app = new Hono17();
25788
26868
  const dir = publicDir();
25789
- if (!existsSync2(dir)) {
26869
+ if (!existsSync3(dir)) {
25790
26870
  throw new Error(
25791
26871
  `public/ directory not found at: ${dir}
25792
26872
  Build the package or check that public/ is bundled alongside dist/.`
@@ -25833,6 +26913,8 @@ Build the package or check that public/ is bundled alongside dist/.`
25833
26913
  app.route("/api/usage", usageRouter());
25834
26914
  app.route("/api/voices", voicesRouter());
25835
26915
  app.route("/api/voice-credentials", voiceCredentialsRouter());
26916
+ app.route("/api/voice-clone", voiceCloneRouter());
26917
+ app.route("/api/voice-labels", voiceLabelsRouter());
25836
26918
  app.route("/api/search", searchRouter());
25837
26919
  app.route("/api/search-credentials", searchCredentialsRouter());
25838
26920
  app.use(
@@ -25929,6 +27011,16 @@ async function bootApp(opts = {}) {
25929
27011
  }
25930
27012
  } catch (e) {
25931
27013
  process.stderr.write(`[boot] persona-job recovery failed: ${errMsg(e)}
27014
+ `);
27015
+ }
27016
+ try {
27017
+ const failed = recoverStuckCloneJobs();
27018
+ if (failed > 0) {
27019
+ process.stderr.write(`[boot] marked ${failed} voice-clone job(s) failed (server restarted mid-clone)
27020
+ `);
27021
+ }
27022
+ } catch (e) {
27023
+ process.stderr.write(`[boot] voice-clone recovery failed: ${errMsg(e)}
25932
27024
  `);
25933
27025
  }
25934
27026
  void (async () => {