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