privateboard 0.1.13 → 0.1.16

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
@@ -510,6 +510,196 @@ var init_room_members_removed_at = __esm({
510
510
  }
511
511
  });
512
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
+
587
+ // src/storage/migrations/036_negative_space.sql
588
+ var negative_space_default;
589
+ var init_negative_space = __esm({
590
+ "src/storage/migrations/036_negative_space.sql"() {
591
+ negative_space_default = `-- Negative-space memory \xB7 Layer 3.2 of the divergence stack.
592
+ -- At round-end the chair extracts "what this round did NOT touch
593
+ -- but should have" \u2014 angles raised-then-abandoned, dimensions
594
+ -- conspicuously absent. These get injected into the next round's
595
+ -- director prompts as "UNEXPLORED ANGLES" so the room has positive-
596
+ -- space breadcrumbs alongside the frame-break negative-space rules.
597
+ --
598
+ -- One row per angle (not one row per round) so the next-round
599
+ -- prompt can pull the top-N most recent regardless of round
600
+ -- boundaries \xB7 enables cross-round angle memory.
601
+ CREATE TABLE IF NOT EXISTS negative_space (
602
+ id TEXT PRIMARY KEY,
603
+ room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
604
+ round_num INTEGER NOT NULL,
605
+ angle TEXT NOT NULL,
606
+ created_at INTEGER NOT NULL,
607
+ -- Soft consumption flag \xB7 when a subsequent round prompt injected
608
+ -- this angle and the room actually engaged with it, the chair
609
+ -- post-processor flips this to 1. Subsequent rounds prefer
610
+ -- unconsumed angles to avoid suggesting the same thing twice.
611
+ consumed INTEGER NOT NULL DEFAULT 0
612
+ );
613
+ CREATE INDEX IF NOT EXISTS idx_negative_space_room_round
614
+ ON negative_space(room_id, round_num);
615
+ CREATE INDEX IF NOT EXISTS idx_negative_space_room_unconsumed
616
+ ON negative_space(room_id, consumed);
617
+ `;
618
+ }
619
+ });
620
+
621
+ // src/storage/migrations/037_topic_branches.sql
622
+ var topic_branches_default;
623
+ var init_topic_branches = __esm({
624
+ "src/storage/migrations/037_topic_branches.sql"() {
625
+ topic_branches_default = `-- Topic-tree tracking \xB7 Layer 3.1 of the divergence stack.
626
+ -- Each director turn gets tagged with a branch_id \xB7 the cluster of
627
+ -- prior turns it extended. New top-level branches are surfaced when
628
+ -- a turn opens a genuinely fresh angle. Drives:
629
+ -- \xB7 dissent-gap picker (Layer 2.1) prefers directors who have NOT
630
+ -- spoken on the dominant branches
631
+ -- \xB7 UI \xB7 optional "topic map" overlay so the user sees the room's
632
+ -- coverage breadth
633
+ -- \xB7 structured summarization \xB7 long rooms can summarize by branch
634
+ -- instead of by chronological round
635
+ CREATE TABLE IF NOT EXISTS topic_branches (
636
+ id TEXT PRIMARY KEY,
637
+ room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
638
+ label TEXT NOT NULL, -- short noun-phrase name (\u2264 8 words)
639
+ parent_id TEXT, -- nullable \xB7 top-level branches have NULL
640
+ opened_at INTEGER NOT NULL, -- ms epoch \xB7 ordering / decay
641
+ -- Activity counters \xB7 maintained by the post-turn tagger so the
642
+ -- picker can score "this branch is hot" / "this branch is unspoken".
643
+ turn_count INTEGER NOT NULL DEFAULT 0,
644
+ last_speaker_id TEXT
645
+ );
646
+ CREATE INDEX IF NOT EXISTS idx_topic_branches_room
647
+ ON topic_branches(room_id, opened_at);
648
+
649
+ -- Per-message branch assignment \xB7 one row per director message.
650
+ -- Populated by a post-turn haiku tagger that reads the message body
651
+ -- + the room's current branch list and decides: "extends branch X"
652
+ -- or "opens new branch Y". Idempotent \xB7 re-running the tagger for
653
+ -- the same message_id replaces the row.
654
+ CREATE TABLE IF NOT EXISTS message_branches (
655
+ message_id TEXT PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
656
+ branch_id TEXT NOT NULL REFERENCES topic_branches(id) ON DELETE CASCADE,
657
+ is_opener INTEGER NOT NULL DEFAULT 0, -- 1 = this message opened the branch
658
+ tagged_at INTEGER NOT NULL
659
+ );
660
+ CREATE INDEX IF NOT EXISTS idx_message_branches_branch
661
+ ON message_branches(branch_id);
662
+ `;
663
+ }
664
+ });
665
+
666
+ // src/storage/migrations/038_qd_archive.sql
667
+ var qd_archive_default;
668
+ var init_qd_archive = __esm({
669
+ "src/storage/migrations/038_qd_archive.sql"() {
670
+ qd_archive_default = `-- Quality-Diversity behavioral archive \xB7 Layer 4 of the divergence
671
+ -- stack. Each director message gets scored on 3 behavioral
672
+ -- dimensions (abstraction-level / time-scale / stakeholder-scope) by
673
+ -- a cheap haiku post-turn. The resulting (a, t, s) triple maps to a
674
+ -- single cell in a 4\xD74\xD74 = 64-cell grid \xB7 the room's "MAP-Elites
675
+ -- archive". Picker layers can query the archive to reward
676
+ -- candidates whose persona is likely to fill an EMPTY cell, and the
677
+ -- room-end report shows coverage as a divergence KPI.
678
+ --
679
+ -- One row per (message, dimensions) so re-tagging is idempotent \xB7
680
+ -- the post-turn scorer always INSERT OR REPLACE.
681
+ CREATE TABLE IF NOT EXISTS qd_archive (
682
+ message_id TEXT PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
683
+ room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
684
+ -- Dimension scores in [0, 1] \xB7 float for diagnostics, INTEGER
685
+ -- bucket [0, 3] for the cell index. All scored by the same haiku
686
+ -- pass to amortize cost.
687
+ abstraction_score REAL NOT NULL,
688
+ abstraction_bucket INTEGER NOT NULL,
689
+ time_score REAL NOT NULL,
690
+ time_bucket INTEGER NOT NULL,
691
+ stakeholder_score REAL NOT NULL,
692
+ stakeholder_bucket INTEGER NOT NULL,
693
+ scored_at INTEGER NOT NULL
694
+ );
695
+ CREATE INDEX IF NOT EXISTS idx_qd_archive_room
696
+ ON qd_archive(room_id);
697
+ CREATE INDEX IF NOT EXISTS idx_qd_archive_room_cell
698
+ ON qd_archive(room_id, abstraction_bucket, time_bucket, stakeholder_bucket);
699
+ `;
700
+ }
701
+ });
702
+
513
703
  // src/storage/db.ts
514
704
  var db_exports = {};
515
705
  __export(db_exports, {
@@ -600,6 +790,11 @@ var init_db = __esm({
600
790
  init_agent_persona_spec();
601
791
  init_room_vote_trigger();
602
792
  init_room_members_removed_at();
793
+ init_user_topic_recs();
794
+ init_topic_rec_tag();
795
+ init_negative_space();
796
+ init_topic_branches();
797
+ init_qd_archive();
603
798
  MIGRATIONS = [
604
799
  { name: "001_init.sql", sql: init_default },
605
800
  { name: "002_default_opus.sql", sql: default_opus_default },
@@ -633,7 +828,12 @@ var init_db = __esm({
633
828
  { name: "030_minimax_region.sql", sql: minimax_region_default },
634
829
  { name: "031_agent_persona_spec.sql", sql: agent_persona_spec_default },
635
830
  { name: "032_room_vote_trigger.sql", sql: room_vote_trigger_default },
636
- { name: "033_room_members_removed_at.sql", sql: room_members_removed_at_default }
831
+ { name: "033_room_members_removed_at.sql", sql: room_members_removed_at_default },
832
+ { name: "034_user_topic_recs.sql", sql: user_topic_recs_default },
833
+ { name: "035_topic_rec_tag.sql", sql: topic_rec_tag_default },
834
+ { name: "036_negative_space.sql", sql: negative_space_default },
835
+ { name: "037_topic_branches.sql", sql: topic_branches_default },
836
+ { name: "038_qd_archive.sql", sql: qd_archive_default }
637
837
  ];
638
838
  _db = null;
639
839
  }
@@ -887,6 +1087,8 @@ function parsePersonaSpec(raw) {
887
1087
  const differentiationScore = typeof obj.differentiationScore === "number" && Number.isFinite(obj.differentiationScore) ? obj.differentiationScore : null;
888
1088
  const toolAccessRaw = obj.toolAccess || {};
889
1089
  const toolAccess = { webSearch: toolAccessRaw.webSearch !== false };
1090
+ const buildLog = parseBuildLog(obj.buildLog);
1091
+ const guessName = typeof obj.guessName === "string" && obj.guessName.trim().length > 0 ? obj.guessName.trim() : void 0;
890
1092
  return {
891
1093
  version: 1,
892
1094
  generatedAt,
@@ -898,12 +1100,75 @@ function parsePersonaSpec(raw) {
898
1100
  reflectionChecklist,
899
1101
  evalSet,
900
1102
  differentiationScore,
901
- toolAccess
1103
+ toolAccess,
1104
+ ...guessName ? { guessName } : {},
1105
+ ...buildLog ? { buildLog } : {}
902
1106
  };
903
1107
  } catch {
904
1108
  return null;
905
1109
  }
906
1110
  }
1111
+ function parseBuildLog(raw) {
1112
+ if (!raw || typeof raw !== "object") return null;
1113
+ const obj = raw;
1114
+ const narrative = typeof obj.narrative === "string" ? obj.narrative : "";
1115
+ const locale = obj.locale === "en" || obj.locale === "zh" || obj.locale === "ja" || obj.locale === "es" ? obj.locale : "en";
1116
+ const generatedAt = typeof obj.generatedAt === "number" && Number.isFinite(obj.generatedAt) ? obj.generatedAt : 0;
1117
+ const events = [];
1118
+ if (Array.isArray(obj.events)) {
1119
+ for (const e of obj.events) {
1120
+ if (!e || typeof e !== "object") continue;
1121
+ const ev = e;
1122
+ const ts = typeof ev.ts === "number" ? ev.ts : 0;
1123
+ const kind = ev.kind;
1124
+ if (kind === "phase-start" && typeof ev.phase === "number" && typeof ev.label === "string") {
1125
+ events.push({ kind, ts, phase: ev.phase, label: ev.label });
1126
+ } else if (kind === "phase-end" && typeof ev.phase === "number" && typeof ev.durationMs === "number") {
1127
+ events.push({ kind, ts, phase: ev.phase, durationMs: ev.durationMs });
1128
+ } else if (kind === "dimension-plan" && Array.isArray(ev.dimensions)) {
1129
+ const dims = ev.dimensions.flatMap((d) => {
1130
+ if (!d || typeof d !== "object") return [];
1131
+ const x = d;
1132
+ if (typeof x.dimension !== "string" || typeof x.query !== "string") return [];
1133
+ return [{
1134
+ dimension: x.dimension,
1135
+ query: x.query,
1136
+ why: typeof x.why === "string" ? x.why : ""
1137
+ }];
1138
+ });
1139
+ events.push({ kind, ts, dimensions: dims });
1140
+ } else if (kind === "search" && typeof ev.query === "string") {
1141
+ events.push({
1142
+ kind,
1143
+ ts,
1144
+ query: ev.query,
1145
+ resultsCount: typeof ev.resultsCount === "number" ? ev.resultsCount : 0,
1146
+ pagesRead: typeof ev.pagesRead === "number" ? ev.pagesRead : 0,
1147
+ ...typeof ev.dimension === "string" ? { dimension: ev.dimension } : {},
1148
+ ...typeof ev.round === "number" ? { round: ev.round } : {},
1149
+ ...ev.topup === true ? { topup: true } : {}
1150
+ });
1151
+ } else if (kind === "divergence") {
1152
+ events.push({
1153
+ kind,
1154
+ ts,
1155
+ score: typeof ev.score === "number" && Number.isFinite(ev.score) ? ev.score : null
1156
+ });
1157
+ } else if (kind === "error" && typeof ev.message === "string") {
1158
+ events.push({ kind, ts, message: ev.message });
1159
+ }
1160
+ }
1161
+ }
1162
+ if (!narrative && events.length === 0) return null;
1163
+ const totalTokens = typeof obj.totalTokens === "number" && Number.isFinite(obj.totalTokens) ? Math.max(0, Math.round(obj.totalTokens)) : void 0;
1164
+ return {
1165
+ narrative,
1166
+ locale,
1167
+ generatedAt,
1168
+ events,
1169
+ ...totalTokens !== void 0 ? { totalTokens } : {}
1170
+ };
1171
+ }
907
1172
  function serializeVoice(v) {
908
1173
  if (!v) return null;
909
1174
  const provider = VALID_VOICE_PROVIDERS.has(v.provider) ? v.provider : null;
@@ -1966,7 +2231,7 @@ function runSeed() {
1966
2231
  // src/server.ts
1967
2232
  import { serve } from "@hono/node-server";
1968
2233
  import { serveStatic } from "@hono/node-server/serve-static";
1969
- import { Hono as Hono12 } from "hono";
2234
+ import { Hono as Hono13 } from "hono";
1970
2235
  import { existsSync as existsSync2 } from "fs";
1971
2236
 
1972
2237
  // src/routes/agents.ts
@@ -4225,6 +4490,169 @@ var PersonaBus = class {
4225
4490
  };
4226
4491
  var personaBus = new PersonaBus();
4227
4492
 
4493
+ // src/orchestrator/persona-narrator.ts
4494
+ var NARRATOR_TIMEOUT_MS = 6e4;
4495
+ var NARRATOR_MAX_TOKENS = 900;
4496
+ function narratorCandidates() {
4497
+ const out = [];
4498
+ const utility = utilityModelFor();
4499
+ if (utility) out.push(utility);
4500
+ const flagship = effectiveDefaultModel();
4501
+ if (flagship && !out.includes(flagship)) out.push(flagship);
4502
+ for (const m of reachableModels()) {
4503
+ if (FLAGSHIP_TIER.has(m.modelV) && !out.includes(m.modelV)) out.push(m.modelV);
4504
+ }
4505
+ return out;
4506
+ }
4507
+ var LOCALE_HINTS = {
4508
+ en: "Write the summary in clear, warm English.",
4509
+ zh: "\u7528\u6D41\u7545\u81EA\u7136\u7684\u7B80\u4F53\u4E2D\u6587\u5199\u8FD9\u6BB5\u603B\u8FF0\u3002",
4510
+ ja: "\u89AA\u3057\u307F\u3084\u3059\u3044\u81EA\u7136\u306A\u65E5\u672C\u8A9E\u3067\u66F8\u3044\u3066\u304F\u3060\u3055\u3044\u3002",
4511
+ es: "Escribe el resumen en espa\xF1ol claro y cercano."
4512
+ };
4513
+ function summarizeEvents(events) {
4514
+ const phases = [];
4515
+ const searchesByDim = /* @__PURE__ */ new Map();
4516
+ const topupSearches = [];
4517
+ let dimensionPlan = [];
4518
+ let divergenceScore = null;
4519
+ for (const e of events) {
4520
+ if (e.kind === "phase-start") {
4521
+ phases.push({ phase: e.phase, label: e.label });
4522
+ } else if (e.kind === "phase-end") {
4523
+ const found = phases.find((p) => p.phase === e.phase);
4524
+ if (found) found.durationMs = e.durationMs;
4525
+ } else if (e.kind === "dimension-plan") {
4526
+ dimensionPlan = e.dimensions;
4527
+ } else if (e.kind === "search") {
4528
+ if (e.topup) {
4529
+ topupSearches.push({ query: e.query, sources: e.pagesRead });
4530
+ } else if (e.dimension) {
4531
+ const cur = searchesByDim.get(e.dimension) ?? { count: 0, sources: 0, queries: [] };
4532
+ cur.count += 1;
4533
+ cur.sources += e.pagesRead;
4534
+ cur.queries.push(e.query);
4535
+ searchesByDim.set(e.dimension, cur);
4536
+ }
4537
+ } else if (e.kind === "divergence") {
4538
+ divergenceScore = e.score;
4539
+ }
4540
+ }
4541
+ const lines = [];
4542
+ lines.push("Phases that ran:");
4543
+ for (const p of phases) {
4544
+ const dur = typeof p.durationMs === "number" ? ` (${Math.round(p.durationMs / 1e3)}s)` : "";
4545
+ lines.push(` ${p.phase}. ${p.label}${dur}`);
4546
+ }
4547
+ if (dimensionPlan.length > 0) {
4548
+ lines.push("");
4549
+ lines.push(`Research angles (picked by the planner, ${dimensionPlan.length} total):`);
4550
+ for (const d of dimensionPlan) {
4551
+ const stats = searchesByDim.get(d.dimension);
4552
+ const tail = stats ? ` \u2014 ${stats.sources} sources read` : "";
4553
+ lines.push(` \u2022 ${d.dimension}: ${d.why || d.query}${tail}`);
4554
+ }
4555
+ }
4556
+ if (topupSearches.length > 0) {
4557
+ lines.push("");
4558
+ lines.push(`Top-up gap searches (${topupSearches.length}):`);
4559
+ for (const t of topupSearches) {
4560
+ lines.push(` \u2022 "${t.query}" \u2192 ${t.sources} sources`);
4561
+ }
4562
+ }
4563
+ if (divergenceScore !== null) {
4564
+ lines.push("");
4565
+ lines.push(`Voice uniqueness vs generic AI baseline: ${Math.round(divergenceScore * 100)}%`);
4566
+ }
4567
+ return lines.join("\n");
4568
+ }
4569
+ var NARRATOR_SYSTEM = [
4570
+ "You are writing a short, pitch-style summary of how a custom AI persona was just built.",
4571
+ "",
4572
+ "Audience: the person who kicked the build. They watched a progress bar \u2014 they have no idea what the pipeline actually DID under the hood. Your job is to tell them, in plain language.",
4573
+ "",
4574
+ "Hard rules:",
4575
+ " 1. NEVER use jargon. Do NOT say 'multi-dimensional retrieval', 'ReAct loop', 'lexical divergence', 'parallel batch', 'synthesis', 'critique pass', 'eval set', 'differentiation probe'.",
4576
+ " 2. Instead, say things like: 'we looked at the question from several different angles', 'we double-checked a couple of gaps', 'we drafted the personality twice \u2014 once cold, once after reading up', 'this voice came out N% different from a generic AI'.",
4577
+ " 3. First-person plural ('we'). Warm tone. Short sentences. 3-5 short paragraphs.",
4578
+ " 4. Use specific details from the build log \u2014 name the angles by name, mention concrete sources-read counts, quote a sample query if it helps.",
4579
+ " 5. Stay between 200 and 400 words.",
4580
+ " 6. Do NOT use Markdown. Plain prose only, no headers, no bullet points.",
4581
+ " 7. Do NOT mention the model name, the agent's database id, or any internal terminology.",
4582
+ " 8. End with a single short sentence that invites the user to talk to the new director."
4583
+ ].join("\n");
4584
+ async function runPersonaNarrator(state, input) {
4585
+ const eventsSummary = summarizeEvents(input.events);
4586
+ if (!eventsSummary.trim()) return "";
4587
+ const profileBlock = input.profileV2 ? [
4588
+ "Persona shape (refined):",
4589
+ "",
4590
+ "```json",
4591
+ JSON.stringify(
4592
+ {
4593
+ intellectualLineage: input.profileV2.intellectualLineage,
4594
+ contrarianTakes: input.profileV2.contrarianTakes,
4595
+ loadBearingConcepts: input.profileV2.loadBearingConcepts
4596
+ },
4597
+ null,
4598
+ 2
4599
+ ),
4600
+ "```"
4601
+ ].join("\n") : "";
4602
+ const userBody = [
4603
+ `What the user asked us to build:`,
4604
+ ``,
4605
+ input.description.trim(),
4606
+ ``,
4607
+ input.guessName ? `Director we ended up naming: ${input.guessName}` : "",
4608
+ "",
4609
+ "Build log (structured):",
4610
+ "",
4611
+ eventsSummary,
4612
+ "",
4613
+ profileBlock,
4614
+ "",
4615
+ LOCALE_HINTS[input.locale],
4616
+ "",
4617
+ "Now write the 200-400 word pitch-style summary."
4618
+ ].filter((s) => s !== "").join("\n");
4619
+ const messages = [
4620
+ { role: "system", content: NARRATOR_SYSTEM },
4621
+ { role: "user", content: userBody }
4622
+ ];
4623
+ const candidates = narratorCandidates();
4624
+ for (const modelV of candidates) {
4625
+ if (!isModelV(modelV)) continue;
4626
+ if (state.controller.signal.aborted) return "";
4627
+ const controller = new AbortController();
4628
+ const onParentAbort = () => controller.abort();
4629
+ state.controller.signal.addEventListener("abort", onParentAbort, { once: true });
4630
+ const timer = setTimeout(() => controller.abort(), NARRATOR_TIMEOUT_MS);
4631
+ try {
4632
+ const r = await callLLMWithUsage({
4633
+ modelV,
4634
+ messages,
4635
+ temperature: 0.7,
4636
+ maxTokens: NARRATOR_MAX_TOKENS,
4637
+ signal: controller.signal
4638
+ });
4639
+ if (r.usage) {
4640
+ state.totalPromptTokens += r.usage.promptTokens;
4641
+ state.totalOutputTokens += r.usage.completionTokens;
4642
+ }
4643
+ const out = (r.text || "").trim();
4644
+ if (out.length >= 80) return out;
4645
+ } catch (e) {
4646
+ process.stderr.write(`[persona-narrator] ${modelV} failed: ${e instanceof Error ? e.message : String(e)}
4647
+ `);
4648
+ } finally {
4649
+ clearTimeout(timer);
4650
+ state.controller.signal.removeEventListener("abort", onParentAbort);
4651
+ }
4652
+ }
4653
+ return "";
4654
+ }
4655
+
4228
4656
  // src/orchestrator/persona-builder.ts
4229
4657
  var LLM_CALL_TIMEOUT_MS = 9e4;
4230
4658
  var BUILD_WALL_CLOCK_MS = 10 * 6e4;
@@ -4263,8 +4691,12 @@ function signalWithTimeout(parent, timeoutMs) {
4263
4691
  };
4264
4692
  }
4265
4693
  function bumpTokens(state, prompt, output) {
4266
- state.promptTokens += Math.max(0, prompt | 0);
4267
- state.outputTokens += Math.max(0, output | 0);
4694
+ const p = Math.max(0, prompt | 0);
4695
+ const o = Math.max(0, output | 0);
4696
+ state.promptTokens += p;
4697
+ state.outputTokens += o;
4698
+ state.totalPromptTokens += p;
4699
+ state.totalOutputTokens += o;
4268
4700
  return state.promptTokens > PROMPT_TOKEN_CEILING || state.outputTokens > OUTPUT_TOKEN_CEILING;
4269
4701
  }
4270
4702
  function flagshipCandidates() {
@@ -4337,14 +4769,19 @@ function startPersonaBuild(opts) {
4337
4769
  const state = {
4338
4770
  id: jobId,
4339
4771
  description,
4772
+ locale: opts.locale ?? "en",
4340
4773
  startedAt: Date.now(),
4341
4774
  controller: new AbortController(),
4342
4775
  promptTokens: 0,
4343
4776
  outputTokens: 0,
4777
+ totalPromptTokens: 0,
4778
+ totalOutputTokens: 0,
4344
4779
  searchRounds: [],
4345
4780
  dimensionPlan: [],
4346
4781
  rawSourcesByDim: /* @__PURE__ */ new Map(),
4347
- rawSourcesTopup: []
4782
+ rawSourcesTopup: [],
4783
+ buildEvents: [],
4784
+ phaseStartedAt: /* @__PURE__ */ new Map()
4348
4785
  };
4349
4786
  inFlightJobs.set(jobId, state);
4350
4787
  const wallClockTimer = setTimeout(() => {
@@ -4384,18 +4821,42 @@ async function runPipeline(state) {
4384
4821
  const phaseEtas = [30, 280, 30, 45, 90, 30, 60];
4385
4822
  const totalEta = phaseEtas.reduce((a, b) => a + b, 0);
4386
4823
  let progressBaselinePct = 0;
4824
+ const recordEvent = (ev) => {
4825
+ state.buildEvents.push(ev);
4826
+ };
4827
+ const syncBuildLogSnapshot = () => {
4828
+ partial.buildLog = {
4829
+ narrative: partial.buildLog?.narrative ?? "",
4830
+ locale: state.locale,
4831
+ generatedAt: partial.buildLog?.generatedAt ?? 0,
4832
+ events: state.buildEvents.slice(),
4833
+ totalTokens: state.totalPromptTokens + state.totalOutputTokens
4834
+ };
4835
+ };
4387
4836
  const startPhase = (phase) => {
4388
4837
  const i = phase - 1;
4838
+ const now = Date.now();
4839
+ state.phaseStartedAt.set(phase, now);
4389
4840
  personaBus.emit(state.id, {
4390
4841
  type: "persona-phase-start",
4391
4842
  phase,
4392
4843
  label: phaseLabels[i],
4393
4844
  etaSec: phaseEtas[i]
4394
4845
  });
4846
+ recordEvent({ kind: "phase-start", ts: now, phase, label: phaseLabels[i] });
4395
4847
  };
4396
4848
  const finishPhase = (phase) => {
4397
4849
  const i = phase - 1;
4398
4850
  progressBaselinePct = Math.round(phaseEtas.slice(0, i + 1).reduce((a, b) => a + b, 0) / totalEta * 100);
4851
+ const startedAt = state.phaseStartedAt.get(phase) ?? Date.now();
4852
+ const finishedAt = Date.now();
4853
+ recordEvent({
4854
+ kind: "phase-end",
4855
+ ts: finishedAt,
4856
+ phase,
4857
+ durationMs: Math.max(0, finishedAt - startedAt)
4858
+ });
4859
+ syncBuildLogSnapshot();
4399
4860
  personaBus.emit(state.id, {
4400
4861
  type: "persona-phase-end",
4401
4862
  phase,
@@ -4520,10 +4981,43 @@ async function runPipeline(state) {
4520
4981
  partial.evalSet = scored;
4521
4982
  const valid = scored.map((e) => e.divergenceScore).filter((s) => s !== null);
4522
4983
  partial.differentiationScore = valid.length > 0 ? valid.reduce((a, b) => a + b, 0) / valid.length : null;
4984
+ state.buildEvents.push({
4985
+ kind: "divergence",
4986
+ ts: Date.now(),
4987
+ score: partial.differentiationScore
4988
+ });
4523
4989
  }
4524
4990
  partial.toolAccess = { webSearch: hasWebSearchKey() };
4525
4991
  reportProgress(7, "naming the director", 0.85);
4526
4992
  partial.guessName = await runNamePhase(state, partial.profileV2) || void 0;
4993
+ reportProgress(7, "summarising the build", 0.92);
4994
+ try {
4995
+ const narrative = await runPersonaNarrator(state, {
4996
+ description: state.description,
4997
+ locale: state.locale,
4998
+ events: state.buildEvents,
4999
+ profileV2: partial.profileV2 ?? null,
5000
+ differentiationScore: partial.differentiationScore ?? null,
5001
+ guessName: partial.guessName ?? null
5002
+ });
5003
+ partial.buildLog = {
5004
+ narrative: narrative || "",
5005
+ locale: state.locale,
5006
+ generatedAt: Date.now(),
5007
+ events: state.buildEvents.slice(),
5008
+ totalTokens: state.totalPromptTokens + state.totalOutputTokens
5009
+ };
5010
+ } catch (e) {
5011
+ process.stderr.write(`[persona-builder/narrator] failed: ${e instanceof Error ? e.message : String(e)}
5012
+ `);
5013
+ partial.buildLog = {
5014
+ narrative: "",
5015
+ locale: state.locale,
5016
+ generatedAt: Date.now(),
5017
+ events: state.buildEvents.slice(),
5018
+ totalTokens: state.totalPromptTokens + state.totalOutputTokens
5019
+ };
5020
+ }
4527
5021
  finishPhase(7);
4528
5022
  {
4529
5023
  const status = checkAbortOrCap();
@@ -4607,6 +5101,11 @@ async function runReActLoop(state, profileV1, reportProgress) {
4607
5101
  type: "persona-dimension-plan",
4608
5102
  dimensions: plan.slice()
4609
5103
  });
5104
+ state.buildEvents.push({
5105
+ kind: "dimension-plan",
5106
+ ts: Date.now(),
5107
+ dimensions: plan.map((d) => ({ dimension: d.dimension, query: d.query, why: d.why }))
5108
+ });
4610
5109
  reportProgress(2, `${plan.length} angles picked \xB7 searching in parallel`, 0.05);
4611
5110
  let doneCount = 0;
4612
5111
  for (let chunkStart = 0; chunkStart < plan.length; chunkStart += DIMENSION_PARALLEL_CHUNK) {
@@ -4728,6 +5227,15 @@ ${ext.text}
4728
5227
  pagesRead: pageExtracts.filter((e) => e.ok).length,
4729
5228
  phase: "topup"
4730
5229
  });
5230
+ state.buildEvents.push({
5231
+ kind: "search",
5232
+ ts: Date.now(),
5233
+ query,
5234
+ resultsCount: resultsArr.length,
5235
+ pagesRead: pageExtracts.filter((e) => e.ok).length,
5236
+ round: roundNum,
5237
+ topup: true
5238
+ });
4731
5239
  }
4732
5240
  const merged = concatRawSources(state);
4733
5241
  if (!merged.trim()) {
@@ -4849,6 +5357,15 @@ ${ext.text}
4849
5357
  dimension: entry.dimension,
4850
5358
  phase: "dimension"
4851
5359
  });
5360
+ state.buildEvents.push({
5361
+ kind: "search",
5362
+ ts: Date.now(),
5363
+ query: entry.query,
5364
+ resultsCount: resultsArr.length,
5365
+ pagesRead: pageCount,
5366
+ dimension: entry.dimension,
5367
+ round: roundNum
5368
+ });
4852
5369
  }
4853
5370
  function concatRawSources(state) {
4854
5371
  const parts = [];
@@ -5076,7 +5593,8 @@ function partialToPersona(partial) {
5076
5593
  evalSet: partial.evalSet || [],
5077
5594
  differentiationScore: partial.differentiationScore ?? null,
5078
5595
  toolAccess: partial.toolAccess || { webSearch: false },
5079
- ...partial.guessName ? { guessName: partial.guessName } : {}
5596
+ ...partial.guessName ? { guessName: partial.guessName } : {},
5597
+ ...partial.buildLog ? { buildLog: partial.buildLog } : {}
5080
5598
  };
5081
5599
  }
5082
5600
  function getPartialPersona(jobId) {
@@ -5096,7 +5614,8 @@ function getPartialPersona(jobId) {
5096
5614
  evalSet: v.evalSet || [],
5097
5615
  differentiationScore: typeof v.differentiationScore === "number" ? v.differentiationScore : null,
5098
5616
  toolAccess: v.toolAccess || { webSearch: false },
5099
- ...typeof v.guessName === "string" && v.guessName ? { guessName: v.guessName } : {}
5617
+ ...typeof v.guessName === "string" && v.guessName ? { guessName: v.guessName } : {},
5618
+ ...v.buildLog ? { buildLog: v.buildLog } : {}
5100
5619
  };
5101
5620
  }
5102
5621
 
@@ -6840,7 +7359,9 @@ function agentsRouter() {
6840
7359
  if (description.length > 1200) {
6841
7360
  return c.json({ error: "description too long (max 1200 chars)" }, 400);
6842
7361
  }
6843
- const jobId = startPersonaBuild({ description });
7362
+ const localeRaw = typeof b.locale === "string" ? b.locale : "";
7363
+ const locale = localeRaw === "zh" || localeRaw === "ja" || localeRaw === "es" ? localeRaw : "en";
7364
+ const jobId = startPersonaBuild({ description, locale });
6844
7365
  return c.json({ jobId });
6845
7366
  });
6846
7367
  r.get("/generate-persona/:jobId/stream", (c) => {
@@ -8088,15 +8609,15 @@ var SCAFFOLD_SYSTEM = [
8088
8609
  "",
8089
8610
  "7b. **Director Perspectives** (`directorPerspectives`) \xB7 MANDATORY in every brief with \u2265 2 active directors. The \"social map\" of the room \u2014 every active director gets a row, alignment / divergence groups are explicit, the chair's structural observation closes the block. Distinct from convergence (narrow), divergence (single central tension), positions (2-3 camps with quotes). views-compared is the bird's-eye view that lets a stakeholder skim WHO was in the room and where each one stood. Object shape: `{ intro, alignment: [...], divergence: [...], perspectives: [...], chairSynthesis }`. Each entry of `perspectives`: `directorId` (must match the room's member list), `stance` (\u2264 60 chars short label of the director's angle), `position` (1-2 sentences \u2264 300 chars \xB7 the director's load-bearing argument in their voice), `quote` (optional verbatim \u2264 40 words; empty when no memorable line), `lens` (data | dissent | narrative | structural | first-principle). EVERY active director gets one entry \u2014 no exceptions. Each `alignment` group: `pointOfAgreement` (\u2264 80 chars), `directorIds` (\u2265 2), `note` (\u2264 220 chars \xB7 why the convergence is structurally interesting \xB7 prefer to surface independent paths to same conclusion). Each `divergence` entry: `hinge` (\u2264 140 chars \xB7 what separates them), `sides` (2-3 named sides; each: label / directorIds / 1-sentence stance), `resolution` (\u2264 220 chars \xB7 what would settle it; empty when unresolved). `chairSynthesis` (2-4 sentences, \u2264 400 chars) is the chair's meta-observation comparing the views \u2014 moderator-neutral voice, observation not advocacy. Set the WHOLE block to `null` ONLY when there's exactly 1 active director (no comparison possible).",
8090
8611
  "",
8091
- "8. **Visuals** \xB7 0\u20134 blocks. Content-driven. **Strongly prefer mermaid sub-types over text matrices** \u2014 mermaid renders as a real diagram readers can scan in seconds; text matrices are dense tables that take paragraphs to absorb. Only fall back to text matrix when the data shape genuinely doesn't fit any mermaid form.",
8612
+ "8. **Visuals** \xB7 0\u20134 blocks. Content-driven. **Strongly prefer chart sub-types over text matrices** \u2014 charts render as a real diagram readers can scan in seconds; text matrices are dense tables that take paragraphs to absorb. Only fall back to text matrix when the data shape genuinely doesn't fit any chart form.",
8092
8613
  "",
8093
- " Mermaid (preferred):",
8094
- " \xB7 `quadrant-chart` \u2014 items plotted on two real axes (mermaid quadrantChart). Effort/impact, support/strength, urgency/uncertainty.",
8095
- " \xB7 `bar-chart` \u2014 2\u20138 named items ranked by ONE quantitative dimension (mermaid xychart-beta \xB7 cost / support / size / time)",
8096
- " \xB7 `timeline` \u2014 3\u20138 dated points telling a narrative arc (mermaid timeline \xB7 retro / historical analogue / projected sequence)",
8097
- " \xB7 `pie-chart` \u2014 2\u20136 slices showing a distribution (mermaid pie \xB7 scenario probabilities / lens shares / vote tallies / market mix). Numbers can be percentages OR raw counts \u2014 mermaid normalises.",
8614
+ " Chart sub-types (strongly preferred \xB7 all rendered as inline SVG through the `kami-chart` pipeline):",
8615
+ " \xB7 `quadrant-chart` \u2014 items plotted on two real axes. Effort/impact, support/strength, urgency/uncertainty. Rendered as inline SVG via the `kami-chart` pipeline.",
8616
+ " \xB7 `bar-chart` \u2014 2\u20138 named items ranked by ONE quantitative dimension \xB7 cost / support / size / time. Rendered as inline SVG via the `kami-chart` pipeline (spine-tokenised, no JS runtime).",
8617
+ " \xB7 `timeline` \u2014 3\u20138 dated points telling a narrative arc \xB7 retro / historical analogue / projected sequence. Rendered as inline SVG via the `kami-chart` pipeline.",
8618
+ " \xB7 `pie-chart` \u2014 2\u20136 slices showing a distribution \xB7 scenario probabilities / lens shares / vote tallies / market mix. Rendered as a DONUT (hollow centre carries the largest slice's value) via the `kami-chart` pipeline. Numbers can be percentages OR raw counts \u2014 the renderer normalises.",
8098
8619
  "",
8099
- " Text matrices (fall back when no mermaid form fits):",
8620
+ " Text matrices (fall back when no chart form fits):",
8100
8621
  " \xB7 `comparison-table` \u2014 \u2265 2 named options compared on shared dimensions",
8101
8622
  " \xB7 `force-field` \u2014 drivers vs resistors of one outcome",
8102
8623
  " \xB7 `strengths-cautions`\u2014 strengths / cautions / verdict per option",
@@ -8109,7 +8630,7 @@ var SCAFFOLD_SYSTEM = [
8109
8630
  " \xB7 ONLY when the data is N-options \xD7 M-criteria with mixed cell types (text + numbers + tags) \u2192 `comparison-table`.",
8110
8631
  " \xB7 ONLY when the room argued exactly one outcome with for/against forces \u2192 `force-field`.",
8111
8632
  " \xB7 ONLY when N options each need a strengths/cautions/verdict triplet AND no numeric ranking \u2192 `strengths-cautions`.",
8112
- " These six routes cover most rooms. Default toward picking 2\u20133 visuals (one quantitative + one categorical) \u2014 the writer is forbidden from emitting only text matrices. Beyond these, the Stage 3 writer also emits inline mermaid (flowchart / mindmap / gantt / sequenceDiagram / stateDiagram / journey) where prose can't carry the structure efficiently \u2014 those don't go in the typed `visuals` list, so don't pre-allocate them here.",
8633
+ " These six routes cover most rooms. Default toward picking 2\u20133 visuals (one quantitative + one categorical) \u2014 the writer is forbidden from emitting only text matrices. Beyond these, the Stage 3 writer also emits inline charts (flowchart / mindmap / gantt / sequenceDiagram / stateDiagram / journey \u2014 all rendered through the `kami-chart` pipeline) where prose can't carry the structure efficiently \u2014 those don't go in the typed `visuals` list, so don't pre-allocate them here.",
8113
8634
  "",
8114
8635
  '9. **Recommendations** \xB7 3\u20135 concrete actions, each with: `priority` (P0/P1/P2), `action` (imperative), `rationale`, `ownerType`, `horizon` (e.g. "next 30 days"), `successMetric` (observable proof of execution), `riskIfSkipped`, `expectedBenefit` (the upside if you act \u2014 stated as a concrete payoff, NOT a metric to watch). Recommendations are imperatives \u2014 "Do X" not "X should happen". The `expectedBenefit` is what gets a stakeholder to actually approve the action \u2014 it answers "if I do this, what do I get?" in one short sentence.',
8115
8636
  "",
@@ -9096,26 +9617,33 @@ var WRITE_SYSTEM = [
9096
9617
  "",
9097
9618
  " For `quadrant-chart`:",
9098
9619
  " ### {title}",
9099
- " Render a fenced ```mermaid block with `quadrantChart`. EXACT shape \u2014 mermaid 10.9.5 is strict:",
9620
+ ' Render a fenced ```kami-chart block \xB7 strict JSON. Two-axis plot where the top-right (Q1) quadrant is the brand\'s "preferred" zone, tinted in the spine accent. Schema:',
9100
9621
  " ```",
9101
- " quadrantChart",
9102
- " title {title}",
9103
- ' x-axis "Low {xLabel}" --> "High {xLabel}"',
9104
- ' y-axis "Low {yLabel}" --> "High {yLabel}"',
9105
- ' quadrant-1 "{q1}"',
9106
- ' quadrant-2 "{q2}"',
9107
- ' quadrant-3 "{q3}"',
9108
- ' quadrant-4 "{q4}"',
9109
- ' "{item.label}": [{item.x}, {item.y}]',
9622
+ " {",
9623
+ ' "type": "quadrant-chart",',
9624
+ ' "title": "{scaffold.title}",',
9625
+ ' "xLabel": "{scaffold.xLabel \xB7 low\u2192high}",',
9626
+ ' "yLabel": "{scaffold.yLabel \xB7 low\u2192high}",',
9627
+ ' "q1": "{top-right \xB7 preferred}",',
9628
+ ' "q2": "{top-left}",',
9629
+ ' "q3": "{bottom-left}",',
9630
+ ' "q4": "{bottom-right}",',
9631
+ ' "focal": "{label of the load-bearing item \xB7 optional}",',
9632
+ ' "items": [',
9633
+ ' { "label": "Item A", "x": 0.72, "y": 0.84 },',
9634
+ ' { "label": "Item B", "x": 0.35, "y": 0.66, "tier": 4 }',
9635
+ " ],",
9636
+ ' "caption": "{One-sentence editorial gloss with *italic* on the operative phrase.}"',
9637
+ " }",
9110
9638
  " ```",
9111
- " Hard rules to avoid mermaid syntax errors (the lexer fails on non-ASCII in unquoted labels):",
9112
- ' \xB7 BOTH x-axis AND y-axis lines MUST be `... "Low X" --> "High X"` form \u2014 both ends in DOUBLE QUOTES.',
9113
- ' \xB7 Quadrant labels MUST be in DOUBLE QUOTES (`quadrant-1 "\u77ED\u8BED"`) \u2014 never bare text. The lexer rejects unquoted CJK / parens / `+`.',
9114
- ' \xB7 Each item line is `"Label": [x, y]` with the label in DOUBLE QUOTES. Inside the label: no `:` no `"` no `[` no `]`. Replace with ` - ` if needed.',
9115
- " \xB7 Use HALFWIDTH parens `()` not fullwidth `\uFF08\uFF09` anywhere inside the diagram.",
9116
- " \xB7 Numeric coords are decimals strictly inside `(0, 1)` \u2014 round to 2 decimals. Never use 0 or 1 exactly.",
9117
- " \xB7 Title is one line, plain text \u2014 no quotes, no colons. Title is the only label that is NOT quoted.",
9118
- " \xB7 One item per indented line. No blank lines inside the fenced block.",
9639
+ " Hard rules:",
9640
+ " \xB7 Strict JSON. No trailing commas, no comments.",
9641
+ " \xB7 `x` and `y` are decimals strictly inside `(0, 1)` \u2014 2 decimals max. Never use 0 or 1 exactly (points clip on the frame).",
9642
+ " \xB7 `q1`\u2013`q4` are short quadrant labels (\u2264 24 chars). `q1` is the preferred / focal quadrant by convention \u2014 pick names accordingly.",
9643
+ " \xB7 `focal` is OPTIONAL. When set, that item renders solid accent + larger radius \u2014 concentrates attention on the load-bearing position. Only one focal per chart.",
9644
+ " \xB7 `tier` (optional, 1\u20134) downranks an item's visual weight: 1 = standard, 2 = near-focal (accent outline), 3 = standard, 4 = faint (muted outline). Use sparingly to push uninteresting positions to the background.",
9645
+ " \xB7 2\u201316 items. Item labels \u2264 24 chars; avoid colons / brackets.",
9646
+ " \xB7 NO blank lines inside the fenced block.",
9119
9647
  "",
9120
9648
  " For `force-field`:",
9121
9649
  " ### {title}",
@@ -9127,62 +9655,88 @@ var WRITE_SYSTEM = [
9127
9655
  "",
9128
9656
  " For `bar-chart`:",
9129
9657
  " ### {title}",
9130
- " Render a fenced ```mermaid block with `xychart-beta` (mermaid 10+ stable). Strict shape so the lexer doesn't reject:",
9658
+ " Render a fenced ```kami-chart block \xB7 strict JSON. The renderer turns it into an inline SVG bar chart styled by the active spine palette:",
9131
9659
  " ```",
9132
- " xychart-beta",
9133
- ' title "{title}"',
9134
- ' x-axis ["{bar.label}", "{bar.label}", ...]',
9135
- ' y-axis "{yLabel}"',
9136
- " bar [{bar.value}, {bar.value}, ...]",
9660
+ " {",
9661
+ ' "type": "bar-chart",',
9662
+ ' "title": "{scaffold.title}",',
9663
+ ' "yLabel": "{scaffold.yLabel}",',
9664
+ ' "unit": "{scaffold.unit}",',
9665
+ ' "focal": "{label of the one bar that carries the argument \xB7 optional}",',
9666
+ ' "bars": [',
9667
+ ' { "label": "{bar.label}", "value": {bar.value} },',
9668
+ ' { "label": "{bar.label}", "value": {bar.value} }',
9669
+ " ],",
9670
+ ' "caption": "{One-sentence editorial gloss \xB7 what the chart says, not what it plots. Use *italic markers* (e.g. *2\xD7 faster*) for the operative phrase.}"',
9671
+ " }",
9137
9672
  " ```",
9138
9673
  " Hard rules:",
9139
- " \xB7 `x-axis` is a literal JSON-style array of DOUBLE-QUOTED labels, comma-separated. No bare strings. CJK is fine inside the quotes.",
9140
- " \xB7 Inside any quoted label: NO double-quote, NO `:`, NO `[`, NO `]`. Replace with ` - ` if needed.",
9141
- " \xB7 `bar` values are bare numbers, in the same order as x-axis labels. Match counts (lexer fails on mismatch).",
9142
- " \xB7 `title` is double-quoted. ASCII parens only \u2014 replace fullwidth `\uFF08\uFF09` with halfwidth `()`.",
9674
+ " \xB7 Strict JSON (double-quoted keys + string values, no trailing commas, no comments). The renderer parses and falls back to an error placeholder if JSON is malformed.",
9143
9675
  " \xB7 2\u20138 bars. Below 2 isn't a comparison; above 8 stops being scannable.",
9144
- " \xB7 NO blank lines inside the fenced block. Indent body lines 4 spaces.",
9676
+ " \xB7 `value` is a bare number (no quotes, no unit suffix). Y-axis auto-scales to the next nice round number.",
9677
+ ' \xB7 `unit` (e.g. "w", "%", "$M") is appended to the data label above each bar \u2014 keep it short (\u2264 4 chars).',
9678
+ " \xB7 `focal` is optional. When set, that bar renders in the spine accent and the others in neutral ink-mid \u2014 concentrates attention on the bar that carries the argument. When omitted, every bar gets the accent.",
9679
+ " \xB7 `caption` is rendered in serif italic below the chart. Wrap the operative phrase in `*\u2026*` so the renderer can italicise it in the spine accent.",
9680
+ " \xB7 Labels \u2264 24 chars; the renderer truncates to 18 at draw time. Avoid colons / brackets in labels (cosmetic, not strict).",
9681
+ " \xB7 NO blank lines inside the fenced block.",
9145
9682
  "",
9146
9683
  " For `timeline`:",
9147
9684
  " ### {title}",
9148
- " Render a fenced ```mermaid block with `timeline`. Strict shape:",
9685
+ " Render a fenced ```kami-chart block \xB7 strict JSON. Horizontal time-axis chart where events alternate above / below the axis; the `focal` event renders in the spine accent. Schema:",
9149
9686
  " ```",
9150
- " timeline",
9151
- " title {title}",
9152
- " {period} : {label} : {description}",
9687
+ " {",
9688
+ ' "type": "timeline",',
9689
+ ' "title": "{scaffold.title}",',
9690
+ ' "focal": "{period string of the load-bearing milestone \xB7 optional}",',
9691
+ ' "points": [',
9692
+ ' { "period": "2019", "label": "First open weights ship", "description": "" },',
9693
+ ' { "period": "2022", "label": "Parity claim", "description": "Optional one-clause expansion" }',
9694
+ " ],",
9695
+ ' "caption": "{Editorial gloss \xB7 what the arc says, with *italic* on the turn.}"',
9696
+ " }",
9153
9697
  " ```",
9154
9698
  " Hard rules:",
9155
- " \xB7 `title` is plain text on its own line \u2014 NO quotes (mermaid timeline syntax differs from xychart). One line. ASCII parens only. NO `:` inside the title.",
9156
- " \xB7 One point per line: `{period} : {label} : {description}` \u2014 colons are the field separators, so labels / descriptions cannot contain `:`. Replace with ` \u2014 ` if needed.",
9157
- ' \xB7 Period (e.g. "2019", "Q3 2024", "Today") is the column header rendered above the dot.',
9158
- " \xB7 Description is optional \xB7 when scaffold.description is empty, use the 2-segment form: `{period} : {label}` (no trailing colon, no empty third segment \u2014 mermaid 11.0+ rejects empty fields).",
9159
- " \xB7 3\u20138 points. Below 3 reads as a stub; above 8 the strip overflows.",
9160
- " \xB7 NO blank lines inside the fenced block. Indent body lines 4 spaces.",
9699
+ " \xB7 Strict JSON. 3\u20138 points. Below 3 is a stub; above 8 overflows the strip.",
9700
+ ' \xB7 `period` is the column header (mono): "2019", "Q3 2024", "Today", "+12 mo". \u2264 16 chars.',
9701
+ " \xB7 `label` is the headline event (sans): \u2264 60 chars. Concrete; avoid corporate verbs.",
9702
+ " \xB7 `description` is optional (\u2264 40 chars after rendering \u2014 gets truncated). Empty string is fine.",
9703
+ " \xB7 `focal` matches the `period` string of the load-bearing event. The matched event renders in accent: filled dot, solid connector, accent-bordered card. Only one focal.",
9704
+ " \xB7 NO blank lines inside the fenced block.",
9161
9705
  "",
9162
9706
  " For `pie-chart`:",
9163
9707
  " ### {title}",
9164
- " Render a fenced ```mermaid block with `pie showData` so each slice's number is printed in the legend:",
9708
+ " Render a fenced ```kami-chart block \xB7 strict JSON. Renders as a DONUT (hollow centre with the largest slice's value + label inside). The largest slice carries the spine accent; smaller slices cascade down the neutral ramp. Schema:",
9165
9709
  " ```",
9166
- " pie showData",
9167
- " title {title}",
9168
- ' "{slice.label}" : {slice.value}',
9169
- ' "{slice.label}" : {slice.value}',
9710
+ " {",
9711
+ ' "type": "donut",',
9712
+ ' "title": "{scaffold.title}",',
9713
+ ` "centerLabel": "{optional \xB7 short text under the centre value \xB7 falls back to the largest slice's label}",`,
9714
+ ' "slices": [',
9715
+ ' { "label": "Base case", "value": 55 },',
9716
+ ' { "label": "Upside", "value": 25 },',
9717
+ ' { "label": "Downside", "value": 20 }',
9718
+ " ],",
9719
+ ' "caption": "{Editorial gloss with *italic* on the operative phrase.}"',
9720
+ " }",
9170
9721
  " ```",
9171
9722
  " Hard rules:",
9172
- " \xB7 `title` is plain text \u2014 NO quotes. ASCII parens only. NO `:` inside the title.",
9173
- ' \xB7 Each slice is `"{label}" : {number}` \u2014 label DOUBLE-QUOTED, value bare number. The literal colon between them is required.',
9174
- ' \xB7 Labels: NO `"`, NO `:`, NO `[`, NO `]` inside. Replace with ` - ` if needed.',
9175
- " \xB7 Values can be percentages summing to ~100 OR raw counts \u2014 mermaid normalises. Keep 2 decimals max.",
9176
- " \xB7 2\u20136 slices. Pies with > 6 slices stop being readable.",
9723
+ " \xB7 `type` MUST be `donut` (the old `pie-chart` is renamed at render time).",
9724
+ " \xB7 Strict JSON. 2\u20136 slices. Above 6 the chart stops being readable.",
9725
+ " \xB7 `value` is a bare number \u2014 percentages summing to ~100 OR raw counts. The renderer normalises and prints percentages in the legend.",
9726
+ " \xB7 The renderer SORTS slices largest-first, so the visual order may differ from the JSON order. Don't try to control colours by ordering \u2014 the accent always lands on the largest slice.",
9727
+ " \xB7 `centerLabel` is OPTIONAL \u2014 defaults to the largest slice's label (uppercased). Set it when the slice label is long or wants a different framing inside the donut.",
9728
+ " \xB7 Slice labels \u2264 24 chars. NO blank lines inside the fenced block.",
9177
9729
  " \xB7 NO blank lines inside the fenced block. Indent body lines 4 spaces.",
9178
9730
  "",
9179
- "## Inline mermaid \xB7 additional chart types",
9731
+ "## Inline charts \xB7 additional chart types beyond the typed visuals",
9180
9732
  "",
9181
- "Beyond the typed visuals (`comparison-table` / `quadrant-chart` / `force-field` / `strengths-cautions` / `bar-chart` / `timeline` / `pie-chart`), the writer drops fenced ```mermaid blocks inline within body sections to surface logic, sequence, hierarchy, or state that prose can't carry as efficiently. Each must visualise something prose can't.",
9733
+ "Beyond the typed visuals (`comparison-table` / `quadrant-chart` / `force-field` / `strengths-cautions` / `bar-chart` / `timeline` / `pie-chart`), the writer drops fenced chart blocks inline within body sections to surface logic, sequence, hierarchy, or state that prose can't carry as efficiently. Each must visualise something prose can't.",
9182
9734
  "",
9183
- "**Mermaid bias \xB7 prefer mermaid where it fits naturally \u2014 no fixed quota.** Two sources count:",
9184
- " \xB7 Typed visuals where the sub-type is mermaid (`quadrant-chart` / `bar-chart` / `timeline` / `pie-chart`).",
9185
- " \xB7 Inline mermaid blocks from the catalogue below (`flowchart` / `mindmap` / `gantt` / `sequenceDiagram` / `journey` / `stateDiagram-v2`).",
9735
+ "All inline charts render through the `kami-chart` pipeline \xB7 strict JSON inside a ```kami-chart fence, spine-tokenised inline SVG, no JS runtime. The catalogue below covers every inline chart type \u2014 `flowchart` / `mindmap`\u2192`tree` / `gantt` / `sequenceDiagram`\u2192`swimlane` / `journey`\u2192`swimlane` / `stateDiagram-v2`\u2192`state-machine`.",
9736
+ "",
9737
+ "**Chart bias \xB7 prefer charts where they fit naturally \u2014 no fixed quota.** Two sources count:",
9738
+ " \xB7 Typed visuals where the sub-type is a chart (`quadrant-chart` / `bar-chart` / `timeline` / `pie-chart`).",
9739
+ " \xB7 Inline chart blocks from the catalogue below (`flowchart` / `mindmap` / `gantt` / `sequenceDiagram` / `journey` / `stateDiagram-v2`).",
9186
9740
  "Quality over quantity. Emit a chart whenever the section's content has structure prose can't carry efficiently. Skip when the prose alone is clear \u2014 a forced chart is worse than no chart. There is NO minimum count; a substantive strategy brief might naturally land at 4\u20136 charts, a tight philosophical brief at 0\u20131, both are correct.",
9187
9741
  "",
9188
9742
  "**The default lean is YES** when a section's content matches one of these shapes: branching logic, sequenced events, multi-party interactions, hierarchical structure, state transitions, 2-axis comparisons, distributions, before/after framings. The cost of an extra chart is small; the cost of a wall of text is a reader who skips. But don't manufacture structure that isn't in the material \u2014 chart only what the room actually produced.",
@@ -9205,12 +9759,12 @@ var WRITE_SYSTEM = [
9205
9759
  " \xB7 `Options Analysis` / `Decision Options` \u2192 \u25C7 ONLY when there are \u2265 4 options OR each option branches into 2+ sub-considerations (5+ nodes total with branching). With 2-3 options, the typed table / decision-options block carries it \u2014 no chart.",
9206
9760
  " \xB7 `Two Paths` \u2192 \u25C7 rarely; two parallel trajectories joined at a hinge is exactly the trivial case. Render the typed table only.",
9207
9761
  " \xB7 `Strategic Outlook` \u2192 \u25C7 `mindmap` ONLY when the room named \u2265 4 distinct forces with internal sub-structure (4 branches \xD7 \u2265 2 children).",
9208
- " \xB7 `Critical Assumptions` \u2192 \u25C7 `flowchart TD` ONLY when assumptions form a multi-step dependency chain WITH branching (assumption A holds \u2192 either B or C \u2192 recommendation D), 5+ nodes. Linear 3-step dependency = prose.",
9209
- " \xB7 `Scenario Tree` \u2192 \u2713 `flowchart TD` when there are \u2265 3 scenarios EACH with named effects/triggers as sub-nodes (root + 3 scenarios + 3-6 effect children = 7-10 nodes). Below that, the typed table is enough.",
9210
- " \xB7 `Threats to Validity` \u2192 \u25C7 `flowchart TD` ONLY when threats compound (sample bias \u2192 selection bias \u2192 generalizability ceiling) with branching, 5+ nodes.",
9762
+ " \xB7 `Critical Assumptions` \u2192 \u25C7 `flowchart` ONLY when assumptions form a multi-step dependency chain WITH branching (assumption A holds \u2192 either B or C \u2192 recommendation D), 5+ nodes. Linear 3-step dependency = prose.",
9763
+ " \xB7 `Scenario Tree` \u2192 \u2713 `flowchart` when there are \u2265 3 scenarios EACH with named effects/triggers as sub-nodes (root + 3 scenarios + 3-6 effect children = 7-10 nodes). Below that, the typed table is enough.",
9764
+ " \xB7 `Threats to Validity` \u2192 \u25C7 `flowchart` ONLY when threats compound (sample bias \u2192 selection bias \u2192 generalizability ceiling) with branching, 5+ nodes.",
9211
9765
  " \xB7 `Recommendations` \u2192 \u2713 `gantt` for multi-phase rollouts (\u2265 2 sections AND \u2265 4 tasks). For sequenced action chains under 4 tasks, use prose / numbered list \u2014 NOT a linear flowchart.",
9212
9766
  " \xB7 `Leading Indicators` \u2192 \u25C7 `stateDiagram-v2` when indicators map to \u2265 4 scenario states with at least one feedback loop / back-transition.",
9213
- " \xB7 `Risk Register` \u2192 \u2713 `flowchart TD` when there are \u2265 5 risks AND multiple risks share a category cluster (root \u2192 category nodes \u2192 individual risks as leaves = \u2265 10 nodes). The typed risk table is enough on its own when the register is < 5 entries.",
9767
+ " \xB7 `Risk Register` \u2192 \u2713 `flowchart` when there are \u2265 5 risks AND multiple risks share a category cluster (root \u2192 category nodes \u2192 individual risks as leaves = \u2265 10 nodes). The typed risk table is enough on its own when the register is < 5 entries.",
9214
9768
  " \xB7 `Risk Register` \u2192 \u2713 `quadrantChart` of severity \xD7 likelihood (always \u2014 the quadrant chart is a 2-axis plot, not subject to the flowchart-complexity floor).",
9215
9769
  " \xB7 `New Questions This Surfaced` \u2192 \u25C7 `mindmap` when there are \u2265 4 new questions clustering into \u2265 3 themes.",
9216
9770
  " \xB7 `Strategic Planning Assumption` \u2192 \u25C7 rarely.",
@@ -9221,130 +9775,178 @@ var WRITE_SYSTEM = [
9221
9775
  "**Routing constraints** (avoid double-rendering the same content):",
9222
9776
  " \xB7 Risk Register flowchart + Risk Register table = \u2713 both, complementary.",
9223
9777
  " \xB7 Risk Register quadrantChart + Risk Register table = \u2713 both, complementary.",
9224
- " \xB7 A single section gets at MOST one inline mermaid (plus the typed visual if any). Never stack 2+ inline charts in one section.",
9778
+ " \xB7 A single section gets at MOST one inline chart (plus the typed visual if any). Never stack 2+ inline charts in one section.",
9225
9779
  " \xB7 A `gantt` and a `flowchart` covering the SAME recommendation rollout = pick one (gantt if dates matter, flowchart if branching matters).",
9226
9780
  " \xB7 If a typed `visuals` block already covers a content shape (e.g. `bar-chart` for ranked options), don't add an inline `flowchart` for the same options.",
9227
9781
  "",
9228
9782
  " ### flowchart \xB7 decision tree / process branches",
9229
- ' Use when a section argues a decision sequence ("if X then Y else Z") or a process where order + branching matters. Natural fits: risk-register branches ("if risk A materialises, do P; else monitor"), the divergence section when there are 3+ positions, scenario trees with named effects.',
9783
+ ' Use when a section argues a decision sequence ("if X then Y else Z") or a short process where order matters. Render as a fenced ```kami-chart JSON with one of two LAYOUTS \u2014 the renderer owns positioning. Schemas:',
9784
+ " Linear chain \xB7 2\u20135 nodes top-to-bottom:",
9785
+ " ```",
9786
+ " {",
9787
+ ' "type": "flowchart",',
9788
+ ' "layout": "linear-v",',
9789
+ ' "focal": "{optional \xB7 label of the load-bearing node}",',
9790
+ ' "nodes": [',
9791
+ ' { "label": "Start", "kind": "start" },',
9792
+ ' { "label": "Validate input", "kind": "step", "hint": "" },',
9793
+ ' { "label": "Persist write", "kind": "step", "hint": "" },',
9794
+ ' { "label": "Done", "kind": "end" }',
9795
+ " ],",
9796
+ ' "caption": "{One-sentence editorial gloss with *italic* on the operative phrase.}"',
9797
+ " }",
9798
+ " ```",
9799
+ " Y-decision \xB7 root (decision) \u2192 2 branches \u2192 optional join:",
9230
9800
  " ```",
9231
- " flowchart TD",
9232
- " A[Starting state] --> B{Decision point}",
9233
- " B -->|condition true| C[Branch A]",
9234
- " B -->|condition false| D[Branch B]",
9235
- " C --> E[Outcome]",
9236
- " D --> E",
9801
+ " {",
9802
+ ' "type": "flowchart",',
9803
+ ' "layout": "y-decision",',
9804
+ ' "focal": "{optional \xB7 label of the load-bearing node \u2014 usually the decision or the preferred branch}",',
9805
+ ' "nodes": [',
9806
+ ' { "label": "Pilot beats threshold?", "kind": "decision" },',
9807
+ ' { "label": "Scale rollout", "kind": "outcome", "hint": "yes" },',
9808
+ ' { "label": "Iterate pilot", "kind": "outcome", "hint": "no" },',
9809
+ ' { "label": "Decision recorded", "kind": "end" }',
9810
+ " ],",
9811
+ ' "caption": "{Editorial gloss.}"',
9812
+ " }",
9237
9813
  " ```",
9238
9814
  " Hard rules:",
9239
- ' \xB7 **Complexity floor \xB7 \u2265 5 nodes AND \u2265 1 branch** (a node with 2+ outgoing edges, OR a `{diamond}` decision with branching paths). Pure linear `A \u2192 B \u2192 C` flowcharts are FORBIDDEN \u2014 those read as naive ("\u5E7C\u9F7F") and must render as prose / a numbered list instead. If your candidate flowchart has 3-4 nodes OR no branching, **drop the chart and use prose**. A missing chart is fine; a trivial one is worse than nothing.',
9240
- " \xB7 Direction is `TD` (top-down) or `LR` (left-right) ONLY. `TD` is the default for decision trees, `LR` for process sequences.",
9241
- " \xB7 Node labels: square brackets `[label]` for boxes, curly `{label}` for diamonds (decisions), round `(label)` for terminal states. Keep labels \u2264 6 words.",
9242
- " \xB7 Edge labels (`-->|text|`) are quoted only if they contain spaces \u2014 short text without spaces can be unquoted.",
9243
- ' \xB7 NO `:`, NO `(`, NO `)`, NO `"` inside node labels. ASCII parens only \u2014 never fullwidth.',
9244
- " \xB7 Maximum 8 nodes per chart. Above 8 stops being scannable; split into multiple charts.",
9245
- " \xB7 NO blank lines inside the fenced block. Indent body lines 4 spaces.",
9246
- "",
9247
- " ### mindmap \xB7 hierarchical idea tree",
9248
- " Use when a section needs to surface the *shape* of the room's thinking \u2014 typically in brainstorm-mode rooms (`adjacent-angles`, `worth-chasing`) where the directors generated multiple branches off a central premise. Avoid in decision-grade briefs where the structure is already linear (anchor \u2192 findings \u2192 action).",
9815
+ ' \xB7 `layout` MUST be `linear-v` or `y-decision`. Pick `linear-v` for a sequence ("A leads to B leads to C"). Pick `y-decision` whenever a decision branches into 2 outcomes.',
9816
+ ' \xB7 `kind` values: `start` (pill, paper-soft) \xB7 `step` (square, ink border) \xB7 `decision` (diamond, accent border) \xB7 `outcome` (square, faint border \u2014 "deferred branch" reads as receded) \xB7 `end` (pill, paper-soft).',
9817
+ " \xB7 `hint` is rendered as a short uppercase mono caption inside the node (for steps / outcomes) OR as the edge label (for y-decision branches \u2014 write `yes` / `no` / `<6 chars`).",
9818
+ " \xB7 For `y-decision`: nodes in canonical order \u2014 [0] root, [1] left branch, [2] right branch, [3] optional join.",
9819
+ " \xB7 For `linear-v`: max 5 nodes. For `y-decision`: 3\u20134 nodes.",
9820
+ " \xB7 Labels \u2264 18 chars (renderer truncates). Avoid colons / brackets in labels.",
9821
+ " \xB7 `focal` matches a node's `label` exactly; that node renders in the spine accent.",
9822
+ " \xB7 **Complexity floor stays**: only emit a flowchart when prose can't carry the structure \u2014 a 2-node linear-v is exactly the trivial case banned above.",
9823
+ "",
9824
+ " ### mindmap \xB7 hierarchical idea tree (renders as `tree`)",
9825
+ ' Use when a section needs to surface the *shape* of the room\'s thinking \u2014 typically in brainstorm-mode rooms (`adjacent-angles`, `worth-chasing`). Render as a fenced ```kami-chart with `type: "tree"`. The renderer draws 1 root + N branches (with right-angle connectors and chevron arrows at child tops), and optional leaves under each branch:',
9249
9826
  " ```",
9250
- " mindmap",
9251
- " root((Central question))",
9252
- " Branch A",
9253
- " Sub-thread A1",
9254
- " Sub-thread A2",
9255
- " Branch B",
9256
- " Sub-thread B1",
9827
+ " {",
9828
+ ' "type": "tree",',
9829
+ ' "root": "Central premise",',
9830
+ ' "focal": "{optional \xB7 branch label that carries the argument}",',
9831
+ ' "branches": [',
9832
+ ' { "label": "Branch A", "leaves": ["Sub A1", "Sub A2"] },',
9833
+ ' { "label": "Branch B", "leaves": ["Sub B1", "Sub B2", "Sub B3"] },',
9834
+ ' { "label": "Branch C", "leaves": [] }',
9835
+ " ],",
9836
+ ' "caption": "{Editorial gloss with *italic* on the operative phrase.}"',
9837
+ " }",
9257
9838
  " ```",
9258
9839
  " Hard rules:",
9259
- " \xB7 **Complexity floor \xB7 \u2265 4 top-level branches OR \u2265 3 branches with \u2265 2 children each (= 9+ leaf nodes total)**. A 2-3 branch mindmap is just a bullet list with extra steps and reads as naive \u2014 drop it and use a markdown bullet list instead.",
9260
- " \xB7 Root node uses `root((label))` \u2014 double parens for the cloud shape. ASCII parens only.",
9261
- " \xB7 Indentation IS the hierarchy \u2014 children indent 4 spaces under their parent.",
9262
- ' \xB7 Node text is plain \u2014 NO `:`, NO `"`, NO brackets, NO leading `-` / `*`. Just the text.',
9263
- " \xB7 4\u20136 top-level branches (after the floor). Each branch 2\u20134 children. Above that the diagram unreads.",
9264
- " \xB7 NO blank lines inside the fenced block.",
9840
+ " \xB7 `type` MUST be `tree` (the old `mindmap` is renamed at render time).",
9841
+ " \xB7 2\u20136 branches. Each branch can have 0\u20134 leaves (no leaves is fine \u2014 the renderer just shows the branch tier).",
9842
+ " \xB7 `root` \u2264 22 chars, branch labels \u2264 18 chars, leaf labels \u2264 16 chars. The renderer truncates.",
9843
+ " \xB7 `focal` matches a branch label; that branch + its leaves render in the spine accent.",
9844
+ " \xB7 **Complexity floor stays**: 2-branch tree with no leaves = naive. The renderer enforces a minimum of 2 branches but the floor in the trigger map applies: \u2265 4 top-level branches OR \u2265 3 branches \xD7 \u2265 2 children each.",
9265
9845
  "",
9266
9846
  " ### gantt \xB7 execution timeline with dependencies",
9267
- " Use when a section names a multi-phase rollout or campaign with time-bound activities and dependencies \u2014 typically inside Recommendations or a follow-up Strategic Outlook. Skip when the recommendations are atomic / not phased.",
9847
+ ' Use when a section names a multi-phase rollout with time-bound activities \u2014 typically inside Recommendations. Render as a fenced ```kami-chart with `type: "gantt"`. The renderer draws bars on a shared horizontal time axis, grouped by section:',
9268
9848
  " ```",
9269
- " gantt",
9270
- " title Rollout phasing",
9271
- " dateFormat YYYY-MM",
9272
- " section Foundations",
9273
- " Discovery :a1, 2026-04, 2M",
9274
- " Pilot scope :a2, after a1, 1M",
9275
- " section Build",
9276
- " Vertical 1 :b1, after a2, 3M",
9277
- " Vertical 2 :b2, after b1, 3M",
9849
+ " {",
9850
+ ' "type": "gantt",',
9851
+ ' "title": "Rollout phasing \xB7 12 months",',
9852
+ ' "unit": "mo",',
9853
+ ' "focal": "{optional \xB7 phase label that carries the most cost / time}",',
9854
+ ' "phases": [',
9855
+ ' { "section": "Foundations", "label": "Discovery", "start": 0, "end": 2 },',
9856
+ ' { "section": "Foundations", "label": "Pilot scope", "start": 2, "end": 3 },',
9857
+ ' { "section": "Build", "label": "Vertical 1", "start": 3, "end": 6 },',
9858
+ ' { "section": "Build", "label": "Vertical 2", "start": 6, "end": 9 },',
9859
+ ' { "section": "Launch", "label": "GA", "start": 9, "end": 12 }',
9860
+ " ],",
9861
+ ' "caption": "{Editorial gloss \xB7 what the schedule says, with *italic* on the operative phrase.}"',
9862
+ " }",
9278
9863
  " ```",
9279
9864
  " Hard rules:",
9280
- " \xB7 **Complexity floor \xB7 \u2265 2 sections AND \u2265 4 tasks total**. A single-section gantt with 2-3 tasks is a bullet list with bars; drop it and render as numbered prose.",
9281
- " \xB7 `title` plain text \u2014 NO quotes, NO `:` inside.",
9282
- " \xB7 `dateFormat` is `YYYY-MM` (months) or `YYYY-MM-DD` (days). Pick one and stick to it.",
9283
- " \xB7 Each task line: `Label :id, start, duration` \u2014 `start` is either an absolute date matching dateFormat OR `after {otherId}`. Duration is `Nd` / `Nw` / `Nm` (days/weeks/months).",
9284
- " \xB7 Section headers (`section Name`) group tasks. 2\u20134 sections, 2\u20136 tasks per section.",
9285
- ' \xB7 Task labels: NO `:`, NO `"`, NO commas, NO brackets. Replace with ` - ` if needed.',
9286
- " \xB7 NO blank lines inside the fenced block.",
9287
- "",
9288
- " ### sequenceDiagram \xB7 actor interactions over time",
9289
- " Use when a section describes a multi-party negotiation, a system-call sequence, or a step-by-step protocol where the *order of who-talks-to-whom* matters. Best fit: technical workflow rooms, governance / approval-chain briefs.",
9865
+ " \xB7 `start` and `end` are bare numbers in the same unit (months / weeks / days \u2014 your choice). The renderer auto-scales the time axis from 0 to a nice-round max.",
9866
+ " \xB7 `unit` (e.g. `mo`, `w`, `d`) is a short suffix on the axis ticks and bar durations.",
9867
+ " \xB7 `section` is optional but recommended \u2014 phases with the same section are grouped under a section label on the left.",
9868
+ " \xB7 `focal` matches a phase label exactly; that bar renders in the spine accent.",
9869
+ " \xB7 2\u201310 phases. **Complexity floor stays**: a 2-phase gantt is a bullet list with bars; only emit when \u2265 4 tasks total AND \u2265 2 sections.",
9870
+ " \xB7 Phase labels \u2264 24 chars (renderer truncates). Avoid colons / brackets.",
9871
+ "",
9872
+ " ### sequenceDiagram \xB7 actor interactions over time (renders as `swimlane`)",
9873
+ ' Use when a section describes a multi-party negotiation, a system-call sequence, or a step-by-step protocol where the *order of who-talks-to-whom* matters. Render as a fenced ```kami-chart with `type: "swimlane"`. The renderer draws N lanes stacked vertically; steps go in their assigned lane in left-to-right order; elbow connectors with chevron arrowheads link consecutive steps. Schema:',
9290
9874
  " ```",
9291
- " sequenceDiagram",
9292
- " participant U as User",
9293
- " participant S as Service",
9294
- " participant A as Auth",
9295
- " U->>S: Request resource",
9296
- " S->>A: Validate token",
9297
- " A-->>S: Token valid",
9298
- " S-->>U: Resource",
9875
+ " {",
9876
+ ' "type": "swimlane",',
9877
+ ' "title": "Request \u2192 response sequence",',
9878
+ ' "lanes": ["Client", "Server", "Database"],',
9879
+ ' "focalLane": "{optional \xB7 lane name to tint as the focal row}",',
9880
+ ' "focal": "{optional \xB7 step label to render in accent}",',
9881
+ ' "steps": [',
9882
+ ' { "label": "Request", "laneIdx": 0, "kicker": "USER" },',
9883
+ ' { "label": "Validate", "laneIdx": 1, "kicker": "AUTH" },',
9884
+ ' { "label": "Query", "laneIdx": 2, "kicker": "STORE" },',
9885
+ ' { "label": "Respond", "laneIdx": 0, "kicker": "USER" }',
9886
+ " ],",
9887
+ ' "caption": "{Editorial gloss with *italic* on the operative phrase.}"',
9888
+ " }",
9299
9889
  " ```",
9300
9890
  " Hard rules:",
9301
- " \xB7 **Complexity floor \xB7 \u2265 2 participants AND \u2265 4 message lines**. A 3-message diagram between 2 actors is just a bullet list with arrow glyphs \u2014 drop it and render as prose.",
9302
- " \xB7 `participant {alias} as {Display Name}` \u2014 short ASCII alias on the left, display name on the right. Use the alias in the message lines.",
9303
- " \xB7 Message arrows: `->>` for solid (request), `-->>` for dashed (response). NEVER use plain `->` (renders as a bare line).",
9304
- ' \xB7 Message text after the colon: NO `:`, NO `"` \u2014 bare text. \u2264 6 words per line.',
9305
- " \xB7 2\u20134 participants, 4\u20138 message lines. Above that the diagram becomes a wall.",
9306
- " \xB7 NO blank lines inside the fenced block. Indent body lines 4 spaces.",
9307
- "",
9308
- " ### journey \xB7 user / stakeholder journey scoring",
9309
- ` Use when a section maps how a stakeholder experiences a process \u2014 best for product / UX rooms scoring touchpoints, or for adoption-friction analysis ("the buyer's journey from awareness to renewal"). Each step gets a 1\u20135 score for satisfaction.`,
9891
+ " \xB7 `type` MUST be `swimlane` (sequence diagrams are rendered through the unified swimlane pipeline).",
9892
+ " \xB7 **Complexity floor \xB7 \u2265 2 lanes AND \u2265 4 steps**. A 3-step sequence is just a bullet list with arrow glyphs \u2014 drop it and use prose.",
9893
+ " \xB7 2\u20134 lanes; 3\u201310 steps total.",
9894
+ " \xB7 `laneIdx` is the 0-based index into the `lanes` array. The renderer routes elbow connectors when consecutive steps cross lanes.",
9895
+ " \xB7 `kicker` (optional) is a short mono caption inside the step box (the role or call type). Defaults to `STEP NN` when omitted.",
9896
+ " \xB7 `focal` matches a step label exactly \xB7 accent border + accent kicker. `focalLane` tints the lane background.",
9897
+ " \xB7 Labels \u2264 18 chars (renderer truncates).",
9898
+ "",
9899
+ " ### journey \xB7 user / stakeholder journey scoring (renders as `swimlane`)",
9900
+ ' Use when a section maps how a stakeholder experiences a process \u2014 best for product / UX rooms scoring touchpoints, or adoption-friction analysis ("the buyer\'s journey from awareness to renewal"). Render through the SAME `swimlane` schema, with an added `score` (1\u20135) per step. The renderer draws 5 small dots under each scored step, filled up to the score. Schema (only differences from sequenceDiagram):',
9310
9901
  " ```",
9311
- " journey",
9312
- " title Stakeholder adoption journey",
9313
- " section Awareness",
9314
- " Hear about it: 3: Buyer",
9315
- " Read landing page: 4: Buyer",
9316
- " section Trial",
9317
- " Sign up: 2: Buyer",
9318
- " First task: 1: Buyer, IT",
9319
- " section Adopt",
9320
- " Approve rollout: 4: Buyer, Legal",
9902
+ " {",
9903
+ ' "type": "swimlane",',
9904
+ ' "lanes": ["Awareness", "Trial", "Adopt"],',
9905
+ ' "steps": [',
9906
+ ' { "label": "Hear about it", "laneIdx": 0, "kicker": "BUYER", "score": 3 },',
9907
+ ' { "label": "Read landing", "laneIdx": 0, "kicker": "BUYER", "score": 4 },',
9908
+ ' { "label": "Sign up", "laneIdx": 1, "kicker": "BUYER", "score": 2 },',
9909
+ ' { "label": "First task", "laneIdx": 1, "kicker": "BUYER \xB7 IT", "score": 1 },',
9910
+ ' { "label": "Approve", "laneIdx": 2, "kicker": "LEGAL", "score": 4 }',
9911
+ " ],",
9912
+ ' "caption": "{Editorial gloss with *italic* on the worst moment.}"',
9913
+ " }",
9321
9914
  " ```",
9322
- " Hard rules:",
9323
- " \xB7 `title` plain text \u2014 NO quotes.",
9324
- " \xB7 Each step: `Step text: score: Actor[, Actor2]` \u2014 score is 1\u20135 (5 = best). Actors comma-separated.",
9325
- ' \xB7 Step text: NO `:`, NO `"`. \u2264 6 words.',
9326
- " \xB7 2\u20134 sections, 2\u20134 steps per section.",
9327
- " \xB7 NO blank lines inside the fenced block. Indent body lines 4 spaces.",
9328
- "",
9329
- " ### stateDiagram \xB7 lifecycle / phase transitions",
9330
- " Use when a section names a process with discrete states the subject moves between (deal lifecycle, customer onboarding, product evolution, regulatory approval phases, scenario branching with feedback loops). Reads cleaner than a flowchart when the *states themselves* are the load-bearing concept, not the conditions. Best fit: execution-plan rollouts with stage gates, market-evolution narratives, retro post-mortems where the system passed through phases.",
9915
+ " Hard rules (additional to swimlane):",
9916
+ " \xB7 `score` is OPTIONAL \xB7 1\u20135 (5 = best). Omit on steps where scoring doesn't apply. Mixing scored and unscored steps in one chart is fine.",
9917
+ " \xB7 `kicker` can list multiple actors with ` \xB7 ` separator (e.g. `BUYER \xB7 IT`).",
9918
+ " \xB7 For journey, set `focal` on the step with the *worst* score (the friction point that drives the argument). Counter-intuitive but correct \u2014 the editorial accent goes where the work is.",
9919
+ "",
9920
+ " ### stateDiagram \xB7 lifecycle / phase transitions (renders as `state-machine`)",
9921
+ ' Use when a section names a process with discrete states the subject moves between (deal lifecycle, customer onboarding, product evolution, regulatory approval phases). Render as a fenced ```kami-chart with `type: "state-machine"`. The renderer draws 2\u20136 states left-to-right with forward arrows between consecutive states; back-transitions render as dashed arcs above. Schema:',
9331
9922
  " ```",
9332
- " stateDiagram-v2",
9333
- " [*] --> Discovery",
9334
- " Discovery --> Pilot: Hypothesis validated",
9335
- " Pilot --> Scale: Pilot win-rate >= 60%",
9336
- " Pilot --> Iterate: Pilot below threshold",
9337
- " Iterate --> Pilot",
9338
- " Scale --> [*]",
9923
+ " {",
9924
+ ' "type": "state-machine",',
9925
+ ' "title": "Deal lifecycle",',
9926
+ ' "showStart": true,',
9927
+ ' "showEnd": true,',
9928
+ ' "focal": "{optional \xB7 state label rendered in accent}",',
9929
+ ' "states": [',
9930
+ ' { "label": "Discovery", "hint": "opportunity" },',
9931
+ ' { "label": "Pilot", "hint": "hypothesis" },',
9932
+ ' { "label": "Scale", "hint": "rolling out" },',
9933
+ ' { "label": "Closed", "hint": "resolved", "terminal": true }',
9934
+ " ],",
9935
+ ' "forwardLabels": ["validated", "win-rate \u2265 60%", "complete"],',
9936
+ ' "backTransitions": [',
9937
+ ' { "fromIdx": 1, "toIdx": 0, "label": "reset" }',
9938
+ " ],",
9939
+ ' "caption": "{Editorial gloss with *italic* on the cycle.}"',
9940
+ " }",
9339
9941
  " ```",
9340
9942
  " Hard rules:",
9341
- " \xB7 **Complexity floor \xB7 \u2265 4 states AND at least one cycle / back-transition** (a state that loops back, OR a state that branches into 2+ next states). A pure linear `Discovery \u2192 Pilot \u2192 Scale` chain is a flowchart waiting to happen \u2014 drop it and use prose. The thing that justifies a stateDiagram (vs a flowchart or prose) is the back-transition / cycle.",
9342
- " \xB7 First line is `stateDiagram-v2` (the v2 dialect \u2014 older `stateDiagram` syntax has lexer quirks). Indent body lines 4 spaces.",
9343
- " \xB7 `[*]` is the start / end pseudo-state. Use `[*] --> First` for entry and `Last --> [*]` for exit. Both are optional.",
9344
- ' \xB7 Each transition: `From --> To` or `From --> To: condition label`. Condition label is plain text after `:` \u2014 NO `"`, NO additional `:`, NO `[` `]` inside.',
9345
- " \xB7 State names: ASCII identifiers (alphanumerics + underscore), \u2264 20 chars, no spaces. Use camelCase or snake_case. The diagram caption can carry the human-readable label via composite states (skip if not needed).",
9346
- " \xB7 4\u20137 states (after the floor). Below 4 isn't a lifecycle; above 7 stops being scannable.",
9347
- " \xB7 NO blank lines inside the fenced block.",
9943
+ " \xB7 `type` MUST be `state-machine`.",
9944
+ " \xB7 **Complexity floor \xB7 \u2265 3 states AND at least one back-transition** (a `backTransitions` entry). A pure linear `Discovery \u2192 Pilot \u2192 Scale` chain has no cycle \u2014 render as `flowchart linear-v` instead. The thing that justifies a state-machine is the back-transition.",
9945
+ " \xB7 `states` array: 2\u20136 entries. Each has `label` (\u2264 16 chars), optional `hint` (\u2264 14 chars \xB7 mono caption inside the box), optional `terminal: true` (draws with darker ink border).",
9946
+ " \xB7 `forwardLabels` is an array indexed by the source state position \xB7 entry `i` labels the arrow from state `i` to state `i+1`. Empty / missing entries leave the arrow unlabeled.",
9947
+ " \xB7 `backTransitions`: each entry needs `fromIdx > toIdx` (back-arcs go right-to-left). The renderer draws a dashed Q-curve above the row.",
9948
+ " \xB7 `showStart: true` draws a solid black dot before the first state (the entry pseudo-state). `showEnd: true` draws a double-circle terminator after the last state.",
9949
+ " \xB7 `focal` matches a state label exactly \xB7 that state renders in the spine accent.",
9348
9950
  "",
9349
9951
  " ## Recommendations",
9350
9952
  " Skip if `recommendations` is empty. Otherwise render as a numbered list, one per recommendation, sorted by priority. Each item:",
@@ -9379,7 +9981,7 @@ var WRITE_SYSTEM = [
9379
9981
  ' "Channel concentration": [0.80, 0.80]',
9380
9982
  ' "Hiring bench thin": [0.50, 0.20]',
9381
9983
  " ```",
9382
- " Leave a BLANK LINE between the H2 heading and the fenced ```mermaid block.",
9984
+ " Leave a BLANK LINE between the H2 heading and the fenced ```kami-chart block.",
9383
9985
  "",
9384
9986
  " 2. **Risk table** \u2014 markdown table with columns `Risk | Category | Severity | Likelihood | Owner | Mitigation`. One row per `RiskItem`. Sort rows: severity high before medium before low; within the same severity, likelihood high before medium before low. Render category and severity / likelihood as **bold inline tags** (`**Market**`, `**High**`). Leave a BLANK LINE between the quadrant chart and the table header row \xB7 without that gap markdown parsers concatenate the prose with the table and pipe syntax leaks.",
9385
9987
  ' When `mitigation` is the literal string `"monitor only"`, render the cell as italic: `_monitor only_` so the reader sees this row as a watch-list rather than a closeable risk.',
@@ -9569,7 +10171,7 @@ var WRITE_SYSTEM = [
9569
10171
  " ### metricStrip (dashboard \xB7 the room's numbers as a row of KPI cards)",
9570
10172
  " When `scaffold.metricStrip` is non-null AND was picked, render it as the report's first quantitative beat \u2014 natural slot is RIGHT AFTER the anchor (Bottom Line / Thesis / Working Hypothesis), so a reader skimming the top of the report sees the headline judgement followed immediately by the numbers behind it. Acceptable alternative slot: right before Recommendations, when the numbers frame the action rather than the judgement.",
9571
10173
  " Heading from the house style (default `## By the Numbers`).",
9572
- " Then emit a fenced code block with language tag `metric-strip` whose body is STRICT JSON. The report renderer detects this block and emits the styled card grid (mirrors how ```mermaid is handled today). Format:",
10174
+ " Then emit a fenced code block with language tag `metric-strip` whose body is STRICT JSON. The report renderer detects this block and emits the styled card grid (mirrors how ```kami-chart is handled today). Format:",
9573
10175
  " ```metric-strip",
9574
10176
  " {",
9575
10177
  ' "intro": "Three numbers worth pricing in",',
@@ -9581,7 +10183,7 @@ var WRITE_SYSTEM = [
9581
10183
  " }",
9582
10184
  " ```",
9583
10185
  " Hard rules:",
9584
- " \xB7 The block opens with the literal three backticks + `metric-strip` and closes with three backticks on a line by itself. Just like mermaid blocks.",
10186
+ " \xB7 The block opens with the literal three backticks + `metric-strip` and closes with three backticks on a line by itself. Just like kami-chart blocks.",
9585
10187
  " \xB7 Body is one JSON object with `intro` (string, may be empty) and `cards` (array of 3\u20135 objects).",
9586
10188
  ' \xB7 Each card object: `label` (required string), `value` (required string), `qualifier` (optional string \xB7 omit key OR set null when empty), `trend` (optional \xB7 one of `"up"` / `"down"` / `"flat"` \xB7 omit key when null), `attribution` (optional string).',
9587
10189
  " \xB7 Mirror the scaffold.metricStrip values 1:1. Don't invent extra cards; don't drop cards the scaffold supplied.",
@@ -9668,7 +10270,7 @@ var WRITE_SYSTEM = [
9668
10270
  "\xB7 Use **bold** for claims and section markers.",
9669
10271
  '\xB7 No "I" or "we" as the writer. The brief is the room speaking.',
9670
10272
  '\xB7 No preamble, no closing remarks, no "in summary". Just the brief.',
9671
- "\xB7 Markdown only \u2014 fenced ```mermaid blocks are part of markdown for our renderer.",
10273
+ "\xB7 Markdown only \u2014 fenced ```kami-chart blocks (and the other structured-fence languages like ```metric-strip / ```callout) are part of markdown for our renderer.",
9672
10274
  "\xB7 Replace all director ids (like `dirId-a`) with display names. Never let a raw id leak into prose.",
9673
10275
  '\xB7 Numbers everywhere \u2014 even qualitative claims get bracketed by numbers when possible ("about 2/3 of the directors", "in the next 18 months", "~30% confidence").',
9674
10276
  '\xB7 Section headings ARE the takeaway \u2014 never use topic-style headings (e.g. "Market analysis"). Always claim-style (e.g. "China growth will slow to <5%").'
@@ -11649,7 +12251,7 @@ var SYSTEM_PROMPT = [
11649
12251
  " \xB7 `convergence` Where directors aligned via independent reasoning paths. Needs \u2265 2 directors via \u2265 2 lenses.",
11650
12252
  " \xB7 `divergence` The single hinge where directors split. Skip when the room had no real central tension.",
11651
12253
  " \xB7 `positions` 2\u20133 named camps with a pull-quote per camp. Skip when directors didn't cluster.",
11652
- " \xB7 `visuals` 0\u20134 visual exhibits. Seven sub-types \u2014 **prefer mermaid (4) over text matrix (3)** unless the data shape genuinely doesn't fit. Mermaid renders as a real diagram in seconds; text matrices are dense and slow to absorb. Mermaid sub-types: `quadrant-chart` (2-axis plot \xB7 mermaid quadrantChart), `bar-chart` (ranked numeric \xB7 mermaid xychart-beta \xB7 2\u20138 items), `timeline` (dated narrative \xB7 mermaid timeline \xB7 3\u20138 beats), `pie-chart` (distribution \xB7 mermaid pie showData \xB7 2\u20136 slices). Text-matrix sub-types: `comparison-table` (N options \xD7 M criteria), `force-field` (drivers vs resistors), `strengths-cautions` (pros/cons/verdict per option). Triggers: ranked numeric \u2192 bar-chart \xB7 chronology / historical analogue / projected sequence \u2192 timeline \xB7 distribution that sums (probability split / vote tallies / shares / mix) \u2192 pie-chart \xB7 2-axis plot \u2192 quadrant-chart \xB7 N-options-with-mixed-cells \u2192 comparison-table \xB7 for/against forces \u2192 force-field \xB7 pros/cons/verdict matrix \u2192 strengths-cautions. **Beyond `visuals`, the writer ALSO emits inline mermaid (flowchart / mindmap / gantt / sequenceDiagram / stateDiagram / journey) inside body sections where prose can't carry the structure efficiently** \u2014 these are automatic and don't need to be picked here.",
12254
+ " \xB7 `visuals` 0\u20134 visual exhibits. Seven sub-types \u2014 **prefer the inline-SVG chart sub-types (4) over text matrix (3)** unless the data shape genuinely doesn't fit. SVG charts render as a real diagram in seconds; text matrices are dense and slow to absorb. Inline-SVG sub-types (rendered through the `kami-chart` pipeline, spine-tokenised): `quadrant-chart` (2-axis plot), `bar-chart` (ranked numeric \xB7 2\u20138 items), `timeline` (dated narrative \xB7 3\u20138 beats), `pie-chart` (distribution \xB7 2\u20136 slices \xB7 renders as a donut). Text-matrix sub-types: `comparison-table` (N options \xD7 M criteria), `force-field` (drivers vs resistors), `strengths-cautions` (pros/cons/verdict per option). Triggers: ranked numeric \u2192 bar-chart \xB7 chronology / historical analogue / projected sequence \u2192 timeline \xB7 distribution that sums (probability split / vote tallies / shares / mix) \u2192 pie-chart \xB7 2-axis plot \u2192 quadrant-chart \xB7 N-options-with-mixed-cells \u2192 comparison-table \xB7 for/against forces \u2192 force-field \xB7 pros/cons/verdict matrix \u2192 strengths-cautions. **Beyond `visuals`, the writer ALSO emits inline charts (flowchart / mindmap / gantt / sequenceDiagram / stateDiagram / journey, all rendered through the `kami-chart` pipeline) inside body sections where prose can't carry the structure efficiently** \u2014 these are automatic and don't need to be picked here.",
11653
12255
  " \xB7 `metric-strip` 3\u20135 KPI / indicator cards \xB7 the room's quantitative reads as a dashboard row. Pick whenever \u2265 3 numbers (percentages, time windows, ratios, counts, ranges) showed up worth surfacing side-by-side. Massively higher information density than the same numbers buried in prose \u2014 strongly favoured for investment / market-forecast / strategic-decision briefs.",
11654
12256
  " \xB7 `two-paths` Side-by-side trajectory comparison (Path A vs Path B). Pick when the room argued two distinct futures or routes \u2014 punchier than a comparison-table.",
11655
12257
  " \xB7 `why-now` Single panel: window opened by what \xB7 window closes when \xB7 the bet implied. For investment / opportunity rooms (a16z-thesis spine).",
@@ -11689,11 +12291,11 @@ var SYSTEM_PROMPT = [
11689
12291
  "",
11690
12292
  "These rules ALSO apply transitively: if the asset bundle has 0 entries for a trigger, the matching component is encouraged but not mandatory.",
11691
12293
  "",
11692
- "## Visualisation discipline \xB7 prefer mermaid where it fits naturally",
12294
+ "## Visualisation discipline \xB7 prefer charts where they fit naturally",
11693
12295
  "",
11694
- "Reports benefit from diagrams when content has structure prose can't carry efficiently \u2014 but no fixed quota. Pick components that auto-fire mermaid (divergence / positions / decision-options / scenario-tree / risk-register / recommendations / convergence) when their material is real in the room. Skip them when material is thin. Source of mermaid (either / both):",
11695
- " 1. Pick `visuals` with mermaid sub-types (`quadrant-chart` / `bar-chart` / `timeline` / `pie-chart`) \u2014 directly produces typed visuals.",
11696
- " 2. Stage 3 writer auto-emits inline mermaid (`flowchart` / `mindmap` / `gantt` / `sequenceDiagram` / `stateDiagram` / `journey`) on a per-section basis when content fits \u2014 knowing this helps you avoid double-allocating the same content to a typed visual.",
12296
+ "Reports benefit from diagrams when content has structure prose can't carry efficiently \u2014 but no fixed quota. Pick components that auto-fire charts (divergence / positions / decision-options / scenario-tree / risk-register / recommendations / convergence) when their material is real in the room. Skip them when material is thin. Source of charts (either / both):",
12297
+ " 1. Pick `visuals` with chart sub-types (`quadrant-chart` / `bar-chart` / `timeline` / `pie-chart`) \u2014 these render as inline SVG through the `kami-chart` pipeline (spine-tokenised, no JS runtime).",
12298
+ " 2. Stage 3 writer auto-emits inline charts (`flowchart` / `mindmap` / `gantt` / `sequenceDiagram` / `stateDiagram` / `journey`, all rendered through the `kami-chart` pipeline) on a per-section basis when content fits \u2014 knowing this helps you avoid double-allocating the same content to a typed visual.",
11697
12299
  "",
11698
12300
  "**Quality over quantity.** A substantive strategy brief might naturally land at 4\u20136 charts; a tight philosophical brief at 0\u20131; both are correct. Don't manufacture chart material that isn't in the conversation \u2014 that produces hollow diagrams that distract more than they inform.",
11699
12301
  "",
@@ -11710,7 +12312,7 @@ var SYSTEM_PROMPT = [
11710
12312
  " \xB7 ANY multi-party negotiation / approval-chain / system-call sequence \u2192 the writer auto-emits an inline `sequenceDiagram`. Surfaces under whichever section names the actors.",
11711
12313
  " \xB7 ANY lifecycle / state-machine / phase-gating story \u2192 the writer auto-emits an inline `stateDiagram-v2`. Attach to `recommendations` or a `risk-register` branch.",
11712
12314
  "",
11713
- "Truly unvisualisable rooms (purely philosophical / definitional / no concrete entities) are allowed to skip \u2014 but they're rare. **When in doubt, pick `visuals` with a mermaid sub-type.** Reports without a mermaid chart in 2026 read as 2018-era memos.",
12315
+ "Truly unvisualisable rooms (purely philosophical / definitional / no concrete entities) are allowed to skip \u2014 but they're rare. **When in doubt, pick `visuals` with a chart sub-type.** Reports without a chart in 2026 read as 2018-era memos.",
11714
12316
  "",
11715
12317
  "## Picking presets",
11716
12318
  "",
@@ -12788,10 +13390,10 @@ function stageFlagshipList() {
12788
13390
  if (cheap && !out.includes(cheap)) out.push(cheap);
12789
13391
  return out;
12790
13392
  }
12791
- var STAGE_2_RETRIES = 1;
12792
- var STAGE_2_TEMPERATURES = [0.2];
13393
+ var STAGE_2_RETRIES = 2;
13394
+ var STAGE_2_TEMPERATURES = [0.2, 0.5];
12793
13395
  var STAGE_1_CALL_TIMEOUT_MS = 75e3;
12794
- var STAGE_2_CALL_TIMEOUT_MS = 12e4;
13396
+ var STAGE_2_CALL_TIMEOUT_MS = 24e4;
12795
13397
  var STAGE_3_CALL_TIMEOUT_MS = 24e4;
12796
13398
  function signalWithTimeout2(parent, timeoutMs) {
12797
13399
  const controller = new AbortController();
@@ -13030,7 +13632,7 @@ var TPS_BY_MODEL = {
13030
13632
  sonnet: 45,
13031
13633
  // sonnet-4-6 — structured JSON output, not free prose
13032
13634
  opus: 28
13033
- // opus-4-7 — rich markdown with tables / mermaid
13635
+ // opus-4-7 — rich markdown with tables / inline-svg charts
13034
13636
  };
13035
13637
  var BASE_OVERHEAD_S = 1;
13036
13638
  var TTFT_S_PER_KT = 0.35;
@@ -13434,7 +14036,16 @@ async function runStage1(roomId, briefId, directors, transcript, room, language,
13434
14036
  modelV,
13435
14037
  messages,
13436
14038
  temperature: 0.2,
13437
- maxTokens: 2200,
14039
+ // 4400 cap (was 2200) · 9-field asset bundle JSON · max 38
14040
+ // entries × ~150 chars per entry ≈ 5700 chars ≈ 2500-3500
14041
+ // output tokens for a dense director. 2200 was right at the
14042
+ // edge for terse models (Gemini Flash) but Anthropic Opus /
14043
+ // Sonnet emit verbose JSON and overflow, producing truncated
14044
+ // output → parser drops to empty assets → `heuristicDirectorAssets`
14045
+ // takes over with weaker signal recovery. Doubling the cap
14046
+ // gives the verbose providers headroom without affecting
14047
+ // the terse ones (they don't hit the cap).
14048
+ maxTokens: 4400,
13438
14049
  signal: timeout.signal
13439
14050
  });
13440
14051
  if (timeout.timedOut()) throw new Error(`timed out after ${STAGE_1_CALL_TIMEOUT_MS / 1e3}s`);
@@ -13795,7 +14406,18 @@ async function runStage2(args) {
13795
14406
  modelV,
13796
14407
  messages: messagesForAttempt(),
13797
14408
  temperature: STAGE_2_TEMPERATURES[attempt] ?? 0.6,
13798
- maxTokens: 8e3,
14409
+ // 16k cap (was 8k) · the structured scaffold JSON for a dense
14410
+ // room (≥3 directors with rich signals, full visuals + risk
14411
+ // register + perspectives) regularly lands at 10–14k output
14412
+ // tokens. Opus 4.6 Fast hit the 8k ceiling mid-`directorPerspectives`
14413
+ // and emitted truncated JSON · parseScaffold then returned
14414
+ // null and the pipeline dropped to deterministic fallback,
14415
+ // which has no visuals / divergence / risk — wiping every
14416
+ // chart from the brief. Gemini 3 Flash is more terse on the
14417
+ // same prompt so it cleared 8k on most rooms; Anthropic
14418
+ // models reliably overflow. 16k matches Anthropic's max
14419
+ // output cap for the Sonnet/Opus class.
14420
+ maxTokens: 16e3,
13799
14421
  signal: timeout.signal,
13800
14422
  onText: (_delta, full) => advanceOnBuffer(full)
13801
14423
  });
@@ -13994,7 +14616,7 @@ async function runBentoStage(args) {
13994
14616
  modelV,
13995
14617
  messages,
13996
14618
  temperature: STAGE_2_TEMPERATURES[attempt] ?? 0.6,
13997
- maxTokens: 8e3,
14619
+ maxTokens: 16e3,
13998
14620
  signal: args.signal
13999
14621
  })) {
14000
14622
  if (chunk.type === "text") {
@@ -14665,108 +15287,850 @@ function collectProviderSummary(models) {
14665
15287
  return Array.from(map.entries()).map(([provider, v]) => ({ provider, ...v }));
14666
15288
  }
14667
15289
 
14668
- // src/routes/notes.ts
15290
+ // src/routes/topic-recs.ts
14669
15291
  import { Hono as Hono6 } from "hono";
15292
+ import { streamSSE as streamSSE2 } from "hono/streaming";
14670
15293
 
14671
- // src/storage/notes.ts
15294
+ // src/orchestrator/topic-recommender.ts
15295
+ import { randomUUID as randomUUID2 } from "crypto";
15296
+
15297
+ // src/storage/topic-recs.ts
14672
15298
  init_db();
14673
- var COLS4 = "id, room_id, message_id, author_kind, author_id, author_name, quote_text, context_before, context_after, char_offset_start, char_offset_end, user_note, tags_json, status, created_at";
14674
- function parseTags(json) {
14675
- if (!json) return [];
14676
- try {
14677
- const parsed = JSON.parse(json);
14678
- if (!Array.isArray(parsed)) return [];
14679
- return parsed.filter((t) => typeof t === "string" && t.trim().length > 0);
14680
- } catch {
14681
- return [];
14682
- }
15299
+ function createTopicRecBatch(input) {
15300
+ const now = Date.now();
15301
+ 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);
15302
+ return { id: input.id, hasWeb: input.hasWeb, keywords: input.keywords, createdAt: now };
14683
15303
  }
14684
- function mapRow9(row) {
15304
+ function mapRec(r) {
15305
+ let seedContext = null;
15306
+ if (r.seed_context_json) {
15307
+ try {
15308
+ const parsed = JSON.parse(r.seed_context_json);
15309
+ if (Array.isArray(parsed)) {
15310
+ seedContext = parsed.filter(
15311
+ (s) => s && typeof s.title === "string" && typeof s.url === "string" && typeof s.description === "string"
15312
+ );
15313
+ }
15314
+ } catch {
15315
+ }
15316
+ }
14685
15317
  return {
14686
- id: row.id,
14687
- roomId: row.room_id,
14688
- messageId: row.message_id,
14689
- authorKind: row.author_kind,
14690
- authorId: row.author_id,
14691
- authorName: row.author_name,
14692
- quoteText: row.quote_text,
14693
- contextBefore: row.context_before,
14694
- contextAfter: row.context_after,
14695
- charOffsetStart: row.char_offset_start,
14696
- charOffsetEnd: row.char_offset_end,
14697
- userNote: row.user_note,
14698
- tags: parseTags(row.tags_json),
14699
- status: row.status === "acted" || row.status === "archived" ? row.status : "open",
14700
- createdAt: row.created_at
15318
+ id: r.id,
15319
+ batchId: r.batch_id,
15320
+ subject: r.subject,
15321
+ rationale: r.rationale,
15322
+ source: r.source === "web" ? "web" : "memory",
15323
+ tag: typeof r.tag === "string" && r.tag.trim().length > 0 ? r.tag.trim() : null,
15324
+ seedContext,
15325
+ createdAt: r.created_at,
15326
+ openedRoomId: r.opened_room_id
14701
15327
  };
14702
15328
  }
14703
- function createNote(n) {
14704
- const db = getDb();
14705
- const id = newId();
15329
+ var REC_COLS = "id, batch_id, subject, rationale, source, tag, seed_context_json, created_at, opened_room_id";
15330
+ function insertTopicRec(input) {
14706
15331
  const now = Date.now();
14707
- db.prepare(
14708
- `INSERT INTO notes (${COLS4}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
15332
+ getDb().prepare(
15333
+ `INSERT INTO topic_recs
15334
+ (id, batch_id, subject, rationale, source, tag, seed_context_json, created_at, opened_room_id)
15335
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)`
14709
15336
  ).run(
14710
- id,
14711
- n.roomId,
14712
- n.messageId,
14713
- n.authorKind,
14714
- n.authorId,
14715
- n.authorName,
14716
- n.quoteText,
14717
- n.contextBefore ?? "",
14718
- n.contextAfter ?? "",
14719
- n.charOffsetStart,
14720
- n.charOffsetEnd,
14721
- null,
14722
- // user_note · deferred
14723
- null,
14724
- // tags_json · deferred
14725
- "open",
15337
+ input.id,
15338
+ input.batchId,
15339
+ input.subject,
15340
+ input.rationale,
15341
+ input.source,
15342
+ input.tag,
15343
+ input.seedContext ? JSON.stringify(input.seedContext) : null,
14726
15344
  now
14727
15345
  );
14728
- return getNote(id);
15346
+ return {
15347
+ id: input.id,
15348
+ batchId: input.batchId,
15349
+ subject: input.subject,
15350
+ rationale: input.rationale,
15351
+ source: input.source,
15352
+ tag: input.tag,
15353
+ seedContext: input.seedContext,
15354
+ createdAt: now,
15355
+ openedRoomId: null
15356
+ };
14729
15357
  }
14730
- function getNote(id) {
14731
- const row = getDb().prepare(`SELECT ${COLS4} FROM notes WHERE id = ?`).get(id);
14732
- return row ? mapRow9(row) : null;
15358
+ function getTopicRec(id) {
15359
+ const row = getDb().prepare(`SELECT ${REC_COLS} FROM topic_recs WHERE id = ?`).get(id);
15360
+ return row ? mapRec(row) : null;
14733
15361
  }
14734
- function listNotesByRoom(roomId) {
14735
- const rows = getDb().prepare(`SELECT ${COLS4} FROM notes WHERE room_id = ? ORDER BY created_at DESC`).all(roomId);
14736
- return rows.map(mapRow9);
15362
+ function listTopicRecs(opts) {
15363
+ const limit = Math.max(1, Math.min(100, opts.limit));
15364
+ 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 ?`);
15365
+ const rows = opts.cursor === null ? stmt.all(limit) : stmt.all(opts.cursor, limit);
15366
+ const items = rows.map(mapRec);
15367
+ const nextCursor = items.length === limit ? items[items.length - 1].createdAt : null;
15368
+ return { items, nextCursor };
14737
15369
  }
14738
- function deleteNote(id) {
14739
- const result = getDb().prepare("DELETE FROM notes WHERE id = ?").run(id);
14740
- return result.changes > 0;
15370
+ function markTopicRecOpened(recId, roomId) {
15371
+ getDb().prepare("UPDATE topic_recs SET opened_room_id = ? WHERE id = ?").run(roomId, recId);
14741
15372
  }
14742
- function countNotes() {
14743
- const row = getDb().prepare("SELECT COUNT(*) AS c FROM notes").get();
14744
- return row.c ?? 0;
15373
+ function clearAllTopicRecs() {
15374
+ const r = getDb().prepare("DELETE FROM topic_recs").run();
15375
+ return r.changes;
14745
15376
  }
14746
- function listAllNotesWithRoom() {
14747
- const rows = getDb().prepare(
14748
- `SELECT n.id, n.room_id, n.message_id, n.author_kind, n.author_id,
14749
- n.author_name, n.quote_text, n.context_before, n.context_after,
14750
- n.char_offset_start, n.char_offset_end, n.user_note, n.tags_json,
14751
- n.status, n.created_at,
14752
- r.name AS room_name, r.subject AS room_subject,
14753
- r.number AS room_number, r.status AS room_status
14754
- FROM notes n
14755
- JOIN rooms r ON r.id = n.room_id
14756
- ORDER BY n.created_at DESC`
14757
- ).all();
14758
- return rows.map((row) => ({
14759
- ...mapRow9(row),
14760
- roomName: row.room_name,
14761
- roomSubject: row.room_subject,
14762
- roomNumber: row.room_number,
14763
- roomStatus: row.room_status
14764
- }));
15377
+ function mapJob(r) {
15378
+ const status = ["running", "done", "failed", "aborted"].includes(r.status) ? r.status : "failed";
15379
+ return {
15380
+ id: r.id,
15381
+ status,
15382
+ currentPhase: r.current_phase,
15383
+ progressPct: r.progress_pct,
15384
+ batchId: r.batch_id,
15385
+ error: r.error,
15386
+ startedAt: r.started_at,
15387
+ updatedAt: r.updated_at
15388
+ };
15389
+ }
15390
+ var JOB_COLS = "id, status, current_phase, progress_pct, batch_id, error, started_at, updated_at";
15391
+ function createTopicRecJob(id) {
15392
+ const now = Date.now();
15393
+ getDb().prepare(
15394
+ `INSERT INTO topic_rec_jobs (id, status, current_phase, progress_pct, batch_id, error, started_at, updated_at)
15395
+ VALUES (?, 'running', 0, 0, NULL, NULL, ?, ?)`
15396
+ ).run(id, now, now);
15397
+ return getTopicRecJob(id);
15398
+ }
15399
+ function getTopicRecJob(id) {
15400
+ const row = getDb().prepare(`SELECT ${JOB_COLS} FROM topic_rec_jobs WHERE id = ?`).get(id);
15401
+ return row ? mapJob(row) : null;
15402
+ }
15403
+ function updateTopicRecJob(id, patch) {
15404
+ const fields = [];
15405
+ const values = [];
15406
+ if (patch.status !== void 0) {
15407
+ fields.push("status = ?");
15408
+ values.push(patch.status);
15409
+ }
15410
+ if (typeof patch.currentPhase === "number") {
15411
+ fields.push("current_phase = ?");
15412
+ values.push(patch.currentPhase);
15413
+ }
15414
+ if (typeof patch.progressPct === "number") {
15415
+ fields.push("progress_pct = ?");
15416
+ values.push(Math.max(0, Math.min(100, Math.round(patch.progressPct))));
15417
+ }
15418
+ if (patch.batchId !== void 0) {
15419
+ fields.push("batch_id = ?");
15420
+ values.push(patch.batchId);
15421
+ }
15422
+ if (patch.error !== void 0) {
15423
+ fields.push("error = ?");
15424
+ values.push(patch.error);
15425
+ }
15426
+ if (fields.length === 0) return getTopicRecJob(id);
15427
+ fields.push("updated_at = ?");
15428
+ values.push(Date.now());
15429
+ values.push(id);
15430
+ getDb().prepare(`UPDATE topic_rec_jobs SET ${fields.join(", ")} WHERE id = ?`).run(...values);
15431
+ return getTopicRecJob(id);
15432
+ }
15433
+ function markRunningTopicRecJobsFailed() {
15434
+ const r = getDb().prepare(
15435
+ `UPDATE topic_rec_jobs
15436
+ SET status = 'failed',
15437
+ error = COALESCE(error, 'server restarted mid-build'),
15438
+ updated_at = ?
15439
+ WHERE status = 'running'`
15440
+ ).run(Date.now());
15441
+ return r.changes;
15442
+ }
15443
+
15444
+ // src/orchestrator/topic-stream.ts
15445
+ import { EventEmitter as EventEmitter3 } from "events";
15446
+ var TopicRecBus = class {
15447
+ emitters = /* @__PURE__ */ new Map();
15448
+ get(jobId) {
15449
+ let e = this.emitters.get(jobId);
15450
+ if (!e) {
15451
+ e = new EventEmitter3();
15452
+ e.setMaxListeners(16);
15453
+ this.emitters.set(jobId, e);
15454
+ }
15455
+ return e;
15456
+ }
15457
+ emit(jobId, event) {
15458
+ this.get(jobId).emit("event", event);
15459
+ }
15460
+ subscribe(jobId, listener) {
15461
+ const e = this.get(jobId);
15462
+ e.on("event", listener);
15463
+ return () => e.off("event", listener);
15464
+ }
15465
+ /** Free the EventEmitter for a job. Call on terminal events
15466
+ * (`topic-final`, `topic-error`, `topic-aborted`) so the Map
15467
+ * doesn't grow unbounded across many runs in one process. */
15468
+ drop(jobId) {
15469
+ const e = this.emitters.get(jobId);
15470
+ if (e) {
15471
+ e.removeAllListeners();
15472
+ this.emitters.delete(jobId);
15473
+ }
15474
+ }
15475
+ };
15476
+ var topicRecBus = new TopicRecBus();
15477
+
15478
+ // src/orchestrator/topic-recommender.ts
15479
+ var LLM_CALL_TIMEOUT_MS2 = 6e4;
15480
+ var PIPELINE_WALL_CLOCK_MS = 12e4;
15481
+ var SEARCH_PARALLEL_CHUNK = 3;
15482
+ var SEARCH_CHUNK_GAP_MS = 1e3;
15483
+ var SEARCH_RESULTS_PER_QUERY = 5;
15484
+ var inFlightJobs2 = /* @__PURE__ */ new Map();
15485
+ function signalWithTimeout3(parent, timeoutMs) {
15486
+ const controller = new AbortController();
15487
+ const onParentAbort = () => controller.abort();
15488
+ if (parent?.aborted) controller.abort();
15489
+ else parent?.addEventListener("abort", onParentAbort, { once: true });
15490
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
15491
+ return {
15492
+ signal: controller.signal,
15493
+ cleanup: () => {
15494
+ clearTimeout(timer);
15495
+ parent?.removeEventListener("abort", onParentAbort);
15496
+ }
15497
+ };
15498
+ }
15499
+ function extractJson5(raw) {
15500
+ const fence = /```(?:json)?\s*([\s\S]*?)```/i.exec(raw);
15501
+ const candidate = fence ? fence[1] : raw;
15502
+ if (!candidate) return null;
15503
+ const start = candidate.indexOf("{");
15504
+ if (start === -1) return null;
15505
+ let depth = 0;
15506
+ let end = -1;
15507
+ for (let i = start; i < candidate.length; i++) {
15508
+ if (candidate[i] === "{") depth++;
15509
+ else if (candidate[i] === "}") {
15510
+ depth--;
15511
+ if (depth === 0) {
15512
+ end = i;
15513
+ break;
15514
+ }
15515
+ }
15516
+ }
15517
+ if (end === -1) return null;
15518
+ try {
15519
+ return JSON.parse(candidate.slice(start, end + 1));
15520
+ } catch {
15521
+ return null;
15522
+ }
15523
+ }
15524
+ async function callPhaseLLM2(state, modelV, messages, opts) {
15525
+ if (!isModelV(modelV)) return null;
15526
+ const t = signalWithTimeout3(state.controller.signal, LLM_CALL_TIMEOUT_MS2);
15527
+ try {
15528
+ const r = await callLLMWithUsage({
15529
+ modelV,
15530
+ messages,
15531
+ temperature: opts.temperature,
15532
+ maxTokens: opts.maxTokens,
15533
+ signal: t.signal
15534
+ });
15535
+ return r.text;
15536
+ } catch (e) {
15537
+ process.stderr.write(
15538
+ `[topic-recommender] ${modelV} failed: ${e instanceof Error ? e.message : String(e)}
15539
+ `
15540
+ );
15541
+ return null;
15542
+ } finally {
15543
+ t.cleanup();
15544
+ }
15545
+ }
15546
+ function sleepWithSignal2(ms, signal) {
15547
+ return new Promise((resolve2) => {
15548
+ if (signal.aborted) return resolve2();
15549
+ const t = setTimeout(() => {
15550
+ signal.removeEventListener("abort", onAbort);
15551
+ resolve2();
15552
+ }, ms);
15553
+ function onAbort() {
15554
+ clearTimeout(t);
15555
+ resolve2();
15556
+ }
15557
+ signal.addEventListener("abort", onAbort, { once: true });
15558
+ });
15559
+ }
15560
+ function startTopicRecommend() {
15561
+ const jobId = randomUUID2();
15562
+ createTopicRecJob(jobId);
15563
+ const state = {
15564
+ id: jobId,
15565
+ startedAt: Date.now(),
15566
+ controller: new AbortController()
15567
+ };
15568
+ inFlightJobs2.set(jobId, state);
15569
+ const wallClock = setTimeout(() => {
15570
+ if (inFlightJobs2.has(jobId)) state.controller.abort();
15571
+ }, PIPELINE_WALL_CLOCK_MS);
15572
+ void runPipeline3(state).finally(() => {
15573
+ clearTimeout(wallClock);
15574
+ inFlightJobs2.delete(jobId);
15575
+ });
15576
+ return jobId;
15577
+ }
15578
+ function abortTopicRecommend(jobId) {
15579
+ const state = inFlightJobs2.get(jobId);
15580
+ if (!state) return false;
15581
+ try {
15582
+ state.controller.abort();
15583
+ } catch {
15584
+ }
15585
+ return true;
15586
+ }
15587
+ function isTopicRecJobRunning(jobId) {
15588
+ return inFlightJobs2.has(jobId);
15589
+ }
15590
+ async function runPipeline3(state) {
15591
+ const phaseLabels = [
15592
+ "Reading your boardroom history",
15593
+ "Distilling interests",
15594
+ "Scanning trending topics",
15595
+ "Synthesising recommendations"
15596
+ ];
15597
+ const emitPhaseStart = (phase) => {
15598
+ topicRecBus.emit(state.id, {
15599
+ type: "topic-phase-start",
15600
+ phase,
15601
+ label: phaseLabels[phase - 1] ?? `Phase ${phase}`
15602
+ });
15603
+ };
15604
+ const emitPhaseProgress = (phase, detail, pct) => {
15605
+ topicRecBus.emit(state.id, {
15606
+ type: "topic-phase-progress",
15607
+ phase,
15608
+ detail,
15609
+ progressPct: Math.max(0, Math.min(100, Math.round(pct)))
15610
+ });
15611
+ updateTopicRecJob(state.id, { currentPhase: phase, progressPct: pct });
15612
+ };
15613
+ const emitPhaseEnd = (phase, pct) => {
15614
+ topicRecBus.emit(state.id, {
15615
+ type: "topic-phase-end",
15616
+ phase,
15617
+ progressPct: Math.max(0, Math.min(100, Math.round(pct)))
15618
+ });
15619
+ updateTopicRecJob(state.id, { currentPhase: phase, progressPct: pct });
15620
+ };
15621
+ const fail = (message) => {
15622
+ updateTopicRecJob(state.id, { status: "failed", error: message });
15623
+ topicRecBus.emit(state.id, { type: "topic-error", message });
15624
+ topicRecBus.drop(state.id);
15625
+ };
15626
+ const cancel = () => {
15627
+ updateTopicRecJob(state.id, { status: "aborted" });
15628
+ topicRecBus.emit(state.id, { type: "topic-aborted" });
15629
+ topicRecBus.drop(state.id);
15630
+ };
15631
+ try {
15632
+ emitPhaseStart(1);
15633
+ const chair = getChairAgent();
15634
+ if (!chair) {
15635
+ fail("chair agent missing \u2014 set up onboarding first");
15636
+ return;
15637
+ }
15638
+ const memories = memoriesForContext(chair.id, 50);
15639
+ emitPhaseProgress(1, `read ${memories.length} memories`, 8);
15640
+ emitPhaseEnd(1, 10);
15641
+ if (state.controller.signal.aborted) {
15642
+ cancel();
15643
+ return;
15644
+ }
15645
+ emitPhaseStart(2);
15646
+ const modelV = utilityModelFor();
15647
+ if (!modelV) {
15648
+ fail("no LLM provider configured \u2014 add an API key first");
15649
+ return;
15650
+ }
15651
+ const keywords = await distilKeywords(state, modelV, memories);
15652
+ if (state.controller.signal.aborted) {
15653
+ cancel();
15654
+ return;
15655
+ }
15656
+ if (keywords.length === 0) {
15657
+ fail("couldn't distil any keywords from the chair's memory yet \u2014 try again after a couple of rooms");
15658
+ return;
15659
+ }
15660
+ emitPhaseProgress(2, `picked ${keywords.length} keywords`, 25);
15661
+ emitPhaseEnd(2, 30);
15662
+ const hasWeb = hasWebSearchKey();
15663
+ let snippetsByKeyword = /* @__PURE__ */ new Map();
15664
+ if (hasWeb) {
15665
+ emitPhaseStart(3);
15666
+ snippetsByKeyword = await runWebSweep(state, keywords, (kw, snippets, idx) => {
15667
+ emitPhaseProgress(
15668
+ 3,
15669
+ `scanned "${kw}" (${snippets.length} hits) \xB7 ${idx}/${keywords.length}`,
15670
+ 30 + Math.round(idx / keywords.length * 40)
15671
+ );
15672
+ topicRecBus.emit(state.id, {
15673
+ type: "topic-search-round",
15674
+ keyword: kw,
15675
+ query: `${kw} site:x.com`,
15676
+ resultsCount: snippets.length,
15677
+ snippets
15678
+ });
15679
+ });
15680
+ if (state.controller.signal.aborted) {
15681
+ cancel();
15682
+ return;
15683
+ }
15684
+ emitPhaseEnd(3, 70);
15685
+ } else {
15686
+ emitPhaseProgress(3, "no web-search key \u2014 skipping", 70);
15687
+ }
15688
+ emitPhaseStart(4);
15689
+ const batchId = randomUUID2();
15690
+ createTopicRecBatch({ id: batchId, hasWeb, keywords });
15691
+ updateTopicRecJob(state.id, { batchId });
15692
+ const synth = await synthesiseTopics(state, modelV, {
15693
+ memories,
15694
+ keywords,
15695
+ snippetsByKeyword,
15696
+ hasWeb
15697
+ });
15698
+ if (state.controller.signal.aborted) {
15699
+ cancel();
15700
+ return;
15701
+ }
15702
+ if (synth.length === 0) {
15703
+ fail("synthesis returned no topics \u2014 try again or refine your boardroom history first");
15704
+ return;
15705
+ }
15706
+ clearAllTopicRecs();
15707
+ let inserted = 0;
15708
+ for (const t of synth) {
15709
+ const rec = insertTopicRec({
15710
+ id: randomUUID2(),
15711
+ batchId,
15712
+ subject: t.subject,
15713
+ rationale: t.rationale,
15714
+ source: t.source,
15715
+ tag: t.tag,
15716
+ seedContext: t.seedContext
15717
+ });
15718
+ inserted++;
15719
+ topicRecBus.emit(state.id, { type: "topic-rec", rec });
15720
+ emitPhaseProgress(
15721
+ 4,
15722
+ `synthesised ${inserted}/${synth.length}`,
15723
+ 70 + Math.round(inserted / synth.length * 28)
15724
+ );
15725
+ }
15726
+ emitPhaseEnd(4, 100);
15727
+ updateTopicRecJob(state.id, {
15728
+ status: "done",
15729
+ progressPct: 100,
15730
+ currentPhase: 4
15731
+ });
15732
+ topicRecBus.emit(state.id, {
15733
+ type: "topic-final",
15734
+ batchId,
15735
+ totalRecs: inserted,
15736
+ hasWeb
15737
+ });
15738
+ topicRecBus.drop(state.id);
15739
+ } catch (e) {
15740
+ if (state.controller.signal.aborted) {
15741
+ cancel();
15742
+ return;
15743
+ }
15744
+ const msg = e instanceof Error ? e.message : String(e);
15745
+ process.stderr.write(`[topic-recommender] pipeline crashed: ${msg}
15746
+ `);
15747
+ fail(msg);
15748
+ }
15749
+ }
15750
+ async function distilKeywords(state, modelV, memories) {
15751
+ if (memories.length === 0) return [];
15752
+ const memoryLines = memories.slice(0, 60).map((m, i) => {
15753
+ const tier = m.tier === "long" ? "STABLE" : "fresh";
15754
+ const prov = m.provenanceRooms > 1 ? ` \xB7 \xD7${m.provenanceRooms} rooms` : "";
15755
+ const recency = Math.max(0, Math.round((Date.now() - m.createdAt) / 864e5));
15756
+ return `${i + 1}. [${tier}${prov} \xB7 ${recency}d ago \xB7 ${m.kind}] ${m.content}`;
15757
+ }).join("\n");
15758
+ 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.`;
15759
+ const user = `# Chair's memory about the user (newest first within each tier)
15760
+ ${memoryLines}
15761
+
15762
+ Return up to 10 keywords as JSON.`;
15763
+ const raw = await callPhaseLLM2(state, modelV, [
15764
+ { role: "system", content: system },
15765
+ { role: "user", content: user }
15766
+ ], { temperature: 0.3, maxTokens: 600 });
15767
+ if (!raw) return [];
15768
+ const parsed = extractJson5(raw);
15769
+ if (!parsed || !Array.isArray(parsed.keywords)) return [];
15770
+ return parsed.keywords.filter((k) => typeof k === "string").map((k) => k.trim()).filter((k) => k.length > 0).slice(0, 10);
15771
+ }
15772
+ async function runWebSweep(state, keywords, onKeywordDone) {
15773
+ const out = /* @__PURE__ */ new Map();
15774
+ const creds = getActiveWebSearchCredentials();
15775
+ if (!creds) return out;
15776
+ let doneCount = 0;
15777
+ for (let i = 0; i < keywords.length; i += SEARCH_PARALLEL_CHUNK) {
15778
+ if (state.controller.signal.aborted) break;
15779
+ const chunk = keywords.slice(i, i + SEARCH_PARALLEL_CHUNK);
15780
+ const settled = await Promise.allSettled(
15781
+ chunk.map((kw) => fetchKeywordSnippets(creds.backend, creds.apiKey, kw))
15782
+ );
15783
+ settled.forEach((res, j) => {
15784
+ const kw = chunk[j];
15785
+ const snippets = res.status === "fulfilled" ? res.value : [];
15786
+ out.set(kw, snippets);
15787
+ doneCount++;
15788
+ onKeywordDone(kw, snippets, doneCount);
15789
+ });
15790
+ if (i + SEARCH_PARALLEL_CHUNK < keywords.length) {
15791
+ await sleepWithSignal2(SEARCH_CHUNK_GAP_MS, state.controller.signal);
15792
+ }
15793
+ }
15794
+ return out;
15795
+ }
15796
+ async function fetchKeywordSnippets(backend, apiKey, keyword) {
15797
+ const xQuery = `${keyword} site:x.com`;
15798
+ const xResults = await runWebSearch(backend, apiKey, xQuery, {
15799
+ count: SEARCH_RESULTS_PER_QUERY
15800
+ });
15801
+ if (xResults && xResults.length > 0) {
15802
+ return xResults.map(toSnippet);
15803
+ }
15804
+ const generic = await runWebSearch(backend, apiKey, keyword, {
15805
+ count: SEARCH_RESULTS_PER_QUERY
15806
+ });
15807
+ return (generic ?? []).map(toSnippet);
15808
+ }
15809
+ function toSnippet(r) {
15810
+ return {
15811
+ title: r.title || "(untitled)",
15812
+ url: r.url,
15813
+ description: r.description || ""
15814
+ };
15815
+ }
15816
+ async function synthesiseTopics(state, modelV, opts) {
15817
+ const { memories, keywords, snippetsByKeyword, hasWeb } = opts;
15818
+ const flatSnippets = [];
15819
+ if (hasWeb) {
15820
+ for (const kw of keywords) {
15821
+ for (const s of snippetsByKeyword.get(kw) ?? []) {
15822
+ flatSnippets.push({ ...s, keyword: kw });
15823
+ }
15824
+ }
15825
+ }
15826
+ 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");
15827
+ const snippetBlock = flatSnippets.length === 0 ? "(no web snippets \u2014 synthesise from memory only)" : flatSnippets.map(
15828
+ (s, i) => `S${i + 1}. [keyword: ${s.keyword}] ${s.title}
15829
+ ${s.description}
15830
+ ${s.url}`
15831
+ ).join("\n\n");
15832
+ const system = 'You recommend boardroom discussion topics to a user, based on (a) the chair\'s long-term memory of who they are + what they care about, and optionally (b) a set of currently-trending web/x.com snippets keyed off the user\'s recent interests. Produce EXACTLY 5 distinct topics \u2014 not 4, not 6, five. Each topic is a subject line a user could plausibly drop into the convene composer.\n\nThe 5 topics MUST span DIFFERENT dimensions/categories \u2014 don\'t return five pricing topics. Use the 10 keywords as a multi-dimensional search index; the 5 final topics should distil ACROSS those dimensions so the picker reads as a balanced board agenda, not a single-angle obsession. Each topic gets a different `tag`.\n\nVoice: tight, specific, opinionated. Avoid corporate-speak. Skew toward questions the user would actually want to debate, not generic explainers.\n\nEVERY topic MUST include a `tag` field \xB7 a SHORT CATEGORY in 1-2 lowercase words naming what bucket the topic falls into. Pick from the user\'s actual subject matter (examples: strategy / product / market / pricing / positioning / brand / hiring / fundraising / ops / infra / research / craft / ethics / personal / leadership / growth / sales / design / data / partnerships / regulation). FORBIDDEN tag values: "web", "memory", "general", "misc", "other", "topic", "recommendation" \u2014 these are meta-vocabulary that leaks the system, not real categories. If the topic spans two areas, pick the dominant one. NEVER omit the tag field.\n\nEach topic must cite either (a) at least one snippet ref (S<n>) \u2014 in which case set "source":"web" \u2014 OR (b) no snippet refs at all \u2014 in which case set "source":"memory". The `source` and `tag` fields are independent: `source` is the data provenance, `tag` is the topic category. Never use "web" or "memory" as a tag.\n\nStrict JSON output only:\n{ "topics": [ { "tag": "pricing", "subject": "...", "rationale": "one sentence on why this fits the user", "source": "web|memory", "snippetRefs": [<S indexes, omit when source=memory>] } ] }';
15833
+ const user = `# Keywords distilled from chair memory
15834
+ ${keywords.map((k, i) => `K${i + 1}. ${k}`).join("\n")}
15835
+
15836
+ # Memory excerpts
15837
+ ${memorySummary}
15838
+
15839
+ # Web snippets ${hasWeb ? "(use these to ground at least some recs as source=web)" : "(none \u2014 synthesise from memory only)"}
15840
+ ${snippetBlock}
15841
+
15842
+ Return EXACTLY 5 topics as JSON, each with a different tag, spanning different dimensions.`;
15843
+ const raw = await callPhaseLLM2(state, modelV, [
15844
+ { role: "system", content: system },
15845
+ { role: "user", content: user }
15846
+ ], { temperature: 0.6, maxTokens: 2e3 });
15847
+ if (!raw) return [];
15848
+ const parsed = extractJson5(raw);
15849
+ if (!parsed || !Array.isArray(parsed.topics)) return [];
15850
+ const out = [];
15851
+ for (const t of parsed.topics) {
15852
+ const subject = typeof t.subject === "string" ? t.subject.trim() : "";
15853
+ const rationale = typeof t.rationale === "string" ? t.rationale.trim() : "";
15854
+ if (!subject || !rationale) continue;
15855
+ let tag = null;
15856
+ const TAG_BLOCKLIST = /* @__PURE__ */ new Set([
15857
+ "web",
15858
+ "memory",
15859
+ "general",
15860
+ "misc",
15861
+ "other",
15862
+ "topic",
15863
+ "recommendation",
15864
+ "recommendations",
15865
+ "rec",
15866
+ "category",
15867
+ "n/a",
15868
+ "na",
15869
+ "none"
15870
+ ]);
15871
+ if (typeof t.tag === "string") {
15872
+ const cleaned = t.tag.trim().replace(/^\/\/\s*/, "").toLowerCase().replace(/[^a-z0-9 -]/g, "").replace(/\s+/g, " ").trim().slice(0, 28);
15873
+ if (cleaned.length > 0 && !TAG_BLOCKLIST.has(cleaned)) {
15874
+ tag = cleaned;
15875
+ }
15876
+ }
15877
+ if (!tag) {
15878
+ 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));
15879
+ if (words.length > 0) {
15880
+ tag = words.slice(0, 2).join(" ").slice(0, 28);
15881
+ } else {
15882
+ tag = "topic";
15883
+ }
15884
+ }
15885
+ let source = t.source === "web" ? "web" : "memory";
15886
+ let seedContext = null;
15887
+ if (source === "web" && hasWeb && Array.isArray(t.snippetRefs)) {
15888
+ const refs = t.snippetRefs.map((r) => {
15889
+ if (typeof r === "number") return r;
15890
+ if (typeof r === "string") {
15891
+ const m = r.match(/^S?(\d+)$/i);
15892
+ return m ? Number(m[1]) : NaN;
15893
+ }
15894
+ return NaN;
15895
+ }).filter((n) => Number.isInteger(n) && n > 0 && n <= flatSnippets.length);
15896
+ const seen = /* @__PURE__ */ new Set();
15897
+ const cited = [];
15898
+ for (const ref of refs) {
15899
+ const snip = flatSnippets[ref - 1];
15900
+ if (!snip || seen.has(snip.url)) continue;
15901
+ seen.add(snip.url);
15902
+ cited.push({ title: snip.title, url: snip.url, description: snip.description });
15903
+ }
15904
+ if (cited.length > 0) {
15905
+ seedContext = cited;
15906
+ } else {
15907
+ source = "memory";
15908
+ }
15909
+ } else if (source === "web") {
15910
+ source = "memory";
15911
+ }
15912
+ out.push({ subject, rationale, source, tag, seedContext });
15913
+ if (out.length >= 6) break;
15914
+ }
15915
+ return out;
15916
+ }
15917
+
15918
+ // src/routes/topic-recs.ts
15919
+ function topicRecsRouter() {
15920
+ const r = new Hono6();
15921
+ r.post("/", (c) => {
15922
+ if (!hasAnyModelKey()) {
15923
+ return c.json({ error: "configure an LLM provider key first" }, 400);
15924
+ }
15925
+ const jobId = startTopicRecommend();
15926
+ return c.json({ jobId });
15927
+ });
15928
+ r.get("/jobs/:id/stream", (c) => {
15929
+ const jobId = c.req.param("id");
15930
+ const job = getTopicRecJob(jobId);
15931
+ if (!job) return c.json({ error: "job not found" }, 404);
15932
+ return streamSSE2(c, async (s) => {
15933
+ await s.writeSSE({
15934
+ event: "hello",
15935
+ data: JSON.stringify({
15936
+ jobId,
15937
+ status: job.status,
15938
+ currentPhase: job.currentPhase,
15939
+ progressPct: job.progressPct,
15940
+ batchId: job.batchId,
15941
+ error: job.error
15942
+ })
15943
+ });
15944
+ if (!isTopicRecJobRunning(jobId)) {
15945
+ if (job.status === "done") {
15946
+ await s.writeSSE({
15947
+ event: "topic-final",
15948
+ data: JSON.stringify({
15949
+ type: "topic-final",
15950
+ batchId: job.batchId,
15951
+ totalRecs: null,
15952
+ hasWeb: null
15953
+ })
15954
+ });
15955
+ } else if (job.status === "aborted") {
15956
+ await s.writeSSE({
15957
+ event: "topic-aborted",
15958
+ data: JSON.stringify({ type: "topic-aborted" })
15959
+ });
15960
+ } else if (job.status === "failed") {
15961
+ await s.writeSSE({
15962
+ event: "topic-error",
15963
+ data: JSON.stringify({
15964
+ type: "topic-error",
15965
+ message: job.error || "generation failed"
15966
+ })
15967
+ });
15968
+ }
15969
+ return;
15970
+ }
15971
+ const queue = [];
15972
+ let resolveWaiter = null;
15973
+ let closed = false;
15974
+ const off = topicRecBus.subscribe(jobId, (event) => {
15975
+ queue.push(event);
15976
+ if (resolveWaiter) {
15977
+ resolveWaiter();
15978
+ resolveWaiter = null;
15979
+ }
15980
+ });
15981
+ s.onAbort(() => {
15982
+ closed = true;
15983
+ off();
15984
+ if (resolveWaiter) {
15985
+ resolveWaiter();
15986
+ resolveWaiter = null;
15987
+ }
15988
+ });
15989
+ while (!closed) {
15990
+ if (queue.length === 0) {
15991
+ await new Promise((resolve2) => {
15992
+ resolveWaiter = resolve2;
15993
+ });
15994
+ continue;
15995
+ }
15996
+ const event = queue.shift();
15997
+ await s.writeSSE({ event: event.type, data: JSON.stringify(event) });
15998
+ if (event.type === "topic-final" || event.type === "topic-error" || event.type === "topic-aborted") {
15999
+ closed = true;
16000
+ off();
16001
+ }
16002
+ }
16003
+ });
16004
+ });
16005
+ r.post("/jobs/:id/abort", (c) => {
16006
+ const jobId = c.req.param("id");
16007
+ const ok = abortTopicRecommend(jobId);
16008
+ if (!ok) {
16009
+ const job = getTopicRecJob(jobId);
16010
+ if (!job) return c.json({ error: "job not found" }, 404);
16011
+ return c.json({ ok: true, status: job.status });
16012
+ }
16013
+ return c.json({ ok: true });
16014
+ });
16015
+ r.get("/", (c) => {
16016
+ const cursorRaw = c.req.query("cursor");
16017
+ const limitRaw = c.req.query("limit");
16018
+ const cursor = cursorRaw && /^\d+$/.test(cursorRaw) ? Number(cursorRaw) : null;
16019
+ const limit = limitRaw && /^\d+$/.test(limitRaw) ? Math.max(1, Math.min(100, Number(limitRaw))) : 20;
16020
+ const { items, nextCursor } = listTopicRecs({ cursor, limit });
16021
+ return c.json({ items, nextCursor });
16022
+ });
16023
+ r.get("/:id", (c) => {
16024
+ const id = c.req.param("id");
16025
+ const rec = getTopicRec(id);
16026
+ if (!rec) return c.json({ error: "not found" }, 404);
16027
+ return c.json(rec);
16028
+ });
16029
+ return r;
16030
+ }
16031
+
16032
+ // src/routes/notes.ts
16033
+ import { Hono as Hono7 } from "hono";
16034
+
16035
+ // src/storage/notes.ts
16036
+ init_db();
16037
+ var COLS4 = "id, room_id, message_id, author_kind, author_id, author_name, quote_text, context_before, context_after, char_offset_start, char_offset_end, user_note, tags_json, status, created_at";
16038
+ function parseTags(json) {
16039
+ if (!json) return [];
16040
+ try {
16041
+ const parsed = JSON.parse(json);
16042
+ if (!Array.isArray(parsed)) return [];
16043
+ return parsed.filter((t) => typeof t === "string" && t.trim().length > 0);
16044
+ } catch {
16045
+ return [];
16046
+ }
16047
+ }
16048
+ function mapRow9(row) {
16049
+ return {
16050
+ id: row.id,
16051
+ roomId: row.room_id,
16052
+ messageId: row.message_id,
16053
+ authorKind: row.author_kind,
16054
+ authorId: row.author_id,
16055
+ authorName: row.author_name,
16056
+ quoteText: row.quote_text,
16057
+ contextBefore: row.context_before,
16058
+ contextAfter: row.context_after,
16059
+ charOffsetStart: row.char_offset_start,
16060
+ charOffsetEnd: row.char_offset_end,
16061
+ userNote: row.user_note,
16062
+ tags: parseTags(row.tags_json),
16063
+ status: row.status === "acted" || row.status === "archived" ? row.status : "open",
16064
+ createdAt: row.created_at
16065
+ };
16066
+ }
16067
+ function createNote(n) {
16068
+ const db = getDb();
16069
+ const id = newId();
16070
+ const now = Date.now();
16071
+ db.prepare(
16072
+ `INSERT INTO notes (${COLS4}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
16073
+ ).run(
16074
+ id,
16075
+ n.roomId,
16076
+ n.messageId,
16077
+ n.authorKind,
16078
+ n.authorId,
16079
+ n.authorName,
16080
+ n.quoteText,
16081
+ n.contextBefore ?? "",
16082
+ n.contextAfter ?? "",
16083
+ n.charOffsetStart,
16084
+ n.charOffsetEnd,
16085
+ null,
16086
+ // user_note · deferred
16087
+ null,
16088
+ // tags_json · deferred
16089
+ "open",
16090
+ now
16091
+ );
16092
+ return getNote(id);
16093
+ }
16094
+ function getNote(id) {
16095
+ const row = getDb().prepare(`SELECT ${COLS4} FROM notes WHERE id = ?`).get(id);
16096
+ return row ? mapRow9(row) : null;
16097
+ }
16098
+ function listNotesByRoom(roomId) {
16099
+ const rows = getDb().prepare(`SELECT ${COLS4} FROM notes WHERE room_id = ? ORDER BY created_at DESC`).all(roomId);
16100
+ return rows.map(mapRow9);
16101
+ }
16102
+ function deleteNote(id) {
16103
+ const result = getDb().prepare("DELETE FROM notes WHERE id = ?").run(id);
16104
+ return result.changes > 0;
16105
+ }
16106
+ function countNotes() {
16107
+ const row = getDb().prepare("SELECT COUNT(*) AS c FROM notes").get();
16108
+ return row.c ?? 0;
16109
+ }
16110
+ function listAllNotesWithRoom() {
16111
+ const rows = getDb().prepare(
16112
+ `SELECT n.id, n.room_id, n.message_id, n.author_kind, n.author_id,
16113
+ n.author_name, n.quote_text, n.context_before, n.context_after,
16114
+ n.char_offset_start, n.char_offset_end, n.user_note, n.tags_json,
16115
+ n.status, n.created_at,
16116
+ r.name AS room_name, r.subject AS room_subject,
16117
+ r.number AS room_number, r.status AS room_status
16118
+ FROM notes n
16119
+ JOIN rooms r ON r.id = n.room_id
16120
+ ORDER BY n.created_at DESC`
16121
+ ).all();
16122
+ return rows.map((row) => ({
16123
+ ...mapRow9(row),
16124
+ roomName: row.room_name,
16125
+ roomSubject: row.room_subject,
16126
+ roomNumber: row.room_number,
16127
+ roomStatus: row.room_status
16128
+ }));
14765
16129
  }
14766
16130
 
14767
16131
  // src/routes/notes.ts
14768
16132
  function notesRouter() {
14769
- const r = new Hono6();
16133
+ const r = new Hono7();
14770
16134
  r.get("/", (c) => {
14771
16135
  const notes = listAllNotesWithRoom();
14772
16136
  return c.json({ notes, total: notes.length });
@@ -14838,9 +16202,9 @@ function deriveAuthorName(kind, authorId) {
14838
16202
  }
14839
16203
 
14840
16204
  // src/routes/prefs.ts
14841
- import { Hono as Hono7 } from "hono";
16205
+ import { Hono as Hono8 } from "hono";
14842
16206
  function prefsRouter() {
14843
- const r = new Hono7();
16207
+ const r = new Hono8();
14844
16208
  r.get("/", (c) => c.json(getPrefs()));
14845
16209
  r.put("/", async (c) => {
14846
16210
  let body;
@@ -14875,8 +16239,8 @@ function prefsRouter() {
14875
16239
  }
14876
16240
 
14877
16241
  // src/routes/rooms.ts
14878
- import { Hono as Hono8 } from "hono";
14879
- import { streamSSE as streamSSE2 } from "hono/streaming";
16242
+ import { Hono as Hono9 } from "hono";
16243
+ import { streamSSE as streamSSE3 } from "hono/streaming";
14880
16244
 
14881
16245
  // src/storage/key_points.ts
14882
16246
  init_db();
@@ -14981,7 +16345,7 @@ Does the chair need to ask a clarifying question before opening the room?`
14981
16345
  }
14982
16346
  return { shouldAsk: true, rationale: "" };
14983
16347
  }
14984
- const parsed = extractJson5(raw);
16348
+ const parsed = extractJson6(raw);
14985
16349
  if (!parsed || typeof parsed !== "object") {
14986
16350
  return { shouldAsk: true, rationale: "" };
14987
16351
  }
@@ -15073,7 +16437,7 @@ async function pickRoundWrap(opts) {
15073
16437
  }
15074
16438
  return { recommendation: "continue", rationale: "" };
15075
16439
  }
15076
- const parsed = extractJson5(raw);
16440
+ const parsed = extractJson6(raw);
15077
16441
  if (!parsed || typeof parsed !== "object") {
15078
16442
  return { recommendation: "continue", rationale: "" };
15079
16443
  }
@@ -15084,9 +16448,21 @@ async function pickRoundWrap(opts) {
15084
16448
  }
15085
16449
  async function pickNextSpeaker(opts) {
15086
16450
  const { candidates, history, room, signal } = opts;
16451
+ const mode = opts.mode === "dissent-gap" ? "dissent-gap" : "lens-gap";
16452
+ const convergentTerms = (opts.convergentTerms || []).filter(Boolean);
15087
16453
  if (candidates.length < 2) return { agentId: null, rationale: "", intervention: null };
15088
- const roster = candidates.map((a) => `- ${a.id} \xB7 ${a.name} (${a.handle}) \xB7 ${a.roleTag}
15089
- ${a.bio}`).join("\n");
16454
+ const roster = candidates.map((a) => {
16455
+ const baseRow = `- ${a.id} \xB7 ${a.name} (${a.handle}) \xB7 ${a.roleTag}
16456
+ ${a.bio}`;
16457
+ if (mode !== "dissent-gap") return baseRow;
16458
+ const takes = a.personaSpec?.spec?.contrarianTakes?.slice(0, 3) || [];
16459
+ const failures = a.personaSpec?.spec?.failureModes?.slice(0, 1) || [];
16460
+ const extras = [];
16461
+ if (takes.length > 0) extras.push(` \xB7 contrarian takes: ${takes.join(" \xB7 ")}`);
16462
+ if (failures.length > 0) extras.push(` \xB7 failure mode: ${failures[0]}`);
16463
+ return extras.length > 0 ? `${baseRow}
16464
+ ${extras.join("\n")}` : baseRow;
16465
+ }).join("\n");
15090
16466
  const transcript = history.slice(-12).filter((m) => {
15091
16467
  if (!m.body || !m.body.trim()) return false;
15092
16468
  const meta = m.meta;
@@ -15097,6 +16473,38 @@ async function pickNextSpeaker(opts) {
15097
16473
  const who = m.authorKind === "user" ? "USER" : m.authorId || "agent";
15098
16474
  return `[${who}] ${m.body.trim().slice(0, 600)}`;
15099
16475
  }).join("\n\n");
16476
+ const decision1Block = mode === "dissent-gap" ? [
16477
+ "DECISION 1 \xB7 Next speaker (DISSENT-GAP MODE).",
16478
+ "The room is converging on a single frame \u2014 for THIS pick, the chair",
16479
+ "needs the director MOST LIKELY to break that frame. Score each",
16480
+ "candidate on:",
16481
+ " \xB7 Their `contrarian takes` (listed in the roster) versus the room's",
16482
+ " detected convergent terms (surfaced in the user message below).",
16483
+ " Pick whose stated contrarian moves DIRECTLY puncture the cluster.",
16484
+ " \xB7 Their `failure mode` is a NEGATIVE signal \u2014 a director whose",
16485
+ " failure mode is 'gets sucked into specifics' is exactly who you",
16486
+ " do NOT pick when the room is already lost in specifics.",
16487
+ " \xB7 Lens distance from the convergent frame \xB7 pick a lens furthest",
16488
+ " from the cluster's gravitational center.",
16489
+ " \xB7 Recency \xB7 prefer directors who haven't spoken in the last 2 turns",
16490
+ " when scores are comparable.",
16491
+ " \xB7 If NO candidate is clearly the frame-breaker (e.g. all candidates",
16492
+ " have already been used recently OR none have relevant contrarian",
16493
+ " takes), set agent_id=null and let round-robin run."
16494
+ ].join("\n") : [
16495
+ "DECISION 1 \xB7 Next speaker. From the candidates below, pick which",
16496
+ "director should speak NEXT \u2014 the one whose lens most sharply",
16497
+ "addresses the unresolved tension, hidden assumption, or missing",
16498
+ "counter-argument in the previous turn.",
16499
+ " \xB7 Match LENS to the gap, not just topic relevance. If the prior",
16500
+ " turn made a structural claim, pick a director whose role",
16501
+ " pushes back from a different lens (data \u2192 narrative,",
16502
+ " empirical \u2192 first-principles, etc.).",
16503
+ " \xB7 Prefer directors who haven't been quoted yet THIS round when",
16504
+ " fits are comparable \u2014 diversity of voice.",
16505
+ " \xB7 If no candidate clearly fits better than the current head of",
16506
+ " queue, set agent_id=null and let round-robin run."
16507
+ ].join("\n");
15100
16508
  const sys = {
15101
16509
  role: "system",
15102
16510
  content: [
@@ -15104,18 +16512,7 @@ async function pickNextSpeaker(opts) {
15104
16512
  "a reactive round; one director just finished. You make TWO",
15105
16513
  "decisions in one pass.",
15106
16514
  "",
15107
- "DECISION 1 \xB7 Next speaker. From the candidates below, pick which",
15108
- "director should speak NEXT \u2014 the one whose lens most sharply",
15109
- "addresses the unresolved tension, hidden assumption, or missing",
15110
- "counter-argument in the previous turn.",
15111
- " \xB7 Match LENS to the gap, not just topic relevance. If the prior",
15112
- " turn made a structural claim, pick a director whose role",
15113
- " pushes back from a different lens (data \u2192 narrative,",
15114
- " empirical \u2192 first-principles, etc.).",
15115
- " \xB7 Prefer directors who haven't been quoted yet THIS round when",
15116
- " fits are comparable \u2014 diversity of voice.",
15117
- " \xB7 If no candidate clearly fits better than the current head of",
15118
- " queue, set agent_id=null and let round-robin run.",
16515
+ decision1Block,
15119
16516
  "",
15120
16517
  "DECISION 2 \xB7 Intervention (optional \xB7 default: null). Read the",
15121
16518
  "prior 2\u20133 turns. Drop a 1-sentence chair note ONLY if a substantive",
@@ -15167,6 +16564,11 @@ async function pickNextSpeaker(opts) {
15167
16564
  // only language signal was "recent transcript" — which a
15168
16565
  // single English chair drift could pollute.
15169
16566
  ...room?.subject ? [`Room subject: ${room.subject}`, ``] : [],
16567
+ ...mode === "dissent-gap" && convergentTerms.length > 0 ? [
16568
+ `Detected convergent terms (room is over-investing here \xB7 the dissent pick should puncture these):`,
16569
+ ...convergentTerms.map((t) => ` \xB7 "${t}"`),
16570
+ ``
16571
+ ] : [],
15170
16572
  `Candidates (queued, in current order):`,
15171
16573
  roster,
15172
16574
  ``,
@@ -15198,7 +16600,7 @@ async function pickNextSpeaker(opts) {
15198
16600
  }
15199
16601
  return { agentId: null, rationale: "", intervention: null };
15200
16602
  }
15201
- const parsed = extractJson5(raw);
16603
+ const parsed = extractJson6(raw);
15202
16604
  if (!parsed || typeof parsed !== "object") {
15203
16605
  return { agentId: null, rationale: "", intervention: null };
15204
16606
  }
@@ -15310,7 +16712,7 @@ async function pickChairWebSearch(opts) {
15310
16712
  }
15311
16713
  return null;
15312
16714
  }
15313
- const parsed = extractJson5(raw);
16715
+ const parsed = extractJson6(raw);
15314
16716
  if (!parsed || typeof parsed !== "object") return null;
15315
16717
  const ws = parsed;
15316
16718
  if (typeof ws.query !== "string") return null;
@@ -15335,7 +16737,7 @@ function buildSkillsIndex(skills) {
15335
16737
  function loadSkillBody(skill) {
15336
16738
  return skill.bodyMd;
15337
16739
  }
15338
- function extractJson5(text) {
16740
+ function extractJson6(text) {
15339
16741
  if (!text) return null;
15340
16742
  let s = text.trim();
15341
16743
  s = s.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/i, "").trim();
@@ -15449,7 +16851,7 @@ Which skills apply, and does this turn need web search?`
15449
16851
  continue;
15450
16852
  }
15451
16853
  }
15452
- const parsed = extractJson5(raw);
16854
+ const parsed = extractJson6(raw);
15453
16855
  if (!parsed || typeof parsed !== "object") {
15454
16856
  return { used: [], reason: "", webSearchQuery: null };
15455
16857
  }
@@ -15601,6 +17003,74 @@ function renderPersonaFewShotBlock(speaker, deliveryMode) {
15601
17003
  }
15602
17004
  return lines.join("\n");
15603
17005
  }
17006
+ function renderFrameBreakGuidance(terms) {
17007
+ if (!terms || terms.length === 0) return "";
17008
+ const bullets = terms.map((t) => ` \xB7 "${t}"`).join("\n");
17009
+ return [
17010
+ "",
17011
+ `\u2500\u2500\u2500 FRAME-BREAK GUIDANCE \xB7 WHAT THE ROOM HAS ALREADY OVER-INVESTED IN \u2500\u2500\u2500`,
17012
+ `The following noun phrases have been the recurring fixation in the last several turns. The room's value is breadth, not depth-in-one-frame. For THIS turn:`,
17013
+ bullets,
17014
+ `Treat these as a soft no-go. You may touch them ONLY (a) as a counter-example ("unlike X, \u2026") OR (b) to point out an assumption inside them the room hasn't questioned. Do NOT extend / refine / give a sub-angle on them. Find an entry point that lives OUTSIDE this cluster \u2014 a different stakeholder, a different time scale, a different mechanism, a different domain analogy.`
17015
+ ].join("\n");
17016
+ }
17017
+ function renderFrameBreakerRole(role) {
17018
+ if (!role || !role.convergentFrame) return "";
17019
+ return [
17020
+ "",
17021
+ `\u2500\u2500\u2500 YOUR EXTRA JOB THIS TURN \xB7 BREAK THE ROOM'S FRAME \u2500\u2500\u2500`,
17022
+ `The room is converging on "${role.convergentFrame}". For THIS turn, you have been designated the frame-breaker. Your turn MUST do at least ONE of:`,
17023
+ ` (a) Propose a concrete scenario / population / domain where "${role.convergentFrame}" simply does NOT apply, and show what the product / decision would look like there instead.`,
17024
+ ` (b) Introduce a constraint dimension the room has not yet considered (a different time scale \xB7 a different stakeholder type \xB7 a different technical layer \xB7 a different cultural / regulatory context \xB7 a different physical / material constraint \xB7 a different value system) and show how it changes what matters.`,
17025
+ `Do NOT execute this as a literal labelled item ("frame-breaker move: \u2026"). Weave the move into your normal turn \u2014 the user sees the angle land naturally, not the assignment.`
17026
+ ].join("\n");
17027
+ }
17028
+ function renderUnexploredAngles(angles) {
17029
+ if (!angles || angles.length === 0) return "";
17030
+ const bullets = angles.map((a) => ` \xB7 ${a}`).join("\n");
17031
+ return [
17032
+ "",
17033
+ `\u2500\u2500\u2500 UNEXPLORED ANGLES \xB7 WHERE THE ROOM HASN'T LOOKED YET \u2500\u2500\u2500`,
17034
+ `The chair noted these angles were raised-then-abandoned or notably absent in recent rounds:`,
17035
+ bullets,
17036
+ `Pick ONE as a possible entry point for your turn \u2014 or generate a fresh one of your own. The room is starved for breadth, not depth; one bullet on a genuinely new angle is worth three bullets refining a familiar one.`
17037
+ ].join("\n");
17038
+ }
17039
+ function renderPersonaLensReminder(speaker) {
17040
+ const spec = speaker.personaSpec;
17041
+ if (spec && spec.spec) {
17042
+ const concepts = (spec.spec.loadBearingConcepts || []).slice(0, 3);
17043
+ const takes = (spec.spec.contrarianTakes || []).slice(0, 2);
17044
+ const failure = (spec.spec.failureModes || [])[0] || "";
17045
+ if (concepts.length === 0 && takes.length === 0) return "";
17046
+ const lines = [
17047
+ "",
17048
+ `\u2500\u2500\u2500 YOUR LENS \xB7 LAST-MINUTE REMINDER \u2500\u2500\u2500`,
17049
+ `Before you generate, re-anchor on who YOU are at this table. The conversation above has its own gravity; do NOT let it pull you off your signature angle.`
17050
+ ];
17051
+ if (concepts.length > 0) {
17052
+ lines.push(`Your load-bearing concepts (the moves you naturally make): ${concepts.join(" \xB7 ")}.`);
17053
+ }
17054
+ if (takes.length > 0) {
17055
+ lines.push(`Your contrarian takes (push back from these when the room is converging): ${takes.join(" \xB7 ")}.`);
17056
+ }
17057
+ if (failure) {
17058
+ lines.push(`Your most common failure mode (avoid it this turn): ${failure}.`);
17059
+ }
17060
+ lines.push(`Speak from this. Not from the room's average frame.`);
17061
+ return lines.join("\n");
17062
+ }
17063
+ const instr = (speaker.instruction || "").trim();
17064
+ if (!instr) return "";
17065
+ const slice = instr.length > 280 ? instr.slice(0, 280) + "\u2026" : instr;
17066
+ return [
17067
+ "",
17068
+ `\u2500\u2500\u2500 YOUR LENS \xB7 LAST-MINUTE REMINDER \u2500\u2500\u2500`,
17069
+ `Before you generate, re-anchor on who YOU are at this table \u2014 the conversation above has its own gravity, do not let it pull you off your signature angle.`,
17070
+ `Recall your director frame: ${slice}`,
17071
+ `Speak from this lens, not from the room's average frame.`
17072
+ ].join("\n");
17073
+ }
15604
17074
  function renderPersonaReflectionBlock(speaker) {
15605
17075
  const spec = speaker.personaSpec;
15606
17076
  if (!spec || spec.reflectionChecklist.length === 0) return "";
@@ -15675,7 +17145,15 @@ var TONE_GUIDANCE = {
15675
17145
  "## Optional \xB7 one synthesis turn per round",
15676
17146
  "Once per round, ONE director may do a synthesis turn instead of pure generation: pick 2\u20133 of the room's ideas and propose 1\u20132 combinations. Use sparingly \u2014 80%+ of turns should be pure generation. The room's value is in the pile of ideas, not the polish on any one.",
15677
17147
  "",
15678
- `PERSONA OVERRIDE \xB7 your director instruction reads like Socrates / First Principles / Value Investor \u2014 the demand-definition / demand-mechanism / demand-base-rate DNA. **For THIS room ALL of that is PAUSED.** You are not your usual analytical self here; you're a fast idea-generator. Lean INTO yes-and, INTO loose connections, INTO half-formed variants. Your TURN_DIRECTIVE ("introduce a new variable / constraint / analogy / counter-example") is fully satisfied by raising a fresh idea or doing a yes-and combination \u2014 you don't need to attack anything. This is the one room where your dissent DNA is suspended end-to-end.`
17148
+ "PERSONA OVERRIDE \xB7 your director instruction reads like Socrates / First Principles / Value Investor \u2014 the demand-definition / demand-mechanism / demand-base-rate DNA. For THIS room, the **rigor / forensic mode** of that DNA is paused (no slow definition-check ladders, no methodical mechanism decomposition, no insistence on base-rate citation before moving) \u2014 that's too slow for brainstorm cadence. But the **contrarian / curiosity / lens-distinctness** half of your DNA is *AMPLIFIED*, not paused. Your persona's signature angles (your loadBearingConcepts, your contrarianTakes, your failureModes) are exactly what produces the unexpected ideas the room needs.",
17149
+ "",
17150
+ "DIVERGENCE DISCIPLINE (this is the anti-convergence rule, read it twice) \xB7",
17151
+ " \xB7 The room's gravitational pull, after 2-3 rounds, is toward whatever phrase has been said the most. Resist this consciously. If a core word from the prior 2 turns is about to appear in your turn, **stop and find a different entry point**. Saying it a second time = you got pulled. Saying it a third = the room has collapsed.",
17152
+ " \xB7 Yes-and is allowed for ONE of your bullets at most. The other 3-5 bullets must each open a direction the prior turn(s) did NOT touch. Yes-and is the local move; the room's job is global coverage.",
17153
+ ` \xB7 If the most recent turn lived in domain X (e.g. "audit", "trust", "workflow"), your turn's center of gravity must be at LEAST one domain away (e.g. if peers are on audit, you go to physical environment, or cultural ritual, or time scale, or hidden user, or material constraint \u2014 not "audit but for X").`,
17154
+ ' \xB7 Half-baked + concrete > polished + abstract. "What if the AI sat on a literal chair and rolled with the team" beats "AI as a collaborative presence layer" every time. Wild specificity > safe generality.',
17155
+ ` \xB7 Your contrarian DNA: if your persona would normally push back against the room's emerging consensus, DO IT \u2014 but in brainstorm style: not as a 5-sentence rebuttal but as ONE counter-idea bullet that opens a fresh angle ("contra all the productivity talk \u2014 what if the assistant's job is to slow people down at exactly 3 inflection points a day?").`,
17156
+ ` \xB7 TURN_DIRECTIVE ("introduce a new variable / constraint / analogy / counter-example") here means **at least one bullet of every turn must satisfy it**. Yes-and bullets don't count toward this floor.`
15679
17157
  ].join("\n"),
15680
17158
  constructive: [
15681
17159
  "CONSTRUCTIVE \xB7 sympathetic interrogator. You want the user to win, but only via an idea that can actually survive scrutiny.",
@@ -16016,14 +17494,35 @@ Name: ${prefs.name}
16016
17494
  `\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.`,
16017
17495
  `\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.`,
16018
17496
  `\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.`,
16019
- // Persona reflection checklist · last persona-tuned entry in
16020
- // the system prompt. Empty string (no-op) for Signal-mode and
16021
- // seeded directors · zero per-turn cost. The checklist is
16022
- // tuned per-persona by Phase 6 of the build pipeline · catches
16023
- // failure modes specific to THIS director (e.g. "Am I
16024
- // repeating @another_director's mechanism point?" for a
16025
- // Historian).
17497
+ // Persona reflection checklist · catches failure modes
17498
+ // specific to THIS director (e.g. "Am I repeating
17499
+ // @another_director's mechanism point?" for a Historian).
17500
+ // Empty for Signal-mode / seed directors.
16026
17501
  renderPersonaReflectionBlock(speaker),
17502
+ // Frame-break guidance (Layer 1.4) · "the room is converging
17503
+ // on X / Y / Z — don't extend them this turn." Soft no-go
17504
+ // list seen by ALL directors. Empty on opening rounds, short
17505
+ // rooms, and diverse rooms where no fixation detected.
17506
+ renderFrameBreakGuidance(opts.frameBreakTerms),
17507
+ // Unexplored angles (Layer 3.2) · positive companion · "here
17508
+ // are angles the room hasn't gone to yet; pick one or generate
17509
+ // your own." Empty when no negative-space record exists.
17510
+ renderUnexploredAngles(opts.unexploredAngles),
17511
+ // Frame-breaker role (Layer 2.2) · single designated
17512
+ // director per round; addendum tells them to do one of two
17513
+ // structural moves to break the room's frame. Empty for
17514
+ // non-frame-breakers and non-reactive rounds.
17515
+ renderFrameBreakerRole(opts.frameBreakerRole),
17516
+ // Persona-lens reminder · re-anchors the director on their
17517
+ // signature angle (top 3 loadBearingConcepts + top 2
17518
+ // contrarianTakes + worst failure mode) at the very tail of
17519
+ // the system prompt. The base persona instruction at the TOP
17520
+ // of the prompt has decayed against transformer attention by
17521
+ // this point; this reminder fights the conversational-mean
17522
+ // gravity that pulls directors into homogeneous voice by
17523
+ // round 3-4. See renderPersonaLensReminder above for the
17524
+ // composition rules.
17525
+ renderPersonaLensReminder(speaker),
16027
17526
  // Target-language LANGUAGE LOCK · TRULY the last block in the
16028
17527
  // system prompt so it's the freshest signal in the LLM's
16029
17528
  // attention. Written in the room's working language (Chinese
@@ -16267,8 +17766,10 @@ function buildChairClarifyMessages(opts) {
16267
17766
  ``,
16268
17767
  `Output: either <ack + blank line + READY> OR the 2-part question block (in the user's language).`
16269
17768
  ].join("\n");
17769
+ const seedSystem = buildSeedContextSystem(opts.history);
16270
17770
  return [
16271
17771
  buildChairSystem(opts, isFirstTurn ? firstTurnTask : followUpTask),
17772
+ ...seedSystem ? [seedSystem] : [],
16272
17773
  ...renderHistoryForChair(opts.history, opts.cast, opts.prefs),
16273
17774
  {
16274
17775
  role: "user",
@@ -16276,6 +17777,39 @@ function buildChairClarifyMessages(opts) {
16276
17777
  }
16277
17778
  ];
16278
17779
  }
17780
+ function buildSeedContextSystem(history) {
17781
+ for (let i = 0; i < history.length; i++) {
17782
+ const m = history[i];
17783
+ if (m.authorKind !== "user") continue;
17784
+ const meta = m.meta;
17785
+ const rationale = typeof meta?.seedContext?.rationale === "string" ? meta.seedContext.rationale.trim() : "";
17786
+ const rawSnippets = meta?.seedContext?.snippets;
17787
+ const snippets = Array.isArray(rawSnippets) ? rawSnippets : [];
17788
+ const snippetLines = [];
17789
+ for (const s of snippets) {
17790
+ if (!s || typeof s !== "object") continue;
17791
+ const title = typeof s.title === "string" ? s.title.trim() : "";
17792
+ const url = typeof s.url === "string" ? s.url.trim() : "";
17793
+ const desc = typeof s.description === "string" ? s.description.trim() : "";
17794
+ if (!title && !url && !desc) continue;
17795
+ snippetLines.push(`\xB7 ${title || "(untitled)"} \u2014 ${url || "(no url)"}
17796
+ ${desc.slice(0, 360)}`);
17797
+ }
17798
+ if (!rationale && snippetLines.length === 0) continue;
17799
+ const blocks = [
17800
+ `\u2500\u2500\u2500 BACKGROUND MATERIAL \xB7 pre-attached by the user \u2500\u2500\u2500`,
17801
+ `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.`
17802
+ ];
17803
+ if (rationale) {
17804
+ blocks.push(``, `Why this topic was recommended (hidden from the user \u2014 your reasoning context):`, `\xB7 ${rationale}`);
17805
+ }
17806
+ if (snippetLines.length > 0) {
17807
+ blocks.push(``, `Source snippets the recommendation was grounded in:`, ...snippetLines);
17808
+ }
17809
+ return { role: "system", content: blocks.join("\n") };
17810
+ }
17811
+ return null;
17812
+ }
16279
17813
  function buildChairConveningMessages(opts) {
16280
17814
  const subject = opts.room.subject;
16281
17815
  const directorList = opts.picksWithReasons.map((p, i) => {
@@ -16460,6 +17994,65 @@ function parseRoundEndOutput(text) {
16460
17994
  return { ping, points, modeShift };
16461
17995
  }
16462
17996
 
17997
+ // src/orchestrator/negative-space-extract.ts
17998
+ function renderTranscriptForRound(roundMessages) {
17999
+ const lines = [];
18000
+ for (const m of roundMessages) {
18001
+ if (m.authorKind !== "agent") continue;
18002
+ const kind = m.meta?.kind;
18003
+ if (kind && (kind === "round-open" || kind === "settings" || kind === "round-prompt")) continue;
18004
+ const body = (m.body || "").trim();
18005
+ if (!body) continue;
18006
+ lines.push(body.length > 500 ? body.slice(0, 500) + "\u2026" : body);
18007
+ }
18008
+ return lines.join("\n---\n");
18009
+ }
18010
+ async function extractNegativeSpace(opts) {
18011
+ const turns = (opts.roundMessages || []).filter(
18012
+ (m) => m.authorKind === "agent" && !m.meta?.kind
18013
+ );
18014
+ if (turns.length < 2) return [];
18015
+ const transcript = renderTranscriptForRound(turns);
18016
+ if (!transcript) return [];
18017
+ const modelV = utilityModelFor();
18018
+ if (!modelV) return [];
18019
+ const prompt = `You are protecting the divergence of a multi-director brainstorm. The room's subject is: "${opts.roomSubject}". A round of director turns just ended. Read those turns below and identify 1-3 ANGLES the round did NOT touch but plausibly SHOULD have, given the room's subject. An "angle" is a short noun phrase \u2264 8 words \u2014 a stakeholder type, a time horizon, a domain analogy, a technical layer, a cultural / regulatory context, a material constraint, a hidden user, a counter-population.
18020
+
18021
+ RULES
18022
+ \xB7 Each angle is a NEW direction the room could explore next, not a critique of what was said.
18023
+ \xB7 Each angle is a CONCRETE noun phrase, not a question. ("informal-economy workers" yes; "what about workers?" no.)
18024
+ \xB7 Each angle is genuinely fresh \u2014 NOT a paraphrase of what the round already discussed.
18025
+ \xB7 Match the language of the transcript.
18026
+ \xB7 Return ONLY a newline-separated list (max 3 lines). No bullets, no numbering, no preamble.
18027
+ \xB7 If the round was already genuinely diverse and no obvious angle is missing, return the literal token NONE.
18028
+
18029
+ Round transcript:
18030
+ ${transcript}
18031
+
18032
+ Unexplored angles (newline-separated, or NONE):`;
18033
+ let body;
18034
+ try {
18035
+ body = await callLLM({
18036
+ modelV,
18037
+ carrier: null,
18038
+ messages: [{ role: "user", content: prompt }],
18039
+ temperature: 0.4,
18040
+ maxTokens: 120
18041
+ });
18042
+ } catch (e) {
18043
+ process.stderr.write(
18044
+ `[negative-space] extract failed: ${e instanceof Error ? e.message : String(e)}
18045
+ `
18046
+ );
18047
+ return [];
18048
+ }
18049
+ const txt = (body || "").trim();
18050
+ if (!txt) return [];
18051
+ if (/^none\b/i.test(txt) || /^无$/.test(txt)) return [];
18052
+ const lines = txt.split(/\n/).map((l) => l.trim().replace(/^[-·•*\d.)\s]+/, "").replace(/[。.]+$/, "")).filter((l) => l.length > 0 && l.length < 200);
18053
+ return lines.slice(0, 3);
18054
+ }
18055
+
16463
18056
  // src/storage/summaries.ts
16464
18057
  init_db();
16465
18058
  function rowFrom(r) {
@@ -16564,7 +18157,13 @@ async function generateL1ForRound(roomId, roundNum) {
16564
18157
  const keyPointsBlock = keyPoints.length ? "\n\nChair's key points from this round:\n" + keyPoints.map((kp, i) => `${i + 1}. ${kp.body}`).join("\n") : "";
16565
18158
  const modelV = utilityModelFor();
16566
18159
  if (!modelV) return;
16567
- const prompt = `Summarise round ${roundNum} of a multi-director boardroom discussion in 2-4 short sentences. Capture: (a) what the directors actually said (their distinct positions, not just topics), (b) where they pushed back on each other, (c) any concrete claim or recommendation that emerged. Do NOT restate the original question. Do NOT add commentary. Plain prose, third person, present tense.
18160
+ const prompt = `Summarise round ${roundNum} of a multi-director boardroom discussion in 4-7 short sentences. Your PRIMARY job is to **protect divergence** \u2014 preserve angles the room raised but did not develop, so future rounds know what's still open. Capture, in this order:
18161
+ (a) **New variables / lenses** any director introduced (even if the room ignored them) \u2014 list each by name. These are the room's unspent fuel.
18162
+ (b) **What was waved past** \u2014 angles mentioned in one sentence then abandoned. Name them explicitly.
18163
+ (c) **What was notably ABSENT** \u2014 if the round all discussed X, name 1-2 obvious dimensions (time scale, stakeholder type, geographic context, technical layer, cultural angle) that the round did NOT touch but should have.
18164
+ (d) **Tensions** \u2014 where directors took different positions (not "they all agreed on X"; that erases the divergence we want to preserve).
18165
+ (e) Only AFTER (a)-(d), 1-2 sentences on the substantive claims that emerged.
18166
+ Do NOT restate the original question. Do NOT add commentary. Do NOT use phrases like "directors converged" or "consensus emerged" \u2014 those distill out exactly the divergence we want to keep. Plain prose, third person, present tense.
16568
18167
 
16569
18168
  --- Round ${roundNum} transcript ---
16570
18169
  ${transcript}` + keyPointsBlock;
@@ -16574,8 +18173,8 @@ ${transcript}` + keyPointsBlock;
16574
18173
  messages: [
16575
18174
  { role: "user", content: prompt }
16576
18175
  ],
16577
- temperature: 0.2,
16578
- maxTokens: 400
18176
+ temperature: 0.3,
18177
+ maxTokens: 650
16579
18178
  });
16580
18179
  const trimmed = body.trim();
16581
18180
  if (!trimmed) return;
@@ -16597,7 +18196,14 @@ async function foldL1IntoL2(roomId, newRoundNum, newL1Body) {
16597
18196
  ${existing.body}
16598
18197
 
16599
18198
  ` : "";
16600
- const prompt = `You maintain a rolling consolidated narrative of an ongoing multi-director boardroom discussion. A new round just dropped out of the recent window and needs to be folded in. Produce a single narrative of 4-7 sentences covering rounds ${startRound} through ${endRound}. Preserve: each director's evolving position, where the discussion sharpened, decisions / commitments that emerged. Drop: minor exchanges, repeated points already captured. Plain prose, third person.
18199
+ const prompt = `You maintain a rolling consolidated narrative of an ongoing multi-director boardroom discussion. A new round just dropped out of the recent window and needs to be folded in. Produce a single narrative of 6-10 sentences covering rounds ${startRound} through ${endRound}.
18200
+
18201
+ Your PRIMARY job is to **protect divergence over time** \u2014 long rooms drift toward a single thread, and your narrative is what the next 10 rounds will use to remember the room. Maintain:
18202
+ (a) **A running list of "still-open angles"** \u2014 variables / lenses that were raised in any round and not deeply developed. Carry these forward across folds; do NOT prune them when a new round dominates a different track.
18203
+ (b) **Each director's distinctive lens** \u2014 what makes their contribution NOT interchangeable with the others. Avoid "all directors agreed on X" framings.
18204
+ (c) **Major pivots** \u2014 points where the room genuinely shifted direction (versus just deepened an existing track).
18205
+ (d) **Tensions that remain unresolved** \u2014 disagreements that haven't been settled. Preserve them; do NOT smooth them into consensus.
18206
+ Drop: minor exchanges, micro-clarifications, repeated points. Plain prose, third person. Do NOT use phrases like "directors converged" or "consensus emerged" \u2014 they erase exactly the divergence we want to preserve.
16601
18207
 
16602
18208
  ${previousBlock}New round ${newRoundNum} (just demoted from L1):
16603
18209
  ${newL1Body}`;
@@ -16605,8 +18211,8 @@ ${newL1Body}`;
16605
18211
  modelV,
16606
18212
  carrier: null,
16607
18213
  messages: [{ role: "user", content: prompt }],
16608
- temperature: 0.25,
16609
- maxTokens: 600
18214
+ temperature: 0.3,
18215
+ maxTokens: 900
16610
18216
  });
16611
18217
  const trimmed = body.trim();
16612
18218
  if (!trimmed) return;
@@ -16642,9 +18248,53 @@ function hashOf(s) {
16642
18248
  return (h >>> 0).toString(16);
16643
18249
  }
16644
18250
 
18251
+ // src/storage/negative-space.ts
18252
+ init_db();
18253
+ function mapRow11(r) {
18254
+ return {
18255
+ id: r.id,
18256
+ roomId: r.room_id,
18257
+ roundNum: r.round_num,
18258
+ angle: r.angle,
18259
+ createdAt: r.created_at,
18260
+ consumed: r.consumed === 1
18261
+ };
18262
+ }
18263
+ function insertNegativeSpaceAngles(roomId, roundNum, angles) {
18264
+ if (angles.length === 0) return;
18265
+ const db = getDb();
18266
+ const stmt = db.prepare(
18267
+ "INSERT INTO negative_space (id, room_id, round_num, angle, created_at, consumed) VALUES (?, ?, ?, ?, ?, 0)"
18268
+ );
18269
+ const now = Date.now();
18270
+ const tx = db.transaction((rows) => {
18271
+ for (const r of rows) {
18272
+ stmt.run(r.id, roomId, roundNum, r.angle, now);
18273
+ }
18274
+ });
18275
+ tx(
18276
+ angles.map((a) => a.trim()).filter((a) => a.length > 0 && a.length < 280).map((a) => ({ id: newId(), angle: a }))
18277
+ );
18278
+ }
18279
+ function getRecentUnexploredAngles(roomId, limit = 5) {
18280
+ const rows = getDb().prepare(
18281
+ "SELECT id, room_id, round_num, angle, created_at, consumed FROM negative_space WHERE room_id = ? AND consumed = 0 ORDER BY created_at DESC LIMIT ?"
18282
+ ).all(roomId, Math.max(1, Math.floor(limit)));
18283
+ return rows.map(mapRow11);
18284
+ }
18285
+ function markAnglesConsumed(angleIds) {
18286
+ if (angleIds.length === 0) return;
18287
+ const db = getDb();
18288
+ const stmt = db.prepare("UPDATE negative_space SET consumed = 1 WHERE id = ?");
18289
+ const tx = db.transaction((ids) => {
18290
+ for (const id of ids) stmt.run(id);
18291
+ });
18292
+ tx(angleIds);
18293
+ }
18294
+
16645
18295
  // src/storage/config-events.ts
16646
18296
  init_db();
16647
- function mapRow11(row) {
18297
+ function mapRow12(row) {
16648
18298
  return {
16649
18299
  id: row.id,
16650
18300
  roomId: row.room_id,
@@ -16658,7 +18308,7 @@ function listConfigEvents(roomId) {
16658
18308
  const rows = getDb().prepare(
16659
18309
  "SELECT id, room_id, kind, payload, actor_kind, created_at FROM config_events WHERE room_id = ? ORDER BY created_at ASC"
16660
18310
  ).all(roomId);
16661
- return rows.map(mapRow11);
18311
+ return rows.map(mapRow12);
16662
18312
  }
16663
18313
  function insertConfigEvent(e) {
16664
18314
  const id = newId();
@@ -16776,6 +18426,382 @@ ${lines.join("\n")}`);
16776
18426
  ].join("\n");
16777
18427
  }
16778
18428
 
18429
+ // src/orchestrator/frame-break.ts
18430
+ function renderForExtraction(messages, maxBodyChars = 360) {
18431
+ const lines = [];
18432
+ for (const m of messages) {
18433
+ if (m.authorKind !== "agent") continue;
18434
+ const meta = m.meta || {};
18435
+ if (meta.kind === "round-open" || meta.kind === "settings" || meta.kind === "round-prompt") continue;
18436
+ const body = (m.body || "").trim();
18437
+ if (!body) continue;
18438
+ lines.push(body.length > maxBodyChars ? body.slice(0, maxBodyChars) + "\u2026" : body);
18439
+ }
18440
+ return lines.join("\n---\n");
18441
+ }
18442
+ async function extractDominantTerms(opts) {
18443
+ const window = opts.windowSize ?? 15;
18444
+ const recent = (opts.messages || []).slice(-window);
18445
+ const directorTurns = recent.filter(
18446
+ (m) => m.authorKind === "agent" && !m.meta?.kind
18447
+ );
18448
+ if (directorTurns.length < 4) return [];
18449
+ const modelV = utilityModelFor();
18450
+ if (!modelV) return [];
18451
+ const transcript = renderForExtraction(directorTurns);
18452
+ if (!transcript.trim()) return [];
18453
+ const prompt = `You are inspecting a multi-director brainstorm for SIGNS OF CONVERGENCE. Read the recent director turns below and identify the noun phrases / concepts that have become the room's recurring fixation \u2014 terms mentioned by multiple directors across multiple turns that are now functioning as the room's gravitational center.
18454
+
18455
+ RULES
18456
+ \xB7 Return ONLY a comma-separated list (no preamble, no JSON, no explanation).
18457
+ \xB7 3-5 terms maximum. Each term \u2264 4 words.
18458
+ \xB7 Prefer the highest-content noun phrases (e.g. "audit responsibility", "compliance burden") over generic words ("AI", "tool", "user").
18459
+ \xB7 If the conversation is genuinely diverse and no single fixation has emerged, return the literal token NONE (no list).
18460
+ \xB7 Match the language of the transcript (Chinese in, Chinese terms out).
18461
+
18462
+ Recent director turns:
18463
+ ${transcript}
18464
+
18465
+ Recurring fixation terms (comma-separated, or NONE):`;
18466
+ let body;
18467
+ try {
18468
+ body = await callLLM({
18469
+ modelV,
18470
+ carrier: null,
18471
+ messages: [{ role: "user", content: prompt }],
18472
+ temperature: 0.1,
18473
+ maxTokens: 80
18474
+ });
18475
+ } catch (e) {
18476
+ process.stderr.write(
18477
+ `[frame-break] extract failed: ${e instanceof Error ? e.message : String(e)}
18478
+ `
18479
+ );
18480
+ return [];
18481
+ }
18482
+ const txt = (body || "").trim();
18483
+ if (!txt) return [];
18484
+ if (/^none\b/i.test(txt) || /^无$/.test(txt)) return [];
18485
+ const raw = txt.split(/[,,、]/);
18486
+ const out = [];
18487
+ for (const r of raw) {
18488
+ const trimmed = r.trim().replace(/^["'"'"']+|["'"'"'.。]+$/g, "");
18489
+ if (!trimmed) continue;
18490
+ if (trimmed.length > 60) continue;
18491
+ if (/[\n::]/.test(trimmed)) continue;
18492
+ out.push(trimmed);
18493
+ if (out.length >= 5) break;
18494
+ }
18495
+ return out;
18496
+ }
18497
+
18498
+ // src/storage/qd-archive.ts
18499
+ init_db();
18500
+ var QD_BUCKETS_PER_DIM = 4;
18501
+ var QD_TOTAL_CELLS = QD_BUCKETS_PER_DIM ** 3;
18502
+ function mapRow13(r) {
18503
+ return {
18504
+ messageId: r.message_id,
18505
+ roomId: r.room_id,
18506
+ abstractionBucket: r.abstraction_bucket,
18507
+ timeBucket: r.time_bucket,
18508
+ stakeholderBucket: r.stakeholder_bucket,
18509
+ abstractionScore: r.abstraction_score,
18510
+ timeScore: r.time_score,
18511
+ stakeholderScore: r.stakeholder_score,
18512
+ scoredAt: r.scored_at
18513
+ };
18514
+ }
18515
+ function bucketize(score) {
18516
+ const s = Math.max(0, Math.min(1, score));
18517
+ const b = Math.floor(s * QD_BUCKETS_PER_DIM);
18518
+ return b === QD_BUCKETS_PER_DIM ? QD_BUCKETS_PER_DIM - 1 : b;
18519
+ }
18520
+ function upsertQDScore(opts) {
18521
+ const now = Date.now();
18522
+ const ab = bucketize(opts.scores.abstractionScore);
18523
+ const tb = bucketize(opts.scores.timeScore);
18524
+ const sb = bucketize(opts.scores.stakeholderScore);
18525
+ getDb().prepare(
18526
+ "INSERT OR REPLACE INTO qd_archive (message_id, room_id, abstraction_score, abstraction_bucket, time_score, time_bucket, stakeholder_score, stakeholder_bucket, scored_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
18527
+ ).run(
18528
+ opts.messageId,
18529
+ opts.roomId,
18530
+ opts.scores.abstractionScore,
18531
+ ab,
18532
+ opts.scores.timeScore,
18533
+ tb,
18534
+ opts.scores.stakeholderScore,
18535
+ sb,
18536
+ now
18537
+ );
18538
+ return {
18539
+ messageId: opts.messageId,
18540
+ roomId: opts.roomId,
18541
+ abstractionBucket: ab,
18542
+ timeBucket: tb,
18543
+ stakeholderBucket: sb,
18544
+ abstractionScore: opts.scores.abstractionScore,
18545
+ timeScore: opts.scores.timeScore,
18546
+ stakeholderScore: opts.scores.stakeholderScore,
18547
+ scoredAt: now
18548
+ };
18549
+ }
18550
+ function listQDForRoom(roomId) {
18551
+ const rows = getDb().prepare(
18552
+ "SELECT message_id, room_id, abstraction_score, abstraction_bucket, time_score, time_bucket, stakeholder_score, stakeholder_bucket, scored_at FROM qd_archive WHERE room_id = ?"
18553
+ ).all(roomId);
18554
+ return rows.map(mapRow13);
18555
+ }
18556
+ function filledCellsForRoom(roomId) {
18557
+ const rows = getDb().prepare(
18558
+ "SELECT DISTINCT abstraction_bucket AS ab, time_bucket AS tb, stakeholder_bucket AS sb FROM qd_archive WHERE room_id = ?"
18559
+ ).all(roomId);
18560
+ const set = /* @__PURE__ */ new Set();
18561
+ for (const r of rows) {
18562
+ set.add(packCell(r.ab, r.tb, r.sb));
18563
+ }
18564
+ return set;
18565
+ }
18566
+ function packCell(a, t, s) {
18567
+ return a * QD_BUCKETS_PER_DIM * QD_BUCKETS_PER_DIM + t * QD_BUCKETS_PER_DIM + s;
18568
+ }
18569
+ function coverageForRoom(roomId) {
18570
+ const filled = filledCellsForRoom(roomId).size;
18571
+ return {
18572
+ filled,
18573
+ total: QD_TOTAL_CELLS,
18574
+ pct: filled / QD_TOTAL_CELLS
18575
+ };
18576
+ }
18577
+
18578
+ // src/orchestrator/qd-scorer.ts
18579
+ function parseScore(line) {
18580
+ const m = line.match(/(\d+\.\d+|\d+)/);
18581
+ if (!m) return null;
18582
+ let n = parseFloat(m[1]);
18583
+ if (!Number.isFinite(n)) return null;
18584
+ if (n > 1 && n <= 10) n = n / 10;
18585
+ if (n > 1 && n <= 100) n = n / 100;
18586
+ return Math.max(0, Math.min(1, n));
18587
+ }
18588
+ async function scoreAndArchive(opts) {
18589
+ const body = (opts.body || "").trim();
18590
+ if (body.length < 40) return null;
18591
+ const modelV = utilityModelFor();
18592
+ if (!modelV) return null;
18593
+ const prompt = `Rate the director turn below on THREE behavioral dimensions. Each rating is a single floating-point number 0.00-1.00.
18594
+
18595
+ Dimension A \xB7 Abstraction level
18596
+ \xB7 0.00 = concrete example (specific named user, specific product, specific scenario)
18597
+ \xB7 0.33 = case / use-case (representative pattern, named domain)
18598
+ \xB7 0.66 = mechanism / structural argument (how-it-works, conditions)
18599
+ \xB7 1.00 = abstract principle (timeless / cross-domain / first-principles)
18600
+
18601
+ Dimension B \xB7 Time scale
18602
+ \xB7 0.00 = this quarter / immediate (months)
18603
+ \xB7 0.33 = product cycle (1-3 years)
18604
+ \xB7 0.66 = strategic / generational (5-20 years)
18605
+ \xB7 1.00 = civilizational / long-horizon (50+ years, structural)
18606
+
18607
+ Dimension C \xB7 Stakeholder scope
18608
+ \xB7 0.00 = individual user / single role
18609
+ \xB7 0.33 = team / org
18610
+ \xB7 0.66 = industry / market
18611
+ \xB7 1.00 = society / civilization
18612
+
18613
+ OUTPUT \xB7 exactly three lines, one float per line, in order A B C. No labels, no JSON, no commentary.
18614
+
18615
+ Turn:
18616
+ ${body.length > 1200 ? body.slice(0, 1200) + "\u2026" : body}
18617
+
18618
+ Three scores (one per line, A then B then C):`;
18619
+ let raw;
18620
+ try {
18621
+ raw = await callLLM({
18622
+ modelV,
18623
+ carrier: null,
18624
+ messages: [{ role: "user", content: prompt }],
18625
+ temperature: 0,
18626
+ maxTokens: 30
18627
+ });
18628
+ } catch (e) {
18629
+ process.stderr.write(
18630
+ `[qd-scorer] LLM call failed: ${e instanceof Error ? e.message : String(e)}
18631
+ `
18632
+ );
18633
+ return null;
18634
+ }
18635
+ const lines = (raw || "").split(/\n/).map((l) => l.trim()).filter(Boolean);
18636
+ if (lines.length < 3) return null;
18637
+ const a = parseScore(lines[0]);
18638
+ const t = parseScore(lines[1]);
18639
+ const s = parseScore(lines[2]);
18640
+ if (a === null || t === null || s === null) return null;
18641
+ const scores = { abstractionScore: a, timeScore: t, stakeholderScore: s };
18642
+ try {
18643
+ upsertQDScore({ messageId: opts.messageId, roomId: opts.roomId, scores });
18644
+ } catch (e) {
18645
+ process.stderr.write(
18646
+ `[qd-scorer] persist failed: ${e instanceof Error ? e.message : String(e)}
18647
+ `
18648
+ );
18649
+ return null;
18650
+ }
18651
+ return scores;
18652
+ }
18653
+
18654
+ // src/storage/topic-branches.ts
18655
+ init_db();
18656
+ function mapBranch(r) {
18657
+ return {
18658
+ id: r.id,
18659
+ roomId: r.room_id,
18660
+ label: r.label,
18661
+ parentId: r.parent_id,
18662
+ openedAt: r.opened_at,
18663
+ turnCount: r.turn_count,
18664
+ lastSpeakerId: r.last_speaker_id
18665
+ };
18666
+ }
18667
+ function listBranchesForRoom(roomId) {
18668
+ const rows = getDb().prepare(
18669
+ "SELECT id, room_id, label, parent_id, opened_at, turn_count, last_speaker_id FROM topic_branches WHERE room_id = ? ORDER BY opened_at ASC"
18670
+ ).all(roomId);
18671
+ return rows.map(mapBranch);
18672
+ }
18673
+ function createBranch(opts) {
18674
+ const now = Date.now();
18675
+ const id = newId();
18676
+ const label = opts.label.trim().slice(0, 80);
18677
+ const parentId = opts.parentId || null;
18678
+ const speakerId = opts.openerSpeakerId || null;
18679
+ getDb().prepare(
18680
+ "INSERT INTO topic_branches (id, room_id, label, parent_id, opened_at, turn_count, last_speaker_id) VALUES (?, ?, ?, ?, ?, 1, ?)"
18681
+ ).run(id, opts.roomId, label, parentId, now, speakerId);
18682
+ return {
18683
+ id,
18684
+ roomId: opts.roomId,
18685
+ label,
18686
+ parentId,
18687
+ openedAt: now,
18688
+ turnCount: 1,
18689
+ lastSpeakerId: speakerId
18690
+ };
18691
+ }
18692
+ function tagMessageWithBranch(opts) {
18693
+ const now = Date.now();
18694
+ const db = getDb();
18695
+ db.prepare(
18696
+ "INSERT OR REPLACE INTO message_branches (message_id, branch_id, is_opener, tagged_at) VALUES (?, ?, ?, ?)"
18697
+ ).run(opts.messageId, opts.branchId, opts.isOpener ? 1 : 0, now);
18698
+ if (!opts.isOpener) {
18699
+ db.prepare(
18700
+ "UPDATE topic_branches SET turn_count = turn_count + 1, last_speaker_id = COALESCE(?, last_speaker_id) WHERE id = ?"
18701
+ ).run(opts.speakerId || null, opts.branchId);
18702
+ } else if (opts.speakerId) {
18703
+ db.prepare("UPDATE topic_branches SET last_speaker_id = ? WHERE id = ?").run(opts.speakerId, opts.branchId);
18704
+ }
18705
+ }
18706
+ function speakersOnBranches(roomId, branchIds) {
18707
+ if (branchIds.length === 0) return /* @__PURE__ */ new Set();
18708
+ const placeholders = branchIds.map(() => "?").join(",");
18709
+ const rows = getDb().prepare(
18710
+ `SELECT DISTINCT m.author_id AS author_id
18711
+ FROM messages m
18712
+ JOIN message_branches mb ON mb.message_id = m.id
18713
+ WHERE m.room_id = ?
18714
+ AND mb.branch_id IN (${placeholders})
18715
+ AND m.author_kind = 'agent'
18716
+ AND m.author_id IS NOT NULL`
18717
+ ).all(roomId, ...branchIds);
18718
+ return new Set(rows.map((r) => r.author_id));
18719
+ }
18720
+ function dominantBranches(roomId, limit = 3) {
18721
+ const rows = getDb().prepare(
18722
+ "SELECT id, room_id, label, parent_id, opened_at, turn_count, last_speaker_id FROM topic_branches WHERE room_id = ? ORDER BY turn_count DESC, opened_at DESC LIMIT ?"
18723
+ ).all(roomId, Math.max(1, Math.floor(limit)));
18724
+ return rows.map(mapBranch);
18725
+ }
18726
+
18727
+ // src/orchestrator/topic-tagger.ts
18728
+ async function tagMessageBranch(opts) {
18729
+ const body = (opts.body || "").trim();
18730
+ if (!body) return null;
18731
+ const modelV = utilityModelFor();
18732
+ if (!modelV) return null;
18733
+ const existing = listBranchesForRoom(opts.roomId);
18734
+ const branchList = existing.length === 0 ? "(none yet \u2014 this is the first branch)" : existing.map((b, i) => `${i + 1}. id=${b.id} \xB7 "${b.label}" \xB7 ${b.turnCount} turn(s)`).join("\n");
18735
+ const prompt = `You are tagging a director's turn in a multi-director brainstorm with the topic branch it belongs to. Branches are short noun-phrase angles the room has been exploring (e.g. "audit responsibility", "informal-economy workers", "ritualised handoff").
18736
+
18737
+ Room subject: "${opts.roomSubject}"
18738
+
18739
+ Existing branches in this room:
18740
+ ${branchList}
18741
+
18742
+ Director's turn to tag (verbatim):
18743
+ ${body.length > 1200 ? body.slice(0, 1200) + "\u2026" : body}
18744
+
18745
+ Decide ONE of:
18746
+ (A) This turn primarily EXTENDS existing branch X. Output: EXTEND <branch-id>
18747
+ (B) This turn primarily OPENS a NEW branch. Output: NEW <short-label-\u2264-8-words>
18748
+
18749
+ Rules:
18750
+ \xB7 A turn that mostly adds detail / sub-angle to an existing branch is EXTEND.
18751
+ \xB7 A turn that introduces a genuinely fresh lens / domain / stakeholder is NEW.
18752
+ \xB7 When the turn could plausibly go either way, prefer NEW \xB7 the room benefits from more branches.
18753
+ \xB7 The label should be a CONCRETE noun phrase, not a question or full sentence.
18754
+ \xB7 Match the language of the turn for new-branch labels.
18755
+ \xB7 Output ONLY the directive \xB7 no explanation, no JSON, no preamble.`;
18756
+ let raw;
18757
+ try {
18758
+ raw = await callLLM({
18759
+ modelV,
18760
+ carrier: null,
18761
+ messages: [{ role: "user", content: prompt }],
18762
+ temperature: 0.1,
18763
+ maxTokens: 40
18764
+ });
18765
+ } catch (e) {
18766
+ process.stderr.write(
18767
+ `[topic-tagger] failed: ${e instanceof Error ? e.message : String(e)}
18768
+ `
18769
+ );
18770
+ return null;
18771
+ }
18772
+ const txt = (raw || "").trim();
18773
+ if (!txt) return null;
18774
+ const extendMatch = txt.match(/^EXTEND\s+(\S+)/i);
18775
+ if (extendMatch) {
18776
+ const branchId = extendMatch[1];
18777
+ if (existing.some((b) => b.id === branchId)) {
18778
+ tagMessageWithBranch({
18779
+ messageId: opts.messageId,
18780
+ branchId,
18781
+ isOpener: false,
18782
+ speakerId: opts.speakerId
18783
+ });
18784
+ return branchId;
18785
+ }
18786
+ }
18787
+ const newMatch = txt.match(/^NEW\s+(.+)$/i);
18788
+ let label = newMatch ? newMatch[1].trim() : txt.length < 80 ? txt : "";
18789
+ label = label.replace(/^["'`]+|["'`]+$/g, "").replace(/[。.!?]+$/, "").trim();
18790
+ if (!label || label.length > 80) return null;
18791
+ const branch = createBranch({
18792
+ roomId: opts.roomId,
18793
+ label,
18794
+ openerSpeakerId: opts.speakerId
18795
+ });
18796
+ tagMessageWithBranch({
18797
+ messageId: opts.messageId,
18798
+ branchId: branch.id,
18799
+ isOpener: true,
18800
+ speakerId: opts.speakerId
18801
+ });
18802
+ return branch.id;
18803
+ }
18804
+
16779
18805
  // src/voice/sentence-splitter.ts
16780
18806
  var END_RE = /[。!?!?;;::\n]|[.](?=\s|$)/;
16781
18807
  var SentenceChunker = class {
@@ -17180,6 +19206,9 @@ function ensureState(roomId) {
17180
19206
  pendingRoundEnd: false,
17181
19207
  savedOnPause: null,
17182
19208
  pendingChairPick: null,
19209
+ pendingFrameBreakTerms: null,
19210
+ pendingFrameBreakerRole: null,
19211
+ lastFrameBreakerAgentId: null,
17183
19212
  billingHaltedThisTurn: false,
17184
19213
  voiceWaiters: /* @__PURE__ */ new Map()
17185
19214
  };
@@ -17423,11 +19452,18 @@ function tickRoom(roomId, opts) {
17423
19452
  if (plan.length === 0) return;
17424
19453
  const state = ensureState(roomId);
17425
19454
  if (state.inflight) state.inflight.abort();
19455
+ for (const [, waiter] of state.voiceWaiters) {
19456
+ waiter();
19457
+ }
19458
+ state.voiceWaiters.clear();
17426
19459
  state.queue = plan.map((a) => ({ agentId: a.id, status: "queued" }));
17427
19460
  state.roundNum = opts.roundNum;
17428
19461
  state.speakersThisTurn = 0;
17429
19462
  state.savedOnPause = null;
17430
19463
  state.pendingChairPick = null;
19464
+ state.pendingFrameBreakTerms = null;
19465
+ state.pendingFrameBreakerRole = null;
19466
+ state.lastFrameBreakerAgentId = null;
17431
19467
  state.billingHaltedThisTurn = false;
17432
19468
  state.maxSpeakersThisTurn = plan.length;
17433
19469
  emitQueueUpdate(roomId, state);
@@ -17490,11 +19526,77 @@ async function pumpQueue(roomId) {
17490
19526
  try {
17491
19527
  emitChairPending(roomId, "next-speaker");
17492
19528
  const pickRoom = getRoom(roomId);
19529
+ let convergentTerms = [];
19530
+ try {
19531
+ convergentTerms = await extractDominantTerms({ messages: recent });
19532
+ } catch {
19533
+ }
19534
+ try {
19535
+ const branches = dominantBranches(roomId, 3);
19536
+ if (branches.length > 0) {
19537
+ const seen = new Set(convergentTerms.map((t) => t.toLowerCase()));
19538
+ for (const b of branches) {
19539
+ if (b.turnCount >= 2 && !seen.has(b.label.toLowerCase())) {
19540
+ convergentTerms.push(b.label);
19541
+ seen.add(b.label.toLowerCase());
19542
+ }
19543
+ }
19544
+ }
19545
+ } catch {
19546
+ }
19547
+ const useDissentMode = convergentTerms.length > 0;
19548
+ if (useDissentMode) {
19549
+ state.pendingFrameBreakTerms = convergentTerms.slice();
19550
+ rlog(roomId, "divergence-detect", {
19551
+ terms: convergentTerms,
19552
+ pickerMode: "dissent-gap"
19553
+ });
19554
+ }
19555
+ let pickerCandidates = candidates;
19556
+ if (useDissentMode) {
19557
+ try {
19558
+ const branches = dominantBranches(roomId, 3);
19559
+ const dominantBranchIds = branches.map((b) => b.id);
19560
+ if (dominantBranchIds.length > 0) {
19561
+ const exposed = speakersOnBranches(roomId, dominantBranchIds);
19562
+ const underexposed = candidates.filter((c) => !exposed.has(c.id));
19563
+ const overexposed = candidates.filter((c) => exposed.has(c.id));
19564
+ if (underexposed.length > 0 && overexposed.length > 0) {
19565
+ pickerCandidates = [...underexposed, ...overexposed];
19566
+ }
19567
+ }
19568
+ } catch {
19569
+ }
19570
+ }
17493
19571
  const pick = await pickNextSpeaker({
17494
- candidates,
19572
+ candidates: pickerCandidates,
17495
19573
  history: recent,
17496
- room: pickRoom ?? void 0
19574
+ room: pickRoom ?? void 0,
19575
+ mode: useDissentMode ? "dissent-gap" : "lens-gap",
19576
+ convergentTerms: useDissentMode ? convergentTerms : void 0
17497
19577
  });
19578
+ if (useDissentMode && convergentTerms.length > 0) {
19579
+ const lastBreaker = state.lastFrameBreakerAgentId;
19580
+ const chosenId = pick.agentId ?? state.queue[0]?.agentId;
19581
+ let breakerId = null;
19582
+ if (chosenId && chosenId !== lastBreaker) {
19583
+ breakerId = chosenId;
19584
+ } else {
19585
+ const alt = candidates.find((c) => c.id !== lastBreaker && c.id !== chosenId);
19586
+ if (alt) breakerId = alt.id;
19587
+ }
19588
+ if (breakerId) {
19589
+ const frameLabel = (convergentTerms[0] || "").slice(0, 60);
19590
+ state.pendingFrameBreakerRole = {
19591
+ agentId: breakerId,
19592
+ convergentFrame: frameLabel
19593
+ };
19594
+ rlog(roomId, "frame-breaker-assign", {
19595
+ agent: getAgent(breakerId)?.name ?? breakerId,
19596
+ frame: frameLabel
19597
+ });
19598
+ }
19599
+ }
17498
19600
  const stillSameQueue = state.queue.length === queueSnapshot.length && state.queue.every((q, i) => q.agentId === queueSnapshot[i].agentId);
17499
19601
  if (stillSameQueue) {
17500
19602
  if (pick.agentId && pick.agentId !== state.queue[0].agentId) {
@@ -17852,6 +19954,46 @@ async function streamSpeakerTurn(args) {
17852
19954
  });
17853
19955
  }
17854
19956
  }
19957
+ const tStateForTurn = ensureState(roomId);
19958
+ let frameBreakTerms;
19959
+ if (tStateForTurn.pendingFrameBreakTerms && tStateForTurn.pendingFrameBreakTerms.length > 0) {
19960
+ frameBreakTerms = tStateForTurn.pendingFrameBreakTerms.slice();
19961
+ } else if (roundNum > 1 && history.length >= 4) {
19962
+ try {
19963
+ frameBreakTerms = await extractDominantTerms({ messages: history });
19964
+ if (frameBreakTerms.length > 0) {
19965
+ rlog(roomId, "frame-break-extract", {
19966
+ round: roundNum,
19967
+ speaker: speaker.name,
19968
+ terms: frameBreakTerms,
19969
+ via: "stream-fallback"
19970
+ });
19971
+ tStateForTurn.pendingFrameBreakTerms = frameBreakTerms.slice();
19972
+ }
19973
+ } catch {
19974
+ }
19975
+ }
19976
+ let frameBreakerRole;
19977
+ if (tStateForTurn.pendingFrameBreakerRole && tStateForTurn.pendingFrameBreakerRole.agentId === speaker.id) {
19978
+ frameBreakerRole = { convergentFrame: tStateForTurn.pendingFrameBreakerRole.convergentFrame };
19979
+ tStateForTurn.lastFrameBreakerAgentId = speaker.id;
19980
+ tStateForTurn.pendingFrameBreakerRole = null;
19981
+ }
19982
+ let unexploredAngles;
19983
+ if (roundNum > 1) {
19984
+ try {
19985
+ const rows = getRecentUnexploredAngles(roomId, 3);
19986
+ if (rows.length > 0) {
19987
+ unexploredAngles = rows.map((r) => r.angle);
19988
+ markAnglesConsumed(rows.map((r) => r.id));
19989
+ }
19990
+ } catch (e) {
19991
+ process.stderr.write(
19992
+ `[room] unexplored-angles read failed: ${e instanceof Error ? e.message : String(e)}
19993
+ `
19994
+ );
19995
+ }
19996
+ }
17855
19997
  const llmMessages = buildDirectorMessages({
17856
19998
  speaker,
17857
19999
  cast,
@@ -17864,6 +20006,9 @@ async function streamSpeakerTurn(args) {
17864
20006
  chairBrief: chairBriefForTurn ?? void 0,
17865
20007
  summaryPreamble,
17866
20008
  priorContext,
20009
+ frameBreakTerms,
20010
+ frameBreakerRole,
20011
+ unexploredAngles,
17867
20012
  deliveryMode: room.deliveryMode
17868
20013
  });
17869
20014
  const placeholderMeta = {
@@ -17908,10 +20053,23 @@ async function streamSpeakerTurn(args) {
17908
20053
  process.stderr.write(`[voice-debug] room=${roomId} deliveryMode="${room.deliveryMode}" voiceMode=${voiceMode}
17909
20054
  `);
17910
20055
  const voiceChunker = voiceMode ? new SentenceChunker({ maxChars: 120 }) : null;
17911
- const voiceProfile = voiceMode ? voiceProfileForAgent(speaker) : null;
20056
+ const initialVoiceProfile = voiceMode ? voiceProfileForAgent(speaker) : null;
17912
20057
  let voiceSeq = 0;
20058
+ function currentVoiceProfile() {
20059
+ if (!voiceMode) return null;
20060
+ try {
20061
+ const fresh = getAgent(speaker.id);
20062
+ if (fresh) return voiceProfileForAgent(fresh);
20063
+ } catch (e) {
20064
+ process.stderr.write(`[tts] currentVoiceProfile read failed: ${e instanceof Error ? e.message : String(e)}
20065
+ `);
20066
+ }
20067
+ return initialVoiceProfile;
20068
+ }
17913
20069
  async function emitVoiceText(text) {
17914
- if (!voiceMode || !voiceProfile || !text.trim()) return;
20070
+ if (!voiceMode || !text.trim()) return;
20071
+ const voiceProfile = currentVoiceProfile();
20072
+ if (!voiceProfile) return;
17915
20073
  process.stderr.write(`[tts] emitVoiceText called: provider=${voiceProfile.provider} voiceId=${voiceProfile.voiceId} textLen=${text.length} text="${text.slice(0, 50)}"
17916
20074
  `);
17917
20075
  let chunkCount = 0;
@@ -17934,8 +20092,10 @@ async function streamSpeakerTurn(args) {
17934
20092
  process.stderr.write(`[tts] emitVoiceText done: ${chunkCount} chunks emitted
17935
20093
  `);
17936
20094
  } catch (e) {
17937
- process.stderr.write(`[tts] ERROR room=${roomId} agent=${speaker.name} \xB7 ${e instanceof Error ? e.stack || e.message : String(e)}
17938
- `);
20095
+ process.stderr.write(
20096
+ `[tts] ERROR room=${roomId} agent=${speaker.name} provider=${voiceProfile.provider} voiceId=${voiceProfile.voiceId} \xB7 ${e instanceof Error ? e.stack || e.message : String(e)}
20097
+ `
20098
+ );
17939
20099
  }
17940
20100
  }
17941
20101
  try {
@@ -18086,6 +20246,38 @@ async function streamSpeakerTurn(args) {
18086
20246
  messageId: placeholder.id,
18087
20247
  finishReason
18088
20248
  });
20249
+ if (buf.trim().length >= 40) {
20250
+ void (async () => {
20251
+ try {
20252
+ await tagMessageBranch({
20253
+ roomId,
20254
+ messageId: placeholder.id,
20255
+ speakerId: speaker.id,
20256
+ body: buf,
20257
+ roomSubject: room.subject || ""
20258
+ });
20259
+ } catch (e) {
20260
+ process.stderr.write(
20261
+ `[room] topic-tag failed: ${e instanceof Error ? e.message : String(e)}
20262
+ `
20263
+ );
20264
+ }
20265
+ })();
20266
+ void (async () => {
20267
+ try {
20268
+ await scoreAndArchive({
20269
+ roomId,
20270
+ messageId: placeholder.id,
20271
+ body: buf
20272
+ });
20273
+ } catch (e) {
20274
+ process.stderr.write(
20275
+ `[room] qd-score failed: ${e instanceof Error ? e.message : String(e)}
20276
+ `
20277
+ );
20278
+ }
20279
+ })();
20280
+ }
18089
20281
  }
18090
20282
  return placeholder.id;
18091
20283
  }
@@ -18892,6 +21084,27 @@ async function runChairRoundEnd(roomId, roundNum) {
18892
21084
  }
18893
21085
  });
18894
21086
  void runRoundEndSummarization(roomId, roundNum);
21087
+ void (async () => {
21088
+ try {
21089
+ const room = getRoom(roomId);
21090
+ if (!room) return;
21091
+ const allMsgs = listMessages(roomId);
21092
+ const roundMsgs = allMsgs.filter((m) => m.roundNum === roundNum);
21093
+ if (roundMsgs.length === 0) return;
21094
+ const angles = await extractNegativeSpace({
21095
+ roundMessages: roundMsgs,
21096
+ roomSubject: room.subject || ""
21097
+ });
21098
+ if (angles.length > 0) {
21099
+ insertNegativeSpaceAngles(roomId, roundNum, angles);
21100
+ }
21101
+ } catch (e) {
21102
+ process.stderr.write(
21103
+ `[chair] negative-space extract failed: ${e instanceof Error ? e.message : String(e)}
21104
+ `
21105
+ );
21106
+ }
21107
+ })();
18895
21108
  }
18896
21109
  async function emitChairAnnouncementVoice(roomId, messageId, body) {
18897
21110
  const room = getRoom(roomId);
@@ -19171,7 +21384,7 @@ function announceRoundOpen(roomId, roundNum, opening) {
19171
21384
  function announceAdjournNoBrief(roomId) {
19172
21385
  const chair = getChairAgent();
19173
21386
  if (!chair) return;
19174
- const body = "Adjourned without filing a brief. The chair (you) declared no report is needed for this session.";
21387
+ const body = "Session adjourned without a report. If you'd like one later, use *Generate report* in the room header.";
19175
21388
  const m = insertMessage({
19176
21389
  roomId,
19177
21390
  authorKind: "agent",
@@ -19732,7 +21945,7 @@ async function runAutoPickAndSeat(roomId, subject) {
19732
21945
  }
19733
21946
  }
19734
21947
  function roomsRouter() {
19735
- const r = new Hono8();
21948
+ const r = new Hono9();
19736
21949
  r.get("/", (c) => c.json({ rooms: listRooms() }));
19737
21950
  r.post("/", async (c) => {
19738
21951
  let body;
@@ -19827,12 +22040,42 @@ function roomsRouter() {
19827
22040
  payload: { mode, intensity, briefStyle, deliveryMode, members: members.map((m) => m.agentId), autoPick },
19828
22041
  actorKind: "user"
19829
22042
  });
22043
+ let seedContext = null;
22044
+ if (b.seedContext && typeof b.seedContext === "object") {
22045
+ const raw = b.seedContext;
22046
+ const topicRecId = typeof raw.topicRecId === "string" && raw.topicRecId.trim().length > 0 ? raw.topicRecId.trim().slice(0, 64) : void 0;
22047
+ const rationale = typeof raw.rationale === "string" && raw.rationale.trim().length > 0 ? raw.rationale.trim().slice(0, 400) : void 0;
22048
+ const rawSnippets = Array.isArray(raw.snippets) ? raw.snippets : [];
22049
+ const snippets = rawSnippets.filter(
22050
+ (s) => !!s && typeof s === "object" && typeof s.title === "string" && typeof s.url === "string" && typeof s.description === "string"
22051
+ ).slice(0, 12).map((s) => ({
22052
+ title: s.title.slice(0, 200),
22053
+ url: s.url.slice(0, 600),
22054
+ description: s.description.slice(0, 600)
22055
+ }));
22056
+ if (topicRecId || rationale || snippets.length > 0) {
22057
+ seedContext = {
22058
+ ...topicRecId ? { topicRecId } : {},
22059
+ ...rationale ? { rationale } : {},
22060
+ ...snippets.length > 0 ? { snippets } : {}
22061
+ };
22062
+ }
22063
+ }
19830
22064
  const opening = insertMessage({
19831
22065
  roomId: room.id,
19832
22066
  authorKind: "user",
19833
22067
  body: subject,
19834
- roundNum: 1
22068
+ roundNum: 1,
22069
+ meta: seedContext ? { seedContext } : void 0
19835
22070
  });
22071
+ if (seedContext?.topicRecId) {
22072
+ try {
22073
+ markTopicRecOpened(seedContext.topicRecId, room.id);
22074
+ } catch (e) {
22075
+ process.stderr.write(`[rooms] topic-rec link failed: ${e instanceof Error ? e.message : String(e)}
22076
+ `);
22077
+ }
22078
+ }
19836
22079
  roomBus.emit(room.id, {
19837
22080
  type: "message-appended",
19838
22081
  messageId: opening.id,
@@ -19964,7 +22207,7 @@ function roomsRouter() {
19964
22207
  r.get("/:id/stream", (c) => {
19965
22208
  const id = c.req.param("id");
19966
22209
  if (!getRoom(id)) return c.json({ error: "not found" }, 404);
19967
- return streamSSE2(c, async (s) => {
22210
+ return streamSSE3(c, async (s) => {
19968
22211
  await s.writeSSE({ event: "hello", data: JSON.stringify({ roomId: id, ts: Date.now() }) });
19969
22212
  const queue = [];
19970
22213
  let resolveWaiter = null;
@@ -20493,6 +22736,42 @@ function roomsRouter() {
20493
22736
  if (!brief) return c.json({ error: "brief not yet generated" }, 404);
20494
22737
  return c.json({ ...brief, isGenerating: isBriefGenerating(brief.id) });
20495
22738
  });
22739
+ r.get("/:id/diversity", (c) => {
22740
+ const id = c.req.param("id");
22741
+ if (!getRoom(id)) return c.json({ error: "not found" }, 404);
22742
+ const branches = listBranchesForRoom(id).slice().sort((a, b) => b.turnCount - a.turnCount || a.openedAt - b.openedAt).map((b) => ({
22743
+ id: b.id,
22744
+ label: b.label,
22745
+ parentId: b.parentId,
22746
+ openedAt: b.openedAt,
22747
+ turnCount: b.turnCount,
22748
+ lastSpeakerId: b.lastSpeakerId
22749
+ }));
22750
+ const coverage = coverageForRoom(id);
22751
+ const qdRows = listQDForRoom(id);
22752
+ const buckets = {
22753
+ abstraction: [0, 0, 0, 0],
22754
+ time: [0, 0, 0, 0],
22755
+ stakeholder: [0, 0, 0, 0]
22756
+ };
22757
+ for (const r2 of qdRows) {
22758
+ buckets.abstraction[r2.abstractionBucket] += 1;
22759
+ buckets.time[r2.timeBucket] += 1;
22760
+ buckets.stakeholder[r2.stakeholderBucket] += 1;
22761
+ }
22762
+ const unexplored = getRecentUnexploredAngles(id, 5).map((a) => ({
22763
+ id: a.id,
22764
+ angle: a.angle,
22765
+ roundNum: a.roundNum
22766
+ }));
22767
+ return c.json({
22768
+ branches,
22769
+ coverage,
22770
+ buckets,
22771
+ messagesScored: qdRows.length,
22772
+ unexplored
22773
+ });
22774
+ });
20496
22775
  r.get("/:id/briefs", (c) => {
20497
22776
  const id = c.req.param("id");
20498
22777
  if (!getRoom(id)) return c.json({ error: "not found" }, 404);
@@ -20628,9 +22907,9 @@ function buildRoomExportMarkdown(opts) {
20628
22907
  }
20629
22908
 
20630
22909
  // src/routes/search.ts
20631
- import { Hono as Hono9 } from "hono";
22910
+ import { Hono as Hono10 } from "hono";
20632
22911
  function searchRouter() {
20633
- const r = new Hono9();
22912
+ const r = new Hono10();
20634
22913
  r.get("/", (c) => {
20635
22914
  const q = (c.req.query("q") || "").trim();
20636
22915
  if (q.length < 1) {
@@ -20669,7 +22948,7 @@ function searchRouter() {
20669
22948
  }
20670
22949
 
20671
22950
  // src/routes/usage.ts
20672
- import { Hono as Hono10 } from "hono";
22951
+ import { Hono as Hono11 } from "hono";
20673
22952
  function modelDisplay(modelV) {
20674
22953
  if (isModelV(modelV)) {
20675
22954
  const m = MODELS[modelV];
@@ -20678,7 +22957,7 @@ function modelDisplay(modelV) {
20678
22957
  return { displayName: modelV, provider: "unknown" };
20679
22958
  }
20680
22959
  function usageRouter() {
20681
- const r = new Hono10();
22960
+ const r = new Hono11();
20682
22961
  r.get("/summary", (c) => {
20683
22962
  const s = getUsageSummary();
20684
22963
  return c.json({
@@ -20728,7 +23007,7 @@ function usageRouter() {
20728
23007
  }
20729
23008
 
20730
23009
  // src/routes/voices.ts
20731
- import { Hono as Hono11 } from "hono";
23010
+ import { Hono as Hono12 } from "hono";
20732
23011
  var TTS_CACHE_MAX = 50;
20733
23012
  var ttsCache = /* @__PURE__ */ new Map();
20734
23013
  function ttsCacheKey(messageId, profile) {
@@ -20758,7 +23037,7 @@ function ttsCacheSet(key, val) {
20758
23037
  }
20759
23038
  }
20760
23039
  function voicesRouter() {
20761
- const r = new Hono11();
23040
+ const r = new Hono12();
20762
23041
  r.get("/", async (c) => c.json({ voices: await listAvailableVoices() }));
20763
23042
  r.post("/preview", async (c) => {
20764
23043
  let body;
@@ -20854,11 +23133,11 @@ function voicesRouter() {
20854
23133
  init_paths();
20855
23134
 
20856
23135
  // src/version.ts
20857
- var VERSION = "0.1.13";
23136
+ var VERSION = "0.1.16";
20858
23137
 
20859
23138
  // src/server.ts
20860
23139
  function createApp() {
20861
- const app = new Hono12();
23140
+ const app = new Hono13();
20862
23141
  const dir = publicDir();
20863
23142
  if (!existsSync2(dir)) {
20864
23143
  throw new Error(
@@ -20898,6 +23177,7 @@ Build the package or check that public/ is bundled alongside dist/.`
20898
23177
  app.route("/api/agents", agentsRouter());
20899
23178
  app.route("/api/keys", keysRouter());
20900
23179
  app.route("/api/models", modelsRouter());
23180
+ app.route("/api/topic-recs", topicRecsRouter());
20901
23181
  app.route("/api/rooms", roomsRouter());
20902
23182
  app.route("/api/briefs", briefsRouter());
20903
23183
  app.route("/api/notes", notesRouter());
@@ -20997,6 +23277,16 @@ async function main() {
20997
23277
  }
20998
23278
  } catch (e) {
20999
23279
  process.stderr.write(`[boot] persona-job recovery failed: ${e instanceof Error ? e.message : String(e)}
23280
+ `);
23281
+ }
23282
+ try {
23283
+ const failed = markRunningTopicRecJobsFailed();
23284
+ if (failed > 0) {
23285
+ process.stderr.write(`[boot] marked ${failed} topic-rec job(s) failed (server restarted mid-build)
23286
+ `);
23287
+ }
23288
+ } catch (e) {
23289
+ process.stderr.write(`[boot] topic-rec recovery failed: ${e instanceof Error ? e.message : String(e)}
21000
23290
  `);
21001
23291
  }
21002
23292
  void (async () => {