negotium 0.2.26 → 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 +200 -118
- 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 +1124 -511
- package/dist/main.js.map +43 -41
- package/dist/mcp-factories.js +294 -216
- 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 +217 -32
- 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 +141 -31
- 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";
|
|
@@ -474,28 +583,41 @@ function backfillTopicSurfaces(): void {
|
|
|
474
583
|
logger.info({ surface }, "api_topics: surface backfilled");
|
|
475
584
|
}
|
|
476
585
|
|
|
477
|
-
/**
|
|
586
|
+
/**
|
|
587
|
+
* Suffix duplicate `(surface, kind, title)` rows so the new uniqueness rule holds.
|
|
588
|
+
*
|
|
589
|
+
* Manager rooms are excluded: they are one per user, so on a multi-user host
|
|
590
|
+
* every member has a personal room with the same title and none of them is a
|
|
591
|
+
* duplicate. Renaming those relabels real rooms belonging to other people.
|
|
592
|
+
*/
|
|
478
593
|
function renameSurfaceTitleCollisions(): void {
|
|
479
594
|
const rows = db
|
|
480
595
|
.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",
|
|
596
|
+
"SELECT id, title, kind, surface FROM api_topics WHERE kind != 'manager' ORDER BY created_at ASC, rowid ASC",
|
|
482
597
|
)
|
|
483
598
|
.all();
|
|
484
|
-
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>();
|
|
485
607
|
const update = db.query("UPDATE api_topics SET title = ? WHERE id = ?");
|
|
486
608
|
for (const row of rows) {
|
|
487
|
-
const key = (title: string) =>
|
|
488
|
-
if (!
|
|
489
|
-
|
|
609
|
+
const key = (title: string) => keyOf(row.surface, row.kind, title);
|
|
610
|
+
if (!used.has(key(row.title))) {
|
|
611
|
+
used.add(key(row.title));
|
|
490
612
|
continue;
|
|
491
613
|
}
|
|
492
614
|
let suffix = 2;
|
|
493
615
|
let candidate = `${row.title} (${suffix})`;
|
|
494
|
-
while (
|
|
616
|
+
while (reserved.has(key(candidate)) || used.has(key(candidate))) {
|
|
495
617
|
suffix += 1;
|
|
496
618
|
candidate = `${row.title} (${suffix})`;
|
|
497
619
|
}
|
|
498
|
-
|
|
620
|
+
used.add(key(candidate));
|
|
499
621
|
update.run(candidate, row.id);
|
|
500
622
|
logger.warn(
|
|
501
623
|
{ topicId: row.id, surface: row.surface, from: row.title, to: candidate },
|
|
@@ -525,6 +647,7 @@ export interface TopicRow {
|
|
|
525
647
|
subagent_report_mode: string | null;
|
|
526
648
|
visibility: string | null;
|
|
527
649
|
surface: string | null;
|
|
650
|
+
surface_scope: string | null;
|
|
528
651
|
browser_profile_owner: string | null;
|
|
529
652
|
session_id: string | null;
|
|
530
653
|
}
|
|
@@ -624,6 +747,7 @@ function rowToDto(
|
|
|
624
747
|
: {}),
|
|
625
748
|
visibility: normalizeTopicVisibility(r.visibility),
|
|
626
749
|
surface: normalizeTopicSurface(r.surface),
|
|
750
|
+
surfaceScope: normalizeSurfaceScope(r.surface_scope),
|
|
627
751
|
};
|
|
628
752
|
}
|
|
629
753
|
|
|
@@ -720,6 +844,22 @@ function normalizedTitle(title: string): string {
|
|
|
720
844
|
return title.trim().toLowerCase();
|
|
721
845
|
}
|
|
722
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
|
+
|
|
723
863
|
export function upsertTopic(t: TopicDto): void {
|
|
724
864
|
const normalized = normalizeTopicState({
|
|
725
865
|
id: t.id,
|
|
@@ -733,8 +873,8 @@ export function upsertTopic(t: TopicDto): void {
|
|
|
733
873
|
`INSERT INTO api_topics
|
|
734
874
|
(id,title,kind,description,agent,base_model,base_effort,response_policy,
|
|
735
875
|
created_at,last_message_at,parent_topic_id,memory_topic_id,memory_key,is_fork,is_subagent,visibility,surface,
|
|
736
|
-
subagent_report_mode)
|
|
737
|
-
VALUES (
|
|
876
|
+
surface_scope,subagent_report_mode)
|
|
877
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
738
878
|
ON CONFLICT(id) DO UPDATE SET
|
|
739
879
|
title = excluded.title,
|
|
740
880
|
kind = excluded.kind,
|
|
@@ -752,6 +892,10 @@ export function upsertTopic(t: TopicDto): void {
|
|
|
752
892
|
is_subagent = excluded.is_subagent,
|
|
753
893
|
visibility = excluded.visibility,
|
|
754
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),
|
|
755
899
|
subagent_report_mode = excluded.subagent_report_mode`,
|
|
756
900
|
).run(
|
|
757
901
|
t.id,
|
|
@@ -775,6 +919,7 @@ export function upsertTopic(t: TopicDto): void {
|
|
|
775
919
|
// never name one, so defaulting to the literal would file every hub room
|
|
776
920
|
// on the wrong surface.
|
|
777
921
|
normalizeTopicSurface(t.surface ?? defaultTopicSurface()),
|
|
922
|
+
surfaceScopeForWrite(t),
|
|
778
923
|
t.subagentReportMode ?? "auto",
|
|
779
924
|
);
|
|
780
925
|
db.query("DELETE FROM topic_members WHERE topic_id = ?").run(t.id);
|
|
@@ -805,12 +950,23 @@ export function upsertTopic(t: TopicDto): void {
|
|
|
805
950
|
* cannot leak a telegram room into the terminal picker; adapters pass their own
|
|
806
951
|
* surface and get a closed world back.
|
|
807
952
|
*/
|
|
808
|
-
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");
|
|
809
959
|
const rows = (
|
|
810
960
|
opts.surface
|
|
811
961
|
? db
|
|
812
|
-
.query(
|
|
813
|
-
|
|
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
|
+
)
|
|
814
970
|
: db.query("SELECT * FROM api_topics ORDER BY last_message_at DESC").all()
|
|
815
971
|
) as TopicRow[];
|
|
816
972
|
const participants = getAllTopicParticipants();
|
|
@@ -828,19 +984,35 @@ export function getTopic(id: string): TopicDto | null {
|
|
|
828
984
|
}
|
|
829
985
|
|
|
830
986
|
/** Return the private manager room owned by a user, excluding the retired shared General row. */
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
|
835
1003
|
JOIN topic_members m ON m.topic_id = t.id
|
|
836
1004
|
WHERE t.kind = 'manager'
|
|
837
1005
|
AND t.id != ?
|
|
838
1006
|
AND m.user_id = ?
|
|
839
1007
|
AND m.role = 'owner'
|
|
1008
|
+
${surface ? "AND t.surface = ?" : ""}
|
|
1009
|
+
${scoped ? "AND t.surface_scope IS ?" : ""}
|
|
840
1010
|
ORDER BY t.created_at ASC
|
|
841
|
-
LIMIT 1
|
|
842
|
-
|
|
843
|
-
|
|
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);
|
|
844
1016
|
return row ? rowToDto(row) : null;
|
|
845
1017
|
}
|
|
846
1018
|
|
|
@@ -895,16 +1067,23 @@ export function getTopicByNameAndKind(title: string, kind: TopicKind): TopicDto
|
|
|
895
1067
|
}
|
|
896
1068
|
|
|
897
1069
|
/**
|
|
898
|
-
* Titles are unique **per surface**, not per node: `otium` may exist
|
|
899
|
-
* 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.
|
|
900
1074
|
*/
|
|
901
1075
|
export function findTopicTitleConflict(
|
|
902
1076
|
title: string,
|
|
903
1077
|
kind: TopicKind,
|
|
904
|
-
opts: { excludeTopicId?: string; surface?: TopicSurface } = {},
|
|
1078
|
+
opts: { excludeTopicId?: string; surface?: TopicSurface; surfaceScope?: string | null } = {},
|
|
905
1079
|
): TopicDto | null {
|
|
906
1080
|
const wanted = normalizedTitle(title);
|
|
907
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;
|
|
908
1087
|
const generalTitleRequested = wanted === normalizedTitle(GENERAL_TOPIC_ID);
|
|
909
1088
|
if (generalTitleRequested && opts.excludeTopicId !== GENERAL_TOPIC_ID) {
|
|
910
1089
|
const general = db.query("SELECT * FROM api_topics WHERE id = ?").get(GENERAL_TOPIC_ID) as
|
|
@@ -913,12 +1092,15 @@ export function findTopicTitleConflict(
|
|
|
913
1092
|
if (general) return rowToDto(general);
|
|
914
1093
|
}
|
|
915
1094
|
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
1095
|
+
// Manager rooms are one-per-user and system-created, so several of them
|
|
1096
|
+
// legitimately share a title (every member's personal "General"). Comparing
|
|
1097
|
+
// them against each other is not a conflict, it is the design — on a
|
|
1098
|
+
// multi-user host it renamed real rooms out from under their owners.
|
|
1099
|
+
if (kind === "manager") return null;
|
|
1100
|
+
|
|
1101
|
+
const params: Array<string | null> = [wanted, surface, surfaceScope, kind, GENERAL_TOPIC_ID];
|
|
1102
|
+
let sql =
|
|
1103
|
+
"SELECT * FROM api_topics WHERE LOWER(TRIM(title)) = ? AND surface = ? AND surface_scope IS ? AND (kind = ? OR id = ?)";
|
|
922
1104
|
if (opts.excludeTopicId) {
|
|
923
1105
|
sql += " AND id != ?";
|
|
924
1106
|
params.push(opts.excludeTopicId);
|
|
@@ -950,12 +1132,13 @@ export function setTopicSurfaces(topicIds: readonly string[], surface: TopicSurf
|
|
|
950
1132
|
export function getTopicByNameForUser(
|
|
951
1133
|
title: string,
|
|
952
1134
|
userId: string,
|
|
953
|
-
opts: { surface?: TopicSurface } = {},
|
|
1135
|
+
opts: { surface?: TopicSurface; surfaceScope?: string | null } = {},
|
|
954
1136
|
): TopicDto | null {
|
|
955
1137
|
const trimmed = title.trim();
|
|
956
1138
|
const qualified = /^(agent|channel|manager):(.+)$/i.exec(trimmed);
|
|
957
1139
|
const requestedKind = qualified ? normalizeTopicKind(qualified[1]?.toLowerCase()) : null;
|
|
958
1140
|
const requestedTitle = qualified ? qualified[2]!.trim() : trimmed;
|
|
1141
|
+
const scoped = Object.hasOwn(opts, "surfaceScope");
|
|
959
1142
|
const rows = db
|
|
960
1143
|
.query(
|
|
961
1144
|
`SELECT t.* FROM api_topics t
|
|
@@ -963,6 +1146,7 @@ export function getTopicByNameForUser(
|
|
|
963
1146
|
AND t.id != ?
|
|
964
1147
|
AND t.visibility != 'hidden'
|
|
965
1148
|
AND (? IS NULL OR t.surface = ?)
|
|
1149
|
+
${scoped ? "AND t.surface_scope IS ?" : ""}
|
|
966
1150
|
AND EXISTS (
|
|
967
1151
|
SELECT 1 FROM topic_members m WHERE m.topic_id = t.id AND m.user_id = ?
|
|
968
1152
|
)`,
|
|
@@ -972,6 +1156,7 @@ export function getTopicByNameForUser(
|
|
|
972
1156
|
GENERAL_TOPIC_ID,
|
|
973
1157
|
opts.surface ?? null,
|
|
974
1158
|
opts.surface ?? null,
|
|
1159
|
+
...(scoped ? [normalizeSurfaceScope(opts.surfaceScope)] : []),
|
|
975
1160
|
userId,
|
|
976
1161
|
) as TopicRow[];
|
|
977
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;
|