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 +2623 -333
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/public/adjourn-overlay.css +6 -6
- package/public/agent-build-bgm.js +292 -0
- package/public/agent-overlay.css +14 -14
- package/public/agent-profile.css +408 -87
- package/public/agent-profile.js +254 -0
- package/public/app.js +2486 -384
- package/public/home.html +26 -26
- package/public/i18n.js +1890 -21
- package/public/icons/logo2.png +0 -0
- package/public/icons/private-board-vi.html +1716 -0
- package/public/index.html +2954 -1018
- package/public/magazine.html +12 -12
- package/public/new-agent.css +29 -29
- package/public/newspaper.html +20 -20
- package/public/onboarding.css +350 -272
- package/public/onboarding.js +614 -323
- package/public/quote-cta.css +4 -4
- package/public/report.html +2008 -1673
- package/public/room-settings.css +192 -24
- package/public/room-settings.js +5 -0
- package/public/share-cover-svg-creator.js +736 -0
- package/public/themes.css +0 -34
- package/public/typing-sfx.js +176 -3
- package/public/user-settings.css +50 -27
- package/public/user-settings.js +43 -14
- package/public/voice-onboarding.css +425 -0
- package/public/voice-onboarding.js +144 -0
- package/public/voice-replay.css +31 -38
- package/public/voice-replay.js +12 -11
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
|
|
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
|
-
|
|
4267
|
-
|
|
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
|
|
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
|
|
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
|
-
"
|
|
8094
|
-
" \xB7 `quadrant-chart` \u2014 items plotted on two real axes
|
|
8095
|
-
" \xB7 `bar-chart` \u2014 2\u20138 named items ranked by ONE quantitative dimension
|
|
8096
|
-
" \xB7 `timeline` \u2014 3\u20138 dated points telling a narrative arc
|
|
8097
|
-
" \xB7 `pie-chart` \u2014 2\u20136 slices showing a distribution
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
"
|
|
9102
|
-
"
|
|
9103
|
-
'
|
|
9104
|
-
'
|
|
9105
|
-
'
|
|
9106
|
-
'
|
|
9107
|
-
'
|
|
9108
|
-
'
|
|
9109
|
-
'
|
|
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
|
|
9112
|
-
|
|
9113
|
-
|
|
9114
|
-
|
|
9115
|
-
" \xB7
|
|
9116
|
-
" \xB7
|
|
9117
|
-
" \xB7
|
|
9118
|
-
" \xB7
|
|
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 ```
|
|
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
|
-
"
|
|
9133
|
-
'
|
|
9134
|
-
'
|
|
9135
|
-
'
|
|
9136
|
-
"
|
|
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
|
|
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
|
|
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 ```
|
|
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
|
-
"
|
|
9151
|
-
"
|
|
9152
|
-
"
|
|
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
|
|
9156
|
-
|
|
9157
|
-
|
|
9158
|
-
" \xB7
|
|
9159
|
-
" \xB7
|
|
9160
|
-
" \xB7 NO blank lines inside the fenced block.
|
|
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 ```
|
|
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
|
-
"
|
|
9167
|
-
"
|
|
9168
|
-
'
|
|
9169
|
-
|
|
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 `
|
|
9173
|
-
|
|
9174
|
-
|
|
9175
|
-
" \xB7
|
|
9176
|
-
" \xB7
|
|
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
|
|
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
|
|
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
|
-
"
|
|
9184
|
-
"
|
|
9185
|
-
"
|
|
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
|
|
9209
|
-
" \xB7 `Scenario Tree` \u2192 \u2713 `flowchart
|
|
9210
|
-
" \xB7 `Threats to Validity` \u2192 \u25C7 `flowchart
|
|
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
|
|
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
|
|
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
|
|
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
|
-
"
|
|
9232
|
-
"
|
|
9233
|
-
"
|
|
9234
|
-
"
|
|
9235
|
-
"
|
|
9236
|
-
|
|
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
|
|
9240
|
-
|
|
9241
|
-
" \xB7
|
|
9242
|
-
" \xB7
|
|
9243
|
-
|
|
9244
|
-
" \xB7
|
|
9245
|
-
" \xB7
|
|
9246
|
-
"",
|
|
9247
|
-
"
|
|
9248
|
-
"
|
|
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
|
-
"
|
|
9251
|
-
"
|
|
9252
|
-
"
|
|
9253
|
-
"
|
|
9254
|
-
"
|
|
9255
|
-
"
|
|
9256
|
-
"
|
|
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
|
|
9260
|
-
" \xB7
|
|
9261
|
-
" \xB7
|
|
9262
|
-
|
|
9263
|
-
" \xB7
|
|
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
|
-
|
|
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
|
-
"
|
|
9270
|
-
"
|
|
9271
|
-
"
|
|
9272
|
-
"
|
|
9273
|
-
"
|
|
9274
|
-
"
|
|
9275
|
-
"
|
|
9276
|
-
|
|
9277
|
-
|
|
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
|
|
9281
|
-
" \xB7 `
|
|
9282
|
-
" \xB7 `
|
|
9283
|
-
" \xB7
|
|
9284
|
-
" \xB7
|
|
9285
|
-
|
|
9286
|
-
"
|
|
9287
|
-
"",
|
|
9288
|
-
|
|
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
|
-
"
|
|
9292
|
-
"
|
|
9293
|
-
"
|
|
9294
|
-
"
|
|
9295
|
-
"
|
|
9296
|
-
"
|
|
9297
|
-
"
|
|
9298
|
-
"
|
|
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
|
|
9302
|
-
" \xB7
|
|
9303
|
-
" \xB7
|
|
9304
|
-
|
|
9305
|
-
" \xB7
|
|
9306
|
-
" \xB7
|
|
9307
|
-
"",
|
|
9308
|
-
"
|
|
9309
|
-
|
|
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
|
-
"
|
|
9312
|
-
"
|
|
9313
|
-
"
|
|
9314
|
-
"
|
|
9315
|
-
"
|
|
9316
|
-
|
|
9317
|
-
"
|
|
9318
|
-
"
|
|
9319
|
-
|
|
9320
|
-
"
|
|
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 `
|
|
9324
|
-
" \xB7
|
|
9325
|
-
|
|
9326
|
-
"
|
|
9327
|
-
"
|
|
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
|
-
"
|
|
9333
|
-
"
|
|
9334
|
-
"
|
|
9335
|
-
"
|
|
9336
|
-
"
|
|
9337
|
-
"
|
|
9338
|
-
"
|
|
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
|
|
9342
|
-
" \xB7
|
|
9343
|
-
" \xB7 `
|
|
9344
|
-
|
|
9345
|
-
" \xB7
|
|
9346
|
-
" \xB7
|
|
9347
|
-
" \xB7
|
|
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 ```
|
|
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 ```
|
|
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
|
|
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 ```
|
|
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
|
|
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
|
|
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
|
|
11695
|
-
" 1. Pick `visuals` with
|
|
11696
|
-
" 2. Stage 3 writer auto-emits inline
|
|
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
|
|
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 =
|
|
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 =
|
|
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 /
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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/
|
|
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/
|
|
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
|
-
|
|
14674
|
-
|
|
14675
|
-
|
|
14676
|
-
|
|
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
|
|
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:
|
|
14687
|
-
|
|
14688
|
-
|
|
14689
|
-
|
|
14690
|
-
|
|
14691
|
-
|
|
14692
|
-
|
|
14693
|
-
|
|
14694
|
-
|
|
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
|
-
|
|
14704
|
-
|
|
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
|
-
|
|
14708
|
-
`INSERT INTO
|
|
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
|
-
|
|
14712
|
-
|
|
14713
|
-
|
|
14714
|
-
|
|
14715
|
-
|
|
14716
|
-
|
|
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
|
|
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
|
|
14731
|
-
const row = getDb().prepare(`SELECT ${
|
|
14732
|
-
return row ?
|
|
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
|
|
14735
|
-
const
|
|
14736
|
-
|
|
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
|
|
14739
|
-
|
|
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
|
|
14743
|
-
const
|
|
14744
|
-
return
|
|
15373
|
+
function clearAllTopicRecs() {
|
|
15374
|
+
const r = getDb().prepare("DELETE FROM topic_recs").run();
|
|
15375
|
+
return r.changes;
|
|
14745
15376
|
}
|
|
14746
|
-
function
|
|
14747
|
-
const
|
|
14748
|
-
|
|
14749
|
-
|
|
14750
|
-
|
|
14751
|
-
|
|
14752
|
-
|
|
14753
|
-
|
|
14754
|
-
|
|
14755
|
-
|
|
14756
|
-
|
|
14757
|
-
|
|
14758
|
-
|
|
14759
|
-
|
|
14760
|
-
|
|
14761
|
-
|
|
14762
|
-
|
|
14763
|
-
|
|
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
|
|
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
|
|
16205
|
+
import { Hono as Hono8 } from "hono";
|
|
14842
16206
|
function prefsRouter() {
|
|
14843
|
-
const r = new
|
|
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
|
|
14879
|
-
import { streamSSE as
|
|
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 =
|
|
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 =
|
|
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) =>
|
|
15089
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 ·
|
|
16020
|
-
//
|
|
16021
|
-
//
|
|
16022
|
-
//
|
|
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
|
|
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.
|
|
16578
|
-
maxTokens:
|
|
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
|
|
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.
|
|
16609
|
-
maxTokens:
|
|
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
|
|
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(
|
|
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
|
|
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 || !
|
|
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(
|
|
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 = "
|
|
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
|
|
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
|
|
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
|
|
22910
|
+
import { Hono as Hono10 } from "hono";
|
|
20632
22911
|
function searchRouter() {
|
|
20633
|
-
const r = new
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
23136
|
+
var VERSION = "0.1.16";
|
|
20858
23137
|
|
|
20859
23138
|
// src/server.ts
|
|
20860
23139
|
function createApp() {
|
|
20861
|
-
const app = new
|
|
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 () => {
|