privateboard 0.1.13 → 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 +921 -25
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/public/app.js +1283 -27
- package/public/index.html +857 -49
package/dist/cli.js
CHANGED
|
@@ -510,6 +510,80 @@ 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
|
+
|
|
513
587
|
// src/storage/db.ts
|
|
514
588
|
var db_exports = {};
|
|
515
589
|
__export(db_exports, {
|
|
@@ -600,6 +674,8 @@ var init_db = __esm({
|
|
|
600
674
|
init_agent_persona_spec();
|
|
601
675
|
init_room_vote_trigger();
|
|
602
676
|
init_room_members_removed_at();
|
|
677
|
+
init_user_topic_recs();
|
|
678
|
+
init_topic_rec_tag();
|
|
603
679
|
MIGRATIONS = [
|
|
604
680
|
{ name: "001_init.sql", sql: init_default },
|
|
605
681
|
{ name: "002_default_opus.sql", sql: default_opus_default },
|
|
@@ -633,7 +709,9 @@ var init_db = __esm({
|
|
|
633
709
|
{ name: "030_minimax_region.sql", sql: minimax_region_default },
|
|
634
710
|
{ name: "031_agent_persona_spec.sql", sql: agent_persona_spec_default },
|
|
635
711
|
{ 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 }
|
|
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 }
|
|
637
715
|
];
|
|
638
716
|
_db = null;
|
|
639
717
|
}
|
|
@@ -1966,7 +2044,7 @@ function runSeed() {
|
|
|
1966
2044
|
// src/server.ts
|
|
1967
2045
|
import { serve } from "@hono/node-server";
|
|
1968
2046
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
1969
|
-
import { Hono as
|
|
2047
|
+
import { Hono as Hono13 } from "hono";
|
|
1970
2048
|
import { existsSync as existsSync2 } from "fs";
|
|
1971
2049
|
|
|
1972
2050
|
// src/routes/agents.ts
|
|
@@ -14665,8 +14743,750 @@ function collectProviderSummary(models) {
|
|
|
14665
14743
|
return Array.from(map.entries()).map(([provider, v]) => ({ provider, ...v }));
|
|
14666
14744
|
}
|
|
14667
14745
|
|
|
14668
|
-
// src/routes/
|
|
14746
|
+
// src/routes/topic-recs.ts
|
|
14669
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";
|
|
14670
15490
|
|
|
14671
15491
|
// src/storage/notes.ts
|
|
14672
15492
|
init_db();
|
|
@@ -14766,7 +15586,7 @@ function listAllNotesWithRoom() {
|
|
|
14766
15586
|
|
|
14767
15587
|
// src/routes/notes.ts
|
|
14768
15588
|
function notesRouter() {
|
|
14769
|
-
const r = new
|
|
15589
|
+
const r = new Hono7();
|
|
14770
15590
|
r.get("/", (c) => {
|
|
14771
15591
|
const notes = listAllNotesWithRoom();
|
|
14772
15592
|
return c.json({ notes, total: notes.length });
|
|
@@ -14838,9 +15658,9 @@ function deriveAuthorName(kind, authorId) {
|
|
|
14838
15658
|
}
|
|
14839
15659
|
|
|
14840
15660
|
// src/routes/prefs.ts
|
|
14841
|
-
import { Hono as
|
|
15661
|
+
import { Hono as Hono8 } from "hono";
|
|
14842
15662
|
function prefsRouter() {
|
|
14843
|
-
const r = new
|
|
15663
|
+
const r = new Hono8();
|
|
14844
15664
|
r.get("/", (c) => c.json(getPrefs()));
|
|
14845
15665
|
r.put("/", async (c) => {
|
|
14846
15666
|
let body;
|
|
@@ -14875,8 +15695,8 @@ function prefsRouter() {
|
|
|
14875
15695
|
}
|
|
14876
15696
|
|
|
14877
15697
|
// src/routes/rooms.ts
|
|
14878
|
-
import { Hono as
|
|
14879
|
-
import { streamSSE as
|
|
15698
|
+
import { Hono as Hono9 } from "hono";
|
|
15699
|
+
import { streamSSE as streamSSE3 } from "hono/streaming";
|
|
14880
15700
|
|
|
14881
15701
|
// src/storage/key_points.ts
|
|
14882
15702
|
init_db();
|
|
@@ -14981,7 +15801,7 @@ Does the chair need to ask a clarifying question before opening the room?`
|
|
|
14981
15801
|
}
|
|
14982
15802
|
return { shouldAsk: true, rationale: "" };
|
|
14983
15803
|
}
|
|
14984
|
-
const parsed =
|
|
15804
|
+
const parsed = extractJson6(raw);
|
|
14985
15805
|
if (!parsed || typeof parsed !== "object") {
|
|
14986
15806
|
return { shouldAsk: true, rationale: "" };
|
|
14987
15807
|
}
|
|
@@ -15073,7 +15893,7 @@ async function pickRoundWrap(opts) {
|
|
|
15073
15893
|
}
|
|
15074
15894
|
return { recommendation: "continue", rationale: "" };
|
|
15075
15895
|
}
|
|
15076
|
-
const parsed =
|
|
15896
|
+
const parsed = extractJson6(raw);
|
|
15077
15897
|
if (!parsed || typeof parsed !== "object") {
|
|
15078
15898
|
return { recommendation: "continue", rationale: "" };
|
|
15079
15899
|
}
|
|
@@ -15198,7 +16018,7 @@ async function pickNextSpeaker(opts) {
|
|
|
15198
16018
|
}
|
|
15199
16019
|
return { agentId: null, rationale: "", intervention: null };
|
|
15200
16020
|
}
|
|
15201
|
-
const parsed =
|
|
16021
|
+
const parsed = extractJson6(raw);
|
|
15202
16022
|
if (!parsed || typeof parsed !== "object") {
|
|
15203
16023
|
return { agentId: null, rationale: "", intervention: null };
|
|
15204
16024
|
}
|
|
@@ -15310,7 +16130,7 @@ async function pickChairWebSearch(opts) {
|
|
|
15310
16130
|
}
|
|
15311
16131
|
return null;
|
|
15312
16132
|
}
|
|
15313
|
-
const parsed =
|
|
16133
|
+
const parsed = extractJson6(raw);
|
|
15314
16134
|
if (!parsed || typeof parsed !== "object") return null;
|
|
15315
16135
|
const ws = parsed;
|
|
15316
16136
|
if (typeof ws.query !== "string") return null;
|
|
@@ -15335,7 +16155,7 @@ function buildSkillsIndex(skills) {
|
|
|
15335
16155
|
function loadSkillBody(skill) {
|
|
15336
16156
|
return skill.bodyMd;
|
|
15337
16157
|
}
|
|
15338
|
-
function
|
|
16158
|
+
function extractJson6(text) {
|
|
15339
16159
|
if (!text) return null;
|
|
15340
16160
|
let s = text.trim();
|
|
15341
16161
|
s = s.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/i, "").trim();
|
|
@@ -15449,7 +16269,7 @@ Which skills apply, and does this turn need web search?`
|
|
|
15449
16269
|
continue;
|
|
15450
16270
|
}
|
|
15451
16271
|
}
|
|
15452
|
-
const parsed =
|
|
16272
|
+
const parsed = extractJson6(raw);
|
|
15453
16273
|
if (!parsed || typeof parsed !== "object") {
|
|
15454
16274
|
return { used: [], reason: "", webSearchQuery: null };
|
|
15455
16275
|
}
|
|
@@ -16267,8 +17087,10 @@ function buildChairClarifyMessages(opts) {
|
|
|
16267
17087
|
``,
|
|
16268
17088
|
`Output: either <ack + blank line + READY> OR the 2-part question block (in the user's language).`
|
|
16269
17089
|
].join("\n");
|
|
17090
|
+
const seedSystem = buildSeedContextSystem(opts.history);
|
|
16270
17091
|
return [
|
|
16271
17092
|
buildChairSystem(opts, isFirstTurn ? firstTurnTask : followUpTask),
|
|
17093
|
+
...seedSystem ? [seedSystem] : [],
|
|
16272
17094
|
...renderHistoryForChair(opts.history, opts.cast, opts.prefs),
|
|
16273
17095
|
{
|
|
16274
17096
|
role: "user",
|
|
@@ -16276,6 +17098,39 @@ function buildChairClarifyMessages(opts) {
|
|
|
16276
17098
|
}
|
|
16277
17099
|
];
|
|
16278
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
|
+
}
|
|
16279
17134
|
function buildChairConveningMessages(opts) {
|
|
16280
17135
|
const subject = opts.room.subject;
|
|
16281
17136
|
const directorList = opts.picksWithReasons.map((p, i) => {
|
|
@@ -19732,7 +20587,7 @@ async function runAutoPickAndSeat(roomId, subject) {
|
|
|
19732
20587
|
}
|
|
19733
20588
|
}
|
|
19734
20589
|
function roomsRouter() {
|
|
19735
|
-
const r = new
|
|
20590
|
+
const r = new Hono9();
|
|
19736
20591
|
r.get("/", (c) => c.json({ rooms: listRooms() }));
|
|
19737
20592
|
r.post("/", async (c) => {
|
|
19738
20593
|
let body;
|
|
@@ -19827,12 +20682,42 @@ function roomsRouter() {
|
|
|
19827
20682
|
payload: { mode, intensity, briefStyle, deliveryMode, members: members.map((m) => m.agentId), autoPick },
|
|
19828
20683
|
actorKind: "user"
|
|
19829
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
|
+
}
|
|
19830
20706
|
const opening = insertMessage({
|
|
19831
20707
|
roomId: room.id,
|
|
19832
20708
|
authorKind: "user",
|
|
19833
20709
|
body: subject,
|
|
19834
|
-
roundNum: 1
|
|
20710
|
+
roundNum: 1,
|
|
20711
|
+
meta: seedContext ? { seedContext } : void 0
|
|
19835
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
|
+
}
|
|
19836
20721
|
roomBus.emit(room.id, {
|
|
19837
20722
|
type: "message-appended",
|
|
19838
20723
|
messageId: opening.id,
|
|
@@ -19964,7 +20849,7 @@ function roomsRouter() {
|
|
|
19964
20849
|
r.get("/:id/stream", (c) => {
|
|
19965
20850
|
const id = c.req.param("id");
|
|
19966
20851
|
if (!getRoom(id)) return c.json({ error: "not found" }, 404);
|
|
19967
|
-
return
|
|
20852
|
+
return streamSSE3(c, async (s) => {
|
|
19968
20853
|
await s.writeSSE({ event: "hello", data: JSON.stringify({ roomId: id, ts: Date.now() }) });
|
|
19969
20854
|
const queue = [];
|
|
19970
20855
|
let resolveWaiter = null;
|
|
@@ -20628,9 +21513,9 @@ function buildRoomExportMarkdown(opts) {
|
|
|
20628
21513
|
}
|
|
20629
21514
|
|
|
20630
21515
|
// src/routes/search.ts
|
|
20631
|
-
import { Hono as
|
|
21516
|
+
import { Hono as Hono10 } from "hono";
|
|
20632
21517
|
function searchRouter() {
|
|
20633
|
-
const r = new
|
|
21518
|
+
const r = new Hono10();
|
|
20634
21519
|
r.get("/", (c) => {
|
|
20635
21520
|
const q = (c.req.query("q") || "").trim();
|
|
20636
21521
|
if (q.length < 1) {
|
|
@@ -20669,7 +21554,7 @@ function searchRouter() {
|
|
|
20669
21554
|
}
|
|
20670
21555
|
|
|
20671
21556
|
// src/routes/usage.ts
|
|
20672
|
-
import { Hono as
|
|
21557
|
+
import { Hono as Hono11 } from "hono";
|
|
20673
21558
|
function modelDisplay(modelV) {
|
|
20674
21559
|
if (isModelV(modelV)) {
|
|
20675
21560
|
const m = MODELS[modelV];
|
|
@@ -20678,7 +21563,7 @@ function modelDisplay(modelV) {
|
|
|
20678
21563
|
return { displayName: modelV, provider: "unknown" };
|
|
20679
21564
|
}
|
|
20680
21565
|
function usageRouter() {
|
|
20681
|
-
const r = new
|
|
21566
|
+
const r = new Hono11();
|
|
20682
21567
|
r.get("/summary", (c) => {
|
|
20683
21568
|
const s = getUsageSummary();
|
|
20684
21569
|
return c.json({
|
|
@@ -20728,7 +21613,7 @@ function usageRouter() {
|
|
|
20728
21613
|
}
|
|
20729
21614
|
|
|
20730
21615
|
// src/routes/voices.ts
|
|
20731
|
-
import { Hono as
|
|
21616
|
+
import { Hono as Hono12 } from "hono";
|
|
20732
21617
|
var TTS_CACHE_MAX = 50;
|
|
20733
21618
|
var ttsCache = /* @__PURE__ */ new Map();
|
|
20734
21619
|
function ttsCacheKey(messageId, profile) {
|
|
@@ -20758,7 +21643,7 @@ function ttsCacheSet(key, val) {
|
|
|
20758
21643
|
}
|
|
20759
21644
|
}
|
|
20760
21645
|
function voicesRouter() {
|
|
20761
|
-
const r = new
|
|
21646
|
+
const r = new Hono12();
|
|
20762
21647
|
r.get("/", async (c) => c.json({ voices: await listAvailableVoices() }));
|
|
20763
21648
|
r.post("/preview", async (c) => {
|
|
20764
21649
|
let body;
|
|
@@ -20854,11 +21739,11 @@ function voicesRouter() {
|
|
|
20854
21739
|
init_paths();
|
|
20855
21740
|
|
|
20856
21741
|
// src/version.ts
|
|
20857
|
-
var VERSION = "0.1.
|
|
21742
|
+
var VERSION = "0.1.15";
|
|
20858
21743
|
|
|
20859
21744
|
// src/server.ts
|
|
20860
21745
|
function createApp() {
|
|
20861
|
-
const app = new
|
|
21746
|
+
const app = new Hono13();
|
|
20862
21747
|
const dir = publicDir();
|
|
20863
21748
|
if (!existsSync2(dir)) {
|
|
20864
21749
|
throw new Error(
|
|
@@ -20898,6 +21783,7 @@ Build the package or check that public/ is bundled alongside dist/.`
|
|
|
20898
21783
|
app.route("/api/agents", agentsRouter());
|
|
20899
21784
|
app.route("/api/keys", keysRouter());
|
|
20900
21785
|
app.route("/api/models", modelsRouter());
|
|
21786
|
+
app.route("/api/topic-recs", topicRecsRouter());
|
|
20901
21787
|
app.route("/api/rooms", roomsRouter());
|
|
20902
21788
|
app.route("/api/briefs", briefsRouter());
|
|
20903
21789
|
app.route("/api/notes", notesRouter());
|
|
@@ -20997,6 +21883,16 @@ async function main() {
|
|
|
20997
21883
|
}
|
|
20998
21884
|
} catch (e) {
|
|
20999
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)}
|
|
21000
21896
|
`);
|
|
21001
21897
|
}
|
|
21002
21898
|
void (async () => {
|