negotium 0.2.23 → 0.2.25
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 +84 -30
- package/dist/agent-helpers.js.map +6 -6
- package/dist/background-bash.js +2 -1
- package/dist/background-bash.js.map +3 -3
- package/dist/browser-runtime.js +2 -1
- package/dist/browser-runtime.js.map +3 -3
- package/dist/{chunk-rhp26p2c.js → chunk-1y2xw0xe.js} +2 -1
- package/dist/{chunk-rhp26p2c.js.map → chunk-1y2xw0xe.js.map} +3 -3
- package/dist/hosted-agent.js +3 -2
- package/dist/hosted-agent.js.map +4 -4
- package/dist/main.js +224 -366
- package/dist/main.js.map +27 -28
- package/dist/mcp-factories.js +86 -31
- package/dist/mcp-factories.js.map +7 -7
- package/dist/prompts.js +2 -1
- package/dist/prompts.js.map +3 -3
- package/dist/query-runtime.js +2 -1
- package/dist/query-runtime.js.map +3 -3
- package/dist/registry.js +3 -3
- package/dist/registry.js.map +2 -2
- package/dist/rollout.js +1 -1
- 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 +4 -4
- package/dist/runtime/src/platform/config.ts +12 -0
- package/dist/runtime/src/storage/api-topics.ts +160 -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/runtime-helpers.js +2 -1
- package/dist/runtime-helpers.js.map +3 -3
- 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/platform/config.d.ts +12 -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/dist/vault.js +2 -1
- package/dist/vault.js.map +3 -3
- package/package.json +1 -1
- package/dist/runtime/src/application/switch-topic-access-mode.ts +0 -110
|
@@ -5,6 +5,8 @@ export interface SessionTopicRow {
|
|
|
5
5
|
agent: string | null;
|
|
6
6
|
sessionId: string | null;
|
|
7
7
|
description: string | null;
|
|
8
|
+
/** Product surface the topic lives on; rows from other surfaces are not addressable. */
|
|
9
|
+
surface?: string | null;
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
export interface SessionTopicEntry<TAgent extends string = string> {
|
|
@@ -36,6 +38,11 @@ export interface SessionTargetCatalogHost<TAgent extends string = string> {
|
|
|
36
38
|
readonly listRows: () => SessionTopicRow[];
|
|
37
39
|
readonly currentTopicId?: string;
|
|
38
40
|
readonly currentTopicName?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Surface of the calling topic. Sessions only converse within their own
|
|
43
|
+
* surface: terminal with terminal, telegram with telegram, otium with otium.
|
|
44
|
+
*/
|
|
45
|
+
readonly currentSurface?: string;
|
|
39
46
|
readonly isAgent: (value: string | null) => value is TAgent;
|
|
40
47
|
}
|
|
41
48
|
|
|
@@ -51,7 +58,16 @@ export function createSessionTargetCatalog<TAgent extends string = string>(
|
|
|
51
58
|
const { currentTopicId, currentTopicName, isAgent, listRows } = host;
|
|
52
59
|
|
|
53
60
|
function listTargets(): SessionTarget<TAgent>[] {
|
|
54
|
-
|
|
61
|
+
// Read through `host` rather than a destructured copy so a host can expose
|
|
62
|
+
// it as a lazy getter and keep DB access out of module import time.
|
|
63
|
+
const currentSurface = host.currentSurface;
|
|
64
|
+
const eligibleRows = listRows().filter(
|
|
65
|
+
(row) =>
|
|
66
|
+
row.kind !== "manager" &&
|
|
67
|
+
// Rows predating the surface column report null and stay addressable
|
|
68
|
+
// from the local surface rather than vanishing mid-migration.
|
|
69
|
+
(!currentSurface || (row.surface ?? currentSurface) === currentSurface),
|
|
70
|
+
);
|
|
55
71
|
const titleCounts = new Map<string, number>();
|
|
56
72
|
const qualifiedCounts = new Map<string, number>();
|
|
57
73
|
for (const row of eligibleRows) {
|
|
@@ -78,6 +78,7 @@ function sessionTargetRows(): Array<{
|
|
|
78
78
|
agent: string | null;
|
|
79
79
|
session_id: string | null;
|
|
80
80
|
description: string | null;
|
|
81
|
+
surface: string | null;
|
|
81
82
|
}> {
|
|
82
83
|
if (!existsSync(SESSIONS_DB)) return [];
|
|
83
84
|
try {
|
|
@@ -91,10 +92,11 @@ function sessionTargetRows(): Array<{
|
|
|
91
92
|
agent: string | null;
|
|
92
93
|
session_id: string | null;
|
|
93
94
|
description: string | null;
|
|
95
|
+
surface: string | null;
|
|
94
96
|
},
|
|
95
97
|
string
|
|
96
98
|
>(
|
|
97
|
-
`SELECT t.id, t.title, t.kind, t.agent, t.session_id, t.description
|
|
99
|
+
`SELECT t.id, t.title, t.kind, t.agent, t.session_id, t.description, t.surface
|
|
98
100
|
FROM api_topics t
|
|
99
101
|
INNER JOIN topic_members m ON m.topic_id = t.id
|
|
100
102
|
WHERE m.user_id = ?`,
|
|
@@ -117,9 +119,28 @@ export function getTopicsForUser(): { [name: string]: TopicEntry } {
|
|
|
117
119
|
return sessionTargetCatalog.getTopics();
|
|
118
120
|
}
|
|
119
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Surface of the room this MCP server is serving. Read once from the canonical
|
|
124
|
+
* store: session-comm only ever addresses sessions on the same surface.
|
|
125
|
+
*/
|
|
126
|
+
let cachedSessionSurface: { value: string | undefined } | null = null;
|
|
127
|
+
|
|
128
|
+
function currentSessionSurface(): string | undefined {
|
|
129
|
+
if (cachedSessionSurface) return cachedSessionSurface.value;
|
|
130
|
+
const rows = sessionTargetRows();
|
|
131
|
+
const mine = currentTopicId
|
|
132
|
+
? rows.find((row) => row.id === currentTopicId)
|
|
133
|
+
: rows.find((row) => row.title === currentTopic);
|
|
134
|
+
cachedSessionSurface = { value: mine?.surface ?? undefined };
|
|
135
|
+
return cachedSessionSurface.value;
|
|
136
|
+
}
|
|
137
|
+
|
|
120
138
|
const sessionTargetCatalog = createSessionTargetCatalog<AgentKind>({
|
|
121
139
|
currentTopicId,
|
|
122
140
|
currentTopicName: currentTopic,
|
|
141
|
+
get currentSurface() {
|
|
142
|
+
return currentSessionSurface();
|
|
143
|
+
},
|
|
123
144
|
isAgent: isAgentKind,
|
|
124
145
|
listRows: () =>
|
|
125
146
|
sessionTargetRows().map((row) => ({
|
|
@@ -129,6 +150,7 @@ const sessionTargetCatalog = createSessionTargetCatalog<AgentKind>({
|
|
|
129
150
|
agent: row.agent,
|
|
130
151
|
sessionId: row.session_id,
|
|
131
152
|
description: row.description,
|
|
153
|
+
surface: row.surface,
|
|
132
154
|
})),
|
|
133
155
|
});
|
|
134
156
|
|
|
@@ -11,7 +11,6 @@ export {
|
|
|
11
11
|
submitRuntimeGatewayTurn,
|
|
12
12
|
} from "#application/submit-runtime-gateway-turn";
|
|
13
13
|
export { submitUserMessage } from "#application/submit-user-message";
|
|
14
|
-
export { switchTopicAccessMode } from "#application/switch-topic-access-mode";
|
|
15
14
|
export { switchTopicEffort } from "#application/switch-topic-effort";
|
|
16
15
|
export { switchTopicModel } from "#application/switch-topic-model";
|
|
17
16
|
export { TopicServiceError, topicService } from "#application/topic-service";
|
|
@@ -29,6 +28,7 @@ export {
|
|
|
29
28
|
DATA_DIR,
|
|
30
29
|
NEGOTIUM_PORT,
|
|
31
30
|
NODE_CONTROL_TOKEN,
|
|
31
|
+
NODE_ID,
|
|
32
32
|
RUN_DIR,
|
|
33
33
|
STATE_DIR,
|
|
34
34
|
WORKSPACE_DIR,
|
|
@@ -65,8 +65,8 @@ export type { FileHooks, UploadAccess } from "#runtime/file-hooks";
|
|
|
65
65
|
export { setFileHooks } from "#runtime/file-hooks";
|
|
66
66
|
export { startSessionInboxWorker } from "#runtime/inbox";
|
|
67
67
|
export { startAiTurn, startDurableTurnRequestWorker } from "#runtime/turn-runner";
|
|
68
|
-
export { listApiMessages } from "#storage/api-messages";
|
|
69
|
-
export { getTopic,
|
|
68
|
+
export { appendApiMessage, listApiMessages } from "#storage/api-messages";
|
|
69
|
+
export { getTopic, upsertTopic } from "#storage/api-topics";
|
|
70
70
|
export type { StoredRuntimeEvent } from "#storage/runtime-events";
|
|
71
71
|
export {
|
|
72
72
|
latestRuntimeEventSeq,
|
|
@@ -85,5 +85,5 @@ export {
|
|
|
85
85
|
export { ensurePersonalGeneral } from "#topics/personal-general";
|
|
86
86
|
export { compactTopicSession } from "#topics/session";
|
|
87
87
|
export type { AgentKind } from "#types";
|
|
88
|
-
export type { AttachmentDto, TopicDto } from "#types/api";
|
|
88
|
+
export type { AttachmentDto, TopicDto, TopicSurface } from "#types/api";
|
|
89
89
|
export { NEGOTIUM_VERSION } from "#version";
|
|
@@ -360,6 +360,18 @@ export const NODE_CONTROL_TOKEN = loadOrCreateLocalSecret(
|
|
|
360
360
|
"NEGOTIUM_CONTROL_TOKEN",
|
|
361
361
|
"node-control-token",
|
|
362
362
|
);
|
|
363
|
+
/**
|
|
364
|
+
* Stable identity of this node's store, minted once and kept across restarts.
|
|
365
|
+
*
|
|
366
|
+
* Deliberately not a secret: it is published in the gateway health response so a
|
|
367
|
+
* host can tell "the node I recorded a mapping against" apart from "whatever is
|
|
368
|
+
* answering on that port today". Without it, pointing a host at a different node
|
|
369
|
+
* makes every existing mapping look like a topic whose owner withdrew it, and the
|
|
370
|
+
* same topics come back as duplicate rooms. It shares the secrets directory only
|
|
371
|
+
* because that is already where per-install state that must survive restarts
|
|
372
|
+
* lives; the stricter file mode costs nothing.
|
|
373
|
+
*/
|
|
374
|
+
export const NODE_ID = loadOrCreateLocalSecret("NEGOTIUM_NODE_ID", "node-id");
|
|
363
375
|
export const VAULT_MASTER_KEY = loadOrCreateLocalSecret(
|
|
364
376
|
"NEGOTIUM_VAULT_MASTER_KEY",
|
|
365
377
|
"vault-master-key",
|
|
@@ -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)}`;
|
|
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,7 @@ 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
|
+
normalizeTopicSurface(t.surface),
|
|
685
774
|
t.subagentReportMode ?? "auto",
|
|
686
775
|
);
|
|
687
776
|
db.query("DELETE FROM topic_members WHERE topic_id = ?").run(t.id);
|
|
@@ -706,35 +795,20 @@ export function upsertTopic(t: TopicDto): void {
|
|
|
706
795
|
}
|
|
707
796
|
|
|
708
797
|
/**
|
|
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.
|
|
798
|
+
* List topics, optionally restricted to one surface.
|
|
714
799
|
*
|
|
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.
|
|
800
|
+
* The filter lives here rather than in each adapter so a forgotten call site
|
|
801
|
+
* cannot leak a telegram room into the terminal picker; adapters pass their own
|
|
802
|
+
* surface and get a closed world back.
|
|
721
803
|
*/
|
|
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[];
|
|
804
|
+
export function listTopics(opts: { surface?: TopicSurface } = {}): TopicDto[] {
|
|
805
|
+
const rows = (
|
|
806
|
+
opts.surface
|
|
807
|
+
? db
|
|
808
|
+
.query("SELECT * FROM api_topics WHERE surface = ? ORDER BY last_message_at DESC")
|
|
809
|
+
.all(normalizeTopicSurface(opts.surface))
|
|
810
|
+
: db.query("SELECT * FROM api_topics ORDER BY last_message_at DESC").all()
|
|
811
|
+
) as TopicRow[];
|
|
738
812
|
const participants = getAllTopicParticipants();
|
|
739
813
|
// Batch-load tell grants once: per-row queries would make every listTopics()
|
|
740
814
|
// call O(N) extra statements as subagent counts grow.
|
|
@@ -816,12 +890,17 @@ export function getTopicByNameAndKind(title: string, kind: TopicKind): TopicDto
|
|
|
816
890
|
return r ? rowToDto(r) : null;
|
|
817
891
|
}
|
|
818
892
|
|
|
893
|
+
/**
|
|
894
|
+
* Titles are unique **per surface**, not per node: `otium` may exist once on
|
|
895
|
+
* the terminal, once on telegram and once on the Otium hub.
|
|
896
|
+
*/
|
|
819
897
|
export function findTopicTitleConflict(
|
|
820
898
|
title: string,
|
|
821
899
|
kind: TopicKind,
|
|
822
|
-
opts: { excludeTopicId?: string } = {},
|
|
900
|
+
opts: { excludeTopicId?: string; surface?: TopicSurface } = {},
|
|
823
901
|
): TopicDto | null {
|
|
824
902
|
const wanted = normalizedTitle(title);
|
|
903
|
+
const surface = normalizeTopicSurface(opts.surface ?? defaultTopicSurface());
|
|
825
904
|
const generalTitleRequested = wanted === normalizedTitle(GENERAL_TOPIC_ID);
|
|
826
905
|
if (generalTitleRequested && opts.excludeTopicId !== GENERAL_TOPIC_ID) {
|
|
827
906
|
const general = db.query("SELECT * FROM api_topics WHERE id = ?").get(GENERAL_TOPIC_ID) as
|
|
@@ -830,8 +909,8 @@ export function findTopicTitleConflict(
|
|
|
830
909
|
if (general) return rowToDto(general);
|
|
831
910
|
}
|
|
832
911
|
|
|
833
|
-
const params: string[] = [wanted];
|
|
834
|
-
let sql = "SELECT * FROM api_topics WHERE LOWER(TRIM(title)) = ?";
|
|
912
|
+
const params: string[] = [wanted, surface];
|
|
913
|
+
let sql = "SELECT * FROM api_topics WHERE LOWER(TRIM(title)) = ? AND surface = ?";
|
|
835
914
|
if (kind !== "manager") {
|
|
836
915
|
sql += " AND (kind = ? OR id = ?)";
|
|
837
916
|
params.push(kind, GENERAL_TOPIC_ID);
|
|
@@ -845,8 +924,30 @@ export function findTopicTitleConflict(
|
|
|
845
924
|
return row ? rowToDto(row) : null;
|
|
846
925
|
}
|
|
847
926
|
|
|
927
|
+
/**
|
|
928
|
+
* Move topics onto a surface. Used by adapters that own a classification the
|
|
929
|
+
* canonical store cannot derive on its own (the telegram chat↔topic mapping
|
|
930
|
+
* lives in that adapter's database, not this one).
|
|
931
|
+
*/
|
|
932
|
+
export function setTopicSurfaces(topicIds: readonly string[], surface: TopicSurface): number {
|
|
933
|
+
if (topicIds.length === 0) return 0;
|
|
934
|
+
const normalized = normalizeTopicSurface(surface);
|
|
935
|
+
let changed = 0;
|
|
936
|
+
db.transaction(() => {
|
|
937
|
+
const update = db.query("UPDATE api_topics SET surface = ? WHERE id = ? AND surface != ?");
|
|
938
|
+
for (const topicId of topicIds) {
|
|
939
|
+
changed += Number(update.run(normalized, topicId, normalized).changes ?? 0);
|
|
940
|
+
}
|
|
941
|
+
})();
|
|
942
|
+
return changed;
|
|
943
|
+
}
|
|
944
|
+
|
|
848
945
|
/** Look up a topic by title, restricted to topics where `userId` participates. */
|
|
849
|
-
export function getTopicByNameForUser(
|
|
946
|
+
export function getTopicByNameForUser(
|
|
947
|
+
title: string,
|
|
948
|
+
userId: string,
|
|
949
|
+
opts: { surface?: TopicSurface } = {},
|
|
950
|
+
): TopicDto | null {
|
|
850
951
|
const trimmed = title.trim();
|
|
851
952
|
const qualified = /^(agent|channel|manager):(.+)$/i.exec(trimmed);
|
|
852
953
|
const requestedKind = qualified ? normalizeTopicKind(qualified[1]?.toLowerCase()) : null;
|
|
@@ -857,11 +958,18 @@ export function getTopicByNameForUser(title: string, userId: string): TopicDto |
|
|
|
857
958
|
WHERE LOWER(t.title) = LOWER(?)
|
|
858
959
|
AND t.id != ?
|
|
859
960
|
AND t.visibility != 'hidden'
|
|
961
|
+
AND (? IS NULL OR t.surface = ?)
|
|
860
962
|
AND EXISTS (
|
|
861
963
|
SELECT 1 FROM topic_members m WHERE m.topic_id = t.id AND m.user_id = ?
|
|
862
964
|
)`,
|
|
863
965
|
)
|
|
864
|
-
.all(
|
|
966
|
+
.all(
|
|
967
|
+
requestedTitle,
|
|
968
|
+
GENERAL_TOPIC_ID,
|
|
969
|
+
opts.surface ?? null,
|
|
970
|
+
opts.surface ?? null,
|
|
971
|
+
userId,
|
|
972
|
+
) as TopicRow[];
|
|
865
973
|
const matches = requestedKind ? rows.filter((row) => row.kind === requestedKind) : rows;
|
|
866
974
|
return matches.length === 1 ? rowToDto(matches[0]!) : null;
|
|
867
975
|
}
|
|
@@ -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.25";
|