negotium 0.2.27 → 0.2.28
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 +197 -113
- package/dist/agent-helpers.js.map +13 -13
- package/dist/hosted-agent.js +2 -2
- package/dist/hosted-agent.js.map +2 -2
- package/dist/main.js +1121 -506
- package/dist/main.js.map +43 -41
- package/dist/mcp-factories.js +291 -211
- package/dist/mcp-factories.js.map +13 -13
- package/dist/registry.js +2 -2
- package/dist/registry.js.map +2 -2
- package/dist/runtime/src/application/submit-runtime-gateway-turn.ts +8 -0
- package/dist/runtime/src/index.ts +6 -0
- package/dist/runtime/src/mcp/session-comm/default-host.ts +25 -6
- package/dist/runtime/src/mcp/session-comm/peer-forward.ts +14 -3
- package/dist/runtime/src/mcp/session-comm/server.ts +1 -1
- package/dist/runtime/src/mcp/session-comm/topics.ts +52 -16
- package/dist/runtime/src/mcp-runtime-host.ts +2 -2
- package/dist/runtime/src/node-host.ts +1 -1
- package/dist/runtime/src/runtime/turn-event-stream.ts +9 -1
- package/dist/runtime/src/runtime/turn-runner.ts +11 -2
- package/dist/runtime/src/storage/api-topics.ts +202 -26
- package/dist/runtime/src/storage/runtime-turn-requests.ts +26 -2
- package/dist/runtime/src/storage/token-stats.ts +27 -2
- package/dist/runtime/src/topics/create.ts +27 -1
- package/dist/runtime/src/topics/derive.ts +20 -6
- package/dist/runtime/src/topics/lifecycle.ts +2 -0
- package/dist/runtime/src/topics/personal-general.ts +28 -4
- package/dist/runtime/src/types/api.ts +7 -0
- package/dist/runtime/src/version.ts +1 -1
- package/dist/storage.js +138 -26
- package/dist/storage.js.map +4 -4
- package/dist/types/packages/core/src/mcp/session-comm/peer-forward.d.ts +7 -2
- package/dist/types/packages/core/src/runtime/turn-event-stream.d.ts +2 -0
- package/dist/types/packages/core/src/runtime/turn-runner.d.ts +3 -0
- package/dist/types/packages/core/src/storage/api-topics.d.ts +42 -3
- package/dist/types/packages/core/src/storage/runtime-turn-requests.d.ts +6 -0
- package/dist/types/packages/core/src/storage/token-stats.d.ts +2 -1
- package/dist/types/packages/core/src/topics/derive.d.ts +2 -0
- package/dist/types/packages/core/src/topics/personal-general.d.ts +4 -2
- package/dist/types/packages/core/src/types/api.d.ts +7 -0
- package/dist/types/packages/core/src/version.d.ts +1 -1
- package/package.json +1 -1
|
@@ -34,6 +34,56 @@ export function normalizeTopicSurface(value: unknown): TopicSurface {
|
|
|
34
34
|
return value === "telegram" || value === "otium" || value === "terminal" ? value : "terminal";
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The surface instance new rooms are filed under when the caller names none.
|
|
39
|
+
*
|
|
40
|
+
* `surface` alone is not enough once a node may be attached to several Otium
|
|
41
|
+
* workspaces (M-2): two workspaces are two independent namespaces on the same
|
|
42
|
+
* surface. The adapter that owns the attachment installs the active scope at
|
|
43
|
+
* mount time; `terminal` and `telegram` are singletons and stay null.
|
|
44
|
+
*
|
|
45
|
+
* Kept as process state rather than an argument threaded through every creation
|
|
46
|
+
* path because the value is a property of *this process's attachment*, not of
|
|
47
|
+
* any individual call — the same reason `defaultTopicSurface()` exists.
|
|
48
|
+
*/
|
|
49
|
+
let activeSurfaceScope: string | null = null;
|
|
50
|
+
|
|
51
|
+
export function normalizeSurfaceScope(value: unknown): string | null {
|
|
52
|
+
if (typeof value !== "string") return null;
|
|
53
|
+
const trimmed = value.trim();
|
|
54
|
+
return trimmed ? trimmed : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function defaultSurfaceScope(): string | null {
|
|
58
|
+
return activeSurfaceScope ?? normalizeSurfaceScope(process.env.NEGOTIUM_SURFACE_SCOPE);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Install (or clear) the scope new rooms inherit. Returns the previous value. */
|
|
62
|
+
export function setDefaultSurfaceScope(scope: string | null): string | null {
|
|
63
|
+
const previous = activeSurfaceScope;
|
|
64
|
+
activeSurfaceScope = normalizeSurfaceScope(scope);
|
|
65
|
+
return previous;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Whether an Otium room must name a workspace to be created at all (M-3).
|
|
70
|
+
*
|
|
71
|
+
* Off by default, and off for every single-workspace host: an unscoped room is
|
|
72
|
+
* then legacy, reachable, and perfectly usable. The adapter turns it on only
|
|
73
|
+
* while several workspaces are attached, because there an unscoped room is
|
|
74
|
+
* reachable from none of them (M-10) — a room nobody can see is worse than a
|
|
75
|
+
* refusal that says so.
|
|
76
|
+
*/
|
|
77
|
+
let surfaceScopeRequired = false;
|
|
78
|
+
|
|
79
|
+
export function setSurfaceScopeRequired(required: boolean): void {
|
|
80
|
+
surfaceScopeRequired = required;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function isSurfaceScopeRequired(): boolean {
|
|
84
|
+
return surfaceScopeRequired;
|
|
85
|
+
}
|
|
86
|
+
|
|
37
87
|
function tableColumns(table: string): Set<string> {
|
|
38
88
|
const rows = db.query(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
|
39
89
|
return new Set(rows.map((row) => row.name));
|
|
@@ -237,6 +287,7 @@ function initializeApiTopicsSchema(): void {
|
|
|
237
287
|
is_subagent INTEGER NOT NULL DEFAULT 0 CHECK (is_subagent IN (0,1)),
|
|
238
288
|
visibility TEXT NOT NULL DEFAULT 'visible' CHECK (visibility IN ('visible','hidden')),
|
|
239
289
|
surface TEXT NOT NULL DEFAULT 'terminal' CHECK (surface IN ('terminal','telegram','otium')),
|
|
290
|
+
surface_scope TEXT,
|
|
240
291
|
browser_profile TEXT NOT NULL DEFAULT 'default',
|
|
241
292
|
browser_profile_owner TEXT,
|
|
242
293
|
session_id TEXT,
|
|
@@ -305,8 +356,8 @@ function initializeApiTopicsSchema(): void {
|
|
|
305
356
|
db.query(
|
|
306
357
|
`INSERT INTO api_topics_next
|
|
307
358
|
(id,title,kind,description,agent,base_model,base_effort,response_policy,
|
|
308
|
-
created_at,last_message_at,parent_topic_id,memory_topic_id,memory_key,is_fork,is_subagent,visibility,surface,session_id)
|
|
309
|
-
VALUES (
|
|
359
|
+
created_at,last_message_at,parent_topic_id,memory_topic_id,memory_key,is_fork,is_subagent,visibility,surface,surface_scope,session_id)
|
|
360
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
310
361
|
).run(
|
|
311
362
|
String(row.id),
|
|
312
363
|
String(row.title),
|
|
@@ -325,6 +376,7 @@ function initializeApiTopicsSchema(): void {
|
|
|
325
376
|
Number(row.is_subagent ?? 0) !== 0 ? 1 : 0,
|
|
326
377
|
row.visibility === "hidden" ? "hidden" : "visible",
|
|
327
378
|
row.surface === undefined ? defaultTopicSurface() : normalizeTopicSurface(row.surface),
|
|
379
|
+
normalizeSurfaceScope(row.surface_scope),
|
|
328
380
|
typeof row.session_id === "string" ? row.session_id : null,
|
|
329
381
|
);
|
|
330
382
|
}
|
|
@@ -393,6 +445,9 @@ function initializeApiTopicsSchema(): void {
|
|
|
393
445
|
if (!tableColumns("api_topics").has("surface")) {
|
|
394
446
|
db.exec("ALTER TABLE api_topics ADD COLUMN surface TEXT NOT NULL DEFAULT 'terminal'");
|
|
395
447
|
}
|
|
448
|
+
if (!tableColumns("api_topics").has("surface_scope")) {
|
|
449
|
+
db.exec("ALTER TABLE api_topics ADD COLUMN surface_scope TEXT");
|
|
450
|
+
}
|
|
396
451
|
// `access_mode` was replaced by `surface`: a topic is reachable from Otium
|
|
397
452
|
// because it lives there, not because a flag was flipped (S-4). Dropping the
|
|
398
453
|
// column removes the second, now-contradictory source of truth.
|
|
@@ -433,6 +488,60 @@ function initializeApiTopicsSchema(): void {
|
|
|
433
488
|
"CREATE INDEX IF NOT EXISTS idx_api_topics_last_message ON api_topics(last_message_at DESC)",
|
|
434
489
|
);
|
|
435
490
|
db.exec("CREATE INDEX IF NOT EXISTS idx_api_topics_surface ON api_topics(surface)");
|
|
491
|
+
db.exec(
|
|
492
|
+
"CREATE INDEX IF NOT EXISTS idx_api_topics_surface_scope ON api_topics(surface, surface_scope)",
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const SURFACE_SCOPE_STAMP_MIGRATION = "api_topics_surface_scope_stamp_20260809";
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* M-9 — file every pre-existing Otium room under the workspace attached now.
|
|
500
|
+
*
|
|
501
|
+
* Deliberately *not* a schema-init step. The scope is only knowable after the
|
|
502
|
+
* first successful Central contact (M-3), which happens long after the store
|
|
503
|
+
* opens; a boot-time migration would see no scope, record itself as applied and
|
|
504
|
+
* leave every room unstamped forever. The Otium runtime calls this instead, once,
|
|
505
|
+
* as soon as it has resolved its workspace.
|
|
506
|
+
*
|
|
507
|
+
* Runs at most once per store: rooms created after this point are stamped at
|
|
508
|
+
* creation, and a second workspace joined later must not swallow the first
|
|
509
|
+
* one's rooms. Unlike the surface migration this can never rename anything —
|
|
510
|
+
* the scope enters the uniqueness key in the same release, so nothing can start
|
|
511
|
+
* colliding because of it.
|
|
512
|
+
*/
|
|
513
|
+
export function stampUnscopedOtiumTopics(scope: string): number {
|
|
514
|
+
const normalized = normalizeSurfaceScope(scope);
|
|
515
|
+
if (!normalized) return 0;
|
|
516
|
+
db.exec(`
|
|
517
|
+
CREATE TABLE IF NOT EXISTS api_schema_migrations (
|
|
518
|
+
key TEXT PRIMARY KEY,
|
|
519
|
+
applied_at TEXT NOT NULL
|
|
520
|
+
)
|
|
521
|
+
`);
|
|
522
|
+
const applied = db
|
|
523
|
+
.query("SELECT key FROM api_schema_migrations WHERE key = ?")
|
|
524
|
+
.get(SURFACE_SCOPE_STAMP_MIGRATION);
|
|
525
|
+
if (applied) return 0;
|
|
526
|
+
|
|
527
|
+
let stamped = 0;
|
|
528
|
+
db.transaction(() => {
|
|
529
|
+
stamped = Number(
|
|
530
|
+
db
|
|
531
|
+
.query(
|
|
532
|
+
"UPDATE api_topics SET surface_scope = ? WHERE surface = 'otium' AND surface_scope IS NULL",
|
|
533
|
+
)
|
|
534
|
+
.run(normalized).changes ?? 0,
|
|
535
|
+
);
|
|
536
|
+
db.query("INSERT INTO api_schema_migrations (key, applied_at) VALUES (?, ?)").run(
|
|
537
|
+
SURFACE_SCOPE_STAMP_MIGRATION,
|
|
538
|
+
new Date().toISOString(),
|
|
539
|
+
);
|
|
540
|
+
})();
|
|
541
|
+
if (stamped > 0) {
|
|
542
|
+
logger.info({ scope: normalized, stamped }, "api_topics: surface scope stamped");
|
|
543
|
+
}
|
|
544
|
+
return stamped;
|
|
436
545
|
}
|
|
437
546
|
|
|
438
547
|
const SURFACE_BACKFILL_MIGRATION = "api_topics_surface_backfill_20260808";
|
|
@@ -487,21 +596,28 @@ function renameSurfaceTitleCollisions(): void {
|
|
|
487
596
|
"SELECT id, title, kind, surface FROM api_topics WHERE kind != 'manager' ORDER BY created_at ASC, rowid ASC",
|
|
488
597
|
)
|
|
489
598
|
.all();
|
|
490
|
-
const
|
|
599
|
+
const keyOf = (surface: string, kind: string, title: string) =>
|
|
600
|
+
[surface, kind, normalizedTitle(title)].join("\u0000");
|
|
601
|
+
// Every original title is reserved up front. Allocating only against the
|
|
602
|
+
// titles seen so far would let an earlier duplicate claim "Foo (2)" while a
|
|
603
|
+
// real room already named "Foo (2)" sits later in the scan — that room would
|
|
604
|
+
// then be pushed to "Foo (2) (2)" for no reason.
|
|
605
|
+
const reserved = new Set(rows.map((row) => keyOf(row.surface, row.kind, row.title)));
|
|
606
|
+
const used = new Set<string>();
|
|
491
607
|
const update = db.query("UPDATE api_topics SET title = ? WHERE id = ?");
|
|
492
608
|
for (const row of rows) {
|
|
493
|
-
const key = (title: string) =>
|
|
494
|
-
if (!
|
|
495
|
-
|
|
609
|
+
const key = (title: string) => keyOf(row.surface, row.kind, title);
|
|
610
|
+
if (!used.has(key(row.title))) {
|
|
611
|
+
used.add(key(row.title));
|
|
496
612
|
continue;
|
|
497
613
|
}
|
|
498
614
|
let suffix = 2;
|
|
499
615
|
let candidate = `${row.title} (${suffix})`;
|
|
500
|
-
while (
|
|
616
|
+
while (reserved.has(key(candidate)) || used.has(key(candidate))) {
|
|
501
617
|
suffix += 1;
|
|
502
618
|
candidate = `${row.title} (${suffix})`;
|
|
503
619
|
}
|
|
504
|
-
|
|
620
|
+
used.add(key(candidate));
|
|
505
621
|
update.run(candidate, row.id);
|
|
506
622
|
logger.warn(
|
|
507
623
|
{ topicId: row.id, surface: row.surface, from: row.title, to: candidate },
|
|
@@ -531,6 +647,7 @@ export interface TopicRow {
|
|
|
531
647
|
subagent_report_mode: string | null;
|
|
532
648
|
visibility: string | null;
|
|
533
649
|
surface: string | null;
|
|
650
|
+
surface_scope: string | null;
|
|
534
651
|
browser_profile_owner: string | null;
|
|
535
652
|
session_id: string | null;
|
|
536
653
|
}
|
|
@@ -630,6 +747,7 @@ function rowToDto(
|
|
|
630
747
|
: {}),
|
|
631
748
|
visibility: normalizeTopicVisibility(r.visibility),
|
|
632
749
|
surface: normalizeTopicSurface(r.surface),
|
|
750
|
+
surfaceScope: normalizeSurfaceScope(r.surface_scope),
|
|
633
751
|
};
|
|
634
752
|
}
|
|
635
753
|
|
|
@@ -726,6 +844,22 @@ function normalizedTitle(title: string): string {
|
|
|
726
844
|
return title.trim().toLowerCase();
|
|
727
845
|
}
|
|
728
846
|
|
|
847
|
+
/**
|
|
848
|
+
* The workspace a written row belongs to.
|
|
849
|
+
*
|
|
850
|
+
* Only the `otium` surface has more than one instance, so terminal and telegram
|
|
851
|
+
* are always null — writing this process's Otium scope onto a terminal room
|
|
852
|
+
* would partition the terminal namespace for no reason. An explicit
|
|
853
|
+
* `surfaceScope` in the DTO wins so a hub can file a room it already knows the
|
|
854
|
+
* workspace of; otherwise the room joins whatever workspace this process is
|
|
855
|
+
* attached to, which is null until the scope resolves (M-3).
|
|
856
|
+
*/
|
|
857
|
+
function surfaceScopeForWrite(t: TopicDto): string | null {
|
|
858
|
+
if (normalizeTopicSurface(t.surface ?? defaultTopicSurface()) !== "otium") return null;
|
|
859
|
+
if (t.surfaceScope !== undefined) return normalizeSurfaceScope(t.surfaceScope);
|
|
860
|
+
return defaultSurfaceScope();
|
|
861
|
+
}
|
|
862
|
+
|
|
729
863
|
export function upsertTopic(t: TopicDto): void {
|
|
730
864
|
const normalized = normalizeTopicState({
|
|
731
865
|
id: t.id,
|
|
@@ -739,8 +873,8 @@ export function upsertTopic(t: TopicDto): void {
|
|
|
739
873
|
`INSERT INTO api_topics
|
|
740
874
|
(id,title,kind,description,agent,base_model,base_effort,response_policy,
|
|
741
875
|
created_at,last_message_at,parent_topic_id,memory_topic_id,memory_key,is_fork,is_subagent,visibility,surface,
|
|
742
|
-
subagent_report_mode)
|
|
743
|
-
VALUES (
|
|
876
|
+
surface_scope,subagent_report_mode)
|
|
877
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
744
878
|
ON CONFLICT(id) DO UPDATE SET
|
|
745
879
|
title = excluded.title,
|
|
746
880
|
kind = excluded.kind,
|
|
@@ -758,6 +892,10 @@ export function upsertTopic(t: TopicDto): void {
|
|
|
758
892
|
is_subagent = excluded.is_subagent,
|
|
759
893
|
visibility = excluded.visibility,
|
|
760
894
|
surface = excluded.surface,
|
|
895
|
+
-- A room's workspace is fixed at creation (M-1). COALESCE, not
|
|
896
|
+
-- assignment: an update may fill in a scope that was unknown when the
|
|
897
|
+
-- room was created, but may never move a room to another workspace.
|
|
898
|
+
surface_scope = COALESCE(api_topics.surface_scope, excluded.surface_scope),
|
|
761
899
|
subagent_report_mode = excluded.subagent_report_mode`,
|
|
762
900
|
).run(
|
|
763
901
|
t.id,
|
|
@@ -781,6 +919,7 @@ export function upsertTopic(t: TopicDto): void {
|
|
|
781
919
|
// never name one, so defaulting to the literal would file every hub room
|
|
782
920
|
// on the wrong surface.
|
|
783
921
|
normalizeTopicSurface(t.surface ?? defaultTopicSurface()),
|
|
922
|
+
surfaceScopeForWrite(t),
|
|
784
923
|
t.subagentReportMode ?? "auto",
|
|
785
924
|
);
|
|
786
925
|
db.query("DELETE FROM topic_members WHERE topic_id = ?").run(t.id);
|
|
@@ -811,12 +950,23 @@ export function upsertTopic(t: TopicDto): void {
|
|
|
811
950
|
* cannot leak a telegram room into the terminal picker; adapters pass their own
|
|
812
951
|
* surface and get a closed world back.
|
|
813
952
|
*/
|
|
814
|
-
export function listTopics(
|
|
953
|
+
export function listTopics(
|
|
954
|
+
opts: { surface?: TopicSurface; surfaceScope?: string | null } = {},
|
|
955
|
+
): TopicDto[] {
|
|
956
|
+
// `surfaceScope: null` is a real filter ("the unscoped rooms"), so presence —
|
|
957
|
+
// not truthiness — decides whether the scope narrows the query at all.
|
|
958
|
+
const scoped = Object.hasOwn(opts, "surfaceScope");
|
|
815
959
|
const rows = (
|
|
816
960
|
opts.surface
|
|
817
961
|
? db
|
|
818
|
-
.query(
|
|
819
|
-
|
|
962
|
+
.query(
|
|
963
|
+
`SELECT * FROM api_topics WHERE surface = ?${scoped ? " AND surface_scope IS ?" : ""} ORDER BY last_message_at DESC`,
|
|
964
|
+
)
|
|
965
|
+
.all(
|
|
966
|
+
...(scoped
|
|
967
|
+
? [normalizeTopicSurface(opts.surface), normalizeSurfaceScope(opts.surfaceScope)]
|
|
968
|
+
: [normalizeTopicSurface(opts.surface)]),
|
|
969
|
+
)
|
|
820
970
|
: db.query("SELECT * FROM api_topics ORDER BY last_message_at DESC").all()
|
|
821
971
|
) as TopicRow[];
|
|
822
972
|
const participants = getAllTopicParticipants();
|
|
@@ -834,19 +984,35 @@ export function getTopic(id: string): TopicDto | null {
|
|
|
834
984
|
}
|
|
835
985
|
|
|
836
986
|
/** Return the private manager room owned by a user, excluding the retired shared General row. */
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
987
|
+
/**
|
|
988
|
+
* The user's own manager room, optionally on one surface.
|
|
989
|
+
*
|
|
990
|
+
* A manager room is one per user **per surface**: Terminal and Telegram each
|
|
991
|
+
* give the same person their own personal room, and returning one surface's
|
|
992
|
+
* room to the other adapter lets that adapter bind to — and reclassify — a room
|
|
993
|
+
* belonging to a different surface. Callers that represent an adapter pass
|
|
994
|
+
* theirs; unscoped lookups stay for maintenance paths that work by user alone.
|
|
995
|
+
*/
|
|
996
|
+
export function getManagerTopicForUser(
|
|
997
|
+
userId: string,
|
|
998
|
+
surface?: TopicSurface,
|
|
999
|
+
opts: { surfaceScope?: string | null } = {},
|
|
1000
|
+
): TopicDto | null {
|
|
1001
|
+
const scoped = Object.hasOwn(opts, "surfaceScope");
|
|
1002
|
+
const sql = `SELECT t.* FROM api_topics t
|
|
841
1003
|
JOIN topic_members m ON m.topic_id = t.id
|
|
842
1004
|
WHERE t.kind = 'manager'
|
|
843
1005
|
AND t.id != ?
|
|
844
1006
|
AND m.user_id = ?
|
|
845
1007
|
AND m.role = 'owner'
|
|
1008
|
+
${surface ? "AND t.surface = ?" : ""}
|
|
1009
|
+
${scoped ? "AND t.surface_scope IS ?" : ""}
|
|
846
1010
|
ORDER BY t.created_at ASC
|
|
847
|
-
LIMIT 1
|
|
848
|
-
|
|
849
|
-
|
|
1011
|
+
LIMIT 1`;
|
|
1012
|
+
const params: Array<string | null> = [GENERAL_TOPIC_ID, userId];
|
|
1013
|
+
if (surface) params.push(surface);
|
|
1014
|
+
if (scoped) params.push(normalizeSurfaceScope(opts.surfaceScope));
|
|
1015
|
+
const row = db.query<TopicRow, Array<string | null>>(sql).get(...params);
|
|
850
1016
|
return row ? rowToDto(row) : null;
|
|
851
1017
|
}
|
|
852
1018
|
|
|
@@ -901,16 +1067,23 @@ export function getTopicByNameAndKind(title: string, kind: TopicKind): TopicDto
|
|
|
901
1067
|
}
|
|
902
1068
|
|
|
903
1069
|
/**
|
|
904
|
-
* Titles are unique **per surface**, not per node: `otium` may exist
|
|
905
|
-
* the terminal, once on telegram and once
|
|
1070
|
+
* Titles are unique **per surface instance**, not per node: `otium` may exist
|
|
1071
|
+
* once on the terminal, once on telegram, and once in *each* attached Otium
|
|
1072
|
+
* workspace (M-1). Two workspaces are two namespaces that never see each other,
|
|
1073
|
+
* so a name taken in one says nothing about the other.
|
|
906
1074
|
*/
|
|
907
1075
|
export function findTopicTitleConflict(
|
|
908
1076
|
title: string,
|
|
909
1077
|
kind: TopicKind,
|
|
910
|
-
opts: { excludeTopicId?: string; surface?: TopicSurface } = {},
|
|
1078
|
+
opts: { excludeTopicId?: string; surface?: TopicSurface; surfaceScope?: string | null } = {},
|
|
911
1079
|
): TopicDto | null {
|
|
912
1080
|
const wanted = normalizedTitle(title);
|
|
913
1081
|
const surface = normalizeTopicSurface(opts.surface ?? defaultTopicSurface());
|
|
1082
|
+
const surfaceScope = Object.hasOwn(opts, "surfaceScope")
|
|
1083
|
+
? normalizeSurfaceScope(opts.surfaceScope)
|
|
1084
|
+
: surface === "otium"
|
|
1085
|
+
? defaultSurfaceScope()
|
|
1086
|
+
: null;
|
|
914
1087
|
const generalTitleRequested = wanted === normalizedTitle(GENERAL_TOPIC_ID);
|
|
915
1088
|
if (generalTitleRequested && opts.excludeTopicId !== GENERAL_TOPIC_ID) {
|
|
916
1089
|
const general = db.query("SELECT * FROM api_topics WHERE id = ?").get(GENERAL_TOPIC_ID) as
|
|
@@ -925,9 +1098,9 @@ export function findTopicTitleConflict(
|
|
|
925
1098
|
// multi-user host it renamed real rooms out from under their owners.
|
|
926
1099
|
if (kind === "manager") return null;
|
|
927
1100
|
|
|
928
|
-
const params: string
|
|
1101
|
+
const params: Array<string | null> = [wanted, surface, surfaceScope, kind, GENERAL_TOPIC_ID];
|
|
929
1102
|
let sql =
|
|
930
|
-
"SELECT * FROM api_topics WHERE LOWER(TRIM(title)) = ? AND surface = ? AND (kind = ? OR id = ?)";
|
|
1103
|
+
"SELECT * FROM api_topics WHERE LOWER(TRIM(title)) = ? AND surface = ? AND surface_scope IS ? AND (kind = ? OR id = ?)";
|
|
931
1104
|
if (opts.excludeTopicId) {
|
|
932
1105
|
sql += " AND id != ?";
|
|
933
1106
|
params.push(opts.excludeTopicId);
|
|
@@ -959,12 +1132,13 @@ export function setTopicSurfaces(topicIds: readonly string[], surface: TopicSurf
|
|
|
959
1132
|
export function getTopicByNameForUser(
|
|
960
1133
|
title: string,
|
|
961
1134
|
userId: string,
|
|
962
|
-
opts: { surface?: TopicSurface } = {},
|
|
1135
|
+
opts: { surface?: TopicSurface; surfaceScope?: string | null } = {},
|
|
963
1136
|
): TopicDto | null {
|
|
964
1137
|
const trimmed = title.trim();
|
|
965
1138
|
const qualified = /^(agent|channel|manager):(.+)$/i.exec(trimmed);
|
|
966
1139
|
const requestedKind = qualified ? normalizeTopicKind(qualified[1]?.toLowerCase()) : null;
|
|
967
1140
|
const requestedTitle = qualified ? qualified[2]!.trim() : trimmed;
|
|
1141
|
+
const scoped = Object.hasOwn(opts, "surfaceScope");
|
|
968
1142
|
const rows = db
|
|
969
1143
|
.query(
|
|
970
1144
|
`SELECT t.* FROM api_topics t
|
|
@@ -972,6 +1146,7 @@ export function getTopicByNameForUser(
|
|
|
972
1146
|
AND t.id != ?
|
|
973
1147
|
AND t.visibility != 'hidden'
|
|
974
1148
|
AND (? IS NULL OR t.surface = ?)
|
|
1149
|
+
${scoped ? "AND t.surface_scope IS ?" : ""}
|
|
975
1150
|
AND EXISTS (
|
|
976
1151
|
SELECT 1 FROM topic_members m WHERE m.topic_id = t.id AND m.user_id = ?
|
|
977
1152
|
)`,
|
|
@@ -981,6 +1156,7 @@ export function getTopicByNameForUser(
|
|
|
981
1156
|
GENERAL_TOPIC_ID,
|
|
982
1157
|
opts.surface ?? null,
|
|
983
1158
|
opts.surface ?? null,
|
|
1159
|
+
...(scoped ? [normalizeSurfaceScope(opts.surfaceScope)] : []),
|
|
984
1160
|
userId,
|
|
985
1161
|
) as TopicRow[];
|
|
986
1162
|
const matches = requestedKind ? rows.filter((row) => row.kind === requestedKind) : rows;
|
|
@@ -42,6 +42,12 @@ export interface RuntimeUserTurnExecution {
|
|
|
42
42
|
providerSessionId?: string;
|
|
43
43
|
/** Request ids whose ordered messages were folded into this replacement. */
|
|
44
44
|
supersededRequestIds?: string[];
|
|
45
|
+
/**
|
|
46
|
+
* Slack-style thread this turn belongs to, when it was asked in one. Carried
|
|
47
|
+
* on the request rather than the message because it decides both where the
|
|
48
|
+
* answer goes and which pending requests may merge with it (S-13).
|
|
49
|
+
*/
|
|
50
|
+
threadRootId?: string;
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
export interface RuntimeUserTurnRequest {
|
|
@@ -312,7 +318,16 @@ export function mergeRuntimeUserTurnRequest(input: {
|
|
|
312
318
|
"SELECT * FROM runtime_user_turn_requests WHERE topic_id = ? ORDER BY created_at ASC, rowid ASC",
|
|
313
319
|
)
|
|
314
320
|
.all(input.topicId);
|
|
315
|
-
|
|
321
|
+
// Merging exists to fold consecutive utterances of one conversation into
|
|
322
|
+
// a single turn. A different thread is by definition a different
|
|
323
|
+
// conversation, and a merged batch spanning two of them has no correct
|
|
324
|
+
// place to answer — so requests are only ever folded within one thread,
|
|
325
|
+
// and a pending request from another thread is left standing to run on
|
|
326
|
+
// its own (S-13).
|
|
327
|
+
const thread = input.execution.threadRootId;
|
|
328
|
+
const previous = rows
|
|
329
|
+
.map(rowToRequest)
|
|
330
|
+
.filter((request) => request.execution?.threadRootId === thread);
|
|
316
331
|
const omittedRequestIds = new Set([
|
|
317
332
|
...(input.omitRequestIds ?? []),
|
|
318
333
|
...previous
|
|
@@ -371,7 +386,16 @@ export function mergeRuntimeUserTurnRequest(input: {
|
|
|
371
386
|
}
|
|
372
387
|
const attachments = flattenUserTurnAttachments(userMessages);
|
|
373
388
|
|
|
374
|
-
|
|
389
|
+
// Delete only what this replacement actually absorbed. Clearing the whole
|
|
390
|
+
// topic would drop another thread's pending question on the floor — it
|
|
391
|
+
// was never folded in, so nothing would ever answer it (S-13).
|
|
392
|
+
const absorbed = previous.map((request) => request.requestId);
|
|
393
|
+
if (absorbed.length > 0) {
|
|
394
|
+
db.query(
|
|
395
|
+
`DELETE FROM runtime_user_turn_requests
|
|
396
|
+
WHERE topic_id = ? AND request_id IN (${absorbed.map(() => "?").join(",")})`,
|
|
397
|
+
).run(input.topicId, ...absorbed);
|
|
398
|
+
}
|
|
375
399
|
db.query(
|
|
376
400
|
`INSERT INTO runtime_user_turn_requests
|
|
377
401
|
(request_id, topic_id, user_id, prompt, user_messages_json, attachments_json,
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { appendJsonlEntry, readJsonlLines } from "#platform/jsonl";
|
|
5
5
|
import { logger } from "#platform/logger";
|
|
6
|
+
import { getTopicSessionId } from "#storage/api-topics";
|
|
6
7
|
import { resolveStorageLogDir } from "#storage/storage-host";
|
|
7
8
|
import type { AgentKind, TokenUsage } from "#types";
|
|
8
9
|
|
|
@@ -207,6 +208,24 @@ export function recordUsage(
|
|
|
207
208
|
}
|
|
208
209
|
}
|
|
209
210
|
|
|
211
|
+
export function deleteTopicStats(userId: number | string, topicId: string): void {
|
|
212
|
+
const path = queriesPath(userId);
|
|
213
|
+
try {
|
|
214
|
+
const kept = readJsonlLines(path).filter((line) => {
|
|
215
|
+
try {
|
|
216
|
+
const record = JSON.parse(line) as { topicId?: unknown };
|
|
217
|
+
return record.topicId !== topicId;
|
|
218
|
+
} catch {
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
writeFileSync(path, kept.length > 0 ? `${kept.join("\n")}\n` : "", "utf-8");
|
|
223
|
+
} catch (e) {
|
|
224
|
+
if ((e as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
225
|
+
logger.warn({ err: e, userId, topicId }, "token-stats: Failed to delete topic stats");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
210
229
|
export function getStats(
|
|
211
230
|
userId: number | string,
|
|
212
231
|
from?: string,
|
|
@@ -292,7 +311,11 @@ export function getStats(
|
|
|
292
311
|
}
|
|
293
312
|
|
|
294
313
|
/** Exact all-time usage for one topic, independent of transcript pagination. */
|
|
295
|
-
export function getTopicStats(
|
|
314
|
+
export function getTopicStats(
|
|
315
|
+
userId: number | string,
|
|
316
|
+
topicId: string,
|
|
317
|
+
activeProviderSessionId = getTopicSessionId(topicId) ?? undefined,
|
|
318
|
+
): TopicUsageSummary {
|
|
296
319
|
const total = emptyBucket();
|
|
297
320
|
let currentSession: CurrentSessionUsage | undefined;
|
|
298
321
|
|
|
@@ -310,6 +333,8 @@ export function getTopicStats(userId: number | string, topicId: string): TopicUs
|
|
|
310
333
|
raw.contextTokens !== undefined &&
|
|
311
334
|
raw.contextWindow !== undefined &&
|
|
312
335
|
raw.contextWindow > 0 &&
|
|
336
|
+
activeProviderSessionId !== undefined &&
|
|
337
|
+
raw.providerSessionId === activeProviderSessionId &&
|
|
313
338
|
(!currentSession || raw.timestamp > currentSession.timestamp)
|
|
314
339
|
) {
|
|
315
340
|
currentSession = {
|
|
@@ -14,8 +14,11 @@ 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
|
+
defaultSurfaceScope,
|
|
17
18
|
defaultTopicSurface,
|
|
18
19
|
findTopicTitleConflict,
|
|
20
|
+
isSurfaceScopeRequired,
|
|
21
|
+
normalizeSurfaceScope,
|
|
19
22
|
normalizeTopicKind,
|
|
20
23
|
normalizeTopicState,
|
|
21
24
|
normalizeTopicSurface,
|
|
@@ -50,6 +53,11 @@ export interface RegisterTopicOptions {
|
|
|
50
53
|
* adapter must pass their own surface; unset falls back to the host default.
|
|
51
54
|
*/
|
|
52
55
|
surface?: TopicSurface;
|
|
56
|
+
/**
|
|
57
|
+
* Which instance of that surface owns the room. Only meaningful for `otium`;
|
|
58
|
+
* unset means "the workspace this process is attached to".
|
|
59
|
+
*/
|
|
60
|
+
surfaceScope?: string | null;
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
/**
|
|
@@ -68,7 +76,24 @@ export function registerTopic(opts: RegisterTopicOptions): TopicDto {
|
|
|
68
76
|
throw new TopicValidationError("Manager rooms are system-managed");
|
|
69
77
|
}
|
|
70
78
|
const surface = normalizeTopicSurface(opts.surface ?? defaultTopicSurface());
|
|
71
|
-
|
|
79
|
+
// Which instance of that surface — for Otium, which workspace (M-1). Names
|
|
80
|
+
// collide only inside one workspace, so the conflict check must be scoped the
|
|
81
|
+
// same way the row will be written.
|
|
82
|
+
const surfaceScope =
|
|
83
|
+
opts.surfaceScope !== undefined
|
|
84
|
+
? normalizeSurfaceScope(opts.surfaceScope)
|
|
85
|
+
: surface === "otium"
|
|
86
|
+
? defaultSurfaceScope()
|
|
87
|
+
: null;
|
|
88
|
+
// M-3: a room that names no workspace while several are attached would be
|
|
89
|
+
// filed under none and therefore visible to none. Refuse it here, where the
|
|
90
|
+
// caller can still be told, rather than creating a room nobody can reach.
|
|
91
|
+
if (surface === "otium" && !surfaceScope && isSurfaceScopeRequired()) {
|
|
92
|
+
throw new TopicValidationError(
|
|
93
|
+
"this node serves several Otium workspaces; a room must name one to be created",
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
const conflict = findTopicTitleConflict(title, requestedKind, { surface, surfaceScope });
|
|
72
97
|
if (conflict) {
|
|
73
98
|
throw new TopicValidationError(`A topic named "${title}" already exists on ${surface}`);
|
|
74
99
|
}
|
|
@@ -113,6 +138,7 @@ export function registerTopic(opts: RegisterTopicOptions): TopicDto {
|
|
|
113
138
|
aiMode,
|
|
114
139
|
participants: [{ userId: opts.userId, role: "owner" }],
|
|
115
140
|
surface,
|
|
141
|
+
surfaceScope,
|
|
116
142
|
createdAt: now,
|
|
117
143
|
lastMessageAt: now,
|
|
118
144
|
};
|
|
@@ -56,7 +56,9 @@ import {
|
|
|
56
56
|
import type { AgentKind } from "#types";
|
|
57
57
|
import type { TopicDto, TopicSurface } from "#types/api";
|
|
58
58
|
|
|
59
|
-
export function getTopics(
|
|
59
|
+
export function getTopics(
|
|
60
|
+
opts: { surface?: TopicSurface; surfaceScope?: string | null } = {},
|
|
61
|
+
): TopicDto[] {
|
|
60
62
|
return listTopics(opts).filter((topic) => !isLegacySharedGeneral(topic.id));
|
|
61
63
|
}
|
|
62
64
|
|
|
@@ -66,7 +68,9 @@ export function getTopics(opts: { surface?: TopicSurface } = {}): TopicDto[] {
|
|
|
66
68
|
* Callers that represent one product surface pass it, so a telegram room never
|
|
67
69
|
* appears in the terminal picker and vice versa (S-6).
|
|
68
70
|
*/
|
|
69
|
-
export function getVisibleTopics(
|
|
71
|
+
export function getVisibleTopics(
|
|
72
|
+
opts: { surface?: TopicSurface; surfaceScope?: string | null } = {},
|
|
73
|
+
): TopicDto[] {
|
|
70
74
|
return getTopics(opts)
|
|
71
75
|
.filter(isTopicVisible)
|
|
72
76
|
.map((topic) => {
|
|
@@ -107,9 +111,10 @@ function nextDerivedTopicTitle(
|
|
|
107
111
|
kind: TopicDto["kind"],
|
|
108
112
|
suffix: "fork" | "spawn" | "agent",
|
|
109
113
|
surface?: TopicSurface,
|
|
114
|
+
surfaceScope?: string | null,
|
|
110
115
|
): string {
|
|
111
116
|
const visibleTitles = new Set(
|
|
112
|
-
listTopics(surface ? { surface } : {})
|
|
117
|
+
listTopics(surface ? { surface, surfaceScope: surfaceScope ?? null } : {})
|
|
113
118
|
.filter((topic) => topic.kind === kind)
|
|
114
119
|
.map((topic) => topic.title.toLowerCase()),
|
|
115
120
|
);
|
|
@@ -272,8 +277,13 @@ async function createDerivedTopicImpl(
|
|
|
272
277
|
: [{ userId, role: "owner" as const }];
|
|
273
278
|
const kind = topic.kind ?? inferTopicKind(topic);
|
|
274
279
|
const surface = topic.surface ?? defaultTopicSurface();
|
|
275
|
-
|
|
276
|
-
|
|
280
|
+
// A derived room joins its parent's workspace, not this process's current
|
|
281
|
+
// one: a subagent spawned from a workspace-A room belongs to workspace A even
|
|
282
|
+
// if the node has since attached to another (M-1).
|
|
283
|
+
const surfaceScope = topic.surfaceScope ?? null;
|
|
284
|
+
const title =
|
|
285
|
+
opts?.name?.trim() || nextDerivedTopicTitle(topic.title, kind, suffix, surface, surfaceScope);
|
|
286
|
+
const conflict = findTopicTitleConflict(title, kind, { surface, surfaceScope });
|
|
277
287
|
if (conflict) {
|
|
278
288
|
logger.info(
|
|
279
289
|
{ sourceTopicId, title, kind, conflictTopicId: conflict.id },
|
|
@@ -301,6 +311,7 @@ async function createDerivedTopicImpl(
|
|
|
301
311
|
visibility: topic.visibility,
|
|
302
312
|
// A derived room lives on the same surface as the room it came from.
|
|
303
313
|
surface,
|
|
314
|
+
surfaceScope,
|
|
304
315
|
};
|
|
305
316
|
|
|
306
317
|
let sessionId: string | undefined;
|
|
@@ -452,7 +463,10 @@ async function createDerivedTopicImpl(
|
|
|
452
463
|
) {
|
|
453
464
|
throw new TopicDeriveBusyError("Source topic changed while deriving; try again");
|
|
454
465
|
}
|
|
455
|
-
const transactionalConflict = findTopicTitleConflict(title, kind, {
|
|
466
|
+
const transactionalConflict = findTopicTitleConflict(title, kind, {
|
|
467
|
+
surface,
|
|
468
|
+
surfaceScope,
|
|
469
|
+
});
|
|
456
470
|
if (transactionalConflict) throw new TopicTitleConflictError(title);
|
|
457
471
|
upsertTopic(derived);
|
|
458
472
|
if (subagent) {
|
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
} from "#storage/runtime-turn-requests";
|
|
48
48
|
import { deleteSelfSchedulesForTopic } from "#storage/self-schedules";
|
|
49
49
|
import { deletePendingAsksForTopic } from "#storage/session-asks";
|
|
50
|
+
import { deleteTopicStats } from "#storage/token-stats";
|
|
50
51
|
import { archiveConversationEvents, archiveTopicMessages } from "#storage/topic-archive";
|
|
51
52
|
import { deleteTopicArchiveState } from "#storage/topic-archive-state";
|
|
52
53
|
import type { TopicDto } from "#types/api";
|
|
@@ -111,6 +112,7 @@ async function cleanupParticipantResources(
|
|
|
111
112
|
cleanupSessionInboxFiles(participantUserId, topic.id, topic.title);
|
|
112
113
|
clearQueryState(participantUserId, topic.id, topic.title);
|
|
113
114
|
clearQueryUsageAlert(participantUserId, topic.id);
|
|
115
|
+
deleteTopicStats(participantUserId, topic.id);
|
|
114
116
|
deletePendingAsksForTopic({ userId: participantUserId, topicName: topic.title });
|
|
115
117
|
}
|
|
116
118
|
return true;
|