negotium 0.2.24 → 0.2.26
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/agent-helpers.js +82 -29
- package/dist/agent-helpers.js.map +5 -5
- package/dist/hosted-agent.js +2 -2
- package/dist/hosted-agent.js.map +2 -2
- package/dist/main.js +167 -364
- package/dist/main.js.map +26 -27
- package/dist/mcp-factories.js +84 -30
- package/dist/mcp-factories.js.map +6 -6
- package/dist/registry.js +2 -2
- package/dist/registry.js.map +2 -2
- package/dist/runtime/src/index.ts +3 -7
- package/dist/runtime/src/mcp/session-comm/default-host.ts +6 -1
- package/dist/runtime/src/mcp/session-comm/topic-catalog.ts +17 -1
- package/dist/runtime/src/mcp/session-comm/topics.ts +23 -1
- package/dist/runtime/src/node-host.ts +2 -3
- package/dist/runtime/src/storage/api-topics.ts +164 -52
- package/dist/runtime/src/storage/storage-public.ts +0 -1
- package/dist/runtime/src/topics/create.ts +12 -6
- package/dist/runtime/src/topics/derive.ts +20 -11
- package/dist/runtime/src/types/api.ts +8 -4
- package/dist/runtime/src/version.ts +1 -1
- package/dist/storage.js +92 -38
- package/dist/storage.js.map +3 -3
- package/dist/types/packages/core/src/mcp/session-comm/topic-catalog.d.ts +7 -0
- package/dist/types/packages/core/src/storage/api-topics.d.ts +34 -19
- package/dist/types/packages/core/src/storage/storage-public.d.ts +1 -1
- package/dist/types/packages/core/src/topics/derive.d.ts +13 -4
- package/dist/types/packages/core/src/types/api.d.ts +8 -4
- package/dist/types/packages/core/src/version.d.ts +1 -1
- package/package.json +1 -1
- package/dist/runtime/src/application/switch-topic-access-mode.ts +0 -110
|
@@ -9,14 +9,31 @@ import type {
|
|
|
9
9
|
AiMode,
|
|
10
10
|
ParticipantDto,
|
|
11
11
|
SubagentReportMode,
|
|
12
|
-
TopicAccessMode,
|
|
13
12
|
TopicDto,
|
|
14
13
|
TopicKind,
|
|
14
|
+
TopicSurface,
|
|
15
15
|
TopicVisibility,
|
|
16
16
|
} from "#types/api";
|
|
17
17
|
|
|
18
18
|
const DEFAULT_AGENT_ROOM_AGENT: AgentKind = "maestro";
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Surface used when a caller does not name one — and the value every existing
|
|
22
|
+
* row is backfilled with on first boot after the surface migration.
|
|
23
|
+
*
|
|
24
|
+
* Hosts that only ever serve one surface declare it once in their environment
|
|
25
|
+
* (`NEGOTIUM_DEFAULT_SURFACE=otium` on the Otium hub and worker); a developer
|
|
26
|
+
* Mac leaves it unset and gets `terminal`, with the telegram adapter
|
|
27
|
+
* reclassifying its own mapped rooms afterwards.
|
|
28
|
+
*/
|
|
29
|
+
export function defaultTopicSurface(): TopicSurface {
|
|
30
|
+
return normalizeTopicSurface(process.env.NEGOTIUM_DEFAULT_SURFACE);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function normalizeTopicSurface(value: unknown): TopicSurface {
|
|
34
|
+
return value === "telegram" || value === "otium" || value === "terminal" ? value : "terminal";
|
|
35
|
+
}
|
|
36
|
+
|
|
20
37
|
function tableColumns(table: string): Set<string> {
|
|
21
38
|
const rows = db.query(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
|
22
39
|
return new Set(rows.map((row) => row.name));
|
|
@@ -219,7 +236,7 @@ function initializeApiTopicsSchema(): void {
|
|
|
219
236
|
is_fork INTEGER NOT NULL DEFAULT 0 CHECK (is_fork IN (0,1)),
|
|
220
237
|
is_subagent INTEGER NOT NULL DEFAULT 0 CHECK (is_subagent IN (0,1)),
|
|
221
238
|
visibility TEXT NOT NULL DEFAULT 'visible' CHECK (visibility IN ('visible','hidden')),
|
|
222
|
-
|
|
239
|
+
surface TEXT NOT NULL DEFAULT 'terminal' CHECK (surface IN ('terminal','telegram','otium')),
|
|
223
240
|
browser_profile TEXT NOT NULL DEFAULT 'default',
|
|
224
241
|
browser_profile_owner TEXT,
|
|
225
242
|
session_id TEXT,
|
|
@@ -288,7 +305,7 @@ function initializeApiTopicsSchema(): void {
|
|
|
288
305
|
db.query(
|
|
289
306
|
`INSERT INTO api_topics_next
|
|
290
307
|
(id,title,kind,description,agent,base_model,base_effort,response_policy,
|
|
291
|
-
created_at,last_message_at,parent_topic_id,memory_topic_id,memory_key,is_fork,is_subagent,visibility,
|
|
308
|
+
created_at,last_message_at,parent_topic_id,memory_topic_id,memory_key,is_fork,is_subagent,visibility,surface,session_id)
|
|
292
309
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
293
310
|
).run(
|
|
294
311
|
String(row.id),
|
|
@@ -307,7 +324,7 @@ function initializeApiTopicsSchema(): void {
|
|
|
307
324
|
Number(row.is_fork ?? 0) !== 0 ? 1 : 0,
|
|
308
325
|
Number(row.is_subagent ?? 0) !== 0 ? 1 : 0,
|
|
309
326
|
row.visibility === "hidden" ? "hidden" : "visible",
|
|
310
|
-
row.
|
|
327
|
+
row.surface === undefined ? defaultTopicSurface() : normalizeTopicSurface(row.surface),
|
|
311
328
|
typeof row.session_id === "string" ? row.session_id : null,
|
|
312
329
|
);
|
|
313
330
|
}
|
|
@@ -373,8 +390,18 @@ function initializeApiTopicsSchema(): void {
|
|
|
373
390
|
if (!tableColumns("api_topics").has("visibility")) {
|
|
374
391
|
db.exec("ALTER TABLE api_topics ADD COLUMN visibility TEXT NOT NULL DEFAULT 'visible'");
|
|
375
392
|
}
|
|
376
|
-
if (!tableColumns("api_topics").has("
|
|
377
|
-
db.exec("ALTER TABLE api_topics ADD COLUMN
|
|
393
|
+
if (!tableColumns("api_topics").has("surface")) {
|
|
394
|
+
db.exec("ALTER TABLE api_topics ADD COLUMN surface TEXT NOT NULL DEFAULT 'terminal'");
|
|
395
|
+
}
|
|
396
|
+
// `access_mode` was replaced by `surface`: a topic is reachable from Otium
|
|
397
|
+
// because it lives there, not because a flag was flipped (S-4). Dropping the
|
|
398
|
+
// column removes the second, now-contradictory source of truth.
|
|
399
|
+
if (tableColumns("api_topics").has("access_mode")) {
|
|
400
|
+
try {
|
|
401
|
+
db.exec("ALTER TABLE api_topics DROP COLUMN access_mode");
|
|
402
|
+
} catch (err) {
|
|
403
|
+
logger.warn({ err }, "api_topics: could not drop the retired access_mode column");
|
|
404
|
+
}
|
|
378
405
|
}
|
|
379
406
|
if (!tableColumns("api_topics").has("browser_profile")) {
|
|
380
407
|
db.exec("ALTER TABLE api_topics ADD COLUMN browser_profile TEXT NOT NULL DEFAULT 'default'");
|
|
@@ -401,9 +428,80 @@ function initializeApiTopicsSchema(): void {
|
|
|
401
428
|
)
|
|
402
429
|
WHERE browser_profile_owner IS NULL
|
|
403
430
|
`);
|
|
431
|
+
backfillTopicSurfaces();
|
|
404
432
|
db.exec(
|
|
405
433
|
"CREATE INDEX IF NOT EXISTS idx_api_topics_last_message ON api_topics(last_message_at DESC)",
|
|
406
434
|
);
|
|
435
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_api_topics_surface ON api_topics(surface)");
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const SURFACE_BACKFILL_MIGRATION = "api_topics_surface_backfill_20260808";
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* One-time classification of pre-surface topics.
|
|
442
|
+
*
|
|
443
|
+
* Every row predates the column, so there is no per-row evidence in this store
|
|
444
|
+
* to distinguish surfaces — the host declares it (`NEGOTIUM_DEFAULT_SURFACE`).
|
|
445
|
+
* The telegram adapter owns the second pass: its chat↔topic mapping lives in a
|
|
446
|
+
* different database file, so only it can reclassify the rooms it created.
|
|
447
|
+
*
|
|
448
|
+
* Names are unique per surface from now on, so collapsing three namespaces into
|
|
449
|
+
* one can produce duplicates. Rather than failing the boot, the oldest room
|
|
450
|
+
* keeps the name and the rest are suffixed — a rename is recoverable, a node
|
|
451
|
+
* that refuses to start is not.
|
|
452
|
+
*/
|
|
453
|
+
function backfillTopicSurfaces(): void {
|
|
454
|
+
db.exec(`
|
|
455
|
+
CREATE TABLE IF NOT EXISTS api_schema_migrations (
|
|
456
|
+
key TEXT PRIMARY KEY,
|
|
457
|
+
applied_at TEXT NOT NULL
|
|
458
|
+
)
|
|
459
|
+
`);
|
|
460
|
+
const applied = db
|
|
461
|
+
.query("SELECT key FROM api_schema_migrations WHERE key = ?")
|
|
462
|
+
.get(SURFACE_BACKFILL_MIGRATION);
|
|
463
|
+
if (applied) return;
|
|
464
|
+
|
|
465
|
+
const surface = defaultTopicSurface();
|
|
466
|
+
db.transaction(() => {
|
|
467
|
+
db.query("UPDATE api_topics SET surface = ?").run(surface);
|
|
468
|
+
renameSurfaceTitleCollisions();
|
|
469
|
+
db.query("INSERT INTO api_schema_migrations (key, applied_at) VALUES (?, ?)").run(
|
|
470
|
+
SURFACE_BACKFILL_MIGRATION,
|
|
471
|
+
new Date().toISOString(),
|
|
472
|
+
);
|
|
473
|
+
})();
|
|
474
|
+
logger.info({ surface }, "api_topics: surface backfilled");
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Suffix duplicate `(surface, kind, title)` rows so the new uniqueness rule holds. */
|
|
478
|
+
function renameSurfaceTitleCollisions(): void {
|
|
479
|
+
const rows = db
|
|
480
|
+
.query<{ id: string; title: string; kind: string; surface: string }, []>(
|
|
481
|
+
"SELECT id, title, kind, surface FROM api_topics ORDER BY created_at ASC, rowid ASC",
|
|
482
|
+
)
|
|
483
|
+
.all();
|
|
484
|
+
const taken = new Set<string>();
|
|
485
|
+
const update = db.query("UPDATE api_topics SET title = ? WHERE id = ?");
|
|
486
|
+
for (const row of rows) {
|
|
487
|
+
const key = (title: string) => [row.surface, row.kind, normalizedTitle(title)].join("\u0000");
|
|
488
|
+
if (!taken.has(key(row.title))) {
|
|
489
|
+
taken.add(key(row.title));
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
let suffix = 2;
|
|
493
|
+
let candidate = `${row.title} (${suffix})`;
|
|
494
|
+
while (taken.has(key(candidate))) {
|
|
495
|
+
suffix += 1;
|
|
496
|
+
candidate = `${row.title} (${suffix})`;
|
|
497
|
+
}
|
|
498
|
+
taken.add(key(candidate));
|
|
499
|
+
update.run(candidate, row.id);
|
|
500
|
+
logger.warn(
|
|
501
|
+
{ topicId: row.id, surface: row.surface, from: row.title, to: candidate },
|
|
502
|
+
"api_topics: renamed a duplicate title for surface-scoped uniqueness",
|
|
503
|
+
);
|
|
504
|
+
}
|
|
407
505
|
}
|
|
408
506
|
|
|
409
507
|
registerStorageSchemaInitializer(initializeApiTopicsSchema, 20);
|
|
@@ -426,7 +524,7 @@ export interface TopicRow {
|
|
|
426
524
|
is_subagent: number;
|
|
427
525
|
subagent_report_mode: string | null;
|
|
428
526
|
visibility: string | null;
|
|
429
|
-
|
|
527
|
+
surface: string | null;
|
|
430
528
|
browser_profile_owner: string | null;
|
|
431
529
|
session_id: string | null;
|
|
432
530
|
}
|
|
@@ -525,19 +623,10 @@ function rowToDto(
|
|
|
525
623
|
}
|
|
526
624
|
: {}),
|
|
527
625
|
visibility: normalizeTopicVisibility(r.visibility),
|
|
528
|
-
|
|
626
|
+
surface: normalizeTopicSurface(r.surface),
|
|
529
627
|
};
|
|
530
628
|
}
|
|
531
629
|
|
|
532
|
-
export function normalizeTopicAccessMode(value: unknown): TopicAccessMode {
|
|
533
|
-
return value === "shared" ? "shared" : "private";
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
/** Otium and other non-local adapters may only address shared topics. */
|
|
537
|
-
export function isTopicShared(topic: Pick<TopicDto, "accessMode">): boolean {
|
|
538
|
-
return topic.accessMode === "shared";
|
|
539
|
-
}
|
|
540
|
-
|
|
541
630
|
export function normalizeTopicVisibility(value: unknown): TopicVisibility {
|
|
542
631
|
return value === "hidden" ? "hidden" : "visible";
|
|
543
632
|
}
|
|
@@ -643,7 +732,7 @@ export function upsertTopic(t: TopicDto): void {
|
|
|
643
732
|
db.query(
|
|
644
733
|
`INSERT INTO api_topics
|
|
645
734
|
(id,title,kind,description,agent,base_model,base_effort,response_policy,
|
|
646
|
-
created_at,last_message_at,parent_topic_id,memory_topic_id,memory_key,is_fork,is_subagent,visibility,
|
|
735
|
+
created_at,last_message_at,parent_topic_id,memory_topic_id,memory_key,is_fork,is_subagent,visibility,surface,
|
|
647
736
|
subagent_report_mode)
|
|
648
737
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
649
738
|
ON CONFLICT(id) DO UPDATE SET
|
|
@@ -662,7 +751,7 @@ export function upsertTopic(t: TopicDto): void {
|
|
|
662
751
|
is_fork = excluded.is_fork,
|
|
663
752
|
is_subagent = excluded.is_subagent,
|
|
664
753
|
visibility = excluded.visibility,
|
|
665
|
-
|
|
754
|
+
surface = excluded.surface,
|
|
666
755
|
subagent_report_mode = excluded.subagent_report_mode`,
|
|
667
756
|
).run(
|
|
668
757
|
t.id,
|
|
@@ -681,7 +770,11 @@ export function upsertTopic(t: TopicDto): void {
|
|
|
681
770
|
t.isFork ? 1 : 0,
|
|
682
771
|
t.isSubagent ? 1 : 0,
|
|
683
772
|
normalizeTopicVisibility(t.visibility),
|
|
684
|
-
|
|
773
|
+
// A DTO without a surface means "wherever this host puts rooms", not
|
|
774
|
+
// "terminal": embedding hosts (Otium) build topic literals by hand and
|
|
775
|
+
// never name one, so defaulting to the literal would file every hub room
|
|
776
|
+
// on the wrong surface.
|
|
777
|
+
normalizeTopicSurface(t.surface ?? defaultTopicSurface()),
|
|
685
778
|
t.subagentReportMode ?? "auto",
|
|
686
779
|
);
|
|
687
780
|
db.query("DELETE FROM topic_members WHERE topic_id = ?").run(t.id);
|
|
@@ -706,35 +799,20 @@ export function upsertTopic(t: TopicDto): void {
|
|
|
706
799
|
}
|
|
707
800
|
|
|
708
801
|
/**
|
|
709
|
-
*
|
|
710
|
-
*
|
|
711
|
-
* A partial write is not a cosmetic glitch here: a public parent left with
|
|
712
|
-
* private subagent children (or the reverse) is exactly the half-exposed state
|
|
713
|
-
* the access-mode cascade exists to prevent, so every row commits or none do.
|
|
802
|
+
* List topics, optionally restricted to one surface.
|
|
714
803
|
*
|
|
715
|
-
*
|
|
716
|
-
*
|
|
717
|
-
*
|
|
718
|
-
* savepoints — nesting one inside an outer transaction would fail outright.
|
|
719
|
-
* It also rewrites participants and browser-profile ownership, none of which
|
|
720
|
-
* an access-mode change should touch.
|
|
804
|
+
* The filter lives here rather than in each adapter so a forgotten call site
|
|
805
|
+
* cannot leak a telegram room into the terminal picker; adapters pass their own
|
|
806
|
+
* surface and get a closed world back.
|
|
721
807
|
*/
|
|
722
|
-
export function
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
for (const topicId of topicIds) update.run(normalized, topicId);
|
|
731
|
-
})();
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
export function listTopics(): TopicDto[] {
|
|
735
|
-
const rows = db
|
|
736
|
-
.query("SELECT * FROM api_topics ORDER BY last_message_at DESC")
|
|
737
|
-
.all() as TopicRow[];
|
|
808
|
+
export function listTopics(opts: { surface?: TopicSurface } = {}): TopicDto[] {
|
|
809
|
+
const rows = (
|
|
810
|
+
opts.surface
|
|
811
|
+
? db
|
|
812
|
+
.query("SELECT * FROM api_topics WHERE surface = ? ORDER BY last_message_at DESC")
|
|
813
|
+
.all(normalizeTopicSurface(opts.surface))
|
|
814
|
+
: db.query("SELECT * FROM api_topics ORDER BY last_message_at DESC").all()
|
|
815
|
+
) as TopicRow[];
|
|
738
816
|
const participants = getAllTopicParticipants();
|
|
739
817
|
// Batch-load tell grants once: per-row queries would make every listTopics()
|
|
740
818
|
// call O(N) extra statements as subagent counts grow.
|
|
@@ -816,12 +894,17 @@ export function getTopicByNameAndKind(title: string, kind: TopicKind): TopicDto
|
|
|
816
894
|
return r ? rowToDto(r) : null;
|
|
817
895
|
}
|
|
818
896
|
|
|
897
|
+
/**
|
|
898
|
+
* Titles are unique **per surface**, not per node: `otium` may exist once on
|
|
899
|
+
* the terminal, once on telegram and once on the Otium hub.
|
|
900
|
+
*/
|
|
819
901
|
export function findTopicTitleConflict(
|
|
820
902
|
title: string,
|
|
821
903
|
kind: TopicKind,
|
|
822
|
-
opts: { excludeTopicId?: string } = {},
|
|
904
|
+
opts: { excludeTopicId?: string; surface?: TopicSurface } = {},
|
|
823
905
|
): TopicDto | null {
|
|
824
906
|
const wanted = normalizedTitle(title);
|
|
907
|
+
const surface = normalizeTopicSurface(opts.surface ?? defaultTopicSurface());
|
|
825
908
|
const generalTitleRequested = wanted === normalizedTitle(GENERAL_TOPIC_ID);
|
|
826
909
|
if (generalTitleRequested && opts.excludeTopicId !== GENERAL_TOPIC_ID) {
|
|
827
910
|
const general = db.query("SELECT * FROM api_topics WHERE id = ?").get(GENERAL_TOPIC_ID) as
|
|
@@ -830,8 +913,8 @@ export function findTopicTitleConflict(
|
|
|
830
913
|
if (general) return rowToDto(general);
|
|
831
914
|
}
|
|
832
915
|
|
|
833
|
-
const params: string[] = [wanted];
|
|
834
|
-
let sql = "SELECT * FROM api_topics WHERE LOWER(TRIM(title)) = ?";
|
|
916
|
+
const params: string[] = [wanted, surface];
|
|
917
|
+
let sql = "SELECT * FROM api_topics WHERE LOWER(TRIM(title)) = ? AND surface = ?";
|
|
835
918
|
if (kind !== "manager") {
|
|
836
919
|
sql += " AND (kind = ? OR id = ?)";
|
|
837
920
|
params.push(kind, GENERAL_TOPIC_ID);
|
|
@@ -845,8 +928,30 @@ export function findTopicTitleConflict(
|
|
|
845
928
|
return row ? rowToDto(row) : null;
|
|
846
929
|
}
|
|
847
930
|
|
|
931
|
+
/**
|
|
932
|
+
* Move topics onto a surface. Used by adapters that own a classification the
|
|
933
|
+
* canonical store cannot derive on its own (the telegram chat↔topic mapping
|
|
934
|
+
* lives in that adapter's database, not this one).
|
|
935
|
+
*/
|
|
936
|
+
export function setTopicSurfaces(topicIds: readonly string[], surface: TopicSurface): number {
|
|
937
|
+
if (topicIds.length === 0) return 0;
|
|
938
|
+
const normalized = normalizeTopicSurface(surface);
|
|
939
|
+
let changed = 0;
|
|
940
|
+
db.transaction(() => {
|
|
941
|
+
const update = db.query("UPDATE api_topics SET surface = ? WHERE id = ? AND surface != ?");
|
|
942
|
+
for (const topicId of topicIds) {
|
|
943
|
+
changed += Number(update.run(normalized, topicId, normalized).changes ?? 0);
|
|
944
|
+
}
|
|
945
|
+
})();
|
|
946
|
+
return changed;
|
|
947
|
+
}
|
|
948
|
+
|
|
848
949
|
/** Look up a topic by title, restricted to topics where `userId` participates. */
|
|
849
|
-
export function getTopicByNameForUser(
|
|
950
|
+
export function getTopicByNameForUser(
|
|
951
|
+
title: string,
|
|
952
|
+
userId: string,
|
|
953
|
+
opts: { surface?: TopicSurface } = {},
|
|
954
|
+
): TopicDto | null {
|
|
850
955
|
const trimmed = title.trim();
|
|
851
956
|
const qualified = /^(agent|channel|manager):(.+)$/i.exec(trimmed);
|
|
852
957
|
const requestedKind = qualified ? normalizeTopicKind(qualified[1]?.toLowerCase()) : null;
|
|
@@ -857,11 +962,18 @@ export function getTopicByNameForUser(title: string, userId: string): TopicDto |
|
|
|
857
962
|
WHERE LOWER(t.title) = LOWER(?)
|
|
858
963
|
AND t.id != ?
|
|
859
964
|
AND t.visibility != 'hidden'
|
|
965
|
+
AND (? IS NULL OR t.surface = ?)
|
|
860
966
|
AND EXISTS (
|
|
861
967
|
SELECT 1 FROM topic_members m WHERE m.topic_id = t.id AND m.user_id = ?
|
|
862
968
|
)`,
|
|
863
969
|
)
|
|
864
|
-
.all(
|
|
970
|
+
.all(
|
|
971
|
+
requestedTitle,
|
|
972
|
+
GENERAL_TOPIC_ID,
|
|
973
|
+
opts.surface ?? null,
|
|
974
|
+
opts.surface ?? null,
|
|
975
|
+
userId,
|
|
976
|
+
) as TopicRow[];
|
|
865
977
|
const matches = requestedKind ? rows.filter((row) => row.kind === requestedKind) : rows;
|
|
866
978
|
return matches.length === 1 ? rowToDto(matches[0]!) : null;
|
|
867
979
|
}
|
|
@@ -14,13 +14,15 @@ import { FALLBACK_AGENT, resolveTopicWorkspaceDir } from "#platform/config";
|
|
|
14
14
|
import { RESERVED_TOPIC_NAMES } from "#platform/constants";
|
|
15
15
|
import { logger } from "#platform/logger";
|
|
16
16
|
import {
|
|
17
|
+
defaultTopicSurface,
|
|
17
18
|
findTopicTitleConflict,
|
|
18
19
|
normalizeTopicKind,
|
|
19
20
|
normalizeTopicState,
|
|
21
|
+
normalizeTopicSurface,
|
|
20
22
|
upsertTopic,
|
|
21
23
|
} from "#storage/api-topics";
|
|
22
24
|
import { type AgentKind, type EffortLevel, isAgentKind } from "#types";
|
|
23
|
-
import type {
|
|
25
|
+
import type { TopicDto, TopicSurface } from "#types/api";
|
|
24
26
|
|
|
25
27
|
// Agent rooms default to the node-level FALLBACK_AGENT (env), not a hardcoded
|
|
26
28
|
// backend — a node without DeepSeek auth can still default to claude/codex.
|
|
@@ -43,8 +45,11 @@ export interface RegisterTopicOptions {
|
|
|
43
45
|
model?: string;
|
|
44
46
|
effort?: EffortLevel;
|
|
45
47
|
description?: string;
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Which product surface owns the room. Callers that represent a user-facing
|
|
50
|
+
* adapter must pass their own surface; unset falls back to the host default.
|
|
51
|
+
*/
|
|
52
|
+
surface?: TopicSurface;
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
/**
|
|
@@ -62,9 +67,10 @@ export function registerTopic(opts: RegisterTopicOptions): TopicDto {
|
|
|
62
67
|
if (requestedKind === "manager") {
|
|
63
68
|
throw new TopicValidationError("Manager rooms are system-managed");
|
|
64
69
|
}
|
|
65
|
-
const
|
|
70
|
+
const surface = normalizeTopicSurface(opts.surface ?? defaultTopicSurface());
|
|
71
|
+
const conflict = findTopicTitleConflict(title, requestedKind, { surface });
|
|
66
72
|
if (conflict) {
|
|
67
|
-
throw new TopicValidationError(`A topic named "${title}" already exists`);
|
|
73
|
+
throw new TopicValidationError(`A topic named "${title}" already exists on ${surface}`);
|
|
68
74
|
}
|
|
69
75
|
|
|
70
76
|
const rawAgent = opts.agent;
|
|
@@ -106,7 +112,7 @@ export function registerTopic(opts: RegisterTopicOptions): TopicDto {
|
|
|
106
112
|
defaultEffort: defaultEffort ?? "medium",
|
|
107
113
|
aiMode,
|
|
108
114
|
participants: [{ userId: opts.userId, role: "owner" }],
|
|
109
|
-
|
|
115
|
+
surface,
|
|
110
116
|
createdAt: now,
|
|
111
117
|
lastMessageAt: now,
|
|
112
118
|
};
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from "#storage/api-messages";
|
|
25
25
|
import { getApiTopicConfig, setApiTopicConfig } from "#storage/api-topic-config";
|
|
26
26
|
import {
|
|
27
|
+
defaultTopicSurface,
|
|
27
28
|
findTopicTitleConflict,
|
|
28
29
|
getTopic,
|
|
29
30
|
getTopicSessionId,
|
|
@@ -53,15 +54,20 @@ import {
|
|
|
53
54
|
shouldCompactForkEntries,
|
|
54
55
|
} from "#topics/session";
|
|
55
56
|
import type { AgentKind } from "#types";
|
|
56
|
-
import type { TopicDto } from "#types/api";
|
|
57
|
+
import type { TopicDto, TopicSurface } from "#types/api";
|
|
57
58
|
|
|
58
|
-
export function getTopics(): TopicDto[] {
|
|
59
|
-
return listTopics().filter((topic) => !isLegacySharedGeneral(topic.id));
|
|
59
|
+
export function getTopics(opts: { surface?: TopicSurface } = {}): TopicDto[] {
|
|
60
|
+
return listTopics(opts).filter((topic) => !isLegacySharedGeneral(topic.id));
|
|
60
61
|
}
|
|
61
62
|
|
|
62
|
-
/**
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Topics adapters may show in lists and selection UIs.
|
|
65
|
+
*
|
|
66
|
+
* Callers that represent one product surface pass it, so a telegram room never
|
|
67
|
+
* appears in the terminal picker and vice versa (S-6).
|
|
68
|
+
*/
|
|
69
|
+
export function getVisibleTopics(opts: { surface?: TopicSurface } = {}): TopicDto[] {
|
|
70
|
+
return getTopics(opts)
|
|
65
71
|
.filter(isTopicVisible)
|
|
66
72
|
.map((topic) => {
|
|
67
73
|
if (!topic.agent) return topic;
|
|
@@ -100,9 +106,10 @@ function nextDerivedTopicTitle(
|
|
|
100
106
|
sourceTitle: string,
|
|
101
107
|
kind: TopicDto["kind"],
|
|
102
108
|
suffix: "fork" | "spawn" | "agent",
|
|
109
|
+
surface?: TopicSurface,
|
|
103
110
|
): string {
|
|
104
111
|
const visibleTitles = new Set(
|
|
105
|
-
listTopics()
|
|
112
|
+
listTopics(surface ? { surface } : {})
|
|
106
113
|
.filter((topic) => topic.kind === kind)
|
|
107
114
|
.map((topic) => topic.title.toLowerCase()),
|
|
108
115
|
);
|
|
@@ -264,8 +271,9 @@ async function createDerivedTopicImpl(
|
|
|
264
271
|
]
|
|
265
272
|
: [{ userId, role: "owner" as const }];
|
|
266
273
|
const kind = topic.kind ?? inferTopicKind(topic);
|
|
267
|
-
const
|
|
268
|
-
const
|
|
274
|
+
const surface = topic.surface ?? defaultTopicSurface();
|
|
275
|
+
const title = opts?.name?.trim() || nextDerivedTopicTitle(topic.title, kind, suffix, surface);
|
|
276
|
+
const conflict = findTopicTitleConflict(title, kind, { surface });
|
|
269
277
|
if (conflict) {
|
|
270
278
|
logger.info(
|
|
271
279
|
{ sourceTopicId, title, kind, conflictTopicId: conflict.id },
|
|
@@ -291,7 +299,8 @@ async function createDerivedTopicImpl(
|
|
|
291
299
|
isFork: copyHistory,
|
|
292
300
|
...(subagent ? { isSubagent: true } : {}),
|
|
293
301
|
visibility: topic.visibility,
|
|
294
|
-
|
|
302
|
+
// A derived room lives on the same surface as the room it came from.
|
|
303
|
+
surface,
|
|
295
304
|
};
|
|
296
305
|
|
|
297
306
|
let sessionId: string | undefined;
|
|
@@ -443,7 +452,7 @@ async function createDerivedTopicImpl(
|
|
|
443
452
|
) {
|
|
444
453
|
throw new TopicDeriveBusyError("Source topic changed while deriving; try again");
|
|
445
454
|
}
|
|
446
|
-
const transactionalConflict = findTopicTitleConflict(title, kind);
|
|
455
|
+
const transactionalConflict = findTopicTitleConflict(title, kind, { surface });
|
|
447
456
|
if (transactionalConflict) throw new TopicTitleConflictError(title);
|
|
448
457
|
upsertTopic(derived);
|
|
449
458
|
if (subagent) {
|
|
@@ -13,8 +13,12 @@
|
|
|
13
13
|
/** Agent identifier — one of the supported AI provider backends. */
|
|
14
14
|
export type AgentKind = "maestro" | "claude" | "codex";
|
|
15
15
|
export type TopicKind = "channel" | "agent" | "manager";
|
|
16
|
-
/**
|
|
17
|
-
|
|
16
|
+
/**
|
|
17
|
+
* The one product surface a topic lives on. A topic belongs to exactly one
|
|
18
|
+
* surface for its whole life; names are unique per surface, not per node.
|
|
19
|
+
*/
|
|
20
|
+
export type TopicSurface = "terminal" | "telegram" | "otium";
|
|
21
|
+
export const TOPIC_SURFACES: readonly TopicSurface[] = ["terminal", "telegram", "otium"];
|
|
18
22
|
/** Whether adapters may expose a topic in user-facing discovery surfaces. */
|
|
19
23
|
export type TopicVisibility = "visible" | "hidden";
|
|
20
24
|
export type ResponsePolicy = "off" | "mention" | "always";
|
|
@@ -124,8 +128,8 @@ export interface TopicDto {
|
|
|
124
128
|
subagentReportMode?: SubagentReportMode;
|
|
125
129
|
/** Hidden topics remain executable/addressable by id but stay out of adapter pickers. */
|
|
126
130
|
visibility?: TopicVisibility;
|
|
127
|
-
/**
|
|
128
|
-
|
|
131
|
+
/** The product surface that owns this topic (terminal / telegram / otium). */
|
|
132
|
+
surface?: TopicSurface;
|
|
129
133
|
/** Stable execution placement. Absent means the hub runs this topic locally. */
|
|
130
134
|
executionNode?: {
|
|
131
135
|
nodeId: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const NEGOTIUM_VERSION = "0.2.
|
|
1
|
+
export const NEGOTIUM_VERSION = "0.2.26";
|