privateboard 0.1.12 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -502,6 +502,88 @@ ALTER TABLE rooms ADD COLUMN vote_trigger TEXT NOT NULL DEFAULT 'auto';
502
502
  }
503
503
  });
504
504
 
505
+ // src/storage/migrations/033_room_members_removed_at.sql
506
+ var room_members_removed_at_default;
507
+ var init_room_members_removed_at = __esm({
508
+ "src/storage/migrations/033_room_members_removed_at.sql"() {
509
+ room_members_removed_at_default = "-- Soft-delete column on room_members so excused directors stay\n-- queryable for chat-history rendering + voice replay. NULL marks\n-- an active member; a non-null timestamp records when the chair\n-- excused them from the room. Prior to this migration a director\n-- removal hard-DELETEd the row, which dropped the director from\n-- `listRoomMembers` and broke speaker-name lookups + voice profile\n-- resolution for their past messages.\nALTER TABLE room_members ADD COLUMN removed_at INTEGER;\n";
510
+ }
511
+ });
512
+
513
+ // src/storage/migrations/034_user_topic_recs.sql
514
+ var user_topic_recs_default;
515
+ var init_user_topic_recs = __esm({
516
+ "src/storage/migrations/034_user_topic_recs.sql"() {
517
+ user_topic_recs_default = `-- Interest-driven topic recommendations \xB7 the home composer's
518
+ -- "\u627E\u4F60\u53EF\u80FD\u611F\u5174\u8DA3\u7684\u8BDD\u9898" trigger drops a row in topic_rec_jobs,
519
+ -- the pipeline writes a topic_rec_batches row at start +
520
+ -- N topic_recs rows per recommendation it synthesises. The
521
+ -- composer tray pages topic_recs newest-first; each card can
522
+ -- carry web-search snippets in seed_context_json that get
523
+ -- forwarded into the room's opening message at convene time.
524
+
525
+ -- One batch per "user clicked the button" event. Lets us
526
+ -- show "generated 3h ago", keep older batches addressable via
527
+ -- pagination, and (later) attribute a room back to its rec.
528
+ CREATE TABLE topic_rec_batches (
529
+ id TEXT PRIMARY KEY,
530
+ has_web INTEGER NOT NULL DEFAULT 0, -- 1 = web-search ran for this batch
531
+ keywords_json TEXT NOT NULL, -- JSON string[] \xB7 the 10 keywords pulled from chair memory
532
+ created_at INTEGER NOT NULL
533
+ );
534
+ CREATE INDEX idx_topic_rec_batches_created ON topic_rec_batches(created_at DESC);
535
+
536
+ -- One row per recommended topic. \`source\` drives the UI badge
537
+ -- (// web vs // memory). \`seed_context_json\` carries the web
538
+ -- snippets that informed this topic so the composer can attach
539
+ -- them to the room's opening message when the user convenes.
540
+ CREATE TABLE topic_recs (
541
+ id TEXT PRIMARY KEY,
542
+ batch_id TEXT NOT NULL REFERENCES topic_rec_batches(id) ON DELETE CASCADE,
543
+ subject TEXT NOT NULL, -- the suggested room subject
544
+ rationale TEXT NOT NULL, -- one-line "why this fits you" hint
545
+ source TEXT NOT NULL, -- 'web' | 'memory'
546
+ seed_context_json TEXT, -- JSON [{ title, url, description }] \xB7 NULL when source=memory
547
+ created_at INTEGER NOT NULL,
548
+ opened_room_id TEXT REFERENCES rooms(id) ON DELETE SET NULL
549
+ );
550
+ CREATE INDEX idx_topic_recs_created ON topic_recs(created_at DESC);
551
+ CREATE INDEX idx_topic_recs_batch ON topic_recs(batch_id);
552
+
553
+ -- Async job tracker \xB7 1:1 mirror of agent_persona_jobs.
554
+ -- Boot-time recovery flips status='running' \u2192 'failed' so
555
+ -- crashed jobs surface a retry CTA instead of a hung spinner.
556
+ CREATE TABLE topic_rec_jobs (
557
+ id TEXT PRIMARY KEY,
558
+ status TEXT NOT NULL, -- running | done | failed | aborted
559
+ current_phase INTEGER NOT NULL DEFAULT 0,
560
+ progress_pct INTEGER NOT NULL DEFAULT 0,
561
+ batch_id TEXT REFERENCES topic_rec_batches(id) ON DELETE SET NULL,
562
+ error TEXT,
563
+ started_at INTEGER NOT NULL,
564
+ updated_at INTEGER NOT NULL
565
+ );
566
+ CREATE INDEX idx_topic_rec_jobs_status ON topic_rec_jobs(status);
567
+ `;
568
+ }
569
+ });
570
+
571
+ // src/storage/migrations/035_topic_rec_tag.sql
572
+ var topic_rec_tag_default;
573
+ var init_topic_rec_tag = __esm({
574
+ "src/storage/migrations/035_topic_rec_tag.sql"() {
575
+ topic_rec_tag_default = `-- Per-topic category tag \xB7 the synthesiser produces a 1-2 word
576
+ -- label per recommendation (e.g. "strategy", "product", "market",
577
+ -- "ops") so the composer card can display a meaningful tag in
578
+ -- the left column instead of the source token (which was always
579
+ -- "web" / "memory" and didn't tell the user what the topic was
580
+ -- about). NULL on rows generated before this migration \xB7 the
581
+ -- frontend falls back to the source token in that case.
582
+ ALTER TABLE topic_recs ADD COLUMN tag TEXT;
583
+ `;
584
+ }
585
+ });
586
+
505
587
  // src/storage/db.ts
506
588
  var db_exports = {};
507
589
  __export(db_exports, {
@@ -591,6 +673,9 @@ var init_db = __esm({
591
673
  init_minimax_region();
592
674
  init_agent_persona_spec();
593
675
  init_room_vote_trigger();
676
+ init_room_members_removed_at();
677
+ init_user_topic_recs();
678
+ init_topic_rec_tag();
594
679
  MIGRATIONS = [
595
680
  { name: "001_init.sql", sql: init_default },
596
681
  { name: "002_default_opus.sql", sql: default_opus_default },
@@ -623,7 +708,10 @@ var init_db = __esm({
623
708
  { name: "029_web_search_provider_pref.sql", sql: web_search_provider_pref_default },
624
709
  { name: "030_minimax_region.sql", sql: minimax_region_default },
625
710
  { name: "031_agent_persona_spec.sql", sql: agent_persona_spec_default },
626
- { name: "032_room_vote_trigger.sql", sql: room_vote_trigger_default }
711
+ { name: "032_room_vote_trigger.sql", sql: room_vote_trigger_default },
712
+ { name: "033_room_members_removed_at.sql", sql: room_members_removed_at_default },
713
+ { name: "034_user_topic_recs.sql", sql: user_topic_recs_default },
714
+ { name: "035_topic_rec_tag.sql", sql: topic_rec_tag_default }
627
715
  ];
628
716
  _db = null;
629
717
  }
@@ -1956,7 +2044,7 @@ function runSeed() {
1956
2044
  // src/server.ts
1957
2045
  import { serve } from "@hono/node-server";
1958
2046
  import { serveStatic } from "@hono/node-server/serve-static";
1959
- import { Hono as Hono12 } from "hono";
2047
+ import { Hono as Hono13 } from "hono";
1960
2048
  import { existsSync as existsSync2 } from "fs";
1961
2049
 
1962
2050
  // src/routes/agents.ts
@@ -3598,11 +3686,11 @@ async function callLLMWithUsage(req) {
3598
3686
 
3599
3687
  // src/storage/reconcile-models.ts
3600
3688
  var PRIMARY_BY_CARRIER = {
3601
- openrouter: "opus-4-7",
3602
- anthropic: "sonnet-4-6",
3603
- openai: "gpt-5-5",
3604
- google: "gemini-3-flash",
3605
- xai: "grok-4-3"
3689
+ openrouter: "opus-4-6-fast",
3690
+ anthropic: "haiku-4-5",
3691
+ openai: "gpt-5-4-mini",
3692
+ google: "gemini-3-1-flash",
3693
+ xai: "grok-4-1-fast"
3606
3694
  };
3607
3695
  var CARRIER_PRIORITY = ["openrouter", "anthropic", "openai", "google", "xai"];
3608
3696
  function reachableModelVs() {
@@ -3650,23 +3738,21 @@ function activeCarrier() {
3650
3738
  }
3651
3739
  return null;
3652
3740
  }
3653
- function activeCarrierPrimary() {
3654
- const carrier = activeCarrier();
3655
- if (!carrier) return null;
3656
- return PRIMARY_BY_CARRIER[carrier] ?? null;
3657
- }
3658
3741
  function reconcileAgentModels(opts = {}) {
3659
3742
  const reachable = reachableModelVs();
3660
- const primary = activeCarrierPrimary();
3743
+ const carrier = activeCarrier();
3744
+ const primary = carrier ? PRIMARY_BY_CARRIER[carrier] ?? null : null;
3661
3745
  const forcePrimary = opts.forcePrimary === true;
3662
3746
  const switched = [];
3663
3747
  const cleared = [];
3664
3748
  for (const agent of listAllAgents()) {
3665
3749
  const v = (agent.modelV || "").trim();
3666
3750
  if (!forcePrimary && v && reachable.has(v)) continue;
3667
- if (primary) {
3668
- if (v === primary) continue;
3669
- updateAgent(agent.id, { modelV: primary });
3751
+ if (primary && carrier) {
3752
+ const isChair = agent.roleKind === "moderator";
3753
+ const target = isChair ? primary : pickRandomFastModel(carrier) ?? primary;
3754
+ if (v === target) continue;
3755
+ updateAgent(agent.id, { modelV: target });
3670
3756
  switched.push(agent.id);
3671
3757
  } else {
3672
3758
  if (v === "") continue;
@@ -3734,6 +3820,42 @@ var PROVIDER_FLAGSHIP = {
3734
3820
  minimax: null,
3735
3821
  elevenlabs: null
3736
3822
  };
3823
+ var PROVIDER_FAST = {
3824
+ anthropic: "haiku-4-5",
3825
+ openai: "gpt-5-4-mini",
3826
+ google: "gemini-3-1-flash",
3827
+ xai: "grok-4-1-fast",
3828
+ deepseek: "deepseek-v4-flash",
3829
+ openrouter: "opus-4-6-fast",
3830
+ brave: null,
3831
+ tavily: null,
3832
+ minimax: null,
3833
+ elevenlabs: null
3834
+ };
3835
+ var FAST_POOL_BY_CARRIER = {
3836
+ openrouter: [
3837
+ "opus-4-6-fast",
3838
+ "haiku-4-5",
3839
+ "gpt-5-4-mini",
3840
+ "gemini-3-flash",
3841
+ "gemini-3-1-flash",
3842
+ "grok-4-1-fast",
3843
+ "deepseek-v4-flash"
3844
+ ],
3845
+ anthropic: ["opus-4-6-fast", "haiku-4-5"],
3846
+ openai: ["gpt-5-4-mini"],
3847
+ google: ["gemini-3-flash", "gemini-3-1-flash"],
3848
+ xai: ["grok-4-1-fast"]
3849
+ };
3850
+ function pickRandomFastModel(carrier) {
3851
+ if (!carrier) return null;
3852
+ const pool = FAST_POOL_BY_CARRIER[carrier];
3853
+ if (!pool || pool.length === 0) return null;
3854
+ const reachable = new Set(reachableModels().map((m) => m.modelV));
3855
+ const candidates = pool.filter((v) => reachable.has(v));
3856
+ const list = candidates.length > 0 ? candidates : pool;
3857
+ return list[Math.floor(Math.random() * list.length)] ?? null;
3858
+ }
3737
3859
  var FLAGSHIP_TIER = /* @__PURE__ */ new Set([
3738
3860
  // Anthropic
3739
3861
  "opus-4-7",
@@ -3766,13 +3888,21 @@ function defaultModelFor(keys = getProviderKeyState()) {
3766
3888
  if (reachable.length === 0) return null;
3767
3889
  if (!keys.hasOpenRouter && keys.directProviders.size === 1) {
3768
3890
  const provider = Array.from(keys.directProviders)[0];
3891
+ const fast = PROVIDER_FAST[provider];
3892
+ if (fast && reachable.find((m) => m.modelV === fast)) return fast;
3769
3893
  const flagship = PROVIDER_FLAGSHIP[provider];
3770
3894
  if (flagship && reachable.find((m) => m.modelV === flagship)) return flagship;
3771
3895
  }
3772
3896
  if (keys.hasOpenRouter) {
3897
+ const fast = reachable.find((m) => m.modelV === "opus-4-6-fast");
3898
+ if (fast) return fast.modelV;
3773
3899
  const opus = reachable.find((m) => m.modelV === "opus-4-7");
3774
3900
  if (opus) return opus.modelV;
3775
3901
  }
3902
+ for (const provider of keys.directProviders) {
3903
+ const fast = PROVIDER_FAST[provider];
3904
+ if (fast && reachable.find((m) => m.modelV === fast)) return fast;
3905
+ }
3776
3906
  for (const provider of keys.directProviders) {
3777
3907
  const flagship = PROVIDER_FLAGSHIP[provider];
3778
3908
  if (flagship && reachable.find((m) => m.modelV === flagship)) return flagship;
@@ -6903,7 +7033,7 @@ function agentsRouter() {
6903
7033
  const base = handle.replace(/^\/+/, "");
6904
7034
  handle = uniqueHandle(base || slugifyHandle(name));
6905
7035
  }
6906
- const modelV = typeof b.modelV === "string" && isModelV(b.modelV) ? b.modelV : effectiveDefaultModel() ?? "opus-4-7";
7036
+ const modelV = typeof b.modelV === "string" && isModelV(b.modelV) ? b.modelV : pickRandomFastModel(activeCarrier()) ?? effectiveDefaultModel() ?? "opus-4-6-fast";
6907
7037
  const roleTag = typeof b.roleTag === "string" && b.roleTag.trim().length > 0 ? b.roleTag.trim().slice(0, 80) : "director";
6908
7038
  const bio = typeof b.bio === "string" && b.bio.trim().length >= BIO_MIN ? b.bio.trim().slice(0, BIO_MAX) : partial.description ? partial.description.slice(0, BIO_MAX) : `A custom director built via deep persona replication.`;
6909
7039
  const coverQuote = typeof b.coverQuote === "string" ? b.coverQuote.trim().slice(0, 220) : null;
@@ -12165,7 +12295,12 @@ function mapRow7(row) {
12165
12295
  };
12166
12296
  }
12167
12297
  function mapMember(row) {
12168
- return { agentId: row.agent_id, position: row.position, joinedAt: row.joined_at };
12298
+ return {
12299
+ agentId: row.agent_id,
12300
+ position: row.position,
12301
+ joinedAt: row.joined_at,
12302
+ removedAt: row.removed_at
12303
+ };
12169
12304
  }
12170
12305
  function listRooms() {
12171
12306
  const rows = getDb().prepare(`SELECT ${ROOM_COLS} FROM rooms ORDER BY created_at DESC`).all();
@@ -12177,7 +12312,13 @@ function getRoom(id) {
12177
12312
  }
12178
12313
  function listRoomMembers(roomId) {
12179
12314
  const rows = getDb().prepare(
12180
- "SELECT agent_id, position, joined_at FROM room_members WHERE room_id = ? ORDER BY position ASC"
12315
+ "SELECT agent_id, position, joined_at, removed_at FROM room_members WHERE room_id = ? AND removed_at IS NULL ORDER BY position ASC"
12316
+ ).all(roomId);
12317
+ return rows.map(mapMember);
12318
+ }
12319
+ function listAllRoomMembers(roomId) {
12320
+ const rows = getDb().prepare(
12321
+ "SELECT agent_id, position, joined_at, removed_at FROM room_members WHERE room_id = ? ORDER BY position ASC"
12181
12322
  ).all(roomId);
12182
12323
  return rows.map(mapMember);
12183
12324
  }
@@ -12252,18 +12393,26 @@ function setRoomStatus(roomId, status, ts = {}) {
12252
12393
  }
12253
12394
  function addRoomMember(roomId, agentId) {
12254
12395
  const db = getDb();
12255
- const existing = db.prepare("SELECT agent_id, position, joined_at FROM room_members WHERE room_id = ? AND agent_id = ?").get(roomId, agentId);
12256
- if (existing) return mapMember(existing);
12396
+ const existing = db.prepare("SELECT agent_id, position, joined_at, removed_at FROM room_members WHERE room_id = ? AND agent_id = ?").get(roomId, agentId);
12397
+ if (existing) {
12398
+ if (existing.removed_at !== null) {
12399
+ db.prepare("UPDATE room_members SET removed_at = NULL WHERE room_id = ? AND agent_id = ?").run(roomId, agentId);
12400
+ return { agentId, position: existing.position, joinedAt: existing.joined_at, removedAt: null };
12401
+ }
12402
+ return mapMember(existing);
12403
+ }
12257
12404
  const maxRow = db.prepare("SELECT COALESCE(MAX(position), -1) AS p FROM room_members WHERE room_id = ?").get(roomId);
12258
12405
  const position = maxRow.p + 1;
12259
12406
  const now = Date.now();
12260
12407
  db.prepare(
12261
- "INSERT INTO room_members (room_id, agent_id, position, joined_at) VALUES (?, ?, ?, ?)"
12408
+ "INSERT INTO room_members (room_id, agent_id, position, joined_at, removed_at) VALUES (?, ?, ?, ?, NULL)"
12262
12409
  ).run(roomId, agentId, position, now);
12263
- return { agentId, position, joinedAt: now };
12410
+ return { agentId, position, joinedAt: now, removedAt: null };
12264
12411
  }
12265
12412
  function removeRoomMember(roomId, agentId) {
12266
- const result = getDb().prepare("DELETE FROM room_members WHERE room_id = ? AND agent_id = ?").run(roomId, agentId);
12413
+ const result = getDb().prepare(
12414
+ "UPDATE room_members SET removed_at = ? WHERE room_id = ? AND agent_id = ? AND removed_at IS NULL"
12415
+ ).run(Date.now(), roomId, agentId);
12267
12416
  return result.changes > 0;
12268
12417
  }
12269
12418
  function setRoomIncognito(roomId, incognito) {
@@ -14594,8 +14743,750 @@ function collectProviderSummary(models) {
14594
14743
  return Array.from(map.entries()).map(([provider, v]) => ({ provider, ...v }));
14595
14744
  }
14596
14745
 
14597
- // src/routes/notes.ts
14746
+ // src/routes/topic-recs.ts
14598
14747
  import { Hono as Hono6 } from "hono";
14748
+ import { streamSSE as streamSSE2 } from "hono/streaming";
14749
+
14750
+ // src/orchestrator/topic-recommender.ts
14751
+ import { randomUUID as randomUUID2 } from "crypto";
14752
+
14753
+ // src/storage/topic-recs.ts
14754
+ init_db();
14755
+ function createTopicRecBatch(input) {
14756
+ const now = Date.now();
14757
+ 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);
14758
+ return { id: input.id, hasWeb: input.hasWeb, keywords: input.keywords, createdAt: now };
14759
+ }
14760
+ function mapRec(r) {
14761
+ let seedContext = null;
14762
+ if (r.seed_context_json) {
14763
+ try {
14764
+ const parsed = JSON.parse(r.seed_context_json);
14765
+ if (Array.isArray(parsed)) {
14766
+ seedContext = parsed.filter(
14767
+ (s) => s && typeof s.title === "string" && typeof s.url === "string" && typeof s.description === "string"
14768
+ );
14769
+ }
14770
+ } catch {
14771
+ }
14772
+ }
14773
+ return {
14774
+ id: r.id,
14775
+ batchId: r.batch_id,
14776
+ subject: r.subject,
14777
+ rationale: r.rationale,
14778
+ source: r.source === "web" ? "web" : "memory",
14779
+ tag: typeof r.tag === "string" && r.tag.trim().length > 0 ? r.tag.trim() : null,
14780
+ seedContext,
14781
+ createdAt: r.created_at,
14782
+ openedRoomId: r.opened_room_id
14783
+ };
14784
+ }
14785
+ var REC_COLS = "id, batch_id, subject, rationale, source, tag, seed_context_json, created_at, opened_room_id";
14786
+ function insertTopicRec(input) {
14787
+ const now = Date.now();
14788
+ getDb().prepare(
14789
+ `INSERT INTO topic_recs
14790
+ (id, batch_id, subject, rationale, source, tag, seed_context_json, created_at, opened_room_id)
14791
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)`
14792
+ ).run(
14793
+ input.id,
14794
+ input.batchId,
14795
+ input.subject,
14796
+ input.rationale,
14797
+ input.source,
14798
+ input.tag,
14799
+ input.seedContext ? JSON.stringify(input.seedContext) : null,
14800
+ now
14801
+ );
14802
+ return {
14803
+ id: input.id,
14804
+ batchId: input.batchId,
14805
+ subject: input.subject,
14806
+ rationale: input.rationale,
14807
+ source: input.source,
14808
+ tag: input.tag,
14809
+ seedContext: input.seedContext,
14810
+ createdAt: now,
14811
+ openedRoomId: null
14812
+ };
14813
+ }
14814
+ function getTopicRec(id) {
14815
+ const row = getDb().prepare(`SELECT ${REC_COLS} FROM topic_recs WHERE id = ?`).get(id);
14816
+ return row ? mapRec(row) : null;
14817
+ }
14818
+ function listTopicRecs(opts) {
14819
+ const limit = Math.max(1, Math.min(100, opts.limit));
14820
+ 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 ?`);
14821
+ const rows = opts.cursor === null ? stmt.all(limit) : stmt.all(opts.cursor, limit);
14822
+ const items = rows.map(mapRec);
14823
+ const nextCursor = items.length === limit ? items[items.length - 1].createdAt : null;
14824
+ return { items, nextCursor };
14825
+ }
14826
+ function markTopicRecOpened(recId, roomId) {
14827
+ getDb().prepare("UPDATE topic_recs SET opened_room_id = ? WHERE id = ?").run(roomId, recId);
14828
+ }
14829
+ function clearAllTopicRecs() {
14830
+ const r = getDb().prepare("DELETE FROM topic_recs").run();
14831
+ return r.changes;
14832
+ }
14833
+ function mapJob(r) {
14834
+ const status = ["running", "done", "failed", "aborted"].includes(r.status) ? r.status : "failed";
14835
+ return {
14836
+ id: r.id,
14837
+ status,
14838
+ currentPhase: r.current_phase,
14839
+ progressPct: r.progress_pct,
14840
+ batchId: r.batch_id,
14841
+ error: r.error,
14842
+ startedAt: r.started_at,
14843
+ updatedAt: r.updated_at
14844
+ };
14845
+ }
14846
+ var JOB_COLS = "id, status, current_phase, progress_pct, batch_id, error, started_at, updated_at";
14847
+ function createTopicRecJob(id) {
14848
+ const now = Date.now();
14849
+ getDb().prepare(
14850
+ `INSERT INTO topic_rec_jobs (id, status, current_phase, progress_pct, batch_id, error, started_at, updated_at)
14851
+ VALUES (?, 'running', 0, 0, NULL, NULL, ?, ?)`
14852
+ ).run(id, now, now);
14853
+ return getTopicRecJob(id);
14854
+ }
14855
+ function getTopicRecJob(id) {
14856
+ const row = getDb().prepare(`SELECT ${JOB_COLS} FROM topic_rec_jobs WHERE id = ?`).get(id);
14857
+ return row ? mapJob(row) : null;
14858
+ }
14859
+ function updateTopicRecJob(id, patch) {
14860
+ const fields = [];
14861
+ const values = [];
14862
+ if (patch.status !== void 0) {
14863
+ fields.push("status = ?");
14864
+ values.push(patch.status);
14865
+ }
14866
+ if (typeof patch.currentPhase === "number") {
14867
+ fields.push("current_phase = ?");
14868
+ values.push(patch.currentPhase);
14869
+ }
14870
+ if (typeof patch.progressPct === "number") {
14871
+ fields.push("progress_pct = ?");
14872
+ values.push(Math.max(0, Math.min(100, Math.round(patch.progressPct))));
14873
+ }
14874
+ if (patch.batchId !== void 0) {
14875
+ fields.push("batch_id = ?");
14876
+ values.push(patch.batchId);
14877
+ }
14878
+ if (patch.error !== void 0) {
14879
+ fields.push("error = ?");
14880
+ values.push(patch.error);
14881
+ }
14882
+ if (fields.length === 0) return getTopicRecJob(id);
14883
+ fields.push("updated_at = ?");
14884
+ values.push(Date.now());
14885
+ values.push(id);
14886
+ getDb().prepare(`UPDATE topic_rec_jobs SET ${fields.join(", ")} WHERE id = ?`).run(...values);
14887
+ return getTopicRecJob(id);
14888
+ }
14889
+ function markRunningTopicRecJobsFailed() {
14890
+ const r = getDb().prepare(
14891
+ `UPDATE topic_rec_jobs
14892
+ SET status = 'failed',
14893
+ error = COALESCE(error, 'server restarted mid-build'),
14894
+ updated_at = ?
14895
+ WHERE status = 'running'`
14896
+ ).run(Date.now());
14897
+ return r.changes;
14898
+ }
14899
+
14900
+ // src/orchestrator/topic-stream.ts
14901
+ import { EventEmitter as EventEmitter3 } from "events";
14902
+ var TopicRecBus = class {
14903
+ emitters = /* @__PURE__ */ new Map();
14904
+ get(jobId) {
14905
+ let e = this.emitters.get(jobId);
14906
+ if (!e) {
14907
+ e = new EventEmitter3();
14908
+ e.setMaxListeners(16);
14909
+ this.emitters.set(jobId, e);
14910
+ }
14911
+ return e;
14912
+ }
14913
+ emit(jobId, event) {
14914
+ this.get(jobId).emit("event", event);
14915
+ }
14916
+ subscribe(jobId, listener) {
14917
+ const e = this.get(jobId);
14918
+ e.on("event", listener);
14919
+ return () => e.off("event", listener);
14920
+ }
14921
+ /** Free the EventEmitter for a job. Call on terminal events
14922
+ * (`topic-final`, `topic-error`, `topic-aborted`) so the Map
14923
+ * doesn't grow unbounded across many runs in one process. */
14924
+ drop(jobId) {
14925
+ const e = this.emitters.get(jobId);
14926
+ if (e) {
14927
+ e.removeAllListeners();
14928
+ this.emitters.delete(jobId);
14929
+ }
14930
+ }
14931
+ };
14932
+ var topicRecBus = new TopicRecBus();
14933
+
14934
+ // src/orchestrator/topic-recommender.ts
14935
+ var LLM_CALL_TIMEOUT_MS2 = 6e4;
14936
+ var PIPELINE_WALL_CLOCK_MS = 12e4;
14937
+ var SEARCH_PARALLEL_CHUNK = 3;
14938
+ var SEARCH_CHUNK_GAP_MS = 1e3;
14939
+ var SEARCH_RESULTS_PER_QUERY = 5;
14940
+ var inFlightJobs2 = /* @__PURE__ */ new Map();
14941
+ function signalWithTimeout3(parent, timeoutMs) {
14942
+ const controller = new AbortController();
14943
+ const onParentAbort = () => controller.abort();
14944
+ if (parent?.aborted) controller.abort();
14945
+ else parent?.addEventListener("abort", onParentAbort, { once: true });
14946
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
14947
+ return {
14948
+ signal: controller.signal,
14949
+ cleanup: () => {
14950
+ clearTimeout(timer);
14951
+ parent?.removeEventListener("abort", onParentAbort);
14952
+ }
14953
+ };
14954
+ }
14955
+ function extractJson5(raw) {
14956
+ const fence = /```(?:json)?\s*([\s\S]*?)```/i.exec(raw);
14957
+ const candidate = fence ? fence[1] : raw;
14958
+ if (!candidate) return null;
14959
+ const start = candidate.indexOf("{");
14960
+ if (start === -1) return null;
14961
+ let depth = 0;
14962
+ let end = -1;
14963
+ for (let i = start; i < candidate.length; i++) {
14964
+ if (candidate[i] === "{") depth++;
14965
+ else if (candidate[i] === "}") {
14966
+ depth--;
14967
+ if (depth === 0) {
14968
+ end = i;
14969
+ break;
14970
+ }
14971
+ }
14972
+ }
14973
+ if (end === -1) return null;
14974
+ try {
14975
+ return JSON.parse(candidate.slice(start, end + 1));
14976
+ } catch {
14977
+ return null;
14978
+ }
14979
+ }
14980
+ async function callPhaseLLM2(state, modelV, messages, opts) {
14981
+ if (!isModelV(modelV)) return null;
14982
+ const t = signalWithTimeout3(state.controller.signal, LLM_CALL_TIMEOUT_MS2);
14983
+ try {
14984
+ const r = await callLLMWithUsage({
14985
+ modelV,
14986
+ messages,
14987
+ temperature: opts.temperature,
14988
+ maxTokens: opts.maxTokens,
14989
+ signal: t.signal
14990
+ });
14991
+ return r.text;
14992
+ } catch (e) {
14993
+ process.stderr.write(
14994
+ `[topic-recommender] ${modelV} failed: ${e instanceof Error ? e.message : String(e)}
14995
+ `
14996
+ );
14997
+ return null;
14998
+ } finally {
14999
+ t.cleanup();
15000
+ }
15001
+ }
15002
+ function sleepWithSignal2(ms, signal) {
15003
+ return new Promise((resolve2) => {
15004
+ if (signal.aborted) return resolve2();
15005
+ const t = setTimeout(() => {
15006
+ signal.removeEventListener("abort", onAbort);
15007
+ resolve2();
15008
+ }, ms);
15009
+ function onAbort() {
15010
+ clearTimeout(t);
15011
+ resolve2();
15012
+ }
15013
+ signal.addEventListener("abort", onAbort, { once: true });
15014
+ });
15015
+ }
15016
+ function startTopicRecommend() {
15017
+ const jobId = randomUUID2();
15018
+ createTopicRecJob(jobId);
15019
+ const state = {
15020
+ id: jobId,
15021
+ startedAt: Date.now(),
15022
+ controller: new AbortController()
15023
+ };
15024
+ inFlightJobs2.set(jobId, state);
15025
+ const wallClock = setTimeout(() => {
15026
+ if (inFlightJobs2.has(jobId)) state.controller.abort();
15027
+ }, PIPELINE_WALL_CLOCK_MS);
15028
+ void runPipeline3(state).finally(() => {
15029
+ clearTimeout(wallClock);
15030
+ inFlightJobs2.delete(jobId);
15031
+ });
15032
+ return jobId;
15033
+ }
15034
+ function abortTopicRecommend(jobId) {
15035
+ const state = inFlightJobs2.get(jobId);
15036
+ if (!state) return false;
15037
+ try {
15038
+ state.controller.abort();
15039
+ } catch {
15040
+ }
15041
+ return true;
15042
+ }
15043
+ function isTopicRecJobRunning(jobId) {
15044
+ return inFlightJobs2.has(jobId);
15045
+ }
15046
+ async function runPipeline3(state) {
15047
+ const phaseLabels = [
15048
+ "Reading your boardroom history",
15049
+ "Distilling interests",
15050
+ "Scanning trending topics",
15051
+ "Synthesising recommendations"
15052
+ ];
15053
+ const emitPhaseStart = (phase) => {
15054
+ topicRecBus.emit(state.id, {
15055
+ type: "topic-phase-start",
15056
+ phase,
15057
+ label: phaseLabels[phase - 1] ?? `Phase ${phase}`
15058
+ });
15059
+ };
15060
+ const emitPhaseProgress = (phase, detail, pct) => {
15061
+ topicRecBus.emit(state.id, {
15062
+ type: "topic-phase-progress",
15063
+ phase,
15064
+ detail,
15065
+ progressPct: Math.max(0, Math.min(100, Math.round(pct)))
15066
+ });
15067
+ updateTopicRecJob(state.id, { currentPhase: phase, progressPct: pct });
15068
+ };
15069
+ const emitPhaseEnd = (phase, pct) => {
15070
+ topicRecBus.emit(state.id, {
15071
+ type: "topic-phase-end",
15072
+ phase,
15073
+ progressPct: Math.max(0, Math.min(100, Math.round(pct)))
15074
+ });
15075
+ updateTopicRecJob(state.id, { currentPhase: phase, progressPct: pct });
15076
+ };
15077
+ const fail = (message) => {
15078
+ updateTopicRecJob(state.id, { status: "failed", error: message });
15079
+ topicRecBus.emit(state.id, { type: "topic-error", message });
15080
+ topicRecBus.drop(state.id);
15081
+ };
15082
+ const cancel = () => {
15083
+ updateTopicRecJob(state.id, { status: "aborted" });
15084
+ topicRecBus.emit(state.id, { type: "topic-aborted" });
15085
+ topicRecBus.drop(state.id);
15086
+ };
15087
+ try {
15088
+ emitPhaseStart(1);
15089
+ const chair = getChairAgent();
15090
+ if (!chair) {
15091
+ fail("chair agent missing \u2014 set up onboarding first");
15092
+ return;
15093
+ }
15094
+ const memories = memoriesForContext(chair.id, 50);
15095
+ emitPhaseProgress(1, `read ${memories.length} memories`, 8);
15096
+ emitPhaseEnd(1, 10);
15097
+ if (state.controller.signal.aborted) {
15098
+ cancel();
15099
+ return;
15100
+ }
15101
+ emitPhaseStart(2);
15102
+ const modelV = utilityModelFor();
15103
+ if (!modelV) {
15104
+ fail("no LLM provider configured \u2014 add an API key first");
15105
+ return;
15106
+ }
15107
+ const keywords = await distilKeywords(state, modelV, memories);
15108
+ if (state.controller.signal.aborted) {
15109
+ cancel();
15110
+ return;
15111
+ }
15112
+ if (keywords.length === 0) {
15113
+ fail("couldn't distil any keywords from the chair's memory yet \u2014 try again after a couple of rooms");
15114
+ return;
15115
+ }
15116
+ emitPhaseProgress(2, `picked ${keywords.length} keywords`, 25);
15117
+ emitPhaseEnd(2, 30);
15118
+ const hasWeb = hasWebSearchKey();
15119
+ let snippetsByKeyword = /* @__PURE__ */ new Map();
15120
+ if (hasWeb) {
15121
+ emitPhaseStart(3);
15122
+ snippetsByKeyword = await runWebSweep(state, keywords, (kw, snippets, idx) => {
15123
+ emitPhaseProgress(
15124
+ 3,
15125
+ `scanned "${kw}" (${snippets.length} hits) \xB7 ${idx}/${keywords.length}`,
15126
+ 30 + Math.round(idx / keywords.length * 40)
15127
+ );
15128
+ topicRecBus.emit(state.id, {
15129
+ type: "topic-search-round",
15130
+ keyword: kw,
15131
+ query: `${kw} site:x.com`,
15132
+ resultsCount: snippets.length,
15133
+ snippets
15134
+ });
15135
+ });
15136
+ if (state.controller.signal.aborted) {
15137
+ cancel();
15138
+ return;
15139
+ }
15140
+ emitPhaseEnd(3, 70);
15141
+ } else {
15142
+ emitPhaseProgress(3, "no web-search key \u2014 skipping", 70);
15143
+ }
15144
+ emitPhaseStart(4);
15145
+ const batchId = randomUUID2();
15146
+ createTopicRecBatch({ id: batchId, hasWeb, keywords });
15147
+ updateTopicRecJob(state.id, { batchId });
15148
+ const synth = await synthesiseTopics(state, modelV, {
15149
+ memories,
15150
+ keywords,
15151
+ snippetsByKeyword,
15152
+ hasWeb
15153
+ });
15154
+ if (state.controller.signal.aborted) {
15155
+ cancel();
15156
+ return;
15157
+ }
15158
+ if (synth.length === 0) {
15159
+ fail("synthesis returned no topics \u2014 try again or refine your boardroom history first");
15160
+ return;
15161
+ }
15162
+ clearAllTopicRecs();
15163
+ let inserted = 0;
15164
+ for (const t of synth) {
15165
+ const rec = insertTopicRec({
15166
+ id: randomUUID2(),
15167
+ batchId,
15168
+ subject: t.subject,
15169
+ rationale: t.rationale,
15170
+ source: t.source,
15171
+ tag: t.tag,
15172
+ seedContext: t.seedContext
15173
+ });
15174
+ inserted++;
15175
+ topicRecBus.emit(state.id, { type: "topic-rec", rec });
15176
+ emitPhaseProgress(
15177
+ 4,
15178
+ `synthesised ${inserted}/${synth.length}`,
15179
+ 70 + Math.round(inserted / synth.length * 28)
15180
+ );
15181
+ }
15182
+ emitPhaseEnd(4, 100);
15183
+ updateTopicRecJob(state.id, {
15184
+ status: "done",
15185
+ progressPct: 100,
15186
+ currentPhase: 4
15187
+ });
15188
+ topicRecBus.emit(state.id, {
15189
+ type: "topic-final",
15190
+ batchId,
15191
+ totalRecs: inserted,
15192
+ hasWeb
15193
+ });
15194
+ topicRecBus.drop(state.id);
15195
+ } catch (e) {
15196
+ if (state.controller.signal.aborted) {
15197
+ cancel();
15198
+ return;
15199
+ }
15200
+ const msg = e instanceof Error ? e.message : String(e);
15201
+ process.stderr.write(`[topic-recommender] pipeline crashed: ${msg}
15202
+ `);
15203
+ fail(msg);
15204
+ }
15205
+ }
15206
+ async function distilKeywords(state, modelV, memories) {
15207
+ if (memories.length === 0) return [];
15208
+ const memoryLines = memories.slice(0, 60).map((m, i) => {
15209
+ const tier = m.tier === "long" ? "STABLE" : "fresh";
15210
+ const prov = m.provenanceRooms > 1 ? ` \xB7 \xD7${m.provenanceRooms} rooms` : "";
15211
+ const recency = Math.max(0, Math.round((Date.now() - m.createdAt) / 864e5));
15212
+ return `${i + 1}. [${tier}${prov} \xB7 ${recency}d ago \xB7 ${m.kind}] ${m.content}`;
15213
+ }).join("\n");
15214
+ 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.`;
15215
+ const user = `# Chair's memory about the user (newest first within each tier)
15216
+ ${memoryLines}
15217
+
15218
+ Return up to 10 keywords as JSON.`;
15219
+ const raw = await callPhaseLLM2(state, modelV, [
15220
+ { role: "system", content: system },
15221
+ { role: "user", content: user }
15222
+ ], { temperature: 0.3, maxTokens: 600 });
15223
+ if (!raw) return [];
15224
+ const parsed = extractJson5(raw);
15225
+ if (!parsed || !Array.isArray(parsed.keywords)) return [];
15226
+ return parsed.keywords.filter((k) => typeof k === "string").map((k) => k.trim()).filter((k) => k.length > 0).slice(0, 10);
15227
+ }
15228
+ async function runWebSweep(state, keywords, onKeywordDone) {
15229
+ const out = /* @__PURE__ */ new Map();
15230
+ const creds = getActiveWebSearchCredentials();
15231
+ if (!creds) return out;
15232
+ let doneCount = 0;
15233
+ for (let i = 0; i < keywords.length; i += SEARCH_PARALLEL_CHUNK) {
15234
+ if (state.controller.signal.aborted) break;
15235
+ const chunk = keywords.slice(i, i + SEARCH_PARALLEL_CHUNK);
15236
+ const settled = await Promise.allSettled(
15237
+ chunk.map((kw) => fetchKeywordSnippets(creds.backend, creds.apiKey, kw))
15238
+ );
15239
+ settled.forEach((res, j) => {
15240
+ const kw = chunk[j];
15241
+ const snippets = res.status === "fulfilled" ? res.value : [];
15242
+ out.set(kw, snippets);
15243
+ doneCount++;
15244
+ onKeywordDone(kw, snippets, doneCount);
15245
+ });
15246
+ if (i + SEARCH_PARALLEL_CHUNK < keywords.length) {
15247
+ await sleepWithSignal2(SEARCH_CHUNK_GAP_MS, state.controller.signal);
15248
+ }
15249
+ }
15250
+ return out;
15251
+ }
15252
+ async function fetchKeywordSnippets(backend, apiKey, keyword) {
15253
+ const xQuery = `${keyword} site:x.com`;
15254
+ const xResults = await runWebSearch(backend, apiKey, xQuery, {
15255
+ count: SEARCH_RESULTS_PER_QUERY
15256
+ });
15257
+ if (xResults && xResults.length > 0) {
15258
+ return xResults.map(toSnippet);
15259
+ }
15260
+ const generic = await runWebSearch(backend, apiKey, keyword, {
15261
+ count: SEARCH_RESULTS_PER_QUERY
15262
+ });
15263
+ return (generic ?? []).map(toSnippet);
15264
+ }
15265
+ function toSnippet(r) {
15266
+ return {
15267
+ title: r.title || "(untitled)",
15268
+ url: r.url,
15269
+ description: r.description || ""
15270
+ };
15271
+ }
15272
+ async function synthesiseTopics(state, modelV, opts) {
15273
+ const { memories, keywords, snippetsByKeyword, hasWeb } = opts;
15274
+ const flatSnippets = [];
15275
+ if (hasWeb) {
15276
+ for (const kw of keywords) {
15277
+ for (const s of snippetsByKeyword.get(kw) ?? []) {
15278
+ flatSnippets.push({ ...s, keyword: kw });
15279
+ }
15280
+ }
15281
+ }
15282
+ 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");
15283
+ const snippetBlock = flatSnippets.length === 0 ? "(no web snippets \u2014 synthesise from memory only)" : flatSnippets.map(
15284
+ (s, i) => `S${i + 1}. [keyword: ${s.keyword}] ${s.title}
15285
+ ${s.description}
15286
+ ${s.url}`
15287
+ ).join("\n\n");
15288
+ 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 6 distinct topics \u2014 not 5, not 7, six. Each topic is a subject line a user could plausibly drop into the convene composer.\n\nThe 6 topics MUST span DIFFERENT dimensions/categories \u2014 don\'t return six pricing topics. Use the 10 keywords as a multi-dimensional search index; the 6 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>] } ] }';
15289
+ const user = `# Keywords distilled from chair memory
15290
+ ${keywords.map((k, i) => `K${i + 1}. ${k}`).join("\n")}
15291
+
15292
+ # Memory excerpts
15293
+ ${memorySummary}
15294
+
15295
+ # Web snippets ${hasWeb ? "(use these to ground at least some recs as source=web)" : "(none \u2014 synthesise from memory only)"}
15296
+ ${snippetBlock}
15297
+
15298
+ Return EXACTLY 6 topics as JSON, each with a different tag, spanning different dimensions.`;
15299
+ const raw = await callPhaseLLM2(state, modelV, [
15300
+ { role: "system", content: system },
15301
+ { role: "user", content: user }
15302
+ ], { temperature: 0.6, maxTokens: 2e3 });
15303
+ if (!raw) return [];
15304
+ const parsed = extractJson5(raw);
15305
+ if (!parsed || !Array.isArray(parsed.topics)) return [];
15306
+ const out = [];
15307
+ for (const t of parsed.topics) {
15308
+ const subject = typeof t.subject === "string" ? t.subject.trim() : "";
15309
+ const rationale = typeof t.rationale === "string" ? t.rationale.trim() : "";
15310
+ if (!subject || !rationale) continue;
15311
+ let tag = null;
15312
+ const TAG_BLOCKLIST = /* @__PURE__ */ new Set([
15313
+ "web",
15314
+ "memory",
15315
+ "general",
15316
+ "misc",
15317
+ "other",
15318
+ "topic",
15319
+ "recommendation",
15320
+ "recommendations",
15321
+ "rec",
15322
+ "category",
15323
+ "n/a",
15324
+ "na",
15325
+ "none"
15326
+ ]);
15327
+ if (typeof t.tag === "string") {
15328
+ const cleaned = t.tag.trim().replace(/^\/\/\s*/, "").toLowerCase().replace(/[^a-z0-9 -]/g, "").replace(/\s+/g, " ").trim().slice(0, 28);
15329
+ if (cleaned.length > 0 && !TAG_BLOCKLIST.has(cleaned)) {
15330
+ tag = cleaned;
15331
+ }
15332
+ }
15333
+ if (!tag) {
15334
+ 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));
15335
+ if (words.length > 0) {
15336
+ tag = words.slice(0, 2).join(" ").slice(0, 28);
15337
+ } else {
15338
+ tag = "topic";
15339
+ }
15340
+ }
15341
+ let source = t.source === "web" ? "web" : "memory";
15342
+ let seedContext = null;
15343
+ if (source === "web" && hasWeb && Array.isArray(t.snippetRefs)) {
15344
+ const refs = t.snippetRefs.map((r) => {
15345
+ if (typeof r === "number") return r;
15346
+ if (typeof r === "string") {
15347
+ const m = r.match(/^S?(\d+)$/i);
15348
+ return m ? Number(m[1]) : NaN;
15349
+ }
15350
+ return NaN;
15351
+ }).filter((n) => Number.isInteger(n) && n > 0 && n <= flatSnippets.length);
15352
+ const seen = /* @__PURE__ */ new Set();
15353
+ const cited = [];
15354
+ for (const ref of refs) {
15355
+ const snip = flatSnippets[ref - 1];
15356
+ if (!snip || seen.has(snip.url)) continue;
15357
+ seen.add(snip.url);
15358
+ cited.push({ title: snip.title, url: snip.url, description: snip.description });
15359
+ }
15360
+ if (cited.length > 0) {
15361
+ seedContext = cited;
15362
+ } else {
15363
+ source = "memory";
15364
+ }
15365
+ } else if (source === "web") {
15366
+ source = "memory";
15367
+ }
15368
+ out.push({ subject, rationale, source, tag, seedContext });
15369
+ if (out.length >= 6) break;
15370
+ }
15371
+ return out;
15372
+ }
15373
+
15374
+ // src/routes/topic-recs.ts
15375
+ function topicRecsRouter() {
15376
+ const r = new Hono6();
15377
+ r.post("/", (c) => {
15378
+ if (!hasAnyModelKey()) {
15379
+ return c.json({ error: "configure an LLM provider key first" }, 400);
15380
+ }
15381
+ const jobId = startTopicRecommend();
15382
+ return c.json({ jobId });
15383
+ });
15384
+ r.get("/jobs/:id/stream", (c) => {
15385
+ const jobId = c.req.param("id");
15386
+ const job = getTopicRecJob(jobId);
15387
+ if (!job) return c.json({ error: "job not found" }, 404);
15388
+ return streamSSE2(c, async (s) => {
15389
+ await s.writeSSE({
15390
+ event: "hello",
15391
+ data: JSON.stringify({
15392
+ jobId,
15393
+ status: job.status,
15394
+ currentPhase: job.currentPhase,
15395
+ progressPct: job.progressPct,
15396
+ batchId: job.batchId,
15397
+ error: job.error
15398
+ })
15399
+ });
15400
+ if (!isTopicRecJobRunning(jobId)) {
15401
+ if (job.status === "done") {
15402
+ await s.writeSSE({
15403
+ event: "topic-final",
15404
+ data: JSON.stringify({
15405
+ type: "topic-final",
15406
+ batchId: job.batchId,
15407
+ totalRecs: null,
15408
+ hasWeb: null
15409
+ })
15410
+ });
15411
+ } else if (job.status === "aborted") {
15412
+ await s.writeSSE({
15413
+ event: "topic-aborted",
15414
+ data: JSON.stringify({ type: "topic-aborted" })
15415
+ });
15416
+ } else if (job.status === "failed") {
15417
+ await s.writeSSE({
15418
+ event: "topic-error",
15419
+ data: JSON.stringify({
15420
+ type: "topic-error",
15421
+ message: job.error || "generation failed"
15422
+ })
15423
+ });
15424
+ }
15425
+ return;
15426
+ }
15427
+ const queue = [];
15428
+ let resolveWaiter = null;
15429
+ let closed = false;
15430
+ const off = topicRecBus.subscribe(jobId, (event) => {
15431
+ queue.push(event);
15432
+ if (resolveWaiter) {
15433
+ resolveWaiter();
15434
+ resolveWaiter = null;
15435
+ }
15436
+ });
15437
+ s.onAbort(() => {
15438
+ closed = true;
15439
+ off();
15440
+ if (resolveWaiter) {
15441
+ resolveWaiter();
15442
+ resolveWaiter = null;
15443
+ }
15444
+ });
15445
+ while (!closed) {
15446
+ if (queue.length === 0) {
15447
+ await new Promise((resolve2) => {
15448
+ resolveWaiter = resolve2;
15449
+ });
15450
+ continue;
15451
+ }
15452
+ const event = queue.shift();
15453
+ await s.writeSSE({ event: event.type, data: JSON.stringify(event) });
15454
+ if (event.type === "topic-final" || event.type === "topic-error" || event.type === "topic-aborted") {
15455
+ closed = true;
15456
+ off();
15457
+ }
15458
+ }
15459
+ });
15460
+ });
15461
+ r.post("/jobs/:id/abort", (c) => {
15462
+ const jobId = c.req.param("id");
15463
+ const ok = abortTopicRecommend(jobId);
15464
+ if (!ok) {
15465
+ const job = getTopicRecJob(jobId);
15466
+ if (!job) return c.json({ error: "job not found" }, 404);
15467
+ return c.json({ ok: true, status: job.status });
15468
+ }
15469
+ return c.json({ ok: true });
15470
+ });
15471
+ r.get("/", (c) => {
15472
+ const cursorRaw = c.req.query("cursor");
15473
+ const limitRaw = c.req.query("limit");
15474
+ const cursor = cursorRaw && /^\d+$/.test(cursorRaw) ? Number(cursorRaw) : null;
15475
+ const limit = limitRaw && /^\d+$/.test(limitRaw) ? Math.max(1, Math.min(100, Number(limitRaw))) : 20;
15476
+ const { items, nextCursor } = listTopicRecs({ cursor, limit });
15477
+ return c.json({ items, nextCursor });
15478
+ });
15479
+ r.get("/:id", (c) => {
15480
+ const id = c.req.param("id");
15481
+ const rec = getTopicRec(id);
15482
+ if (!rec) return c.json({ error: "not found" }, 404);
15483
+ return c.json(rec);
15484
+ });
15485
+ return r;
15486
+ }
15487
+
15488
+ // src/routes/notes.ts
15489
+ import { Hono as Hono7 } from "hono";
14599
15490
 
14600
15491
  // src/storage/notes.ts
14601
15492
  init_db();
@@ -14695,7 +15586,7 @@ function listAllNotesWithRoom() {
14695
15586
 
14696
15587
  // src/routes/notes.ts
14697
15588
  function notesRouter() {
14698
- const r = new Hono6();
15589
+ const r = new Hono7();
14699
15590
  r.get("/", (c) => {
14700
15591
  const notes = listAllNotesWithRoom();
14701
15592
  return c.json({ notes, total: notes.length });
@@ -14767,9 +15658,9 @@ function deriveAuthorName(kind, authorId) {
14767
15658
  }
14768
15659
 
14769
15660
  // src/routes/prefs.ts
14770
- import { Hono as Hono7 } from "hono";
15661
+ import { Hono as Hono8 } from "hono";
14771
15662
  function prefsRouter() {
14772
- const r = new Hono7();
15663
+ const r = new Hono8();
14773
15664
  r.get("/", (c) => c.json(getPrefs()));
14774
15665
  r.put("/", async (c) => {
14775
15666
  let body;
@@ -14804,8 +15695,8 @@ function prefsRouter() {
14804
15695
  }
14805
15696
 
14806
15697
  // src/routes/rooms.ts
14807
- import { Hono as Hono8 } from "hono";
14808
- import { streamSSE as streamSSE2 } from "hono/streaming";
15698
+ import { Hono as Hono9 } from "hono";
15699
+ import { streamSSE as streamSSE3 } from "hono/streaming";
14809
15700
 
14810
15701
  // src/storage/key_points.ts
14811
15702
  init_db();
@@ -14910,7 +15801,7 @@ Does the chair need to ask a clarifying question before opening the room?`
14910
15801
  }
14911
15802
  return { shouldAsk: true, rationale: "" };
14912
15803
  }
14913
- const parsed = extractJson5(raw);
15804
+ const parsed = extractJson6(raw);
14914
15805
  if (!parsed || typeof parsed !== "object") {
14915
15806
  return { shouldAsk: true, rationale: "" };
14916
15807
  }
@@ -14920,7 +15811,7 @@ Does the chair need to ask a clarifying question before opening the room?`
14920
15811
  return { shouldAsk: ask, rationale };
14921
15812
  }
14922
15813
  async function pickRoundWrap(opts) {
14923
- const { history, roundNum, signal } = opts;
15814
+ const { history, roundNum, room, signal } = opts;
14924
15815
  const transcript = history.slice(-20).filter((m) => {
14925
15816
  if (!m.body || !m.body.trim()) return false;
14926
15817
  const meta = m.meta;
@@ -14961,7 +15852,12 @@ async function pickRoundWrap(opts) {
14961
15852
  "your phrasing across calls; don't lean on the same opener twice.",
14962
15853
  "",
14963
15854
  "Reply with STRICT JSON ONLY (no prose, no fences):",
14964
- '{ "recommendation": "end" | "continue", "rationale": "\u2264120 chars \xB7 one tight sentence on the load-bearing reason" }'
15855
+ '{ "recommendation": "end" | "continue", "rationale": "\u2264120 chars \xB7 one tight sentence on the load-bearing reason" }',
15856
+ // Target-language LANGUAGE LOCK · the rationale must be in the
15857
+ // room's working language so the round-prompt the chair posts
15858
+ // afterwards is consistent with the rest of a zh / en room.
15859
+ // Appended at the tail of the system prompt (recency bias).
15860
+ ...room ? [languageLockBlock(detectRoomLang(room))] : []
14965
15861
  ].join("\n")
14966
15862
  };
14967
15863
  const userMsg = {
@@ -14997,7 +15893,7 @@ async function pickRoundWrap(opts) {
14997
15893
  }
14998
15894
  return { recommendation: "continue", rationale: "" };
14999
15895
  }
15000
- const parsed = extractJson5(raw);
15896
+ const parsed = extractJson6(raw);
15001
15897
  if (!parsed || typeof parsed !== "object") {
15002
15898
  return { recommendation: "continue", rationale: "" };
15003
15899
  }
@@ -15007,7 +15903,7 @@ async function pickRoundWrap(opts) {
15007
15903
  return { recommendation: rec, rationale };
15008
15904
  }
15009
15905
  async function pickNextSpeaker(opts) {
15010
- const { candidates, history, signal } = opts;
15906
+ const { candidates, history, room, signal } = opts;
15011
15907
  if (candidates.length < 2) return { agentId: null, rationale: "", intervention: null };
15012
15908
  const roster = candidates.map((a) => `- ${a.id} \xB7 ${a.name} (${a.handle}) \xB7 ${a.roleTag}
15013
15909
  ${a.bio}`).join("\n");
@@ -15073,12 +15969,24 @@ async function pickNextSpeaker(opts) {
15073
15969
  ' "agent_id": "<exact id from roster>" | null,',
15074
15970
  ' "rationale": "\u2264120 chars \xB7 why this lens fits next",',
15075
15971
  ' "intervention": "\u2264200 chars \xB7 the one-sentence note" | null',
15076
- "}"
15972
+ "}",
15973
+ // Target-language LANGUAGE LOCK · the intervention must match
15974
+ // the room's working language. Earlier "detect from transcript"
15975
+ // wording was unreliable in feedback-loop scenarios (one past
15976
+ // English director turn would re-bias the detector). Locked to
15977
+ // room.subject via the helper. Appended at the tail (recency).
15978
+ ...room ? [languageLockBlock(detectRoomLang(room))] : []
15077
15979
  ].join("\n")
15078
15980
  };
15079
15981
  const userMsg = {
15080
15982
  role: "user",
15081
15983
  content: [
15984
+ // Surface room.subject at the TOP of the user message so the
15985
+ // picker has the canonical language signal alongside the
15986
+ // candidate roster + transcript. Without this, the prompt's
15987
+ // only language signal was "recent transcript" — which a
15988
+ // single English chair drift could pollute.
15989
+ ...room?.subject ? [`Room subject: ${room.subject}`, ``] : [],
15082
15990
  `Candidates (queued, in current order):`,
15083
15991
  roster,
15084
15992
  ``,
@@ -15110,7 +16018,7 @@ async function pickNextSpeaker(opts) {
15110
16018
  }
15111
16019
  return { agentId: null, rationale: "", intervention: null };
15112
16020
  }
15113
- const parsed = extractJson5(raw);
16021
+ const parsed = extractJson6(raw);
15114
16022
  if (!parsed || typeof parsed !== "object") {
15115
16023
  return { agentId: null, rationale: "", intervention: null };
15116
16024
  }
@@ -15222,7 +16130,7 @@ async function pickChairWebSearch(opts) {
15222
16130
  }
15223
16131
  return null;
15224
16132
  }
15225
- const parsed = extractJson5(raw);
16133
+ const parsed = extractJson6(raw);
15226
16134
  if (!parsed || typeof parsed !== "object") return null;
15227
16135
  const ws = parsed;
15228
16136
  if (typeof ws.query !== "string") return null;
@@ -15247,7 +16155,7 @@ function buildSkillsIndex(skills) {
15247
16155
  function loadSkillBody(skill) {
15248
16156
  return skill.bodyMd;
15249
16157
  }
15250
- function extractJson5(text) {
16158
+ function extractJson6(text) {
15251
16159
  if (!text) return null;
15252
16160
  let s = text.trim();
15253
16161
  s = s.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/i, "").trim();
@@ -15361,7 +16269,7 @@ Which skills apply, and does this turn need web search?`
15361
16269
  continue;
15362
16270
  }
15363
16271
  }
15364
- const parsed = extractJson5(raw);
16272
+ const parsed = extractJson6(raw);
15365
16273
  if (!parsed || typeof parsed !== "object") {
15366
16274
  return { used: [], reason: "", webSearchQuery: null };
15367
16275
  }
@@ -15404,6 +16312,26 @@ function renderActiveSkillsBlock(used) {
15404
16312
  }
15405
16313
 
15406
16314
  // src/orchestrator/prompt.ts
16315
+ function detectRoomLang(room) {
16316
+ return /[一-鿿]/.test(room.subject || "") ? "zh" : "en";
16317
+ }
16318
+ function languageLockBlock(roomLang) {
16319
+ if (roomLang === "zh") {
16320
+ return [
16321
+ "",
16322
+ "\u2500\u2500\u2500 \u8BED\u8A00\u9501\u5B9A (LANGUAGE LOCK) \u2500\u2500\u2500",
16323
+ "\u672C\u5BF9\u8BDD\u7684\u5DE5\u4F5C\u8BED\u8A00\u5DF2\u9501\u5B9A\u4E3A\u3010\u4E2D\u6587\u3011\u3002",
16324
+ "\u4F60\u7684\u6240\u6709\u8F93\u51FA\u5FC5\u987B\u4F7F\u7528\u4E2D\u6587\u3002\u7981\u6B62\u4F7F\u7528\u82F1\u6587\u3002\u7981\u6B62\u4E2D\u82F1\u6DF7\u5408\u3002",
16325
+ "\u6B64\u89C4\u5219\u8986\u76D6\u6240\u6709\u4E0A\u6587 \u2014 \u5373\u4F7F\u672C\u63D0\u793A\u8BCD\u662F\u82F1\u6587\u5199\u7684\uFF0C\u4E5F\u5FC5\u987B\u7528\u4E2D\u6587\u56DE\u590D\u3002",
16326
+ "(This room's working language is LOCKED to Chinese. Your entire output MUST be in Chinese. No English, no mixed languages. This rule overrides everything above \u2014 even though this prompt is written in English, you MUST reply in Chinese.)"
16327
+ ].join("\n");
16328
+ }
16329
+ return [
16330
+ "",
16331
+ "\u2500\u2500\u2500 LANGUAGE LOCK \u2500\u2500\u2500",
16332
+ "This room's working language is LOCKED to English. Your entire output MUST be in English. No mixed languages."
16333
+ ].join("\n");
16334
+ }
15407
16335
  function buildFollowUpPriorContext(opts) {
15408
16336
  const { parentRoomNumber, parentRoomSubject, parentBrief, parentSignals, language } = opts;
15409
16337
  const isZh = language === "zh";
@@ -15908,15 +16836,25 @@ Name: ${prefs.name}
15908
16836
  `\xB7 When the user's most recent input is already in the room (visible above as a [${prefs.name || "You"}] turn), you may acknowledge it ONCE in the opening sweep \u2014 never again. On any later turn, do NOT open with "Since you asked \u2026" / "As you requested \u2026" / "\u65E2\u7136\u4F60\u8981\u6C42\u4E86 \u2026" / "\u6309\u4F60\u8BF4\u7684 \u2026" / "\u65E2\u7136\u4F60\u63D0\u51FA \u2026" / "\u4F60\u65E2\u7136\u8BA9\u6211 \u2026" or any rephrasing. The user's direction is absorbed context now; engage with the discussion, don't re-preface every turn \u2014 that loops. If you've already spoken once on this user input, your next turn must move PAST that acknowledgment.`,
15909
16837
  `\xB7 If you genuinely have NOTHING substantive to add this turn \u2014 the room has exhausted your angle, every point you'd make has already been made \u2014 return an EMPTY response (no text at all). Do NOT narrate your silence. Never output "\uFF08\u6C89\u9ED8\uFF09", "(silent)", "\u6211\u6CA1\u6709\u66F4\u591A\u8981\u8865\u5145\u7684", "I have nothing to add", "pass this round", "skip this turn", "abstain", or any variant. Those bubbles read as "the director gave up" and pollute the transcript; the system handles silent turns gracefully and moves the queue on. Return empty OR find one genuinely fresh angle (a different lens, a sharper edge case, a counter-frame, a missing trade-off) \u2014 never the meta-narration in between.`,
15910
16838
  `\xB7 The TONE and INTENSITY blocks above are the room's working agreement \u2014 they OVERRIDE ${toneOverrideTarget} The user explicitly opted into this register; staying in role is the helpful behaviour, not breaking it for trained politeness or trained adversariness.`,
15911
- // Persona reflection checklist · the ABSOLUTE LAST entry in
15912
- // the system prompt so it's the freshest context the model
15913
- // reads before generating. Empty string (no-op) for Signal-mode
15914
- // and seeded directors · zero per-turn cost. The checklist is
16839
+ // Persona reflection checklist · last persona-tuned entry in
16840
+ // the system prompt. Empty string (no-op) for Signal-mode and
16841
+ // seeded directors · zero per-turn cost. The checklist is
15915
16842
  // tuned per-persona by Phase 6 of the build pipeline · catches
15916
16843
  // failure modes specific to THIS director (e.g. "Am I
15917
16844
  // repeating @another_director's mechanism point?" for a
15918
16845
  // Historian).
15919
- renderPersonaReflectionBlock(speaker)
16846
+ renderPersonaReflectionBlock(speaker),
16847
+ // Target-language LANGUAGE LOCK · TRULY the last block in the
16848
+ // system prompt so it's the freshest signal in the LLM's
16849
+ // attention. Written in the room's working language (Chinese
16850
+ // for zh rooms, English for en rooms), which strongly biases
16851
+ // the LLM toward producing output in the matching language.
16852
+ // Replaces the weaker English-only "Reply in the SAME LANGUAGE"
16853
+ // rule earlier in this prompt as the load-bearing directive —
16854
+ // that rule sits above 30+ lines of HOUSE RULES + voice mode
16855
+ // copy, so by the time the LLM gets to generating it has been
16856
+ // long-decayed. See languageLockBlock at top of this file.
16857
+ languageLockBlock(detectRoomLang(room))
15920
16858
  ].join("\n")
15921
16859
  };
15922
16860
  const out = [system];
@@ -16015,7 +16953,15 @@ function buildChairSystem(opts, task) {
16015
16953
  `Inside those constraints, write **spoken table talk** \u2014 \u5927\u767D\u8BDD / natural conversational English: very short clauses, everyday connectors, sparse fillers. Avoid written-report register (\u7EFC\u4E0A\u6240\u8FF0 / \u9274\u4E8E\u6B64 / "It is worth noting\u2026"). Host lines should sound awake \u2014 not chapter-length.`
16016
16954
  ] : [],
16017
16955
  "",
16018
- task
16956
+ task,
16957
+ // Target-language LANGUAGE LOCK · APPENDED AT THE TAIL of every
16958
+ // chair system prompt so it's the freshest instruction in the
16959
+ // LLM's attention (recency bias). The earlier English LANGUAGE
16960
+ // block above describes detection logic; this tail block STATES
16961
+ // the result in the target language and forbids drift. Both
16962
+ // blocks are kept (defense in depth). See detectRoomLang /
16963
+ // languageLockBlock at top of this file.
16964
+ languageLockBlock(detectRoomLang(room))
16019
16965
  ].join("\n")
16020
16966
  };
16021
16967
  }
@@ -16141,8 +17087,10 @@ function buildChairClarifyMessages(opts) {
16141
17087
  ``,
16142
17088
  `Output: either <ack + blank line + READY> OR the 2-part question block (in the user's language).`
16143
17089
  ].join("\n");
17090
+ const seedSystem = buildSeedContextSystem(opts.history);
16144
17091
  return [
16145
17092
  buildChairSystem(opts, isFirstTurn ? firstTurnTask : followUpTask),
17093
+ ...seedSystem ? [seedSystem] : [],
16146
17094
  ...renderHistoryForChair(opts.history, opts.cast, opts.prefs),
16147
17095
  {
16148
17096
  role: "user",
@@ -16150,6 +17098,39 @@ function buildChairClarifyMessages(opts) {
16150
17098
  }
16151
17099
  ];
16152
17100
  }
17101
+ function buildSeedContextSystem(history) {
17102
+ for (let i = 0; i < history.length; i++) {
17103
+ const m = history[i];
17104
+ if (m.authorKind !== "user") continue;
17105
+ const meta = m.meta;
17106
+ const rationale = typeof meta?.seedContext?.rationale === "string" ? meta.seedContext.rationale.trim() : "";
17107
+ const rawSnippets = meta?.seedContext?.snippets;
17108
+ const snippets = Array.isArray(rawSnippets) ? rawSnippets : [];
17109
+ const snippetLines = [];
17110
+ for (const s of snippets) {
17111
+ if (!s || typeof s !== "object") continue;
17112
+ const title = typeof s.title === "string" ? s.title.trim() : "";
17113
+ const url = typeof s.url === "string" ? s.url.trim() : "";
17114
+ const desc = typeof s.description === "string" ? s.description.trim() : "";
17115
+ if (!title && !url && !desc) continue;
17116
+ snippetLines.push(`\xB7 ${title || "(untitled)"} \u2014 ${url || "(no url)"}
17117
+ ${desc.slice(0, 360)}`);
17118
+ }
17119
+ if (!rationale && snippetLines.length === 0) continue;
17120
+ const blocks = [
17121
+ `\u2500\u2500\u2500 BACKGROUND MATERIAL \xB7 pre-attached by the user \u2500\u2500\u2500`,
17122
+ `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.`
17123
+ ];
17124
+ if (rationale) {
17125
+ blocks.push(``, `Why this topic was recommended (hidden from the user \u2014 your reasoning context):`, `\xB7 ${rationale}`);
17126
+ }
17127
+ if (snippetLines.length > 0) {
17128
+ blocks.push(``, `Source snippets the recommendation was grounded in:`, ...snippetLines);
17129
+ }
17130
+ return { role: "system", content: blocks.join("\n") };
17131
+ }
17132
+ return null;
17133
+ }
16153
17134
  function buildChairConveningMessages(opts) {
16154
17135
  const subject = opts.room.subject;
16155
17136
  const directorList = opts.picksWithReasons.map((p, i) => {
@@ -17363,7 +18344,12 @@ async function pumpQueue(roomId) {
17363
18344
  if (candidates.length >= 2) {
17364
18345
  try {
17365
18346
  emitChairPending(roomId, "next-speaker");
17366
- const pick = await pickNextSpeaker({ candidates, history: recent });
18347
+ const pickRoom = getRoom(roomId);
18348
+ const pick = await pickNextSpeaker({
18349
+ candidates,
18350
+ history: recent,
18351
+ room: pickRoom ?? void 0
18352
+ });
17367
18353
  const stillSameQueue = state.queue.length === queueSnapshot.length && state.queue.every((q, i) => q.agentId === queueSnapshot[i].agentId);
17368
18354
  if (stillSameQueue) {
17369
18355
  if (pick.agentId && pick.agentId !== state.queue[0].agentId) {
@@ -17576,7 +18562,7 @@ async function pumpQueue(roomId) {
17576
18562
  let recommendation;
17577
18563
  try {
17578
18564
  const recent = listRecentMessages(roomId, 30);
17579
- const wrap = await pickRoundWrap({ history: recent, roundNum: wrappedRound });
18565
+ const wrap = await pickRoundWrap({ history: recent, roundNum: wrappedRound, room });
17580
18566
  recommendation = { kind: wrap.recommendation, rationale: wrap.rationale };
17581
18567
  } catch (e) {
17582
18568
  rlog(roomId, "round-wrap-error", {
@@ -17999,13 +18985,19 @@ function appendSystemMessage(roomId, body) {
17999
18985
  function getRoomFullState(roomId) {
18000
18986
  const room = getRoom(roomId);
18001
18987
  if (!room) return null;
18002
- const memberRows = listRoomMembers(roomId);
18003
- const all = memberRows.map((m) => getAgent(m.agentId)).filter((a) => a !== null);
18004
- const members = all.filter((a) => a.roleKind === "director");
18005
- const chair = all.find((a) => a.roleKind === "moderator") ?? null;
18988
+ const allRows = listAllRoomMembers(roomId);
18989
+ const activeAgents = allRows.filter((m) => m.removedAt === null).map((m) => getAgent(m.agentId)).filter((a) => a !== null);
18990
+ const members = activeAgents.filter((a) => a.roleKind === "director");
18991
+ const chair = activeAgents.find((a) => a.roleKind === "moderator") ?? null;
18992
+ const historicalMembers = [];
18993
+ for (const m of allRows) {
18994
+ const a = getAgent(m.agentId);
18995
+ if (!a || a.roleKind !== "director") continue;
18996
+ historicalMembers.push({ ...a, joinedAt: m.joinedAt, removedAt: m.removedAt });
18997
+ }
18006
18998
  const messages = listRecentMessages(roomId, 200);
18007
18999
  const keyPoints = listKeyPointsForRoom(roomId);
18008
- return { room, members, chair, messages, keyPoints };
19000
+ return { room, members, historicalMembers, chair, messages, keyPoints };
18009
19001
  }
18010
19002
  function getRoomQueueSnapshot(roomId) {
18011
19003
  const s = _state.get(roomId);
@@ -18787,57 +19779,98 @@ async function emitChairAnnouncementVoice(roomId, messageId, body) {
18787
19779
  `);
18788
19780
  }
18789
19781
  }
18790
- var ROUND_OPENERS = [
19782
+ var ROUND_OPENERS_EN = [
18791
19783
  "Round done.",
18792
19784
  "That closes the round.",
18793
19785
  "End of round.",
18794
19786
  "Round wrapped."
18795
19787
  ];
18796
- var END_TAILS_WITH_RATIONALE = [
19788
+ var ROUND_OPENERS_ZH = [
19789
+ "\u672C\u8F6E\u7ED3\u675F\u3002",
19790
+ "\u8FD9\u4E00\u8F6E\u544A\u4E00\u6BB5\u843D\u3002",
19791
+ "\u521A\u624D\u8FD9\u4E00\u8F6E\u7ED3\u675F\u3002",
19792
+ "\u672C\u8F6E\u6536\u5C3E\u3002"
19793
+ ];
19794
+ var END_TAILS_WITH_RATIONALE_EN = [
18797
19795
  "Ready to file \u2014 or push once more.",
18798
19796
  "I'd wrap here. Another sweep is fair.",
18799
19797
  "Enough to file. Continue if there's more.",
18800
19798
  "File now, or run another round."
18801
19799
  ];
18802
- var END_TAILS_BARE = [
19800
+ var END_TAILS_WITH_RATIONALE_ZH = [
19801
+ "\u53EF\u4EE5\u5F52\u6863\u4E86 \u2014 \u6216\u8005\u518D\u6765\u4E00\u8F6E\u3002",
19802
+ "\u6211\u503E\u5411\u6536\u5C3E\uFF0C\u4F46\u518D\u8BA8\u8BBA\u4E00\u8F6E\u4E5F\u5408\u7406\u3002",
19803
+ "\u591F\u5F52\u6863\u4E86\u3002\u5982\u679C\u8FD8\u6709\u8981\u8865\u7684\u5C31\u7EE7\u7EED\u3002",
19804
+ "\u73B0\u5728\u5F52\u6863\uFF0C\u6216\u8005\u518D\u8BA8\u8BBA\u4E00\u8F6E\u3002"
19805
+ ];
19806
+ var END_TAILS_BARE_EN = [
18803
19807
  "Looks ready to file \u2014 or another sweep.",
18804
19808
  "Vote and wrap, or push for more.",
18805
19809
  "Ready to file. Continue if you want.",
18806
19810
  "Wrap here, or another round."
18807
19811
  ];
18808
- var CONTINUE_TAILS_WITH_RATIONALE = [
19812
+ var END_TAILS_BARE_ZH = [
19813
+ "\u770B\u6765\u53EF\u4EE5\u5F52\u6863\u4E86 \u2014 \u6216\u518D\u8BA8\u8BBA\u4E00\u8F6E\u3002",
19814
+ "\u6295\u7968\u6536\u5C3E\uFF0C\u6216\u7EE7\u7EED\u63A8\u8FDB\u3002",
19815
+ "\u53EF\u4EE5\u5F52\u6863\u4E86\u3002\u8981\u7EE7\u7EED\u5C31\u7EE7\u7EED\u3002",
19816
+ "\u8FD9\u91CC\u6536\u5C3E\uFF0C\u6216\u518D\u6765\u4E00\u8F6E\u3002"
19817
+ ];
19818
+ var CONTINUE_TAILS_WITH_RATIONALE_EN = [
18809
19819
  "Worth another pass \u2014 or call it.",
18810
19820
  "I'd push once more, or end here.",
18811
19821
  "One more sweep earns its keep \u2014 or wrap.",
18812
19822
  "Another round, or file now."
18813
19823
  ];
18814
- var CONTINUE_TAILS_BARE = [
19824
+ var CONTINUE_TAILS_WITH_RATIONALE_ZH = [
19825
+ "\u503C\u5F97\u518D\u8BA8\u8BBA\u4E00\u8F6E \u2014 \u6216\u8005\u5C31\u6B64\u6253\u4F4F\u3002",
19826
+ "\u6211\u503E\u5411\u518D\u63A8\u4E00\u8F6E\uFF0C\u6216\u8005\u5C31\u6B64\u7ED3\u675F\u3002",
19827
+ "\u518D\u8BA8\u8BBA\u4E00\u8F6E\u662F\u503C\u5F97\u7684 \u2014 \u6216\u8005\u6536\u5C3E\u3002",
19828
+ "\u518D\u6765\u4E00\u8F6E\uFF0C\u6216\u73B0\u5728\u5F52\u6863\u3002"
19829
+ ];
19830
+ var CONTINUE_TAILS_BARE_EN = [
18815
19831
  "Worth another pass \u2014 or call it.",
18816
19832
  "One more sweep, or wrap.",
18817
19833
  "Push another round, or end here.",
18818
19834
  "Another pass, or file now."
18819
19835
  ];
18820
- var NEUTRAL_TAILS = [
19836
+ var CONTINUE_TAILS_BARE_ZH = [
19837
+ "\u503C\u5F97\u518D\u8BA8\u8BBA\u4E00\u8F6E \u2014 \u6216\u5C31\u6B64\u6253\u4F4F\u3002",
19838
+ "\u518D\u8BA8\u8BBA\u4E00\u8F6E\uFF0C\u6216\u8005\u6536\u5C3E\u3002",
19839
+ "\u63A8\u8FDB\u4E0B\u4E00\u8F6E\uFF0C\u6216\u5728\u8FD9\u91CC\u7ED3\u675F\u3002",
19840
+ "\u518D\u6765\u4E00\u8F6E\uFF0C\u6216\u73B0\u5728\u5F52\u6863\u3002"
19841
+ ];
19842
+ var NEUTRAL_TAILS_EN = [
18821
19843
  "Vote a point, or roll on.",
18822
19844
  "Weight a point with a vote, or continue.",
18823
19845
  "Vote to bias the next round \u2014 or skip.",
18824
19846
  "Vote, or continue without one."
18825
19847
  ];
19848
+ var NEUTRAL_TAILS_ZH = [
19849
+ "\u4E3A\u5173\u952E\u70B9\u6295\u7968\uFF0C\u6216\u7EE7\u7EED\u3002",
19850
+ "\u7528\u6295\u7968\u7ED9\u67D0\u4E2A\u70B9\u52A0\u6743\uFF0C\u6216\u76F4\u63A5\u7EE7\u7EED\u3002",
19851
+ "\u6295\u7968\u5F71\u54CD\u4E0B\u4E00\u8F6E \u2014 \u6216\u8DF3\u8FC7\u3002",
19852
+ "\u6295\u7968\uFF0C\u6216\u4E0D\u6295\u7968\u76F4\u63A5\u7EE7\u7EED\u3002"
19853
+ ];
18826
19854
  var pickByRound = (arr, seed) => arr[(seed % arr.length + arr.length) % arr.length];
19855
+ function poolFor(en, zh, lang) {
19856
+ return lang === "zh" ? zh : en;
19857
+ }
18827
19858
  async function announceRoundPrompt(roomId, roundNum, recommendation) {
18828
19859
  const chair = getChairAgent();
18829
19860
  if (!chair) return;
18830
- const opener = pickByRound(ROUND_OPENERS, roundNum);
19861
+ const room = getRoom(roomId);
19862
+ const roomLang = detectRoomLang(room || {});
19863
+ const opener = pickByRound(poolFor(ROUND_OPENERS_EN, ROUND_OPENERS_ZH, roomLang), roundNum);
18831
19864
  let body;
18832
19865
  if (recommendation) {
18833
19866
  const rationale = recommendation.rationale.trim();
18834
19867
  if (recommendation.kind === "end") {
18835
- body = rationale ? `${opener} ${rationale} ${pickByRound(END_TAILS_WITH_RATIONALE, roundNum)}` : `${opener} ${pickByRound(END_TAILS_BARE, roundNum)}`;
19868
+ body = rationale ? `${opener} ${rationale} ${pickByRound(poolFor(END_TAILS_WITH_RATIONALE_EN, END_TAILS_WITH_RATIONALE_ZH, roomLang), roundNum)}` : `${opener} ${pickByRound(poolFor(END_TAILS_BARE_EN, END_TAILS_BARE_ZH, roomLang), roundNum)}`;
18836
19869
  } else {
18837
- body = rationale ? `${opener} ${rationale} ${pickByRound(CONTINUE_TAILS_WITH_RATIONALE, roundNum)}` : `${opener} ${pickByRound(CONTINUE_TAILS_BARE, roundNum)}`;
19870
+ body = rationale ? `${opener} ${rationale} ${pickByRound(poolFor(CONTINUE_TAILS_WITH_RATIONALE_EN, CONTINUE_TAILS_WITH_RATIONALE_ZH, roomLang), roundNum)}` : `${opener} ${pickByRound(poolFor(CONTINUE_TAILS_BARE_EN, CONTINUE_TAILS_BARE_ZH, roomLang), roundNum)}`;
18838
19871
  }
18839
19872
  } else {
18840
- body = `${opener} ${pickByRound(NEUTRAL_TAILS, roundNum)}`;
19873
+ body = `${opener} ${pickByRound(poolFor(NEUTRAL_TAILS_EN, NEUTRAL_TAILS_ZH, roomLang), roundNum)}`;
18841
19874
  }
18842
19875
  const m = insertMessage({
18843
19876
  roomId,
@@ -19554,7 +20587,7 @@ async function runAutoPickAndSeat(roomId, subject) {
19554
20587
  }
19555
20588
  }
19556
20589
  function roomsRouter() {
19557
- const r = new Hono8();
20590
+ const r = new Hono9();
19558
20591
  r.get("/", (c) => c.json({ rooms: listRooms() }));
19559
20592
  r.post("/", async (c) => {
19560
20593
  let body;
@@ -19649,12 +20682,42 @@ function roomsRouter() {
19649
20682
  payload: { mode, intensity, briefStyle, deliveryMode, members: members.map((m) => m.agentId), autoPick },
19650
20683
  actorKind: "user"
19651
20684
  });
20685
+ let seedContext = null;
20686
+ if (b.seedContext && typeof b.seedContext === "object") {
20687
+ const raw = b.seedContext;
20688
+ const topicRecId = typeof raw.topicRecId === "string" && raw.topicRecId.trim().length > 0 ? raw.topicRecId.trim().slice(0, 64) : void 0;
20689
+ const rationale = typeof raw.rationale === "string" && raw.rationale.trim().length > 0 ? raw.rationale.trim().slice(0, 400) : void 0;
20690
+ const rawSnippets = Array.isArray(raw.snippets) ? raw.snippets : [];
20691
+ const snippets = rawSnippets.filter(
20692
+ (s) => !!s && typeof s === "object" && typeof s.title === "string" && typeof s.url === "string" && typeof s.description === "string"
20693
+ ).slice(0, 12).map((s) => ({
20694
+ title: s.title.slice(0, 200),
20695
+ url: s.url.slice(0, 600),
20696
+ description: s.description.slice(0, 600)
20697
+ }));
20698
+ if (topicRecId || rationale || snippets.length > 0) {
20699
+ seedContext = {
20700
+ ...topicRecId ? { topicRecId } : {},
20701
+ ...rationale ? { rationale } : {},
20702
+ ...snippets.length > 0 ? { snippets } : {}
20703
+ };
20704
+ }
20705
+ }
19652
20706
  const opening = insertMessage({
19653
20707
  roomId: room.id,
19654
20708
  authorKind: "user",
19655
20709
  body: subject,
19656
- roundNum: 1
20710
+ roundNum: 1,
20711
+ meta: seedContext ? { seedContext } : void 0
19657
20712
  });
20713
+ if (seedContext?.topicRecId) {
20714
+ try {
20715
+ markTopicRecOpened(seedContext.topicRecId, room.id);
20716
+ } catch (e) {
20717
+ process.stderr.write(`[rooms] topic-rec link failed: ${e instanceof Error ? e.message : String(e)}
20718
+ `);
20719
+ }
20720
+ }
19658
20721
  roomBus.emit(room.id, {
19659
20722
  type: "message-appended",
19660
20723
  messageId: opening.id,
@@ -19786,7 +20849,7 @@ function roomsRouter() {
19786
20849
  r.get("/:id/stream", (c) => {
19787
20850
  const id = c.req.param("id");
19788
20851
  if (!getRoom(id)) return c.json({ error: "not found" }, 404);
19789
- return streamSSE2(c, async (s) => {
20852
+ return streamSSE3(c, async (s) => {
19790
20853
  await s.writeSSE({ event: "hello", data: JSON.stringify({ roomId: id, ts: Date.now() }) });
19791
20854
  const queue = [];
19792
20855
  let resolveWaiter = null;
@@ -20450,9 +21513,9 @@ function buildRoomExportMarkdown(opts) {
20450
21513
  }
20451
21514
 
20452
21515
  // src/routes/search.ts
20453
- import { Hono as Hono9 } from "hono";
21516
+ import { Hono as Hono10 } from "hono";
20454
21517
  function searchRouter() {
20455
- const r = new Hono9();
21518
+ const r = new Hono10();
20456
21519
  r.get("/", (c) => {
20457
21520
  const q = (c.req.query("q") || "").trim();
20458
21521
  if (q.length < 1) {
@@ -20491,7 +21554,7 @@ function searchRouter() {
20491
21554
  }
20492
21555
 
20493
21556
  // src/routes/usage.ts
20494
- import { Hono as Hono10 } from "hono";
21557
+ import { Hono as Hono11 } from "hono";
20495
21558
  function modelDisplay(modelV) {
20496
21559
  if (isModelV(modelV)) {
20497
21560
  const m = MODELS[modelV];
@@ -20500,7 +21563,7 @@ function modelDisplay(modelV) {
20500
21563
  return { displayName: modelV, provider: "unknown" };
20501
21564
  }
20502
21565
  function usageRouter() {
20503
- const r = new Hono10();
21566
+ const r = new Hono11();
20504
21567
  r.get("/summary", (c) => {
20505
21568
  const s = getUsageSummary();
20506
21569
  return c.json({
@@ -20550,7 +21613,7 @@ function usageRouter() {
20550
21613
  }
20551
21614
 
20552
21615
  // src/routes/voices.ts
20553
- import { Hono as Hono11 } from "hono";
21616
+ import { Hono as Hono12 } from "hono";
20554
21617
  var TTS_CACHE_MAX = 50;
20555
21618
  var ttsCache = /* @__PURE__ */ new Map();
20556
21619
  function ttsCacheKey(messageId, profile) {
@@ -20580,7 +21643,7 @@ function ttsCacheSet(key, val) {
20580
21643
  }
20581
21644
  }
20582
21645
  function voicesRouter() {
20583
- const r = new Hono11();
21646
+ const r = new Hono12();
20584
21647
  r.get("/", async (c) => c.json({ voices: await listAvailableVoices() }));
20585
21648
  r.post("/preview", async (c) => {
20586
21649
  let body;
@@ -20676,11 +21739,11 @@ function voicesRouter() {
20676
21739
  init_paths();
20677
21740
 
20678
21741
  // src/version.ts
20679
- var VERSION = "0.1.12";
21742
+ var VERSION = "0.1.15";
20680
21743
 
20681
21744
  // src/server.ts
20682
21745
  function createApp() {
20683
- const app = new Hono12();
21746
+ const app = new Hono13();
20684
21747
  const dir = publicDir();
20685
21748
  if (!existsSync2(dir)) {
20686
21749
  throw new Error(
@@ -20720,6 +21783,7 @@ Build the package or check that public/ is bundled alongside dist/.`
20720
21783
  app.route("/api/agents", agentsRouter());
20721
21784
  app.route("/api/keys", keysRouter());
20722
21785
  app.route("/api/models", modelsRouter());
21786
+ app.route("/api/topic-recs", topicRecsRouter());
20723
21787
  app.route("/api/rooms", roomsRouter());
20724
21788
  app.route("/api/briefs", briefsRouter());
20725
21789
  app.route("/api/notes", notesRouter());
@@ -20819,6 +21883,16 @@ async function main() {
20819
21883
  }
20820
21884
  } catch (e) {
20821
21885
  process.stderr.write(`[boot] persona-job recovery failed: ${e instanceof Error ? e.message : String(e)}
21886
+ `);
21887
+ }
21888
+ try {
21889
+ const failed = markRunningTopicRecJobsFailed();
21890
+ if (failed > 0) {
21891
+ process.stderr.write(`[boot] marked ${failed} topic-rec job(s) failed (server restarted mid-build)
21892
+ `);
21893
+ }
21894
+ } catch (e) {
21895
+ process.stderr.write(`[boot] topic-rec recovery failed: ${e instanceof Error ? e.message : String(e)}
20822
21896
  `);
20823
21897
  }
20824
21898
  void (async () => {