negotium 0.1.12 → 0.1.13

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.
Files changed (137) hide show
  1. package/dist/canonical-mcp-bridge.js +2 -3
  2. package/dist/canonical-mcp-bridge.js.map +1 -1
  3. package/dist/chunk-a9y2rwxf.js.map +1 -1
  4. package/dist/{chunk-j0kjscb7.js → chunk-s2gez3wg.js} +20 -2
  5. package/dist/{chunk-j0kjscb7.js.map → chunk-s2gez3wg.js.map} +2 -2
  6. package/dist/cron.js +49 -25
  7. package/dist/cron.js.map +15 -14
  8. package/dist/hosted-agent.js +3012 -17
  9. package/dist/hosted-agent.js.map +30 -3
  10. package/dist/main.js +31323 -36
  11. package/dist/main.js.map +198 -3
  12. package/dist/prompts.js +11 -8
  13. package/dist/prompts.js.map +4 -4
  14. package/dist/runtime/src/application/submit-user-message.ts +3 -0
  15. package/dist/runtime/src/index.ts +7 -0
  16. package/dist/runtime/src/mcp/runtime-spec.ts +3 -0
  17. package/dist/runtime/src/mcp/session-comm/server.ts +1 -1
  18. package/dist/runtime/src/platform/mcp-config.ts +13 -0
  19. package/dist/runtime/src/prompts/builders.ts +20 -6
  20. package/dist/runtime/src/query/active-rooms.ts +2 -0
  21. package/dist/runtime/src/runtime/delivery-ack.ts +112 -0
  22. package/dist/runtime/src/runtime/errors.ts +12 -12
  23. package/dist/runtime/src/runtime/turn-runner.ts +12 -0
  24. package/dist/runtime/src/storage/runtime-turn-requests.ts +1 -0
  25. package/dist/runtime/src/types/api.ts +2 -0
  26. package/dist/runtime/src/types.ts +2 -0
  27. package/dist/runtime/src/version.ts +1 -1
  28. package/dist/runtime-helpers.js.map +1 -1
  29. package/dist/storage.js.map +1 -1
  30. package/dist/types/packages/core/src/mcp/runtime-spec.d.ts +2 -0
  31. package/dist/types/packages/core/src/platform/mcp-config.d.ts +4 -0
  32. package/dist/types/packages/core/src/prompts/builders.d.ts +2 -0
  33. package/dist/types/packages/core/src/types/api.d.ts +2 -0
  34. package/dist/types/packages/core/src/types.d.ts +2 -0
  35. package/dist/types/packages/core/src/version.d.ts +1 -1
  36. package/dist/vault.js.map +1 -1
  37. package/package.json +1 -1
  38. package/dist/chunk-00q25k7p.js +0 -2387
  39. package/dist/chunk-00q25k7p.js.map +0 -26
  40. package/dist/chunk-03kv7mvj.js +0 -2152
  41. package/dist/chunk-03kv7mvj.js.map +0 -15
  42. package/dist/chunk-05pm51dh.js +0 -589
  43. package/dist/chunk-05pm51dh.js.map +0 -11
  44. package/dist/chunk-0eh16bk1.js +0 -2936
  45. package/dist/chunk-0eh16bk1.js.map +0 -20
  46. package/dist/chunk-0yfqyv88.js +0 -108
  47. package/dist/chunk-0yfqyv88.js.map +0 -10
  48. package/dist/chunk-1k7t7bnx.js +0 -97
  49. package/dist/chunk-1k7t7bnx.js.map +0 -10
  50. package/dist/chunk-3gkfr9vb.js +0 -431
  51. package/dist/chunk-3gkfr9vb.js.map +0 -11
  52. package/dist/chunk-5akd1gbj.js +0 -281
  53. package/dist/chunk-5akd1gbj.js.map +0 -10
  54. package/dist/chunk-7k19pcg8.js +0 -577
  55. package/dist/chunk-7k19pcg8.js.map +0 -14
  56. package/dist/chunk-81tbfbmt.js +0 -10
  57. package/dist/chunk-81tbfbmt.js.map +0 -10
  58. package/dist/chunk-82qhv1mq.js +0 -512
  59. package/dist/chunk-82qhv1mq.js.map +0 -13
  60. package/dist/chunk-8dy7e2a9.js +0 -103
  61. package/dist/chunk-8dy7e2a9.js.map +0 -10
  62. package/dist/chunk-8f2sqmrj.js +0 -1712
  63. package/dist/chunk-8f2sqmrj.js.map +0 -18
  64. package/dist/chunk-9hjeemfz.js +0 -372
  65. package/dist/chunk-9hjeemfz.js.map +0 -10
  66. package/dist/chunk-a4aa2ccv.js +0 -141
  67. package/dist/chunk-a4aa2ccv.js.map +0 -10
  68. package/dist/chunk-bsg215q3.js +0 -3536
  69. package/dist/chunk-bsg215q3.js.map +0 -20
  70. package/dist/chunk-bxnfsc7w.js +0 -58
  71. package/dist/chunk-bxnfsc7w.js.map +0 -10
  72. package/dist/chunk-c500xe9z.js +0 -33
  73. package/dist/chunk-c500xe9z.js.map +0 -10
  74. package/dist/chunk-cngrf7ab.js +0 -149
  75. package/dist/chunk-cngrf7ab.js.map +0 -10
  76. package/dist/chunk-d7tprf06.js +0 -53
  77. package/dist/chunk-d7tprf06.js.map +0 -10
  78. package/dist/chunk-ddnrmkrv.js +0 -61
  79. package/dist/chunk-ddnrmkrv.js.map +0 -10
  80. package/dist/chunk-edb07sjp.js +0 -567
  81. package/dist/chunk-edb07sjp.js.map +0 -14
  82. package/dist/chunk-ejcm6zx8.js +0 -591
  83. package/dist/chunk-ejcm6zx8.js.map +0 -11
  84. package/dist/chunk-ezs352qs.js +0 -237
  85. package/dist/chunk-ezs352qs.js.map +0 -11
  86. package/dist/chunk-f5xgprg1.js +0 -59
  87. package/dist/chunk-f5xgprg1.js.map +0 -10
  88. package/dist/chunk-fbtb3v0s.js +0 -1339
  89. package/dist/chunk-fbtb3v0s.js.map +0 -16
  90. package/dist/chunk-ffefbd3r.js +0 -17
  91. package/dist/chunk-ffefbd3r.js.map +0 -10
  92. package/dist/chunk-gaknyw04.js +0 -708
  93. package/dist/chunk-gaknyw04.js.map +0 -14
  94. package/dist/chunk-gxmny6tq.js +0 -1637
  95. package/dist/chunk-gxmny6tq.js.map +0 -15
  96. package/dist/chunk-h7bj5zwb.js +0 -3069
  97. package/dist/chunk-h7bj5zwb.js.map +0 -32
  98. package/dist/chunk-hp4g7wx7.js +0 -46
  99. package/dist/chunk-hp4g7wx7.js.map +0 -10
  100. package/dist/chunk-hwy3n31w.js +0 -21
  101. package/dist/chunk-hwy3n31w.js.map +0 -9
  102. package/dist/chunk-jzz91jwh.js +0 -70
  103. package/dist/chunk-jzz91jwh.js.map +0 -10
  104. package/dist/chunk-k778y16p.js +0 -153
  105. package/dist/chunk-k778y16p.js.map +0 -11
  106. package/dist/chunk-mh5mexsf.js +0 -147
  107. package/dist/chunk-mh5mexsf.js.map +0 -11
  108. package/dist/chunk-mnwaext5.js +0 -318
  109. package/dist/chunk-mnwaext5.js.map +0 -10
  110. package/dist/chunk-nkgs40ch.js +0 -23
  111. package/dist/chunk-nkgs40ch.js.map +0 -10
  112. package/dist/chunk-pz57vwsm.js +0 -250
  113. package/dist/chunk-pz57vwsm.js.map +0 -11
  114. package/dist/chunk-r6kdp7gn.js +0 -252
  115. package/dist/chunk-r6kdp7gn.js.map +0 -10
  116. package/dist/chunk-rkvrkcgx.js +0 -117
  117. package/dist/chunk-rkvrkcgx.js.map +0 -10
  118. package/dist/chunk-s74rdyqe.js +0 -117
  119. package/dist/chunk-s74rdyqe.js.map +0 -12
  120. package/dist/chunk-sa874j5c.js +0 -9
  121. package/dist/chunk-sa874j5c.js.map +0 -10
  122. package/dist/chunk-sffqdkvq.js +0 -122
  123. package/dist/chunk-sffqdkvq.js.map +0 -10
  124. package/dist/chunk-tcs9qjps.js +0 -64
  125. package/dist/chunk-tcs9qjps.js.map +0 -10
  126. package/dist/chunk-wx83ep8e.js +0 -131
  127. package/dist/chunk-wx83ep8e.js.map +0 -10
  128. package/dist/chunk-xag86ng3.js +0 -122
  129. package/dist/chunk-xag86ng3.js.map +0 -10
  130. package/dist/chunk-xfdkep71.js +0 -371
  131. package/dist/chunk-xfdkep71.js.map +0 -11
  132. package/dist/chunk-xhm1rfpt.js +0 -2907
  133. package/dist/chunk-xhm1rfpt.js.map +0 -24
  134. package/dist/chunk-xpcps28p.js +0 -2138
  135. package/dist/chunk-xpcps28p.js.map +0 -29
  136. package/dist/chunk-yb4rbev9.js +0 -214
  137. package/dist/chunk-yb4rbev9.js.map +0 -10
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Cross-process delivery acknowledgements for files rendered by an external
3
+ * channel adapter. Signals ride the durable RuntimeBus so the runtime MCP and
4
+ * adapter may live in separate processes (the default CLI topology).
5
+ */
6
+
7
+ import { runtimeBus } from "#bus";
8
+
9
+ export interface DeliveryAckResult {
10
+ ok: boolean;
11
+ error?: string;
12
+ }
13
+
14
+ interface DeliveryAckSignal {
15
+ kind: "delivery_ack";
16
+ messageId: string;
17
+ phase: "claimed" | "settled";
18
+ result?: DeliveryAckResult;
19
+ }
20
+
21
+ export interface DeliveryAckWaiter {
22
+ promise: Promise<DeliveryAckResult | null>;
23
+ cancel(): void;
24
+ }
25
+
26
+ function signalForMessage(payload: unknown, messageId: string): DeliveryAckSignal | null {
27
+ if (!payload || typeof payload !== "object") return null;
28
+ const value = payload as Partial<DeliveryAckSignal>;
29
+ if (
30
+ value.kind !== "delivery_ack" ||
31
+ value.messageId !== messageId ||
32
+ (value.phase !== "claimed" && value.phase !== "settled")
33
+ ) {
34
+ return null;
35
+ }
36
+ return value as DeliveryAckSignal;
37
+ }
38
+
39
+ /**
40
+ * Install the listener before publishing the attachment message. If no
41
+ * adapter claims the message within `claimTimeoutMs`, no external delivery
42
+ * surface was interested and the caller may keep the host-storage result.
43
+ * Once claimed, lack of a final result is a delivery failure, never success.
44
+ */
45
+ export function prepareDeliveryAck(
46
+ messageId: string,
47
+ claimTimeoutMs: number,
48
+ deliveryTimeoutMs: number,
49
+ ): DeliveryAckWaiter {
50
+ let settled = false;
51
+ let claimed = false;
52
+ let finishPromise: (result: DeliveryAckResult | null) => void = () => {};
53
+ let timer: ReturnType<typeof setTimeout> | undefined;
54
+ let unsubscribe = () => {};
55
+
56
+ const promise = new Promise<DeliveryAckResult | null>((resolve) => {
57
+ finishPromise = resolve;
58
+ });
59
+
60
+ unsubscribe = runtimeBus().subscribe((event) => {
61
+ if (event.type !== "ai-status") return;
62
+ const signal = signalForMessage(event.payload, messageId);
63
+ if (!signal) return;
64
+ if (signal.phase === "claimed") {
65
+ if (claimed || settled) return;
66
+ claimed = true;
67
+ if (timer) clearTimeout(timer);
68
+ timer = setTimeout(() => {
69
+ finish({ ok: false, error: "channel delivery confirmation timed out" });
70
+ }, deliveryTimeoutMs);
71
+ timer.unref?.();
72
+ return;
73
+ }
74
+ if (signal.result) finish(signal.result);
75
+ });
76
+
77
+ function finish(result: DeliveryAckResult | null): void {
78
+ if (settled) return;
79
+ settled = true;
80
+ if (timer) clearTimeout(timer);
81
+ unsubscribe();
82
+ finishPromise(result);
83
+ }
84
+
85
+ timer = setTimeout(() => finish(null), claimTimeoutMs);
86
+ timer.unref?.();
87
+
88
+ return { promise, cancel: () => finish(null) };
89
+ }
90
+
91
+ /** Announce that an adapter accepted this message for external delivery. */
92
+ export function claimDeliveryAck(topicId: string, messageId: string): void {
93
+ runtimeBus().broadcastAiStatus(topicId, {
94
+ kind: "delivery_ack",
95
+ messageId,
96
+ phase: "claimed",
97
+ } satisfies DeliveryAckSignal);
98
+ }
99
+
100
+ /** Publish the adapter's one aggregated result for this message. */
101
+ export function resolveDeliveryAck(
102
+ topicId: string,
103
+ messageId: string,
104
+ result: DeliveryAckResult,
105
+ ): void {
106
+ runtimeBus().broadcastAiStatus(topicId, {
107
+ kind: "delivery_ack",
108
+ messageId,
109
+ phase: "settled",
110
+ result,
111
+ } satisfies DeliveryAckSignal);
112
+ }
@@ -16,11 +16,11 @@ export function stringifyError(err: unknown): string {
16
16
  export function authRecoveryHint(agent: AgentKind): string {
17
17
  switch (agent) {
18
18
  case "claude":
19
- return "Claude Code 로그인을 갱신해주세요";
19
+ return "Please refresh your Claude Code login";
20
20
  case "codex":
21
- return "`codex login` 으로 다시 로그인해주세요 (~/.codex/auth.json)";
21
+ return "Please log in again with `codex login` (~/.codex/auth.json)";
22
22
  case "maestro":
23
- return "DEEPSEEK_API_KEY 환경변수를 확인해주세요";
23
+ return "Please check the DEEPSEEK_API_KEY environment variable";
24
24
  }
25
25
  }
26
26
 
@@ -31,14 +31,14 @@ export function classifyAgentError(err: unknown, agent: AgentKind): string {
31
31
  typeof err === "object" && err !== null
32
32
  ? (err as Record<string, unknown>).constructor?.name
33
33
  : undefined;
34
- if (ctor === "AuthenticationError") return `${name} 인증이 만료되었습니다. ${hint}. (401)`;
34
+ if (ctor === "AuthenticationError") return `${name} authentication expired. ${hint}. (401)`;
35
35
  if (ctor === "RateLimitError")
36
- return `${name} 요청 한도를 초과했습니다. 잠시 다시 시도해주세요. (429)`;
36
+ return `${name} request limit exceeded. Please try again in a moment. (429)`;
37
37
  if (ctor === "InternalServerError") {
38
38
  const status = (err as Record<string, unknown>).status;
39
39
  return status === 529
40
- ? `${name} 서버가 과부하 상태입니다. 잠시 다시 시도해주세요. (529)`
41
- : `${name} 서버 오류가 발생했습니다. 잠시 다시 시도해주세요. (500)`;
40
+ ? `${name} server is overloaded. Please try again in a moment. (529)`
41
+ : `${name} server error occurred. Please try again in a moment. (500)`;
42
42
  }
43
43
 
44
44
  const s = stringifyError(err);
@@ -47,17 +47,17 @@ export function classifyAgentError(err: unknown, agent: AgentKind): string {
47
47
  s,
48
48
  )
49
49
  ) {
50
- return `${name} 인증이 만료되었습니다. ${hint}. (401)`;
50
+ return `${name} authentication expired. ${hint}. (401)`;
51
51
  }
52
52
  if (/429|rate.limit/i.test(s))
53
- return `${name} 요청 한도를 초과했습니다. 잠시 다시 시도해주세요. (429)`;
53
+ return `${name} request limit exceeded. Please try again in a moment. (429)`;
54
54
  if (/529|overloaded/i.test(s))
55
- return `${name} 서버가 과부하 상태입니다. 잠시 다시 시도해주세요. (529)`;
55
+ return `${name} server is overloaded. Please try again in a moment. (529)`;
56
56
  if (/500|internal.server/i.test(s))
57
- return `${name} 서버 오류가 발생했습니다. 잠시 다시 시도해주세요. (500)`;
57
+ return `${name} server error occurred. Please try again in a moment. (500)`;
58
58
 
59
59
  const snippet = s.length > 200 ? `${s.slice(0, 200)}...` : s;
60
- return `${name} 오류: ${snippet}`;
60
+ return `${name} error: ${snippet}`;
61
61
  }
62
62
 
63
63
  export function isSessionExpiredError(message: string): boolean {
@@ -946,6 +946,7 @@ function redispatchInject(inject: DeferredInject): void {
946
946
  sessionName: inject.sessionName,
947
947
  sessionType: inject.sessionType,
948
948
  visualTools: inject.visualTools,
949
+ fileDeliveryTools: inject.fileDeliveryTools,
949
950
  onSessionId: inject.onSessionId,
950
951
  onSessionReset: inject.onSessionReset,
951
952
  bridgeSessionFromHistory: inject.bridgeSessionFromHistory,
@@ -1221,6 +1222,8 @@ export interface AiTurnExecutionOptions {
1221
1222
  sessionType?: "dm" | "forum" | "ephemeral" | "manager" | "cron";
1222
1223
  /** Adapter-granted access to Otium's visual panel tools. Default-deny. */
1223
1224
  visualTools?: boolean;
1225
+ /** Adapter-granted access to chat file-delivery tools. Default-deny. */
1226
+ fileDeliveryTools?: boolean;
1224
1227
  /** Own this turn's provider session without replacing the topic's main session. */
1225
1228
  onSessionId?: (sessionId: string) => void;
1226
1229
  /** Clear the externally-owned provider session after recovery fails. */
@@ -1366,6 +1369,7 @@ function serializableUserTurnExecution(params: StartAiTurnParams): RuntimeUserTu
1366
1369
  sessionName: params.sessionName,
1367
1370
  sessionType: params.sessionType,
1368
1371
  visualTools: params.visualTools,
1372
+ fileDeliveryTools: params.fileDeliveryTools,
1369
1373
  bridgeSessionFromHistory: params.bridgeSessionFromHistory,
1370
1374
  peerBridge: params.peerBridge,
1371
1375
  from: params.from,
@@ -1451,6 +1455,7 @@ async function drainOneDurableUserTurn(): Promise<void> {
1451
1455
  sessionName: execution?.sessionName,
1452
1456
  sessionType: execution?.sessionType,
1453
1457
  visualTools: execution?.visualTools,
1458
+ fileDeliveryTools: execution?.fileDeliveryTools,
1454
1459
  bridgeSessionFromHistory: execution?.bridgeSessionFromHistory,
1455
1460
  peerBridge: execution?.peerBridge,
1456
1461
  from: execution?.from,
@@ -1555,6 +1560,7 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
1555
1560
  const sessionName = params.sessionName ?? topic.title;
1556
1561
  const sessionType = params.sessionType;
1557
1562
  const visualTools = params.visualTools === true;
1563
+ const fileDeliveryTools = params.fileDeliveryTools === true;
1558
1564
  const onSessionId = params.onSessionId;
1559
1565
  const onSessionReset = params.onSessionReset;
1560
1566
  const bridgeSessionFromHistory = params.bridgeSessionFromHistory === true;
@@ -1606,6 +1612,7 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
1606
1612
  sessionName,
1607
1613
  sessionType,
1608
1614
  visualTools,
1615
+ fileDeliveryTools,
1609
1616
  onSessionId,
1610
1617
  onSessionReset,
1611
1618
  bridgeSessionFromHistory,
@@ -1743,6 +1750,7 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
1743
1750
  sessionName,
1744
1751
  sessionType,
1745
1752
  visualTools,
1753
+ fileDeliveryTools,
1746
1754
  onSessionId,
1747
1755
  onSessionReset,
1748
1756
  bridgeSessionFromHistory,
@@ -1872,6 +1880,7 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
1872
1880
  canSpawnSubagents:
1873
1881
  peerBridge?.canSpawnSubagents ?? (topicRecord?.kind === "agent" && !topicRecord.isSubagent),
1874
1882
  visualTools,
1883
+ fileDeliveryTools,
1875
1884
  };
1876
1885
  const isManager = topicRecord?.kind === "manager";
1877
1886
  const isMentionOnlyChannel = topic.aiMode === "mention" && !isManager;
@@ -2063,6 +2072,7 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
2063
2072
  wikiTopicId: memoryTopic.id,
2064
2073
  autoContinue: allowAutoContinue && !silent,
2065
2074
  visualTools,
2075
+ fileDeliveryTools,
2066
2076
  peerBridge,
2067
2077
  });
2068
2078
  } finally {
@@ -2155,6 +2165,7 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
2155
2165
  sessionName,
2156
2166
  sessionType,
2157
2167
  visualTools,
2168
+ fileDeliveryTools,
2158
2169
  onSessionId,
2159
2170
  onSessionReset,
2160
2171
  bridgeSessionFromHistory,
@@ -2337,6 +2348,7 @@ export function triggerTopicAiTurn(
2337
2348
  sessionName: opts?.sessionName,
2338
2349
  sessionType: opts?.sessionType,
2339
2350
  visualTools: opts?.visualTools,
2351
+ fileDeliveryTools: opts?.fileDeliveryTools,
2340
2352
  onSessionId: opts?.onSessionId,
2341
2353
  onSessionReset: opts?.onSessionReset,
2342
2354
  bridgeSessionFromHistory: opts?.bridgeSessionFromHistory,
@@ -20,6 +20,7 @@ export interface RuntimeUserTurnExecution {
20
20
  sessionName?: string;
21
21
  sessionType?: "dm" | "forum" | "ephemeral" | "manager" | "cron";
22
22
  visualTools?: boolean;
23
+ fileDeliveryTools?: boolean;
23
24
  bridgeSessionFromHistory?: boolean;
24
25
  peerBridge?: PeerRuntimeBridgeContext;
25
26
  from?: string;
@@ -206,6 +206,8 @@ export interface MessageDto {
206
206
  authorAvatarUrl?: string | null;
207
207
  text: string;
208
208
  attachments?: AttachmentDto[];
209
+ /** Internal hint: an external channel may claim and acknowledge attachment delivery. */
210
+ deliveryAckRequested?: boolean;
209
211
  queryId?: string;
210
212
  agentType?: AgentKind;
211
213
  model?: string;
@@ -182,6 +182,8 @@ export interface AgentQueryOptions {
182
182
  autoContinue?: boolean;
183
183
  /** Expose Otium-only visual panel tools for this turn. Default-deny. */
184
184
  visualTools?: boolean;
185
+ /** Expose adapter-backed file-delivery tools for this turn. Default-deny. */
186
+ fileDeliveryTools?: boolean;
185
187
  abortController?: AbortController;
186
188
  model?: string;
187
189
  /** Provider-side hard budget when the selected SDK supports one. */
@@ -1 +1 @@
1
- export const NEGOTIUM_VERSION = "0.1.12";
1
+ export const NEGOTIUM_VERSION = "0.1.13";
@@ -4,7 +4,7 @@
4
4
  "sourcesContent": [
5
5
  "import { randomBytes } from \"node:crypto\";\nimport { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { dirname, join, resolve } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { logger } from \"#platform/logger\";\nimport { type AgentKind, isAgentKind } from \"#types\";\n\nexport function envText(envKey: string): string | undefined {\n const value = process.env[envKey]?.trim();\n return value ? value : undefined;\n}\n\nfunction resolveAgentEnv(envKey: string, fallback: AgentKind, legacyEnvKey?: string): AgentKind {\n const value = envText(envKey) ?? (legacyEnvKey ? envText(legacyEnvKey) : undefined);\n return isAgentKind(value) ? value : fallback;\n}\n\nconst HOME = homedir();\n\n// new URL(\"../..\", import.meta.url) causes webpack to treat \"../..\" as a module import.\n// Split into fileURLToPath → dirname → resolve to avoid that.\nfunction resolveProjectRoot(): string {\n const moduleDir = dirname(fileURLToPath(import.meta.url));\n const packagedRuntime = resolve(moduleDir, \"runtime\");\n if (existsSync(resolve(packagedRuntime, \"src\"))) return packagedRuntime;\n return resolve(moduleDir, \"../..\");\n}\n\nexport const PROJECT_ROOT = resolveProjectRoot();\n\n/** Resolve a dependency executable from either a package-local or hoisted install. */\nfunction resolveDependencyBin(name: string): string {\n let dir = PROJECT_ROOT;\n while (true) {\n const candidate = resolve(dir, \"node_modules\", \".bin\", name);\n if (existsSync(candidate)) return candidate;\n const parent = dirname(dir);\n if (parent === dir) return candidate;\n dir = parent;\n }\n}\n\n// Each machine is one negotium node; all node state lives in one dotdir.\n// NEGOTIUM_STATE_DIR overrides (useful for tests and multi-node-on-one-box).\nconst STATE_DIR_ENV = envText(\"NEGOTIUM_STATE_DIR\");\nexport const STATE_DIR = STATE_DIR_ENV ? resolve(STATE_DIR_ENV) : resolve(HOME, \".negotium\");\n\nfunction resolveLocalStateDir(envKey: string, stateName: string): string {\n const envValue = envText(envKey);\n if (envValue) return resolve(envValue);\n return resolve(STATE_DIR, stateName);\n}\n\nfunction parsePortEnv(envValue: string | undefined, fallback: number): number {\n if (!envValue) return fallback;\n const port = Number.parseInt(envValue, 10);\n return Number.isInteger(port) && port > 0 ? port : fallback;\n}\n\nexport const WORKSPACE_DIR = resolveLocalStateDir(\"NEGOTIUM_WORKSPACE_DIR\", \"workspace\");\nexport const TOPIC_WORKSPACE_DIR = resolve(WORKSPACE_DIR, \"topics\");\nexport const SHARED_WIKI_DIR = resolve(WORKSPACE_DIR, \"wiki\");\nexport const CONTEXTS_DIR = resolve(WORKSPACE_DIR, \"contexts\");\nexport const BROWSER_PROFILES_DIR = resolve(WORKSPACE_DIR, \"browser-profiles\");\nexport const DM_WORKSPACE_DIR = resolve(WORKSPACE_DIR, \"dm\");\nexport const SESSION_WORKSPACE_DIR = resolve(WORKSPACE_DIR, \"sessions\");\nexport const CLAUDE_EXECUTABLE = resolve(HOME, \".local/bin/claude\");\n\n// Browser automation uses the authenticated local Patchright HTTP wrapper.\nexport function resolveBrowserMcpBin(envValue?: string): string {\n const override = envValue?.trim();\n if (override) {\n if (!/(^|\\/)(mcp-patchright|mcp-patchright-http\\.mjs)$/.test(override)) {\n throw new Error(\n \"NEGOTIUM_BROWSER_MCP_BIN must point to the authenticated mcp-patchright wrapper.\",\n );\n }\n return override;\n }\n return PATCHRIGHT_MCP_BIN;\n}\n\nexport const PATCHRIGHT_MCP_BIN = resolve(PROJECT_ROOT, \"scripts/mcp-patchright-http.mjs\");\nexport const PLAYWRIGHT_MCP_BIN = resolveBrowserMcpBin(envText(\"NEGOTIUM_BROWSER_MCP_BIN\"));\n\n// --- Browser egress proxy ---\n//\n// On a datacenter host (AWS) the browser's egress IP is a known cloud range,\n// so anti-bot services (Cloudflare, DataDome, reCAPTCHA) challenge or block it\n// far more than a residential IP would. Routing the automation browser through\n// a residential/ISP proxy moves the egress IP out of the datacenter range.\n//\n// Operators set BROWSER_PROXY_URL, e.g. http://user:pass@proxy.host:8080 or\n// socks5://proxy.host:1080. Credentials in the URL are split out because\n// Playwright takes them as separate fields. BROWSER_PROXY_BYPASS is an optional\n// comma-separated no-proxy list (e.g. \"localhost,127.0.0.1,*.internal\").\n//\n// NOTE: Chromium does not support authentication for SOCKS proxies — put\n// credentials only on http/https proxy URLs.\nexport type BrowserProxyConfig = {\n server: string;\n username?: string;\n password?: string;\n bypass?: string;\n};\n\nexport function resolveBrowserProxy(): BrowserProxyConfig | null {\n const raw = envText(\"BROWSER_PROXY_URL\");\n if (!raw) return null;\n let url: URL;\n try {\n url = new URL(raw);\n } catch {\n logger.warn({ raw }, \"Ignoring malformed BROWSER_PROXY_URL\");\n return null;\n }\n // Playwright wants the server without embedded credentials.\n const server = `${url.protocol}//${url.host}`;\n const proxy: BrowserProxyConfig = { server };\n if (url.username) proxy.username = decodeURIComponent(url.username);\n if (url.password) proxy.password = decodeURIComponent(url.password);\n const bypass = envText(\"BROWSER_PROXY_BYPASS\");\n if (bypass) proxy.bypass = bypass;\n return proxy;\n}\n\n// --- Node/tsx runtime for the `codex` agent's MCP servers ---\n//\n// codex 0.135's rmcp stdio MCP client cannot reliably complete the initialize\n// handshake with servers spawned via `bun` (the JSON-RPC initialize is\n// dropped/raced and no tools ever reach the model). Pure-node servers connect\n// reliably, so codex turns launch the SAME .ts servers via node + tsx instead\n// of `bun run`. claude/maestro keep using `bun run` (fast, native, unaffected).\n//\n// tsx transpiles .ts on the fly and resolves the `@/*` tsconfig path aliases,\n// but only when it can find the tsconfig — and MCP servers run with cwd set to\n// the user's workspace dir, not PROJECT_ROOT — so we pass TSX_TSCONFIG_PATH\n// explicitly via env. Requires package.json `\"type\": \"module\"` so the servers'\n// top-level `await` loads as ESM under node.\nexport const TSX_BIN = resolveDependencyBin(\"tsx\");\nexport const TSCONFIG_PATH = resolve(PROJECT_ROOT, \"tsconfig.json\");\n\nexport const SESSION_COMM_SERVER = resolve(PROJECT_ROOT, \"src/mcp/session-comm/server.ts\");\n\nexport const TASK_SERVER = resolve(PROJECT_ROOT, \"src/mcp/task-server.ts\");\nexport const CANONICAL_MCP_PROXY_SERVER = resolve(\n PROJECT_ROOT,\n \"src/mcp/canonical-proxy-server.ts\",\n);\n\nexport const WIKI_SERVER = resolve(PROJECT_ROOT, \"src/mcp/wiki-server.ts\");\n\nexport const PADDLEOCR_SERVER = resolve(PROJECT_ROOT, \"src/mcp/paddleocr-server.ts\");\n\nexport const TOKEN_STATS_SERVER = resolve(PROJECT_ROOT, \"src/mcp/token-stats-server.ts\");\n\nexport const SYSTEM_HEALTH_SERVER = resolve(PROJECT_ROOT, \"src/mcp/system-health-server.ts\");\n\nexport const AGENT_HEALTH_SERVER = resolve(PROJECT_ROOT, \"src/mcp/agent-health-server.ts\");\n\nexport const BACKGROUND_BASH_SERVER = resolve(PROJECT_ROOT, \"src/mcp/background-bash-server.ts\");\n\nexport const VAULT_SERVER = resolve(PROJECT_ROOT, \"src/mcp/vault-server.ts\");\n\nexport const BG_BASH_BASE_PORT = parsePortEnv(process.env.BG_BASH_BASE_PORT, 9700);\nexport const BG_BASH_MAX_PORT = parsePortEnv(process.env.BG_BASH_MAX_PORT, 9799);\n\nfunction safeWorkspaceSegment(value: string, fallback: string): string {\n const cleaned = value\n .trim()\n .replace(/[^A-Za-z0-9._-]/g, \"_\")\n .replace(/^_+|_+$/g, \"\")\n .slice(0, 160);\n return cleaned || fallback;\n}\n\n/** Resolve the shared filesystem workspace for an API topic. */\nexport function resolveTopicWorkspaceDir(topicId: string): string {\n return join(TOPIC_WORKSPACE_DIR, safeWorkspaceSegment(topicId, \"topic\"));\n}\n\nexport function isProductionEnv(): boolean {\n return process.env.NODE_ENV === \"production\";\n}\n\nfunction loadOrCreateLocalSecret(\n envKey: string,\n filename: string,\n options: { persistEnvValue?: boolean } = {},\n): string {\n const envValue = envText(envKey);\n const secretFile = resolve(STATE_DIR, filename);\n mkdirSync(dirname(secretFile), { recursive: true });\n if (envValue) {\n if (options.persistEnvValue) {\n writeFileSync(secretFile, `${envValue}\\n`, { mode: 0o600 });\n chmodSync(secretFile, 0o600);\n }\n return envValue;\n }\n\n if (existsSync(secretFile)) {\n const stored = readFileSync(secretFile, \"utf-8\").trim();\n if (stored) {\n chmodSync(secretFile, 0o600);\n return stored;\n }\n }\n\n const secret = randomBytes(32).toString(\"base64url\");\n try {\n writeFileSync(secretFile, `${secret}\\n`, { mode: 0o600, flag: \"wx\" });\n return secret;\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== \"EEXIST\") throw error;\n const stored = readFileSync(secretFile, \"utf-8\").trim();\n if (!stored) throw new Error(`Secret file exists but is empty: ${secretFile}`);\n chmodSync(secretFile, 0o600);\n return stored;\n }\n}\n\nexport const RUNTIME_MCP_SECRET = loadOrCreateLocalSecret(\n \"RUNTIME_MCP_SECRET\",\n \"runtime-mcp-secret\",\n);\n/** Local bearer token for the loopback node-control API. */\nexport const NODE_CONTROL_TOKEN = loadOrCreateLocalSecret(\n \"NEGOTIUM_CONTROL_TOKEN\",\n \"node-control-token\",\n);\nexport const VAULT_MASTER_KEY = loadOrCreateLocalSecret(\n \"NEGOTIUM_VAULT_MASTER_KEY\",\n \"vault-master-key\",\n { persistEnvValue: true },\n);\n// Agent/tool subprocesses inherit process.env. Keep the loaded key in this\n// process only so `env`/`ps` inside an agent workspace cannot reveal it.\ndelete process.env.NEGOTIUM_VAULT_MASTER_KEY;\n\n/** The node's single open port: runtime MCP endpoint + node API. */\nexport const NEGOTIUM_PORT = parseInt(process.env.NEGOTIUM_PORT || \"7777\", 10);\nexport const hostname = process.env.HOSTNAME || \"127.0.0.1\";\n\n// Persistent state (survives restarts, long-lived)\nexport const DATA_DIR = resolveLocalStateDir(\"NEGOTIUM_DATA_DIR\", \"data\");\nexport const LOG_DIR = resolveLocalStateDir(\"NEGOTIUM_LOG_DIR\", \"logs\");\n// SESSIONS_DB_PATH env override lets tests point the DB singleton at a temp file.\nexport const SESSIONS_DB = process.env.SESSIONS_DB_PATH\n ? resolve(process.env.SESSIONS_DB_PATH)\n : resolve(DATA_DIR, \"sessions.db\");\nexport const DEBUG_FILE = resolve(DATA_DIR, \"debug-users.json\");\nexport const USERS_LOG_DIR = resolve(DATA_DIR, \"users\");\n\n// Runtime IPC queues (transient, safe to clear on restart)\nexport const RUN_DIR = resolveLocalStateDir(\"NEGOTIUM_RUN_DIR\", \"run\");\nexport const PROGRESS_DIR = resolve(RUN_DIR, \"progress\");\nexport const DM_CMD_DIR = resolve(RUN_DIR, \"dm-commands\");\nexport const DM_RESP_DIR = resolve(RUN_DIR, \"dm-responses\");\nexport const SESSION_INBOX_DIR = resolve(RUN_DIR, \"session-inbox\");\nexport const SESSION_ASKS_DIR = resolve(RUN_DIR, \"session-asks\");\nexport const PLAYWRIGHT_BASE_PORT = parsePortEnv(process.env.PLAYWRIGHT_BASE_PORT, 9100);\nexport const PLAYWRIGHT_MAX_PORT = parsePortEnv(process.env.PLAYWRIGHT_MAX_PORT, 9499);\nexport const PLAYWRIGHT_PORTS_DIR = resolve(RUN_DIR, \"playwright-ports\");\nmkdirSync(STATE_DIR, { recursive: true });\nmkdirSync(DATA_DIR, { recursive: true });\nmkdirSync(LOG_DIR, { recursive: true });\nmkdirSync(PROGRESS_DIR, { recursive: true });\nmkdirSync(DM_CMD_DIR, { recursive: true });\nmkdirSync(DM_RESP_DIR, { recursive: true });\nmkdirSync(SESSION_INBOX_DIR, { recursive: true });\nmkdirSync(SESSION_ASKS_DIR, { recursive: true });\nmkdirSync(PLAYWRIGHT_PORTS_DIR, { recursive: true });\nmkdirSync(WORKSPACE_DIR, { recursive: true });\nmkdirSync(TOPIC_WORKSPACE_DIR, { recursive: true });\nmkdirSync(SHARED_WIKI_DIR, { recursive: true });\nmkdirSync(CONTEXTS_DIR, { recursive: true });\nmkdirSync(BROWSER_PROFILES_DIR, { recursive: true });\nmkdirSync(DM_WORKSPACE_DIR, { recursive: true });\nmkdirSync(SESSION_WORKSPACE_DIR, { recursive: true });\n\n/** Stale threshold for active-query state files (crash recovery) */\nexport const ACTIVE_QUERY_STALE_MS = 10 * 60 * 1000; // 10 minutes\n\nexport const AGENTS_PROMPTS_DIR = resolve(PROJECT_ROOT, \"src/prompts/agents\");\nexport const RESOURCES_DIR = resolve(PROJECT_ROOT, \"src/resources\");\n\n/** Returns process.env without CLAUDECODE, to prevent nested claude-code detection in subprocesses. */\nexport function getCleanEnv(): NodeJS.ProcessEnv {\n const env = { ...process.env };\n delete env.CLAUDECODE;\n return env;\n}\n\nexport const FILE_EXTENSIONS_REGEX =\n /(?:\\/[^\\s\"'<>|*?[\\]]+\\.(?:png|jpg|jpeg|gif|webp|svg|pdf|csv|xlsx|xls|json|txt|md|html|zip|py|js|ts|tsx|jsx|css|xml|yaml|yml|docx|pptx))/gi;\n\nexport const FILE_TAG_REGEX = /\\[FILE:(\\/[^\\]]+)\\]/gi;\n\n// Canonical Claude model IDs — update here when Anthropic releases new versions\nexport const MODEL_SONNET = \"claude-sonnet-5\";\nexport const MODEL_OPUS = \"claude-opus-4-8\";\nexport const MODEL_HAIKU = \"claude-haiku-4-5-20251001\";\nexport const MODEL_FABLE = \"claude-fable-5\"; // Mythos-class, announced 2026-06-09\n\n// DeepSeek V4 (released 2026-04-24). API is OpenAI-compatible at\n// https://api.deepseek.com/v1/chat/completions; thinking mode is enabled via\n// `extra_body.thinking.type` + `reasoning_effort`. Legacy `deepseek-chat` /\n// `deepseek-reasoner` are deprecated 2026-07-24.\nexport const MODEL_DEEPSEEK_V4_PRO = \"deepseek-v4-pro\";\nexport const MODEL_DEEPSEEK_V4_FLASH = \"deepseek-v4-flash\";\n\n// Agent + model defaults split by session role. FALLBACK_* is the shared base;\n// SESSION_* overrides topic + ephemeral; GATEWAY_* overrides dm + manager.\n// DEFAULT_* is accepted as a legacy alias during the env migration window.\nexport const FALLBACK_AGENT: AgentKind = resolveAgentEnv(\n \"FALLBACK_AGENT\",\n \"maestro\",\n \"DEFAULT_AGENT\",\n);\nexport const SESSION_AGENT: AgentKind = resolveAgentEnv(\"SESSION_AGENT\", FALLBACK_AGENT);\nexport const GATEWAY_AGENT: AgentKind = resolveAgentEnv(\"GATEWAY_AGENT\", FALLBACK_AGENT);\n\nexport const FALLBACK_MODEL = envText(\"FALLBACK_MODEL\") ?? envText(\"DEFAULT_MODEL\");\n\nfunction resolveModelEnv(envKey: string, agentConst: AgentKind): string | undefined {\n return envText(envKey) ?? (agentConst === FALLBACK_AGENT ? FALLBACK_MODEL : undefined);\n}\n\nexport const SESSION_MODEL = resolveModelEnv(\"SESSION_MODEL\", SESSION_AGENT);\nexport const GATEWAY_MODEL = resolveModelEnv(\"GATEWAY_MODEL\", GATEWAY_AGENT);\n\n/** Resolve the effective display/default model for a topic (session context).\n * Applies the session model override only when that role owns the agent;\n * otherwise each registry's native default stays authoritative. */\nexport function resolveDefaultModel(agent: string, registryDefaultModel: string): string {\n return agent === SESSION_AGENT && SESSION_MODEL ? SESSION_MODEL : registryDefaultModel;\n}\n\n// ── External tool binaries + media pipeline env ───────────────────\n// (src/media/* 에서 사용. 미설정 시 fallback 의미는 기존 그대로:\n// FFMPEG_BIN은 text-extractor에서 필수(undefined면 spawn 시점 실패),\n// video.ts에서는 PATH의 ffmpeg/ffprobe로 fallback.)\nexport const FFMPEG_BIN = envText(\"FFMPEG_BIN\");\nexport const FFPROBE_BIN = envText(\"FFPROBE_BIN\");\nexport const PYTHON_BIN = envText(\"PYTHON_BIN\") ?? \"python3\";\nexport const FASTER_WHISPER_WRAPPER =\n envText(\"FASTER_WHISPER_WRAPPER\") ?? resolve(PROJECT_ROOT, \"scripts/faster-whisper-wrapper.py\");\nexport const WHISPER_MODEL = envText(\"WHISPER_MODEL_FILE\") ?? \"turbo\";\nexport const TESSERACT_BIN = envText(\"TESSERACT_BIN\") ?? \"tesseract\";\nexport const PDFTOTEXT_BIN = envText(\"PDFTOTEXT_BIN\") ?? \"pdftotext\";\n\n// Max tell_session relay depth from origin user. ask_session forks reset to\n// depth=0, so this only caps tell_session chains. Override via MAX_TELL_DEPTH\n// (positive int); defaults to 20 when unset or invalid.\nconst _envMaxTellDepth = Number.parseInt(process.env.MAX_TELL_DEPTH ?? \"\", 10);\nexport const MAX_TELL_DEPTH =\n Number.isInteger(_envMaxTellDepth) && _envMaxTellDepth > 0 ? _envMaxTellDepth : 20;\n\n/** Codex CLI auth file. 호출 시점에 env를 읽는다 — 테스트가 런타임에\n * NEGOTIUM_CODEX_AUTH_FILE을 바꾸므로 모듈 로드 상수로 만들면 안 된다. */\nexport function codexAuthFilePath(): string {\n return process.env.NEGOTIUM_CODEX_AUTH_FILE || join(homedir(), \".codex\", \"auth.json\");\n}\n\n// System defaults moved to per-agent registries\n// (`src/agents/{claude,codex}-registry.ts`). Read via\n// `getRegistry(agent).defaultModel` / `.defaultEffort`.\n\n// MCP server builders -> src/platform/mcp-config.ts\n",
6
6
  "import pino from \"pino\";\n\n/**\n * Always write logs to stderr (fd 2), never stdout.\n *\n * MCP servers under `src/mcp/**` run as stdio subprocesses where stdout is the\n * JSON-RPC transport channel. A single log line on stdout corrupts the next\n * message and the MCP client closes the transport (\"Transport closed\"). The\n * main bot process is also fine with stderr — pm2 captures both streams.\n *\n * In dev mode pino spawns a `pino-pretty` worker that owns its own sink, so we\n * pass `destination: 2` through transport.options. In prod (no transport) the\n * second arg to `pino()` sets the destination directly.\n */\nexport const logger = pino(\n {\n level: process.env.LOG_LEVEL || \"info\",\n transport:\n process.env.NODE_ENV === \"development\"\n ? {\n target: \"pino-pretty\",\n options: {\n colorize: true,\n translateTime: \"SYS:yyyy-mm-dd HH:MM:ss\",\n destination: 2,\n },\n }\n : undefined,\n },\n pino.destination(2),\n);\n",
7
- "/**\n * Common context carried through the attachment/prompt-build pipeline.\n * Used by buildPromptFromMessage and related helpers.\n */\nexport interface SessionContext {\n userId: number;\n topicName?: string;\n userDir?: string;\n sessionType?: \"dm\" | \"forum\" | \"ephemeral\" | \"manager\" | \"cron\";\n}\n\nexport interface TokenUsage {\n /** Aggregate billable input across every model call made during this turn. */\n inputTokens: number;\n outputTokens: number;\n cacheCreationInputTokens?: number;\n cacheReadInputTokens?: number;\n /** Provider-reported query cost when available. */\n costUsd?: number;\n /** Tokens occupied by the latest model call, not aggregate turn spend. */\n contextTokens?: number;\n /** Provider-reported context window for the latest model call. */\n contextWindow?: number;\n}\n\n/** Agent identifier — one of the supported AI provider backends. */\nexport type AgentKind = \"maestro\" | \"claude\" | \"codex\";\n\nexport const SUPPORTED_AGENTS: readonly AgentKind[] = [\"maestro\", \"claude\", \"codex\"] as const;\n\nexport function isAgentKind(value: unknown): value is AgentKind {\n return typeof value === \"string\" && (SUPPORTED_AGENTS as readonly string[]).includes(value);\n}\n\n/**\n * Per-agent supported reasoning efforts. Single source of truth for both the\n * `EffortLevel` type and each registry's `validEfforts` runtime list — the\n * registries import these directly so adding a value in one place\n * propagates to validation, footer rendering, and zod enums.\n *\n * Claude SDK rejects 'minimal'; Codex SDK rejects 'max'. The two sets\n * intersect on low/medium/high/xhigh. Maestro (TS port) currently piggybacks\n * on the Anthropic provider, so its efforts mirror the Claude set; this can\n * narrow per-provider once Phase 5 lands.\n *\n * 'minimal' removed from codex: Codex API rejects it when default tools\n * (image_gen, web_search) are active, making agent sessions unusable.\n */\nexport const CLAUDE_EFFORT_VALUES = [\"low\", \"medium\", \"high\", \"xhigh\", \"max\"] as const;\nexport const CODEX_EFFORT_VALUES = [\"low\", \"medium\", \"high\", \"xhigh\", \"max\"] as const;\nexport const MAESTRO_EFFORT_VALUES = [\"low\", \"medium\", \"high\", \"xhigh\", \"max\"] as const;\n\nexport type EffortLevel =\n | (typeof CLAUDE_EFFORT_VALUES)[number]\n | (typeof CODEX_EFFORT_VALUES)[number]\n | (typeof MAESTRO_EFFORT_VALUES)[number];\n\n/**\n * Runtime iteration list (used by zod enums and any callers that need to\n * loop over every accepted value). Manually ordered for readability; the\n * `satisfies` check fails the build if an entry here isn't covered by the\n * per-agent unions above.\n */\nexport const EFFORT_VALUES = [\n \"low\",\n \"medium\",\n \"high\",\n \"xhigh\",\n \"max\",\n] as const satisfies readonly EffortLevel[];\n\n/**\n * Normalized events yielded by any agent provider (claudeProvider, codexProvider).\n * The handler/event-processor consumes these without caring which backend produced them.\n *\n * `user_message` is the lone \"into-the-log\" variant — no provider yields it.\n * The query handler writes it directly to the conversation log right before\n * `runAgent()` starts, so cross-agent rollout reconstruction can pair every\n * assistant turn with the user prompt that triggered it. Consumers that only\n * react to provider output (e.g. processAgentEvent) can safely ignore it.\n */\n/**\n * Wire-safe projection of one task, carried by the `tasks` UnifiedEvent.\n *\n * This is also the on-disk shape of Otium's shared task store, so claude,\n * codex, and maestro render the same live panel from the same source of truth.\n */\nexport interface TaskSnapshot {\n id: string;\n subject: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n /** Task ids this one is blocked by; omitted when empty. */\n blockedBy?: string[];\n /** Present-continuous label for spinners, when set. */\n activeForm?: string;\n /** Owner / agent name for multi-agent runs, when set. */\n owner?: string;\n}\n\nexport type UnifiedEvent =\n | { type: \"user_message\"; content: string }\n | { type: \"session\"; sessionId: string }\n | {\n type: \"tool_use\";\n name: string;\n input: Record<string, unknown>;\n /** Provider-assigned id so the client can match tool_use→tool_result pairs. */\n toolUseId?: string;\n }\n | { type: \"tool_progress\"; toolName: string; elapsed: number }\n | { type: \"tool_use_summary\"; summary: string }\n // Provider reasoning/thinking summary text (Codex `reasoning` items; Claude\n // extended-thinking). Surfaced so background runs (cron/archiver) show the\n // agent's thought process, not just tool calls.\n | { type: \"reasoning\"; content: string }\n // Full task-list snapshot (replace, not delta) from Otium's shared task\n // store. Provider-native task/todo stores are not authoritative.\n | { type: \"tasks\"; tasks: TaskSnapshot[] }\n | {\n type: \"tool_result\";\n toolUseId: string;\n content: string;\n metadata?: {\n truncatedForModel: boolean;\n originalBytes: number;\n returnedBytes: number;\n omittedBytes?: number;\n outputPath?: string;\n };\n }\n | { type: \"text_delta\"; content: string }\n | { type: \"text\"; content: string }\n | { type: \"result\"; content: string; stopReason: string; usage?: TokenUsage }\n | { type: \"file\"; path: string; source: string; origin: \"tag\" | \"extension\" }\n | {\n type: \"error\";\n content: string;\n usage?: TokenUsage;\n code?: \"budget_exceeded\";\n }\n | { type: \"status\"; content: string };\n\nexport interface AgentInputAttachment {\n id: string;\n type: \"image\" | \"file\" | \"audio\";\n filename: string;\n mimeType: string;\n sizeBytes: number;\n path: string;\n}\n\n/** Worker-side runtime tools proxy user-facing state back to the canonical\n * hub topic identified here. */\nexport interface PeerRuntimeBridgeContext {\n hubCellId: string;\n hostTopicId: string;\n hostQueryId: string;\n canSpawnSubagents: boolean;\n}\n\nexport interface AgentQueryOptions {\n agent: AgentKind;\n prompt: string;\n attachments?: AgentInputAttachment[];\n sessionId?: string | null;\n cwd: string;\n systemPrompt: string;\n userId?: string;\n session?: string;\n playwrightPort?: number;\n playwrightCapability?: string;\n bgBashPort?: number;\n sessionType?: \"dm\" | \"forum\" | \"ephemeral\" | \"manager\" | \"cron\";\n /** API topic id (REST/WS world). Carries per-query topic context for MCP servers. */\n topicId?: string;\n /** API query id for the currently running turn. Used by runtime MCP tools. */\n queryId?: string;\n /** Optional wiki-memory topic id. Derived topics use their root origin here\n * while other per-topic MCP servers keep `topicId` bound to the live room. */\n wikiTopicId?: string;\n /** Whether self-config MCP may enqueue an automatic continue turn after set_* changes. */\n autoContinue?: boolean;\n /** Expose Otium-only visual panel tools for this turn. Default-deny. */\n visualTools?: boolean;\n abortController?: AbortController;\n model?: string;\n /** Provider-side hard budget when the selected SDK supports one. */\n maxBudgetUsd?: number;\n depth?: number;\n agents?: Record<\n string,\n {\n description: string;\n prompt: string;\n model?: string;\n tools?: string[];\n maxTurns?: number;\n effort?: EffortLevel | number;\n }\n >;\n effort?: EffortLevel;\n /**\n * Per-API-call `max_tokens` ceiling on the assistant's output. Wired\n * through to the underlying provider request body for every agent\n * (claude/codex/maestro). Omit to inherit each provider SDK's per-model\n * default — for maestro that's the v0.1.21+ `getNativeMaxOutputTokens`\n * catalog (deepseek-pro=64K, deepseek-flash=32K).\n *\n * Pass an explicit number when a specific topic / surface needs a tighter\n * latency cap or a higher ceiling for long-form generation (legal\n * report writing, multi-K Write/Edit file bodies). Pre-0.1.21 maestro\n * builds silently clamped at 4096 and truncated outputs mid-string;\n * setting this field is now the supported way to lift that ceiling.\n */\n maxTokens?: number;\n /**\n * v0.1.22+: Claude-Code-style deferred tool catalog + `ToolSearch` built-in.\n *\n * Wired straight through to `maestro-agent-sdk`'s\n * `AgentQueryOptions.enableToolSearch`. When `true`, the maestro provider\n * registers every MCP tool as deferred — schemas stay off the wire until\n * the model promotes them via `ToolSearch(\"select:Name1,Name2\")` or\n * `ToolSearch(\"keyword\")`. Active set persists across resume.\n *\n * Otium's maestro provider supplies `true` when the caller leaves this\n * option unset, because most forum turns carry enough MCP surface for the\n * reminder-token savings to outweigh the first-use `ToolSearch` round-trip.\n * Callers can still pass `false` per call when a narrow surface or\n * latency-sensitive workflow is better served by eager MCP schemas.\n *\n * No-op for claude / codex agents — they have their own deferred-tool\n * machinery owned by their respective SDKs.\n */\n enableToolSearch?: boolean;\n /**\n * Claude-Code-compatible exact tool denylist. Maestro v0.1.42+ hides these\n * tools from provider schemas / ToolSearch and blocks dispatch if a stale\n * call still arrives. Claude maps this to its SDK option. Codex does not\n * support this name-based list; its provider-native multi-agent tool family\n * is disabled separately through the Codex feature config.\n */\n disallowedTools?: readonly string[];\n mcpEnabled?: string[] | null;\n peerBridge?: PeerRuntimeBridgeContext;\n mcpExtra?: Record<string, unknown>;\n /**\n * true for silent fork runs generating ask_session replies — restricts session-comm\n * outbound tools (ask/tell/abort) so the forked session can only produce text\n */\n silent?: boolean;\n}\n\n/** State file written to data/users/{userId}/active-queries/{topic}.json while a query is running. */\nexport interface QueryState {\n task?: string; // first 100 chars of prompt, newlines normalized\n since: string; // ISO timestamp\n}\n",
7
+ "/**\n * Common context carried through the attachment/prompt-build pipeline.\n * Used by buildPromptFromMessage and related helpers.\n */\nexport interface SessionContext {\n userId: number;\n topicName?: string;\n userDir?: string;\n sessionType?: \"dm\" | \"forum\" | \"ephemeral\" | \"manager\" | \"cron\";\n}\n\nexport interface TokenUsage {\n /** Aggregate billable input across every model call made during this turn. */\n inputTokens: number;\n outputTokens: number;\n cacheCreationInputTokens?: number;\n cacheReadInputTokens?: number;\n /** Provider-reported query cost when available. */\n costUsd?: number;\n /** Tokens occupied by the latest model call, not aggregate turn spend. */\n contextTokens?: number;\n /** Provider-reported context window for the latest model call. */\n contextWindow?: number;\n}\n\n/** Agent identifier — one of the supported AI provider backends. */\nexport type AgentKind = \"maestro\" | \"claude\" | \"codex\";\n\nexport const SUPPORTED_AGENTS: readonly AgentKind[] = [\"maestro\", \"claude\", \"codex\"] as const;\n\nexport function isAgentKind(value: unknown): value is AgentKind {\n return typeof value === \"string\" && (SUPPORTED_AGENTS as readonly string[]).includes(value);\n}\n\n/**\n * Per-agent supported reasoning efforts. Single source of truth for both the\n * `EffortLevel` type and each registry's `validEfforts` runtime list — the\n * registries import these directly so adding a value in one place\n * propagates to validation, footer rendering, and zod enums.\n *\n * Claude SDK rejects 'minimal'; Codex SDK rejects 'max'. The two sets\n * intersect on low/medium/high/xhigh. Maestro (TS port) currently piggybacks\n * on the Anthropic provider, so its efforts mirror the Claude set; this can\n * narrow per-provider once Phase 5 lands.\n *\n * 'minimal' removed from codex: Codex API rejects it when default tools\n * (image_gen, web_search) are active, making agent sessions unusable.\n */\nexport const CLAUDE_EFFORT_VALUES = [\"low\", \"medium\", \"high\", \"xhigh\", \"max\"] as const;\nexport const CODEX_EFFORT_VALUES = [\"low\", \"medium\", \"high\", \"xhigh\", \"max\"] as const;\nexport const MAESTRO_EFFORT_VALUES = [\"low\", \"medium\", \"high\", \"xhigh\", \"max\"] as const;\n\nexport type EffortLevel =\n | (typeof CLAUDE_EFFORT_VALUES)[number]\n | (typeof CODEX_EFFORT_VALUES)[number]\n | (typeof MAESTRO_EFFORT_VALUES)[number];\n\n/**\n * Runtime iteration list (used by zod enums and any callers that need to\n * loop over every accepted value). Manually ordered for readability; the\n * `satisfies` check fails the build if an entry here isn't covered by the\n * per-agent unions above.\n */\nexport const EFFORT_VALUES = [\n \"low\",\n \"medium\",\n \"high\",\n \"xhigh\",\n \"max\",\n] as const satisfies readonly EffortLevel[];\n\n/**\n * Normalized events yielded by any agent provider (claudeProvider, codexProvider).\n * The handler/event-processor consumes these without caring which backend produced them.\n *\n * `user_message` is the lone \"into-the-log\" variant — no provider yields it.\n * The query handler writes it directly to the conversation log right before\n * `runAgent()` starts, so cross-agent rollout reconstruction can pair every\n * assistant turn with the user prompt that triggered it. Consumers that only\n * react to provider output (e.g. processAgentEvent) can safely ignore it.\n */\n/**\n * Wire-safe projection of one task, carried by the `tasks` UnifiedEvent.\n *\n * This is also the on-disk shape of Otium's shared task store, so claude,\n * codex, and maestro render the same live panel from the same source of truth.\n */\nexport interface TaskSnapshot {\n id: string;\n subject: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n /** Task ids this one is blocked by; omitted when empty. */\n blockedBy?: string[];\n /** Present-continuous label for spinners, when set. */\n activeForm?: string;\n /** Owner / agent name for multi-agent runs, when set. */\n owner?: string;\n}\n\nexport type UnifiedEvent =\n | { type: \"user_message\"; content: string }\n | { type: \"session\"; sessionId: string }\n | {\n type: \"tool_use\";\n name: string;\n input: Record<string, unknown>;\n /** Provider-assigned id so the client can match tool_use→tool_result pairs. */\n toolUseId?: string;\n }\n | { type: \"tool_progress\"; toolName: string; elapsed: number }\n | { type: \"tool_use_summary\"; summary: string }\n // Provider reasoning/thinking summary text (Codex `reasoning` items; Claude\n // extended-thinking). Surfaced so background runs (cron/archiver) show the\n // agent's thought process, not just tool calls.\n | { type: \"reasoning\"; content: string }\n // Full task-list snapshot (replace, not delta) from Otium's shared task\n // store. Provider-native task/todo stores are not authoritative.\n | { type: \"tasks\"; tasks: TaskSnapshot[] }\n | {\n type: \"tool_result\";\n toolUseId: string;\n content: string;\n metadata?: {\n truncatedForModel: boolean;\n originalBytes: number;\n returnedBytes: number;\n omittedBytes?: number;\n outputPath?: string;\n };\n }\n | { type: \"text_delta\"; content: string }\n | { type: \"text\"; content: string }\n | { type: \"result\"; content: string; stopReason: string; usage?: TokenUsage }\n | { type: \"file\"; path: string; source: string; origin: \"tag\" | \"extension\" }\n | {\n type: \"error\";\n content: string;\n usage?: TokenUsage;\n code?: \"budget_exceeded\";\n }\n | { type: \"status\"; content: string };\n\nexport interface AgentInputAttachment {\n id: string;\n type: \"image\" | \"file\" | \"audio\";\n filename: string;\n mimeType: string;\n sizeBytes: number;\n path: string;\n}\n\n/** Worker-side runtime tools proxy user-facing state back to the canonical\n * hub topic identified here. */\nexport interface PeerRuntimeBridgeContext {\n hubCellId: string;\n hostTopicId: string;\n hostQueryId: string;\n canSpawnSubagents: boolean;\n}\n\nexport interface AgentQueryOptions {\n agent: AgentKind;\n prompt: string;\n attachments?: AgentInputAttachment[];\n sessionId?: string | null;\n cwd: string;\n systemPrompt: string;\n userId?: string;\n session?: string;\n playwrightPort?: number;\n playwrightCapability?: string;\n bgBashPort?: number;\n sessionType?: \"dm\" | \"forum\" | \"ephemeral\" | \"manager\" | \"cron\";\n /** API topic id (REST/WS world). Carries per-query topic context for MCP servers. */\n topicId?: string;\n /** API query id for the currently running turn. Used by runtime MCP tools. */\n queryId?: string;\n /** Optional wiki-memory topic id. Derived topics use their root origin here\n * while other per-topic MCP servers keep `topicId` bound to the live room. */\n wikiTopicId?: string;\n /** Whether self-config MCP may enqueue an automatic continue turn after set_* changes. */\n autoContinue?: boolean;\n /** Expose Otium-only visual panel tools for this turn. Default-deny. */\n visualTools?: boolean;\n /** Expose adapter-backed file-delivery tools for this turn. Default-deny. */\n fileDeliveryTools?: boolean;\n abortController?: AbortController;\n model?: string;\n /** Provider-side hard budget when the selected SDK supports one. */\n maxBudgetUsd?: number;\n depth?: number;\n agents?: Record<\n string,\n {\n description: string;\n prompt: string;\n model?: string;\n tools?: string[];\n maxTurns?: number;\n effort?: EffortLevel | number;\n }\n >;\n effort?: EffortLevel;\n /**\n * Per-API-call `max_tokens` ceiling on the assistant's output. Wired\n * through to the underlying provider request body for every agent\n * (claude/codex/maestro). Omit to inherit each provider SDK's per-model\n * default — for maestro that's the v0.1.21+ `getNativeMaxOutputTokens`\n * catalog (deepseek-pro=64K, deepseek-flash=32K).\n *\n * Pass an explicit number when a specific topic / surface needs a tighter\n * latency cap or a higher ceiling for long-form generation (legal\n * report writing, multi-K Write/Edit file bodies). Pre-0.1.21 maestro\n * builds silently clamped at 4096 and truncated outputs mid-string;\n * setting this field is now the supported way to lift that ceiling.\n */\n maxTokens?: number;\n /**\n * v0.1.22+: Claude-Code-style deferred tool catalog + `ToolSearch` built-in.\n *\n * Wired straight through to `maestro-agent-sdk`'s\n * `AgentQueryOptions.enableToolSearch`. When `true`, the maestro provider\n * registers every MCP tool as deferred — schemas stay off the wire until\n * the model promotes them via `ToolSearch(\"select:Name1,Name2\")` or\n * `ToolSearch(\"keyword\")`. Active set persists across resume.\n *\n * Otium's maestro provider supplies `true` when the caller leaves this\n * option unset, because most forum turns carry enough MCP surface for the\n * reminder-token savings to outweigh the first-use `ToolSearch` round-trip.\n * Callers can still pass `false` per call when a narrow surface or\n * latency-sensitive workflow is better served by eager MCP schemas.\n *\n * No-op for claude / codex agents — they have their own deferred-tool\n * machinery owned by their respective SDKs.\n */\n enableToolSearch?: boolean;\n /**\n * Claude-Code-compatible exact tool denylist. Maestro v0.1.42+ hides these\n * tools from provider schemas / ToolSearch and blocks dispatch if a stale\n * call still arrives. Claude maps this to its SDK option. Codex does not\n * support this name-based list; its provider-native multi-agent tool family\n * is disabled separately through the Codex feature config.\n */\n disallowedTools?: readonly string[];\n mcpEnabled?: string[] | null;\n peerBridge?: PeerRuntimeBridgeContext;\n mcpExtra?: Record<string, unknown>;\n /**\n * true for silent fork runs generating ask_session replies — restricts session-comm\n * outbound tools (ask/tell/abort) so the forked session can only produce text\n */\n silent?: boolean;\n}\n\n/** State file written to data/users/{userId}/active-queries/{topic}.json while a query is running. */\nexport interface QueryState {\n task?: string; // first 100 chars of prompt, newlines normalized\n since: string; // ISO timestamp\n}\n",
8
8
  "import { mkdirSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { dirname, join, resolve } from \"node:path\";\nimport { Database } from \"#storage/sqlite\";\nimport type { StorageDatabase, StorageHostConfig } from \"#storage/storage-contract\";\n\nexport type {\n StorageDatabase,\n StorageDatabaseAdapter,\n StorageDatabaseInput,\n StorageHostConfig,\n StorageHostOptions,\n StorageStatement,\n StorageTransaction,\n} from \"#storage/storage-contract\";\n\nlet configuredHost: Readonly<StorageHostConfig> = {};\ntype InternalStorageDatabase = InstanceType<typeof Database>;\ntype OwnedStorageDatabase = InternalStorageDatabase & { close(): void };\nlet fallbackDatabase: OwnedStorageDatabase | null = null;\nlet fallbackDatabasePath: string | null = null;\n\ninterface StorageHostFrame {\n active: boolean;\n patch: Readonly<StorageHostConfig>;\n}\n\nconst storageHostFrames: StorageHostFrame[] = [];\n\ntype StorageSchemaInitializer = (database: InternalStorageDatabase) => void;\ninterface RegisteredSchemaInitializer {\n initialize: StorageSchemaInitializer;\n priority: number;\n}\n\nconst schemaInitializers: RegisteredSchemaInitializer[] = [];\nconst initializedSchemas = new WeakMap<InternalStorageDatabase, Set<StorageSchemaInitializer>>();\nconst initializingDatabases = new WeakSet<InternalStorageDatabase>();\n\nfunction envPath(name: string, fallback: string): string {\n const value = process.env[name]?.trim();\n return resolve(value || fallback);\n}\n\nfunction defaultStateDir(): string {\n return envPath(\"NEGOTIUM_STATE_DIR\", join(homedir(), \".negotium\"));\n}\n\nfunction defaultDataDir(): string {\n return envPath(\"NEGOTIUM_DATA_DIR\", join(defaultStateDir(), \"data\"));\n}\n\nfunction defaultLogDir(): string {\n return envPath(\"NEGOTIUM_LOG_DIR\", join(defaultStateDir(), \"logs\"));\n}\n\nfunction defaultWorkspaceDir(): string {\n return envPath(\"NEGOTIUM_WORKSPACE_DIR\", join(defaultStateDir(), \"workspace\"));\n}\n\nfunction defaultSessionAsksDir(): string {\n const runDir = envPath(\"NEGOTIUM_RUN_DIR\", join(defaultStateDir(), \"run\"));\n return join(runDir, \"session-asks\");\n}\n\nfunction defaultSessionsDatabasePath(): string {\n return envPath(\"SESSIONS_DB_PATH\", join(resolveStorageDataDir(), \"sessions.db\"));\n}\n\nfunction initializeDatabase(database: InternalStorageDatabase): void {\n database.exec(\"PRAGMA journal_mode = WAL\");\n database.exec(\"PRAGMA foreign_keys = ON\");\n database.exec(\"PRAGMA busy_timeout = 5000\");\n database.exec(\"PRAGMA wal_autocheckpoint = 1000\");\n try {\n database.exec(\"PRAGMA wal_checkpoint(TRUNCATE)\");\n } catch {\n // Non-fatal; a concurrent writer may hold the WAL briefly.\n }\n}\n\nfunction defaultDatabase(): InternalStorageDatabase {\n const path = defaultSessionsDatabasePath();\n if (fallbackDatabase && fallbackDatabasePath === path) return fallbackDatabase;\n if (fallbackDatabase) fallbackDatabase.close();\n mkdirSync(dirname(path), { recursive: true });\n fallbackDatabase = new Database(path, { create: true }) as unknown as OwnedStorageDatabase;\n fallbackDatabasePath = path;\n initializeDatabase(fallbackDatabase);\n return fallbackDatabase;\n}\n\nexport function resolveStorageDatabase(): InternalStorageDatabase {\n return (configuredHost.database ?? defaultDatabase()) as InternalStorageDatabase;\n}\n\nexport function resolveStorageDataDir(): string {\n return configuredHost.dataDir ?? defaultDataDir();\n}\n\nexport function resolveStorageLogDir(): string {\n return configuredHost.logDir ?? defaultLogDir();\n}\n\nexport function resolveStorageSessionAsksDir(): string {\n return configuredHost.sessionAsksDir ?? defaultSessionAsksDir();\n}\n\nexport function resolveStorageWorkspaceDir(): string {\n return configuredHost.workspaceDir ?? defaultWorkspaceDir();\n}\n\nexport function resolveStorageSharedWikiDir(): string {\n return configuredHost.sharedWikiDir ?? join(resolveStorageWorkspaceDir(), \"wiki\");\n}\n\nexport function resolveStorageUsersLogDir(): string {\n return configuredHost.usersLogDir ?? join(resolveStorageDataDir(), \"users\");\n}\n\nconst STORAGE_PATH_KEYS = [\n \"dataDir\",\n \"logDir\",\n \"sessionAsksDir\",\n \"workspaceDir\",\n \"sharedWikiDir\",\n \"usersLogDir\",\n] as const;\n\nfunction normalizeStorageHostPatch(options: StorageHostConfig): Readonly<StorageHostConfig> {\n const patch: StorageHostConfig = {};\n if (options.database !== undefined) patch.database = options.database;\n for (const key of STORAGE_PATH_KEYS) {\n const value = options[key];\n if (value === undefined) continue;\n if (!value.trim()) throw new TypeError(`${key} must not be empty`);\n patch[key] = resolve(value);\n }\n return Object.freeze(patch);\n}\n\nfunction refreshConfiguredHost(): void {\n configuredHost = Object.freeze(\n Object.assign(\n {},\n ...storageHostFrames.filter((frame) => frame.active).map((frame) => frame.patch),\n ),\n );\n}\n\n/**\n * Configure the process-local storage boundary for an embedding host.\n *\n * Resolution is lazy: importing `negotium/storage` never opens a database or\n * touches a filesystem path. The returned disposer restores the exact prior\n * host, which keeps tests and nested embeddings isolated.\n */\nexport function configureStorageHost(options: StorageHostConfig): () => void {\n const frame: StorageHostFrame = { active: true, patch: normalizeStorageHostPatch(options) };\n storageHostFrames.push(frame);\n refreshConfiguredHost();\n return () => {\n if (!frame.active) return;\n frame.active = false;\n const index = storageHostFrames.indexOf(frame);\n if (index >= 0) storageHostFrames.splice(index, 1);\n refreshConfiguredHost();\n };\n}\n\n/** Remove every configured host layer and restore standalone fallbacks. */\nexport function resetStorageHost(): void {\n for (const frame of storageHostFrames) frame.active = false;\n storageHostFrames.length = 0;\n refreshConfiguredHost();\n}\n\n/** Close only Negotium's fallback connection. Injected connections are borrowed. */\nexport function closeStorageDatabase(): void {\n if (!fallbackDatabase) return;\n fallbackDatabase.close();\n fallbackDatabase = null;\n fallbackDatabasePath = null;\n}\n\nexport function registerStorageSchemaInitializer(\n initialize: StorageSchemaInitializer,\n priority = 100,\n): void {\n schemaInitializers.push({ initialize, priority });\n schemaInitializers.sort((a, b) => a.priority - b.priority);\n}\n\nexport function ensureStorageSchemas(\n database: InternalStorageDatabase = resolveStorageDatabase(),\n): void {\n if (initializingDatabases.has(database)) return;\n let initialized = initializedSchemas.get(database);\n if (!initialized) {\n initialized = new Set();\n initializedSchemas.set(database, initialized);\n }\n initializingDatabases.add(database);\n try {\n for (const entry of schemaInitializers) {\n if (initialized.has(entry.initialize)) continue;\n // Mark first so a migration that calls through the public db proxy does\n // not recursively invoke itself. Remove on failure so the next call can retry.\n initialized.add(entry.initialize);\n try {\n entry.initialize(database);\n } catch (error) {\n initialized.delete(entry.initialize);\n throw error;\n }\n }\n } finally {\n initializingDatabases.delete(database);\n }\n}\n\n/** Stable proxy identity used by legacy imports and embedding hosts. */\nexport const internalStorageDatabase = new Proxy({} as InternalStorageDatabase, {\n get(_target, property) {\n const database = resolveStorageDatabase();\n ensureStorageSchemas(database);\n const value = Reflect.get(database as object, property, database);\n return typeof value === \"function\" ? value.bind(database) : value;\n },\n set(_target, property, value) {\n const database = resolveStorageDatabase();\n ensureStorageSchemas(database);\n return Reflect.set(database as object, property, value, database);\n },\n});\n\n/** Structurally typed view intended for embedding hosts. */\nexport const storageDatabase = internalStorageDatabase as unknown as StorageDatabase;\n",
9
9
  "// Runtime-adaptive SQLite façade.\n//\n// Why this exists: Otium normally runs on bun and uses `bun:sqlite` (native,\n// fast, zero-build). But the `codex` agent's MCP servers must run on pure node\n// — codex 0.135's rmcp stdio client can't handshake with bun-spawned servers\n// (see serverLaunch in platform/mcp-config.ts). `bun:sqlite` doesn't exist\n// under node, so any MCP server that touches the DB would crash on import.\n//\n// This module picks the backend at runtime and re-exports it as `Database`:\n// - under bun → the real `bun:sqlite` Database (unchanged behavior)\n// - under node → a thin shim over node:sqlite's DatabaseSync exposing the\n// exact subset Otium uses: new Database(path, {readonly?,create?}),\n// .query()/.prepare() → statement, .exec(), .run(), .transaction(fn),\n// .close(); statements support positional `?` params via .run()/.get()/\n// .all(). (No named-object params / .values()/.iterate()/.as() are used in\n// this codebase, so the shim intentionally omits them.)\n//\n// The TYPE of the exported `Database` is bun:sqlite's own class type (imported\n// type-only, so it's erased at runtime and never resolved under node). That\n// keeps every existing call site — including generic `.query<Row, Params>()`\n// usages — type-checking exactly as before with zero changes.\n//\n// The bun branch uses a dynamic import so node never resolves the `bun:sqlite`\n// specifier (dead code under node), and the node branch's `node:sqlite` import\n// never runs under bun.\n\nimport type { Database as BunDatabase } from \"bun:sqlite\";\n\n// `mock.module()` can replace the Bun global while a test graph is being\n// evaluated. The runtime version flag is stable and is also the documented\n// cross-runtime discriminator.\nconst isBun = typeof process.versions.bun === \"string\";\n\ntype DatabaseCtor = typeof BunDatabase;\n\nlet Database: DatabaseCtor;\n\nif (isBun) {\n ({ Database } = await import(\"bun:sqlite\"));\n} else {\n // node:sqlite has no type declarations under our tsconfig (`types: bun-types`\n // only), and `bun-types` doesn't ship them — so type the dynamic import\n // locally and use a `string` specifier to keep tsc from trying to resolve it.\n type NodeStatement = {\n run(...params: unknown[]): unknown;\n get(...params: unknown[]): unknown;\n all(...params: unknown[]): unknown[];\n };\n type NodeDatabaseSync = {\n prepare(sql: string): NodeStatement;\n exec(sql: string): void;\n close(): void;\n };\n type NodeSqliteModule = {\n DatabaseSync: new (path: string, options?: { readOnly?: boolean }) => NodeDatabaseSync;\n };\n // Keep Bun's resolver from eagerly resolving the Node-only builtin even\n // though this branch is dead there (notably under `bun test` + mock.module).\n const nodeSqliteSpecifier = [\"node\", \"sqlite\"].join(\":\");\n const { DatabaseSync } = (await import(nodeSqliteSpecifier)) as NodeSqliteModule;\n\n type DbOptions = { readonly?: boolean; create?: boolean };\n\n class NodeDatabase {\n #db: NodeDatabaseSync;\n\n constructor(path: string, options: DbOptions = {}) {\n // node:sqlite opens read-write and creates-if-missing by default, which\n // matches bun's `{create:true}`. For readonly we must not create.\n this.#db = options.readonly\n ? new DatabaseSync(path, { readOnly: true })\n : new DatabaseSync(path);\n }\n\n query(sql: string) {\n return this.#db.prepare(sql);\n }\n\n prepare(sql: string) {\n return this.#db.prepare(sql);\n }\n\n exec(sql: string): void {\n this.#db.exec(sql);\n }\n\n run(sql: string, ...params: unknown[]) {\n return this.#db.prepare(sql).run(...params);\n }\n\n transaction<Args extends unknown[], R>(fn: (...args: Args) => R): (...args: Args) => R {\n // bun's db.transaction(fn) returns a function that runs fn inside a\n // transaction when called. node:sqlite has no helper, so emulate with\n // BEGIN/COMMIT/ROLLBACK. It also exposes deferred/immediate/exclusive\n // variants; mirror those so Node-launched MCP servers can share call\n // sites with Bun.\n const run =\n (begin: \"BEGIN\" | \"BEGIN DEFERRED\" | \"BEGIN IMMEDIATE\" | \"BEGIN EXCLUSIVE\") =>\n (...args: Args): R => {\n this.#db.exec(begin);\n try {\n const result = fn(...args);\n this.#db.exec(\"COMMIT\");\n return result;\n } catch (err) {\n this.#db.exec(\"ROLLBACK\");\n throw err;\n }\n };\n const tx = run(\"BEGIN\") as ((...args: Args) => R) & {\n deferred: (...args: Args) => R;\n immediate: (...args: Args) => R;\n exclusive: (...args: Args) => R;\n };\n tx.deferred = run(\"BEGIN DEFERRED\");\n tx.immediate = run(\"BEGIN IMMEDIATE\");\n tx.exclusive = run(\"BEGIN EXCLUSIVE\");\n return tx;\n }\n\n close(): void {\n this.#db.close();\n }\n }\n\n Database = NodeDatabase as unknown as DatabaseCtor;\n}\n\nexport { Database };\n",
10
10
  "import type { StorageDatabase } from \"#storage/storage-contract\";\nimport { internalStorageDatabase } from \"#storage/storage-host\";\n\nexport const db = internalStorageDatabase as unknown as StorageDatabase;\n",
@@ -17,7 +17,7 @@
17
17
  "// Persistent topic store backed by shared SQLite.\n\nimport { GENERAL_TOPIC_ID } from \"#platform/constants\";\nimport { logger } from \"#platform/logger\";\nimport { db } from \"#storage/forum-db\";\nimport { registerStorageSchemaInitializer } from \"#storage/storage-host\";\nimport type { AgentKind, EffortLevel } from \"#types\";\nimport type {\n AiMode,\n ParticipantDto,\n TopicAccessMode,\n TopicDto,\n TopicKind,\n TopicVisibility,\n} from \"#types/api\";\n\nconst DEFAULT_AGENT_ROOM_AGENT: AgentKind = \"maestro\";\n\nfunction tableColumns(table: string): Set<string> {\n const rows = db.query(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;\n return new Set(rows.map((row) => row.name));\n}\n\n/** Persist the authoritative agent and, when supplied, its normalized base defaults. */\nexport function setApiTopicAgent(\n topicId: string,\n agent: AgentKind,\n defaults?: { model: string; effort?: EffortLevel },\n): void {\n if (!defaults) {\n db.query(\"UPDATE api_topics SET agent = ? WHERE id = ?\").run(agent, topicId);\n return;\n }\n db.query(\"UPDATE api_topics SET agent = ?, base_model = ?, base_effort = ? WHERE id = ?\").run(\n agent,\n defaults.model,\n defaults.effort ?? null,\n topicId,\n );\n}\n\nfunction initializeApiTopicsSchema(): void {\n db.exec(`\n CREATE TABLE IF NOT EXISTS api_topics (\n id TEXT PRIMARY KEY,\n title TEXT NOT NULL,\n kind TEXT NOT NULL DEFAULT 'channel',\n description TEXT,\n agent TEXT,\n default_model TEXT,\n default_effort TEXT,\n participants TEXT,\n created_at TEXT NOT NULL,\n last_message_at TEXT,\n is_archived INTEGER NOT NULL DEFAULT 0,\n ai_mention INTEGER NOT NULL DEFAULT 0,\n ai_mode TEXT\n )\n`);\n\n const initialTopicColumns = tableColumns(\"api_topics\");\n const legacyTopicSchema = !initialTopicColumns.has(\"response_policy\");\n const needsCanonicalTopicRebuild = legacyTopicSchema || !initialTopicColumns.has(\"agent\");\n if (legacyTopicSchema) {\n // Rename the legacy column once; new databases are created with `agent`.\n try {\n db.exec(\"ALTER TABLE api_topics RENAME COLUMN default_agent TO agent\");\n } catch {\n // Already migrated or freshly created.\n }\n\n // Migrate existing DBs that predate the ai_mention column (team-mode topics).\n try {\n db.exec(\"ALTER TABLE api_topics ADD COLUMN ai_mention INTEGER NOT NULL DEFAULT 0\");\n } catch {\n // Column already exists — nothing to do.\n }\n\n // Migrate for spawn/fork tracking (R1).\n try {\n db.exec(\"ALTER TABLE api_topics ADD COLUMN parent_topic_id TEXT\");\n } catch {\n // Column already exists.\n }\n try {\n db.exec(\"ALTER TABLE api_topics ADD COLUMN is_fork INTEGER NOT NULL DEFAULT 0\");\n } catch {\n // Column already exists.\n }\n\n // Migrate for agent-spawned subagent worker rooms.\n try {\n db.exec(\"ALTER TABLE api_topics ADD COLUMN is_subagent INTEGER NOT NULL DEFAULT 0\");\n } catch {\n // Column already exists.\n }\n\n // Migrate for session persistence.\n try {\n db.exec(\"ALTER TABLE api_topics ADD COLUMN session_id TEXT\");\n } catch {\n // Column already exists.\n }\n\n try {\n db.exec(\"ALTER TABLE api_topics ADD COLUMN kind TEXT NOT NULL DEFAULT 'channel'\");\n } catch {\n // Column already exists.\n }\n\n try {\n db.exec(\"ALTER TABLE api_topics ADD COLUMN ai_mode TEXT\");\n } catch {\n // Column already exists.\n }\n\n db.exec(`\n CREATE TABLE IF NOT EXISTS api_schema_migrations (\n key TEXT PRIMARY KEY,\n applied_at TEXT NOT NULL\n )\n`);\n\n const ALWAYS_RESPOND_MIGRATION = \"api_topics_ai_invited_default_always_respond_20260623\";\n const alwaysRespondMigration = db\n .query(\"SELECT key FROM api_schema_migrations WHERE key = ?\")\n .get(ALWAYS_RESPOND_MIGRATION);\n if (!alwaysRespondMigration) {\n db.transaction(() => {\n db.query(\"UPDATE api_topics SET ai_mention = 0 WHERE agent IS NOT NULL\").run();\n db.query(\"INSERT INTO api_schema_migrations (key, applied_at) VALUES (?, ?)\").run(\n ALWAYS_RESPOND_MIGRATION,\n new Date().toISOString(),\n );\n })();\n }\n\n const GENERAL_AGENT_KIND_MIGRATION = \"api_topics_general_agent_kind_20260704\";\n const generalAgentKindMigration = db\n .query(\"SELECT key FROM api_schema_migrations WHERE key = ?\")\n .get(GENERAL_AGENT_KIND_MIGRATION);\n if (!generalAgentKindMigration) {\n db.transaction(() => {\n db.query(\"UPDATE api_topics SET kind = 'agent', ai_mention = 0 WHERE id = ?\").run(\n GENERAL_TOPIC_ID,\n );\n db.query(\"INSERT INTO api_schema_migrations (key, applied_at) VALUES (?, ?)\").run(\n GENERAL_AGENT_KIND_MIGRATION,\n new Date().toISOString(),\n );\n })();\n }\n\n const AI_MODE_MIGRATION = \"api_topics_ai_mode_20260704\";\n const aiModeMigration = db\n .query(\"SELECT key FROM api_schema_migrations WHERE key = ?\")\n .get(AI_MODE_MIGRATION);\n if (!aiModeMigration) {\n db.transaction(() => {\n db.query(\n `UPDATE api_topics\n SET\n kind = CASE\n WHEN id = ? THEN 'agent'\n WHEN kind = 'channel' AND agent IS NOT NULL AND ai_mention = 0 THEN 'agent'\n ELSE kind\n END,\n ai_mode = CASE\n WHEN id = ? THEN 'always'\n WHEN agent IS NULL THEN 'off'\n WHEN kind = 'agent' THEN 'always'\n WHEN kind = 'channel' AND ai_mention != 0 THEN 'mention'\n WHEN kind = 'channel' AND ai_mention = 0 THEN 'always'\n WHEN ai_mention != 0 THEN 'mention'\n ELSE 'always'\n END`,\n ).run(GENERAL_TOPIC_ID, GENERAL_TOPIC_ID);\n db.query(\"INSERT INTO api_schema_migrations (key, applied_at) VALUES (?, ?)\").run(\n AI_MODE_MIGRATION,\n new Date().toISOString(),\n );\n })();\n }\n\n const GENERAL_MANAGER_KIND_MIGRATION = \"api_topics_general_manager_kind_20260704\";\n const generalManagerKindMigration = db\n .query(\"SELECT key FROM api_schema_migrations WHERE key = ?\")\n .get(GENERAL_MANAGER_KIND_MIGRATION);\n if (!generalManagerKindMigration) {\n db.transaction(() => {\n db.query(\n \"UPDATE api_topics SET kind = 'manager', ai_mention = 0, ai_mode = 'always' WHERE id = ?\",\n ).run(GENERAL_TOPIC_ID);\n db.query(\"INSERT INTO api_schema_migrations (key, applied_at) VALUES (?, ?)\").run(\n GENERAL_MANAGER_KIND_MIGRATION,\n new Date().toISOString(),\n );\n })();\n }\n }\n\n function createCanonicalTopicsTable(name: string): void {\n db.exec(`\n CREATE TABLE IF NOT EXISTS ${name} (\n id TEXT PRIMARY KEY,\n title TEXT NOT NULL,\n kind TEXT NOT NULL CHECK (kind IN ('channel','agent','manager')),\n description TEXT,\n agent TEXT CHECK (agent IS NULL OR agent IN ('maestro','claude','codex')),\n base_model TEXT,\n base_effort TEXT CHECK (base_effort IS NULL OR base_effort IN ('low','medium','high','xhigh','max')),\n response_policy TEXT NOT NULL CHECK (response_policy IN ('off','mention','always')),\n created_at TEXT NOT NULL,\n last_message_at TEXT,\n parent_topic_id TEXT,\n is_fork INTEGER NOT NULL DEFAULT 0 CHECK (is_fork IN (0,1)),\n is_subagent INTEGER NOT NULL DEFAULT 0 CHECK (is_subagent IN (0,1)),\n visibility TEXT NOT NULL DEFAULT 'visible' CHECK (visibility IN ('visible','hidden')),\n access_mode TEXT NOT NULL DEFAULT 'private' CHECK (access_mode IN ('private','shared')),\n browser_profile TEXT NOT NULL DEFAULT 'default',\n browser_profile_owner TEXT,\n session_id TEXT,\n CHECK (\n (kind = 'channel' AND response_policy = 'off' AND agent IS NULL) OR\n (kind = 'channel' AND response_policy = 'mention' AND agent IS NOT NULL) OR\n (kind IN ('agent','manager') AND response_policy = 'always' AND agent IS NOT NULL)\n )\n )\n `);\n }\n\n function createTopicMembersTable(): void {\n db.exec(`\n CREATE TABLE IF NOT EXISTS topic_members (\n topic_id TEXT NOT NULL REFERENCES api_topics(id) ON DELETE CASCADE,\n user_id TEXT NOT NULL,\n role TEXT NOT NULL CHECK (role IN ('owner','member')),\n PRIMARY KEY (topic_id, user_id)\n )\n `);\n db.exec(\"CREATE INDEX IF NOT EXISTS idx_topic_members_user ON topic_members(user_id)\");\n }\n\n if (needsCanonicalTopicRebuild) {\n const legacyRows = db.query(\"SELECT * FROM api_topics\").all() as Array<Record<string, unknown>>;\n const existingMemberRows = tableColumns(\"topic_members\").has(\"topic_id\")\n ? (db.query(\"SELECT topic_id, user_id, role FROM topic_members\").all() as Array<{\n topic_id: string;\n user_id: string;\n role: string;\n }>)\n : [];\n const configColumns = tableColumns(\"api_topic_config\");\n const legacyAgentOverrides = configColumns.has(\"agent\")\n ? new Map(\n (\n db\n .query(\"SELECT topic_id, agent FROM api_topic_config WHERE agent IS NOT NULL\")\n .all() as Array<{ topic_id: string; agent: AgentKind }>\n ).map((row) => [row.topic_id, row.agent]),\n )\n : new Map<string, AgentKind>();\n const previousForeignKeys = db\n .query<{ foreign_keys: number }, []>(\"PRAGMA foreign_keys\")\n .get()?.foreign_keys;\n db.exec(\"PRAGMA foreign_keys = OFF\");\n try {\n db.transaction(() => {\n createCanonicalTopicsTable(\"api_topics_next\");\n for (const row of legacyRows) {\n const selectedAgent = (legacyAgentOverrides.get(String(row.id)) ??\n row.agent ??\n row.runtime_agent ??\n row.default_agent ??\n undefined) as AgentKind | undefined;\n const normalized = normalizeTopicState({\n id: String(row.id),\n kind: normalizeTopicKind(row.kind),\n agent: selectedAgent,\n aiMode: normalizeAiMode(row.response_policy ?? row.ai_mode),\n aiMention: Number(row.ai_mention ?? 0) !== 0,\n });\n const legacyBaseModel = row.base_model ?? row.default_model;\n const legacyBaseEffort = row.base_effort ?? row.default_effort;\n db.query(\n `INSERT INTO api_topics_next\n (id,title,kind,description,agent,base_model,base_effort,response_policy,\n created_at,last_message_at,parent_topic_id,is_fork,is_subagent,visibility,access_mode,session_id)\n VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,\n ).run(\n String(row.id),\n String(row.title),\n normalized.kind,\n typeof row.description === \"string\" ? row.description : null,\n normalized.agent ?? null,\n typeof legacyBaseModel === \"string\" ? legacyBaseModel : null,\n typeof legacyBaseEffort === \"string\" ? legacyBaseEffort : null,\n normalized.aiMode,\n String(row.created_at),\n typeof row.last_message_at === \"string\" ? row.last_message_at : null,\n typeof row.parent_topic_id === \"string\" ? row.parent_topic_id : null,\n Number(row.is_fork ?? 0) !== 0 ? 1 : 0,\n Number(row.is_subagent ?? 0) !== 0 ? 1 : 0,\n row.visibility === \"hidden\" ? \"hidden\" : \"visible\",\n row.access_mode === \"shared\" ? \"shared\" : \"private\",\n typeof row.session_id === \"string\" ? row.session_id : null,\n );\n }\n db.exec(\"DROP TABLE IF EXISTS topic_members\");\n db.exec(\"DROP TABLE api_topics\");\n db.exec(\"ALTER TABLE api_topics_next RENAME TO api_topics\");\n createTopicMembersTable();\n if (existingMemberRows.length > 0) {\n for (const member of existingMemberRows) {\n db.query(\n \"INSERT OR REPLACE INTO topic_members (topic_id,user_id,role) VALUES (?,?,?)\",\n ).run(member.topic_id, member.user_id, member.role === \"owner\" ? \"owner\" : \"member\");\n }\n } else {\n for (const row of legacyRows) {\n let participants: ParticipantDto[] = [];\n try {\n const parsed = JSON.parse(String(row.participants ?? \"[]\"));\n if (Array.isArray(parsed)) participants = parsed as ParticipantDto[];\n } catch {\n participants = [];\n }\n for (const participant of participants) {\n if (!participant?.userId) continue;\n db.query(\n \"INSERT OR REPLACE INTO topic_members (topic_id,user_id,role) VALUES (?,?,?)\",\n ).run(\n String(row.id),\n participant.userId,\n participant.role === \"owner\" ? \"owner\" : \"member\",\n );\n }\n }\n }\n })();\n } finally {\n db.exec(`PRAGMA foreign_keys = ${previousForeignKeys ? \"ON\" : \"OFF\"}`);\n }\n } else {\n createCanonicalTopicsTable(\"api_topics\");\n createTopicMembersTable();\n }\n\n // Canonical databases created before explicit adapter visibility need a\n // lightweight additive migration; fresh/rebuilt databases already have it.\n if (!tableColumns(\"api_topics\").has(\"visibility\")) {\n db.exec(\"ALTER TABLE api_topics ADD COLUMN visibility TEXT NOT NULL DEFAULT 'visible'\");\n }\n if (!tableColumns(\"api_topics\").has(\"access_mode\")) {\n db.exec(\"ALTER TABLE api_topics ADD COLUMN access_mode TEXT NOT NULL DEFAULT 'private'\");\n }\n if (!tableColumns(\"api_topics\").has(\"browser_profile\")) {\n db.exec(\"ALTER TABLE api_topics ADD COLUMN browser_profile TEXT NOT NULL DEFAULT 'default'\");\n }\n if (!tableColumns(\"api_topics\").has(\"browser_profile_owner\")) {\n db.exec(\"ALTER TABLE api_topics ADD COLUMN browser_profile_owner TEXT\");\n }\n db.exec(`\n UPDATE api_topics\n SET browser_profile_owner = (\n SELECT m.user_id FROM topic_members m\n WHERE m.topic_id = api_topics.id AND m.role = 'owner'\n ORDER BY m.rowid\n LIMIT 1\n )\n WHERE browser_profile_owner IS NULL\n`);\n db.exec(\n \"CREATE INDEX IF NOT EXISTS idx_api_topics_last_message ON api_topics(last_message_at DESC)\",\n );\n}\n\nregisterStorageSchemaInitializer(initializeApiTopicsSchema, 20);\n\nexport interface TopicRow {\n id: string;\n title: string;\n kind: string | null;\n description: string | null;\n agent: string | null;\n base_model: string | null;\n base_effort: string | null;\n response_policy: string;\n created_at: string;\n last_message_at: string | null;\n parent_topic_id: string | null;\n is_fork: number;\n is_subagent: number;\n visibility: string | null;\n access_mode: string | null;\n browser_profile_owner: string | null;\n session_id: string | null;\n}\n\nexport interface TopicSessionLogContext {\n reason?: string;\n queryId?: string;\n agent?: AgentKind;\n}\n\nfunction shortSessionId(sessionId: string | null | undefined): string | null {\n return sessionId ? sessionId.slice(0, 8) : null;\n}\n\nfunction getTopicParticipants(topicId: string): ParticipantDto[] {\n return db\n .query<{ user_id: string; role: \"owner\" | \"member\" }, string>(\n \"SELECT user_id, role FROM topic_members WHERE topic_id = ? ORDER BY rowid\",\n )\n .all(topicId)\n .map((row) => ({ userId: row.user_id, role: row.role }));\n}\n\nfunction getAllTopicParticipants(): Map<string, ParticipantDto[]> {\n const grouped = new Map<string, ParticipantDto[]>();\n const rows = db\n .query<{ topic_id: string; user_id: string; role: \"owner\" | \"member\" }, []>(\n \"SELECT topic_id, user_id, role FROM topic_members ORDER BY rowid\",\n )\n .all();\n for (const row of rows) {\n const participants = grouped.get(row.topic_id) ?? [];\n participants.push({ userId: row.user_id, role: row.role });\n grouped.set(row.topic_id, participants);\n }\n return grouped;\n}\n\nfunction rowToDto(r: TopicRow, participants = getTopicParticipants(r.id)): TopicDto {\n const normalized = normalizeTopicState({\n id: r.id,\n kind: normalizeTopicKind(r.kind),\n agent: (r.agent as AgentKind | null) ?? undefined,\n aiMode: normalizeAiMode(r.response_policy),\n });\n return {\n id: r.id,\n title: r.title,\n kind: normalized.kind,\n description: r.description ?? undefined,\n agent: normalized.agent,\n defaultModel: r.base_model ?? \"deepseek-pro\",\n defaultEffort: (r.base_effort as EffortLevel) ?? undefined,\n aiMode: normalized.aiMode,\n aiMention: aiMentionFromMode(normalized.aiMode),\n participants,\n createdAt: r.created_at,\n lastMessageAt: r.last_message_at ?? new Date().toISOString(),\n parentTopicId: r.parent_topic_id ?? undefined,\n isFork: r.is_fork !== 0,\n ...(r.is_subagent !== 0 ? { isSubagent: true } : {}),\n visibility: normalizeTopicVisibility(r.visibility),\n accessMode: normalizeTopicAccessMode(r.access_mode),\n };\n}\n\nexport function normalizeTopicAccessMode(value: unknown): TopicAccessMode {\n return value === \"shared\" ? \"shared\" : \"private\";\n}\n\n/** Otium and other non-local adapters may only address shared topics. */\nexport function isTopicShared(topic: Pick<TopicDto, \"accessMode\">): boolean {\n return topic.accessMode === \"shared\";\n}\n\nexport function normalizeTopicVisibility(value: unknown): TopicVisibility {\n return value === \"hidden\" ? \"hidden\" : \"visible\";\n}\n\n/** Discovery boundary shared by user-facing adapters. */\nexport function isTopicVisible(topic: Pick<TopicDto, \"visibility\">): boolean {\n return topic.visibility !== \"hidden\";\n}\n\nexport function normalizeTopicKind(value: unknown): TopicKind | null {\n return value === \"channel\" || value === \"agent\" || value === \"manager\" ? value : null;\n}\n\nexport function normalizeAiMode(value: unknown): AiMode | null {\n return value === \"off\" || value === \"mention\" || value === \"always\" ? value : null;\n}\n\nexport function aiMentionFromMode(mode: AiMode): boolean {\n return mode === \"mention\";\n}\n\nexport function inferTopicKind(input: {\n agent?: string | null;\n aiMention?: boolean | null;\n}): TopicKind {\n return input.agent && input.aiMention !== true ? \"agent\" : \"channel\";\n}\n\nexport function inferAiMode(input: {\n kind?: TopicKind | null;\n agent?: string | null;\n aiMode?: AiMode | null;\n aiMention?: boolean | null;\n}): AiMode {\n if (input.kind === \"manager\") return \"always\";\n if (input.kind === \"agent\") return \"always\";\n if (input.kind === \"channel\") {\n if (!input.agent) return \"off\";\n if (input.aiMode === \"mention\") return \"mention\";\n if (input.aiMention === true) return \"mention\";\n return \"mention\";\n }\n if (!input.agent) return \"off\";\n if (input.aiMode) return input.aiMode;\n return input.aiMention === true ? \"mention\" : \"always\";\n}\n\nexport function normalizeTopicState(input: {\n id?: string;\n kind?: TopicKind | null;\n agent?: AgentKind | null;\n aiMode?: AiMode | null;\n aiMention?: boolean | null;\n}): { kind: TopicKind; aiMode: AiMode; agent?: AgentKind } {\n if (input.kind === \"manager\" || input.id === GENERAL_TOPIC_ID) {\n return {\n kind: \"manager\",\n aiMode: \"always\",\n agent: input.agent ?? DEFAULT_AGENT_ROOM_AGENT,\n };\n }\n\n const requestedKind = input.kind;\n const kind =\n requestedKind ??\n (input.aiMode === \"always\"\n ? \"agent\"\n : input.aiMode === \"mention\" || input.aiMode === \"off\"\n ? \"channel\"\n : input.agent && input.aiMention !== true\n ? \"agent\"\n : \"channel\");\n\n if (kind === \"agent\") {\n return {\n kind: \"agent\",\n aiMode: \"always\",\n agent: input.agent ?? DEFAULT_AGENT_ROOM_AGENT,\n };\n }\n\n const agent = input.aiMode === \"off\" ? undefined : (input.agent ?? undefined);\n return {\n kind: \"channel\",\n aiMode: agent ? \"mention\" : \"off\",\n agent,\n };\n}\n\nfunction normalizedTitle(title: string): string {\n return title.trim().toLowerCase();\n}\n\nexport function upsertTopic(t: TopicDto): void {\n const normalized = normalizeTopicState({\n id: t.id,\n kind: normalizeTopicKind(t.kind),\n agent: t.agent ?? undefined,\n aiMode: normalizeAiMode(t.aiMode),\n aiMention: t.aiMention,\n });\n db.transaction(() => {\n db.query(\n `INSERT INTO api_topics\n (id,title,kind,description,agent,base_model,base_effort,response_policy,\n created_at,last_message_at,parent_topic_id,is_fork,is_subagent,visibility,access_mode)\n VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)\n ON CONFLICT(id) DO UPDATE SET\n title = excluded.title,\n kind = excluded.kind,\n description = excluded.description,\n agent = excluded.agent,\n base_model = excluded.base_model,\n base_effort = excluded.base_effort,\n response_policy = excluded.response_policy,\n created_at = excluded.created_at,\n last_message_at = excluded.last_message_at,\n parent_topic_id = excluded.parent_topic_id,\n is_fork = excluded.is_fork,\n is_subagent = excluded.is_subagent,\n visibility = excluded.visibility,\n access_mode = excluded.access_mode`,\n ).run(\n t.id,\n t.title,\n normalized.kind,\n t.description ?? null,\n normalized.agent ?? null,\n t.defaultModel ?? null,\n t.defaultEffort ?? null,\n normalized.aiMode,\n t.createdAt,\n t.lastMessageAt ?? null,\n t.parentTopicId ?? null,\n t.isFork ? 1 : 0,\n t.isSubagent ? 1 : 0,\n normalizeTopicVisibility(t.visibility),\n normalizeTopicAccessMode(t.accessMode),\n );\n db.query(\"DELETE FROM topic_members WHERE topic_id = ?\").run(t.id);\n for (const participant of t.participants) {\n db.query(\"INSERT INTO topic_members (topic_id,user_id,role) VALUES (?,?,?)\").run(\n t.id,\n participant.userId,\n participant.role,\n );\n }\n const initialBrowserProfileOwner = t.participants.find(\n (participant) => participant.role === \"owner\",\n )?.userId;\n if (initialBrowserProfileOwner) {\n db.query(\n `UPDATE api_topics\n SET browser_profile_owner = COALESCE(browser_profile_owner, ?)\n WHERE id = ?`,\n ).run(initialBrowserProfileOwner, t.id);\n }\n })();\n}\n\nexport function listTopics(): TopicDto[] {\n const rows = db\n .query(\"SELECT * FROM api_topics ORDER BY last_message_at DESC\")\n .all() as TopicRow[];\n const participants = getAllTopicParticipants();\n return rows.map((row) => rowToDto(row, participants.get(row.id) ?? []));\n}\n\nexport function getTopic(id: string): TopicDto | null {\n const r = db.query(\"SELECT * FROM api_topics WHERE id = ?\").get(id) as TopicRow | undefined;\n return r ? rowToDto(r) : null;\n}\n\n/** Return the private manager room owned by a user, excluding the retired shared General row. */\nexport function getManagerTopicForUser(userId: string): TopicDto | null {\n const row = db\n .query<TopicRow, [string, string]>(\n `SELECT t.* FROM api_topics t\n JOIN topic_members m ON m.topic_id = t.id\n WHERE t.kind = 'manager'\n AND t.id != ?\n AND m.user_id = ?\n AND m.role = 'owner'\n ORDER BY t.created_at ASC\n LIMIT 1`,\n )\n .get(GENERAL_TOPIC_ID, userId);\n return row ? rowToDto(row) : null;\n}\n\n/**\n * Resolve the topic whose wiki memory should be used for this topic.\n *\n * Derived rooms keep `parentTopicId` as an immediate UI link, but memory follows\n * Otium's `forkOrigin` semantics: a fork/spawn chain writes to and reads from\n * the original root topic.\n */\nexport function getTopicMemoryOrigin(id: string): TopicDto | null {\n let current = getTopic(id);\n if (!current) return null;\n\n const seen = new Set<string>([current.id]);\n while (current.parentTopicId && !seen.has(current.parentTopicId)) {\n const parent = getTopic(current.parentTopicId);\n if (!parent) break;\n current = parent;\n seen.add(current.id);\n }\n\n return current;\n}\n\n/** Look up a topic by its user-visible title (case-insensitive exact match). */\nexport function getTopicByName(title: string): TopicDto | null {\n const r = db.query(\"SELECT * FROM api_topics WHERE LOWER(title) = LOWER(?)\").get(title) as\n | TopicRow\n | undefined;\n return r ? rowToDto(r) : null;\n}\n\nexport function getTopicByNameAndKind(title: string, kind: TopicKind): TopicDto | null {\n const r = db\n .query(\"SELECT * FROM api_topics WHERE LOWER(title) = LOWER(?) AND kind = ?\")\n .get(title, kind) as TopicRow | undefined;\n return r ? rowToDto(r) : null;\n}\n\nexport function findTopicTitleConflict(\n title: string,\n kind: TopicKind,\n opts: { excludeTopicId?: string } = {},\n): TopicDto | null {\n const wanted = normalizedTitle(title);\n const generalTitleRequested = wanted === normalizedTitle(GENERAL_TOPIC_ID);\n if (generalTitleRequested && opts.excludeTopicId !== GENERAL_TOPIC_ID) {\n const general = db.query(\"SELECT * FROM api_topics WHERE id = ?\").get(GENERAL_TOPIC_ID) as\n | TopicRow\n | undefined;\n if (general) return rowToDto(general);\n }\n\n const params: string[] = [wanted];\n let sql = \"SELECT * FROM api_topics WHERE LOWER(TRIM(title)) = ?\";\n if (kind !== \"manager\") {\n sql += \" AND (kind = ? OR id = ?)\";\n params.push(kind, GENERAL_TOPIC_ID);\n }\n if (opts.excludeTopicId) {\n sql += \" AND id != ?\";\n params.push(opts.excludeTopicId);\n }\n sql += \" LIMIT 1\";\n const row = db.query<TopicRow, string[]>(sql).get(...params);\n return row ? rowToDto(row) : null;\n}\n\n/** Look up a topic by title, restricted to topics where `userId` participates. */\nexport function getTopicByNameForUser(title: string, userId: string): TopicDto | null {\n const trimmed = title.trim();\n const qualified = /^(agent|channel|manager):(.+)$/i.exec(trimmed);\n const requestedKind = qualified ? normalizeTopicKind(qualified[1]?.toLowerCase()) : null;\n const requestedTitle = qualified ? qualified[2]!.trim() : trimmed;\n const rows = db\n .query(\n `SELECT t.* FROM api_topics t\n WHERE LOWER(t.title) = LOWER(?)\n AND t.id != ?\n AND t.visibility != 'hidden'\n AND EXISTS (\n SELECT 1 FROM topic_members m WHERE m.topic_id = t.id AND m.user_id = ?\n )`,\n )\n .all(requestedTitle, GENERAL_TOPIC_ID, userId) as TopicRow[];\n const matches = requestedKind ? rows.filter((row) => row.kind === requestedKind) : rows;\n return matches.length === 1 ? rowToDto(matches[0]!) : null;\n}\n\n/** Persist the agent session ID for a topic after a successful turn. */\nexport function setTopicSessionId(\n topicId: string,\n sessionId: string,\n context: TopicSessionLogContext = {},\n): void {\n const previous = getTopicSessionId(topicId);\n const result = db\n .query(\"UPDATE api_topics SET session_id = ? WHERE id = ?\")\n .run(sessionId, topicId);\n const changes = Number(result.changes ?? 0);\n const logContext = {\n topicId,\n previousSessionId: shortSessionId(previous),\n sessionId: shortSessionId(sessionId),\n reason: context.reason,\n queryId: context.queryId,\n agent: context.agent,\n };\n if (changes === 0) {\n logger.warn(logContext, \"api-topic session_id update missed topic\");\n } else if (previous !== sessionId) {\n logger.debug(logContext, \"api-topic session_id updated\");\n } else {\n logger.debug(logContext, \"api-topic session_id unchanged\");\n }\n}\n\n/** Clear the session ID (e.g. on topic reset). */\nexport function clearTopicSessionId(topicId: string, reason = \"unspecified\"): void {\n const previous = getTopicSessionId(topicId);\n const result = db.query(\"UPDATE api_topics SET session_id = NULL WHERE id = ?\").run(topicId);\n const changes = Number(result.changes ?? 0);\n const logContext = { topicId, previousSessionId: shortSessionId(previous), reason };\n if (changes === 0) {\n logger.warn(logContext, \"api-topic session_id clear missed topic\");\n } else if (previous) {\n logger.info(logContext, \"api-topic session_id cleared\");\n } else {\n logger.debug(logContext, \"api-topic session_id already clear\");\n }\n}\n\n/** Read the persisted session ID for a topic, or null if none. */\nexport function getTopicSessionId(topicId: string): string | null {\n const r = db\n .query<{ session_id: string | null }, string>(\"SELECT session_id FROM api_topics WHERE id = ?\")\n .get(topicId);\n return r?.session_id ?? null;\n}\n\n/**\n * Hard-delete a topic row. Irreversible — the caller is responsible for also\n * deleting the topic's messages (see `deleteMessagesForTopic`). Returns false\n * if the topic didn't exist.\n */\nexport function deleteTopic(id: string, options: { allowManager?: boolean } = {}): boolean {\n const r = db\n .query<{ id: string; kind: string }, string>(\"SELECT id, kind FROM api_topics WHERE id = ?\")\n .get(id);\n if (!r) return false;\n if (id === GENERAL_TOPIC_ID || (r.kind === \"manager\" && !options.allowManager)) return false;\n db.query(\"DELETE FROM api_topics WHERE id = ?\").run(id);\n return true;\n}\n\n/**\n * Preserve a derived-topic chain when an intermediate parent is deleted.\n * Direct children move to the deleted topic's parent (or become roots) rather\n * than retaining a dangling parent id that would break memory-origin lookup.\n */\nexport function reparentTopicChildren(\n deletedTopicId: string,\n replacementParentTopicId: string | null,\n): string[] {\n const rows = db\n .query<{ id: string }, string>(\"SELECT id FROM api_topics WHERE parent_topic_id = ?\")\n .all(deletedTopicId);\n if (rows.length === 0) return [];\n db.query(\"UPDATE api_topics SET parent_topic_id = ? WHERE parent_topic_id = ?\").run(\n replacementParentTopicId,\n deletedTopicId,\n );\n return rows.map((row) => row.id);\n}\n\nexport function addParticipantToDB(\n topicId: string,\n userId: string,\n role: \"member\" | \"owner\",\n): boolean {\n if (!db.query(\"SELECT 1 FROM api_topics WHERE id = ?\").get(topicId)) return false;\n db.query(\"INSERT OR IGNORE INTO topic_members (topic_id,user_id,role) VALUES (?,?,?)\").run(\n topicId,\n userId,\n role,\n );\n return true;\n}\n\nexport function removeParticipantFromDB(topicId: string, userId: string): boolean {\n if (!db.query(\"SELECT 1 FROM api_topics WHERE id = ?\").get(topicId)) return false;\n db.query(\"DELETE FROM topic_members WHERE topic_id = ? AND user_id = ?\").run(topicId, userId);\n return true;\n}\n",
18
18
  "/**\n * Global (workspace-wide) application settings — a single shared record, not\n * per-user and not per-topic.\n *\n * Currently holds the global AI name (default \"Otium\"): the AI is one named\n * entity for the whole workspace. Changing it is an admin-only action (see the\n * settings route). Loaded lazily from the currently configured storage host.\n */\nimport { existsSync, mkdirSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { resolveStorageDataDir } from \"#storage/storage-host\";\n\nexport const DEFAULT_AI_NAME = \"Otium\";\n\nlet aiName = DEFAULT_AI_NAME;\nlet loadedSettingsFile: string | null = null;\n\nfunction settingsFile(): string {\n return join(resolveStorageDataDir(), \"otium-settings.json\");\n}\n\nfunction ensureSettingsLoaded(): string {\n const path = settingsFile();\n if (loadedSettingsFile === path) return path;\n loadedSettingsFile = path;\n aiName = DEFAULT_AI_NAME;\n try {\n if (existsSync(path)) {\n const data = JSON.parse(readFileSync(path, \"utf8\")) as { aiName?: unknown };\n if (typeof data.aiName === \"string\" && data.aiName.trim()) {\n aiName = data.aiName.trim();\n }\n }\n } catch {\n // Corrupt/missing file → keep the default.\n }\n return path;\n}\n\nexport function getGlobalAiName(): string {\n ensureSettingsLoaded();\n return aiName || DEFAULT_AI_NAME;\n}\n\n/** Set the global AI name (empty → reset to default). Persists to disk. */\nexport function setGlobalAiName(name: string): string {\n const path = ensureSettingsLoaded();\n aiName = name.trim() || DEFAULT_AI_NAME;\n try {\n mkdirSync(dirname(path), { recursive: true });\n writeFileSync(path, JSON.stringify({ aiName }, null, 2));\n } catch {\n // Best-effort persistence; the in-memory value still updates.\n }\n return aiName;\n}\n",
19
19
  "import {\n existsSync,\n mkdirSync,\n readFileSync,\n renameSync,\n unlinkSync,\n writeFileSync,\n} from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { appendJsonlLine } from \"#platform/jsonl\";\nimport { logger } from \"#platform/logger\";\nimport { sanitizeTopicName } from \"#security/sanitize\";\nimport { resolveStorageDataDir } from \"#storage/storage-host\";\nimport type { AgentKind, UnifiedEvent } from \"#types\";\n\n/**\n * Per-topic conversation log (UnifiedEvent stream) used as the\n * **provider-agnostic source of truth** for cross-agent portability.\n *\n * Storage layout:\n * {DATA_DIR}/conversations/{userId}/{sanitizedTopicName}.jsonl\n *\n * Each line is a `ConversationEntry` JSON object. Append-only: every yielded\n * UnifiedEvent during a `runAgent()` turn is captured here by the recording\n * wrapper, so on agent switch we can rebuild a synthetic native rollout for\n * the target SDK and preserve the conversation across providers.\n *\n * The Claude/Codex SDK rollouts (~/.claude/projects/, ~/.codex/sessions/) are\n * intentionally treated as opaque side-effects of the SDKs only this file\n * is canonical.\n */\nexport interface ConversationEntry {\n ts: string;\n /**\n * Agent that produced this event, frozen at write time. A topic that has\n * been switched (via `set_agent`) will have a mixed-agent log: earlier\n * entries keep the agent that originally generated them. Replay code\n * (rollout-codec `extractChatPairs`) intentionally ignores this field —\n * the cross-agent rollout's whole point is to feed past dialogue, no\n * matter who produced it, into the *new* SDK as if it were native.\n */\n agent: AgentKind;\n event: UnifiedEvent;\n}\n\n/**\n * Validate that a userId stringifies to a safe path component. Rejects empties,\n * slashes, and traversal sequences before they touch the filesystem. The\n * conversation log is local-only so we keep this strict but simple if a\n * caller sends something unexpected it is their bug to fix, not ours to coerce.\n */\nfunction safeUserIdComponent(userId: number | string): string {\n const str = String(userId);\n if (!str || /[/\\\\]|\\.\\./.test(str)) {\n throw new Error(`conversations: refusing unsafe userId path component: ${str}`);\n }\n return str;\n}\n\nfunction conversationDir(userId: number | string): string {\n return join(resolveStorageDataDir(), \"conversations\", safeUserIdComponent(userId));\n}\n\nfunction topicFilename(topicName: string): string {\n const t = sanitizeTopicName(topicName, true);\n return `${t}.jsonl`;\n}\n\n/** Compute the absolute path for a given user/topic conversation log. */\nexport function getConversationPath(userId: number | string, topicName: string): string {\n return join(conversationDir(userId), topicFilename(topicName));\n}\n\n/**\n * Append a single UnifiedEvent for the given topic. Creates the parent\n * directory and the file as needed. Best-effort: I/O failures are logged but\n * never throw, since recording must not break the live stream to Telegram.\n *\n * **Concurrency note (review item M2, revised):** within the bot process the\n * single-threaded event loop already serializes writes — but this module is\n * NOT single-process: the self-config MCP server (a separate stdio process,\n * via `topic-agent-switch`) and provider bridge helpers append to the same\n * topic logs.\n * Cross-process interleaving on macOS is real for lines beyond PIPE_BUF\n * (512B), and a torn line is silently dropped by `readConversation` —\n * corrupting the canonical source for cross-agent rollout reconstruction.\n * Writes therefore go through `appendJsonlLine` (sidecar `.lock` via O_EXCL,\n * stale-lock reclaim, unlocked-append fallback on timeout — interleave is\n * accepted over dropping the entry).\n *\n * The append stays SYNCHRONOUS on purpose: a previous attempt at a\n * Promise-chained per-topic queue made writes async (durability gap before a\n * synchronous `readConversation`) and broke `set_agent` in the bridge tests.\n */\nexport function appendConversationEvent(\n userId: number | string,\n topicName: string,\n agent: AgentKind,\n event: UnifiedEvent,\n): void {\n try {\n appendConversationEventStrict(userId, topicName, agent, event);\n } catch (err) {\n logger.warn(\n { err, userId, topicName, eventType: event.type },\n \"appendConversationEvent: write failed\",\n );\n }\n}\n\n/**\n * Strict variant for state transitions where the conversation log is a manifest\n * rather than telemetry. Throws on I/O failure so callers can avoid committing\n * DB state that points at an unmanifested SDK session.\n */\nexport function appendConversationEventStrict(\n userId: number | string,\n topicName: string,\n agent: AgentKind,\n event: UnifiedEvent,\n): void {\n const path = getConversationPath(userId, topicName);\n const entry: ConversationEntry = {\n ts: new Date().toISOString(),\n agent,\n event,\n };\n mkdirSync(dirname(path), { recursive: true });\n appendJsonlLine(path, JSON.stringify(entry));\n}\n\n/**\n * Read all entries for a topic in chronological order. Returns an empty array\n * if the file does not exist. Malformed lines are skipped with a warning so a\n * single corrupted entry does not poison the whole conversation.\n *\n * NOTE(perf): reads the whole file each call. Topics in the kilobyte range\n * are fine; if a single topic ever grows into multi-megabyte territory,\n * consider a streaming reader (`readline`/`Bun.file().stream()`) and a\n * size-bounded tail.\n */\nexport function readConversation(userId: number | string, topicName: string): ConversationEntry[] {\n const path = getConversationPath(userId, topicName);\n const out: ConversationEntry[] = [];\n if (!existsSync(path)) return out;\n let raw: string;\n try {\n raw = readFileSync(path, \"utf8\");\n } catch (err) {\n logger.warn({ err, path }, \"readConversation: read failed\");\n return out;\n }\n for (const line of raw.split(\"\\n\")) {\n if (!line.trim()) continue;\n try {\n out.push(JSON.parse(line) as ConversationEntry);\n } catch (err) {\n logger.warn(\n { err, line: line.slice(0, 200) },\n \"readConversation: malformed JSONL line skipped\",\n );\n }\n }\n return out;\n}\n\n/** Atomically replace a provider-neutral topic log with an explicit event set. */\nexport function replaceConversationStrict(\n userId: number | string,\n topicName: string,\n entries: ConversationEntry[],\n): void {\n const path = getConversationPath(userId, topicName);\n const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;\n mkdirSync(dirname(path), { recursive: true });\n try {\n writeFileSync(\n tempPath,\n entries.length > 0 ? `${entries.map((entry) => JSON.stringify(entry)).join(\"\\n\")}\\n` : \"\",\n { flag: \"wx\" },\n );\n renameSync(tempPath, path);\n } catch (error) {\n try {\n unlinkSync(tempPath);\n } catch {}\n throw error;\n }\n}\n\n/**\n * Copy the unified conversation log of `srcTopic` into the file path that\n * `dstTopic` will read from. Used by `/fork` so a forked child topic inherits\n * the parent's full cross-agent history, not just the agent's native SDK\n * rollout. `/spawn` intentionally does not call this; it starts with no\n * conversation history. Without this copy, a fork followed immediately by\n * `/agent <other>` would feed the empty child log to `switchTopicAgent` and\n * the new agent would start from zero — see the bug report from 2026-05-24.\n *\n * Semantics:\n * - Reads the parent via `readConversation`, matching the history an in-place\n * `set_agent` on the parent would have seen.\n * - Writes a single file at the child's canonical path.\n * - Refuses to overwrite a non-empty destination (returns `{copied:false}`).\n * `/fork`'s only caller runs this immediately after topic creation when\n * the dst file is guaranteed empty, so a non-empty dst means a programmer\n * error somewhere upstream — fail loud rather than silently merging.\n * - On any I/O error: throws. The caller (`createChildTopic`) already has\n * a rollback path that wraps this call.\n */\nexport function cloneConversationLog(opts: {\n userId: number | string;\n srcTopic: string;\n dstTopic: string;\n}): { copied: boolean; entries: number } {\n const { userId, srcTopic, dstTopic } = opts;\n const dstPath = getConversationPath(userId, dstTopic);\n if (existsSync(dstPath) && readFileSync(dstPath, \"utf8\").trim().length > 0) {\n logger.warn(\n { userId, srcTopic, dstTopic, dstPath },\n \"cloneConversationLog: dst already non-empty — refusing to overwrite\",\n );\n return { copied: false, entries: 0 };\n }\n const entries = readConversation(userId, srcTopic);\n if (entries.length === 0) {\n return { copied: false, entries: 0 };\n }\n const body = `${entries.map((e) => JSON.stringify(e)).join(\"\\n\")}\\n`;\n mkdirSync(dirname(dstPath), { recursive: true });\n writeFileSync(dstPath, body);\n return { copied: true, entries: entries.length };\n}\n\n/**\n * Walk the unified log backwards and return the most recent SDK-emitted\n * session id for the given agent — or null if that agent has never run on\n * this topic.\n *\n * Used by `set_agent` to round-trip the SAME native rollout file across\n * agent switches: when the user does claude → codex → claude, we reuse\n * the original claude sessionId so the synthetic rollout lands at the\n * same path the SDK already manages, preserving prompt-cache continuity\n * and avoiding orphan `~/.claude/projects/<dir>/<id>.jsonl` files.\n *\n * Behavior:\n * - First-ever switch to an agent (no prior session events) → null,\n * caller falls back to a fresh randomUUID/uuidv7. Same as before.\n * - Roundtrip switch (prior session exists) → that sessionId.\n *\n * `session` events are emitted on every turn by claude-/codex-provider and\n * captured into the unified log by runAgent's append wrapper. The most\n * recent one for the target agent is by definition the current SDK-side\n * resume key for that agent.\n */\nexport function findLastSessionIdForAgent(\n entries: ConversationEntry[],\n agent: AgentKind,\n): string | null {\n for (let i = entries.length - 1; i >= 0; i--) {\n const entry = entries[i];\n if (entry.agent !== agent) continue;\n if (entry.event.type === \"session\") {\n return entry.event.sessionId;\n }\n }\n return null;\n}\n",
20
- "/**\n * Common context carried through the attachment/prompt-build pipeline.\n * Used by buildPromptFromMessage and related helpers.\n */\nexport interface SessionContext {\n userId: number;\n topicName?: string;\n userDir?: string;\n sessionType?: \"dm\" | \"forum\" | \"ephemeral\" | \"manager\" | \"cron\";\n}\n\nexport interface TokenUsage {\n /** Aggregate billable input across every model call made during this turn. */\n inputTokens: number;\n outputTokens: number;\n cacheCreationInputTokens?: number;\n cacheReadInputTokens?: number;\n /** Provider-reported query cost when available. */\n costUsd?: number;\n /** Tokens occupied by the latest model call, not aggregate turn spend. */\n contextTokens?: number;\n /** Provider-reported context window for the latest model call. */\n contextWindow?: number;\n}\n\n/** Agent identifier — one of the supported AI provider backends. */\nexport type AgentKind = \"maestro\" | \"claude\" | \"codex\";\n\nexport const SUPPORTED_AGENTS: readonly AgentKind[] = [\"maestro\", \"claude\", \"codex\"] as const;\n\nexport function isAgentKind(value: unknown): value is AgentKind {\n return typeof value === \"string\" && (SUPPORTED_AGENTS as readonly string[]).includes(value);\n}\n\n/**\n * Per-agent supported reasoning efforts. Single source of truth for both the\n * `EffortLevel` type and each registry's `validEfforts` runtime list — the\n * registries import these directly so adding a value in one place\n * propagates to validation, footer rendering, and zod enums.\n *\n * Claude SDK rejects 'minimal'; Codex SDK rejects 'max'. The two sets\n * intersect on low/medium/high/xhigh. Maestro (TS port) currently piggybacks\n * on the Anthropic provider, so its efforts mirror the Claude set; this can\n * narrow per-provider once Phase 5 lands.\n *\n * 'minimal' removed from codex: Codex API rejects it when default tools\n * (image_gen, web_search) are active, making agent sessions unusable.\n */\nexport const CLAUDE_EFFORT_VALUES = [\"low\", \"medium\", \"high\", \"xhigh\", \"max\"] as const;\nexport const CODEX_EFFORT_VALUES = [\"low\", \"medium\", \"high\", \"xhigh\", \"max\"] as const;\nexport const MAESTRO_EFFORT_VALUES = [\"low\", \"medium\", \"high\", \"xhigh\", \"max\"] as const;\n\nexport type EffortLevel =\n | (typeof CLAUDE_EFFORT_VALUES)[number]\n | (typeof CODEX_EFFORT_VALUES)[number]\n | (typeof MAESTRO_EFFORT_VALUES)[number];\n\n/**\n * Runtime iteration list (used by zod enums and any callers that need to\n * loop over every accepted value). Manually ordered for readability; the\n * `satisfies` check fails the build if an entry here isn't covered by the\n * per-agent unions above.\n */\nexport const EFFORT_VALUES = [\n \"low\",\n \"medium\",\n \"high\",\n \"xhigh\",\n \"max\",\n] as const satisfies readonly EffortLevel[];\n\n/**\n * Normalized events yielded by any agent provider (claudeProvider, codexProvider).\n * The handler/event-processor consumes these without caring which backend produced them.\n *\n * `user_message` is the lone \"into-the-log\" variant — no provider yields it.\n * The query handler writes it directly to the conversation log right before\n * `runAgent()` starts, so cross-agent rollout reconstruction can pair every\n * assistant turn with the user prompt that triggered it. Consumers that only\n * react to provider output (e.g. processAgentEvent) can safely ignore it.\n */\n/**\n * Wire-safe projection of one task, carried by the `tasks` UnifiedEvent.\n *\n * This is also the on-disk shape of Otium's shared task store, so claude,\n * codex, and maestro render the same live panel from the same source of truth.\n */\nexport interface TaskSnapshot {\n id: string;\n subject: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n /** Task ids this one is blocked by; omitted when empty. */\n blockedBy?: string[];\n /** Present-continuous label for spinners, when set. */\n activeForm?: string;\n /** Owner / agent name for multi-agent runs, when set. */\n owner?: string;\n}\n\nexport type UnifiedEvent =\n | { type: \"user_message\"; content: string }\n | { type: \"session\"; sessionId: string }\n | {\n type: \"tool_use\";\n name: string;\n input: Record<string, unknown>;\n /** Provider-assigned id so the client can match tool_use→tool_result pairs. */\n toolUseId?: string;\n }\n | { type: \"tool_progress\"; toolName: string; elapsed: number }\n | { type: \"tool_use_summary\"; summary: string }\n // Provider reasoning/thinking summary text (Codex `reasoning` items; Claude\n // extended-thinking). Surfaced so background runs (cron/archiver) show the\n // agent's thought process, not just tool calls.\n | { type: \"reasoning\"; content: string }\n // Full task-list snapshot (replace, not delta) from Otium's shared task\n // store. Provider-native task/todo stores are not authoritative.\n | { type: \"tasks\"; tasks: TaskSnapshot[] }\n | {\n type: \"tool_result\";\n toolUseId: string;\n content: string;\n metadata?: {\n truncatedForModel: boolean;\n originalBytes: number;\n returnedBytes: number;\n omittedBytes?: number;\n outputPath?: string;\n };\n }\n | { type: \"text_delta\"; content: string }\n | { type: \"text\"; content: string }\n | { type: \"result\"; content: string; stopReason: string; usage?: TokenUsage }\n | { type: \"file\"; path: string; source: string; origin: \"tag\" | \"extension\" }\n | {\n type: \"error\";\n content: string;\n usage?: TokenUsage;\n code?: \"budget_exceeded\";\n }\n | { type: \"status\"; content: string };\n\nexport interface AgentInputAttachment {\n id: string;\n type: \"image\" | \"file\" | \"audio\";\n filename: string;\n mimeType: string;\n sizeBytes: number;\n path: string;\n}\n\n/** Worker-side runtime tools proxy user-facing state back to the canonical\n * hub topic identified here. */\nexport interface PeerRuntimeBridgeContext {\n hubCellId: string;\n hostTopicId: string;\n hostQueryId: string;\n canSpawnSubagents: boolean;\n}\n\nexport interface AgentQueryOptions {\n agent: AgentKind;\n prompt: string;\n attachments?: AgentInputAttachment[];\n sessionId?: string | null;\n cwd: string;\n systemPrompt: string;\n userId?: string;\n session?: string;\n playwrightPort?: number;\n playwrightCapability?: string;\n bgBashPort?: number;\n sessionType?: \"dm\" | \"forum\" | \"ephemeral\" | \"manager\" | \"cron\";\n /** API topic id (REST/WS world). Carries per-query topic context for MCP servers. */\n topicId?: string;\n /** API query id for the currently running turn. Used by runtime MCP tools. */\n queryId?: string;\n /** Optional wiki-memory topic id. Derived topics use their root origin here\n * while other per-topic MCP servers keep `topicId` bound to the live room. */\n wikiTopicId?: string;\n /** Whether self-config MCP may enqueue an automatic continue turn after set_* changes. */\n autoContinue?: boolean;\n /** Expose Otium-only visual panel tools for this turn. Default-deny. */\n visualTools?: boolean;\n abortController?: AbortController;\n model?: string;\n /** Provider-side hard budget when the selected SDK supports one. */\n maxBudgetUsd?: number;\n depth?: number;\n agents?: Record<\n string,\n {\n description: string;\n prompt: string;\n model?: string;\n tools?: string[];\n maxTurns?: number;\n effort?: EffortLevel | number;\n }\n >;\n effort?: EffortLevel;\n /**\n * Per-API-call `max_tokens` ceiling on the assistant's output. Wired\n * through to the underlying provider request body for every agent\n * (claude/codex/maestro). Omit to inherit each provider SDK's per-model\n * default — for maestro that's the v0.1.21+ `getNativeMaxOutputTokens`\n * catalog (deepseek-pro=64K, deepseek-flash=32K).\n *\n * Pass an explicit number when a specific topic / surface needs a tighter\n * latency cap or a higher ceiling for long-form generation (legal\n * report writing, multi-K Write/Edit file bodies). Pre-0.1.21 maestro\n * builds silently clamped at 4096 and truncated outputs mid-string;\n * setting this field is now the supported way to lift that ceiling.\n */\n maxTokens?: number;\n /**\n * v0.1.22+: Claude-Code-style deferred tool catalog + `ToolSearch` built-in.\n *\n * Wired straight through to `maestro-agent-sdk`'s\n * `AgentQueryOptions.enableToolSearch`. When `true`, the maestro provider\n * registers every MCP tool as deferred — schemas stay off the wire until\n * the model promotes them via `ToolSearch(\"select:Name1,Name2\")` or\n * `ToolSearch(\"keyword\")`. Active set persists across resume.\n *\n * Otium's maestro provider supplies `true` when the caller leaves this\n * option unset, because most forum turns carry enough MCP surface for the\n * reminder-token savings to outweigh the first-use `ToolSearch` round-trip.\n * Callers can still pass `false` per call when a narrow surface or\n * latency-sensitive workflow is better served by eager MCP schemas.\n *\n * No-op for claude / codex agents — they have their own deferred-tool\n * machinery owned by their respective SDKs.\n */\n enableToolSearch?: boolean;\n /**\n * Claude-Code-compatible exact tool denylist. Maestro v0.1.42+ hides these\n * tools from provider schemas / ToolSearch and blocks dispatch if a stale\n * call still arrives. Claude maps this to its SDK option. Codex does not\n * support this name-based list; its provider-native multi-agent tool family\n * is disabled separately through the Codex feature config.\n */\n disallowedTools?: readonly string[];\n mcpEnabled?: string[] | null;\n peerBridge?: PeerRuntimeBridgeContext;\n mcpExtra?: Record<string, unknown>;\n /**\n * true for silent fork runs generating ask_session replies — restricts session-comm\n * outbound tools (ask/tell/abort) so the forked session can only produce text\n */\n silent?: boolean;\n}\n\n/** State file written to data/users/{userId}/active-queries/{topic}.json while a query is running. */\nexport interface QueryState {\n task?: string; // first 100 chars of prompt, newlines normalized\n since: string; // ISO timestamp\n}\n",
20
+ "/**\n * Common context carried through the attachment/prompt-build pipeline.\n * Used by buildPromptFromMessage and related helpers.\n */\nexport interface SessionContext {\n userId: number;\n topicName?: string;\n userDir?: string;\n sessionType?: \"dm\" | \"forum\" | \"ephemeral\" | \"manager\" | \"cron\";\n}\n\nexport interface TokenUsage {\n /** Aggregate billable input across every model call made during this turn. */\n inputTokens: number;\n outputTokens: number;\n cacheCreationInputTokens?: number;\n cacheReadInputTokens?: number;\n /** Provider-reported query cost when available. */\n costUsd?: number;\n /** Tokens occupied by the latest model call, not aggregate turn spend. */\n contextTokens?: number;\n /** Provider-reported context window for the latest model call. */\n contextWindow?: number;\n}\n\n/** Agent identifier — one of the supported AI provider backends. */\nexport type AgentKind = \"maestro\" | \"claude\" | \"codex\";\n\nexport const SUPPORTED_AGENTS: readonly AgentKind[] = [\"maestro\", \"claude\", \"codex\"] as const;\n\nexport function isAgentKind(value: unknown): value is AgentKind {\n return typeof value === \"string\" && (SUPPORTED_AGENTS as readonly string[]).includes(value);\n}\n\n/**\n * Per-agent supported reasoning efforts. Single source of truth for both the\n * `EffortLevel` type and each registry's `validEfforts` runtime list — the\n * registries import these directly so adding a value in one place\n * propagates to validation, footer rendering, and zod enums.\n *\n * Claude SDK rejects 'minimal'; Codex SDK rejects 'max'. The two sets\n * intersect on low/medium/high/xhigh. Maestro (TS port) currently piggybacks\n * on the Anthropic provider, so its efforts mirror the Claude set; this can\n * narrow per-provider once Phase 5 lands.\n *\n * 'minimal' removed from codex: Codex API rejects it when default tools\n * (image_gen, web_search) are active, making agent sessions unusable.\n */\nexport const CLAUDE_EFFORT_VALUES = [\"low\", \"medium\", \"high\", \"xhigh\", \"max\"] as const;\nexport const CODEX_EFFORT_VALUES = [\"low\", \"medium\", \"high\", \"xhigh\", \"max\"] as const;\nexport const MAESTRO_EFFORT_VALUES = [\"low\", \"medium\", \"high\", \"xhigh\", \"max\"] as const;\n\nexport type EffortLevel =\n | (typeof CLAUDE_EFFORT_VALUES)[number]\n | (typeof CODEX_EFFORT_VALUES)[number]\n | (typeof MAESTRO_EFFORT_VALUES)[number];\n\n/**\n * Runtime iteration list (used by zod enums and any callers that need to\n * loop over every accepted value). Manually ordered for readability; the\n * `satisfies` check fails the build if an entry here isn't covered by the\n * per-agent unions above.\n */\nexport const EFFORT_VALUES = [\n \"low\",\n \"medium\",\n \"high\",\n \"xhigh\",\n \"max\",\n] as const satisfies readonly EffortLevel[];\n\n/**\n * Normalized events yielded by any agent provider (claudeProvider, codexProvider).\n * The handler/event-processor consumes these without caring which backend produced them.\n *\n * `user_message` is the lone \"into-the-log\" variant — no provider yields it.\n * The query handler writes it directly to the conversation log right before\n * `runAgent()` starts, so cross-agent rollout reconstruction can pair every\n * assistant turn with the user prompt that triggered it. Consumers that only\n * react to provider output (e.g. processAgentEvent) can safely ignore it.\n */\n/**\n * Wire-safe projection of one task, carried by the `tasks` UnifiedEvent.\n *\n * This is also the on-disk shape of Otium's shared task store, so claude,\n * codex, and maestro render the same live panel from the same source of truth.\n */\nexport interface TaskSnapshot {\n id: string;\n subject: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n /** Task ids this one is blocked by; omitted when empty. */\n blockedBy?: string[];\n /** Present-continuous label for spinners, when set. */\n activeForm?: string;\n /** Owner / agent name for multi-agent runs, when set. */\n owner?: string;\n}\n\nexport type UnifiedEvent =\n | { type: \"user_message\"; content: string }\n | { type: \"session\"; sessionId: string }\n | {\n type: \"tool_use\";\n name: string;\n input: Record<string, unknown>;\n /** Provider-assigned id so the client can match tool_use→tool_result pairs. */\n toolUseId?: string;\n }\n | { type: \"tool_progress\"; toolName: string; elapsed: number }\n | { type: \"tool_use_summary\"; summary: string }\n // Provider reasoning/thinking summary text (Codex `reasoning` items; Claude\n // extended-thinking). Surfaced so background runs (cron/archiver) show the\n // agent's thought process, not just tool calls.\n | { type: \"reasoning\"; content: string }\n // Full task-list snapshot (replace, not delta) from Otium's shared task\n // store. Provider-native task/todo stores are not authoritative.\n | { type: \"tasks\"; tasks: TaskSnapshot[] }\n | {\n type: \"tool_result\";\n toolUseId: string;\n content: string;\n metadata?: {\n truncatedForModel: boolean;\n originalBytes: number;\n returnedBytes: number;\n omittedBytes?: number;\n outputPath?: string;\n };\n }\n | { type: \"text_delta\"; content: string }\n | { type: \"text\"; content: string }\n | { type: \"result\"; content: string; stopReason: string; usage?: TokenUsage }\n | { type: \"file\"; path: string; source: string; origin: \"tag\" | \"extension\" }\n | {\n type: \"error\";\n content: string;\n usage?: TokenUsage;\n code?: \"budget_exceeded\";\n }\n | { type: \"status\"; content: string };\n\nexport interface AgentInputAttachment {\n id: string;\n type: \"image\" | \"file\" | \"audio\";\n filename: string;\n mimeType: string;\n sizeBytes: number;\n path: string;\n}\n\n/** Worker-side runtime tools proxy user-facing state back to the canonical\n * hub topic identified here. */\nexport interface PeerRuntimeBridgeContext {\n hubCellId: string;\n hostTopicId: string;\n hostQueryId: string;\n canSpawnSubagents: boolean;\n}\n\nexport interface AgentQueryOptions {\n agent: AgentKind;\n prompt: string;\n attachments?: AgentInputAttachment[];\n sessionId?: string | null;\n cwd: string;\n systemPrompt: string;\n userId?: string;\n session?: string;\n playwrightPort?: number;\n playwrightCapability?: string;\n bgBashPort?: number;\n sessionType?: \"dm\" | \"forum\" | \"ephemeral\" | \"manager\" | \"cron\";\n /** API topic id (REST/WS world). Carries per-query topic context for MCP servers. */\n topicId?: string;\n /** API query id for the currently running turn. Used by runtime MCP tools. */\n queryId?: string;\n /** Optional wiki-memory topic id. Derived topics use their root origin here\n * while other per-topic MCP servers keep `topicId` bound to the live room. */\n wikiTopicId?: string;\n /** Whether self-config MCP may enqueue an automatic continue turn after set_* changes. */\n autoContinue?: boolean;\n /** Expose Otium-only visual panel tools for this turn. Default-deny. */\n visualTools?: boolean;\n /** Expose adapter-backed file-delivery tools for this turn. Default-deny. */\n fileDeliveryTools?: boolean;\n abortController?: AbortController;\n model?: string;\n /** Provider-side hard budget when the selected SDK supports one. */\n maxBudgetUsd?: number;\n depth?: number;\n agents?: Record<\n string,\n {\n description: string;\n prompt: string;\n model?: string;\n tools?: string[];\n maxTurns?: number;\n effort?: EffortLevel | number;\n }\n >;\n effort?: EffortLevel;\n /**\n * Per-API-call `max_tokens` ceiling on the assistant's output. Wired\n * through to the underlying provider request body for every agent\n * (claude/codex/maestro). Omit to inherit each provider SDK's per-model\n * default — for maestro that's the v0.1.21+ `getNativeMaxOutputTokens`\n * catalog (deepseek-pro=64K, deepseek-flash=32K).\n *\n * Pass an explicit number when a specific topic / surface needs a tighter\n * latency cap or a higher ceiling for long-form generation (legal\n * report writing, multi-K Write/Edit file bodies). Pre-0.1.21 maestro\n * builds silently clamped at 4096 and truncated outputs mid-string;\n * setting this field is now the supported way to lift that ceiling.\n */\n maxTokens?: number;\n /**\n * v0.1.22+: Claude-Code-style deferred tool catalog + `ToolSearch` built-in.\n *\n * Wired straight through to `maestro-agent-sdk`'s\n * `AgentQueryOptions.enableToolSearch`. When `true`, the maestro provider\n * registers every MCP tool as deferred — schemas stay off the wire until\n * the model promotes them via `ToolSearch(\"select:Name1,Name2\")` or\n * `ToolSearch(\"keyword\")`. Active set persists across resume.\n *\n * Otium's maestro provider supplies `true` when the caller leaves this\n * option unset, because most forum turns carry enough MCP surface for the\n * reminder-token savings to outweigh the first-use `ToolSearch` round-trip.\n * Callers can still pass `false` per call when a narrow surface or\n * latency-sensitive workflow is better served by eager MCP schemas.\n *\n * No-op for claude / codex agents — they have their own deferred-tool\n * machinery owned by their respective SDKs.\n */\n enableToolSearch?: boolean;\n /**\n * Claude-Code-compatible exact tool denylist. Maestro v0.1.42+ hides these\n * tools from provider schemas / ToolSearch and blocks dispatch if a stale\n * call still arrives. Claude maps this to its SDK option. Codex does not\n * support this name-based list; its provider-native multi-agent tool family\n * is disabled separately through the Codex feature config.\n */\n disallowedTools?: readonly string[];\n mcpEnabled?: string[] | null;\n peerBridge?: PeerRuntimeBridgeContext;\n mcpExtra?: Record<string, unknown>;\n /**\n * true for silent fork runs generating ask_session replies — restricts session-comm\n * outbound tools (ask/tell/abort) so the forked session can only produce text\n */\n silent?: boolean;\n}\n\n/** State file written to data/users/{userId}/active-queries/{topic}.json while a query is running. */\nexport interface QueryState {\n task?: string; // first 100 chars of prompt, newlines normalized\n since: string; // ISO timestamp\n}\n",
21
21
  "/**\n * Stringify an unknown thrown value for logging or user-visible messages.\n * Default fallback is `String(e)`. Pass an explicit fallback (e.g. \"unknown\")\n * to override what non-Error throws turn into.\n */\nexport function errMsg(e: unknown, fallback?: string): string {\n if (e instanceof Error) return e.message;\n return fallback ?? String(e);\n}\n",
22
22
  "import { errMsg } from \"#platform/error\";\nimport { logger } from \"#platform/logger\";\nimport { db } from \"#storage/forum-db\";\nimport { closeStorageDatabase, registerStorageSchemaInitializer } from \"#storage/storage-host\";\nimport { type AgentKind, isAgentKind } from \"#types\";\n\nexport { db };\n\nexport interface ForumTopicInfo {\n messageThreadId: number;\n sessionId: string;\n createdAt: string;\n name: string;\n description?: string;\n forkOrigin?: string;\n agent: AgentKind;\n}\n\nexport interface UserForumConfig {\n communicateThreadId?: number;\n dmSessionId?: string;\n topics: { [topicName: string]: ForumTopicInfo };\n}\n\nexport type TopicRow = {\n user_id: string;\n name: string;\n message_thread_id: number;\n session_id: string | null;\n created_at: string;\n description: string | null;\n fork_origin: string | null;\n agent: string | null;\n};\n\nexport type UserRow = {\n id: string;\n dm_session_id: string | null;\n communicate_thread_id: number | null;\n};\n\nfunction initializeForumSchema(): void {\n db.exec(`\n CREATE TABLE IF NOT EXISTS users (\n id TEXT PRIMARY KEY,\n dm_session_id TEXT,\n communicate_thread_id INTEGER\n );\n\n CREATE TABLE IF NOT EXISTS topics (\n user_id TEXT NOT NULL REFERENCES users(id),\n name TEXT NOT NULL,\n message_thread_id INTEGER NOT NULL,\n session_id TEXT,\n created_at TEXT NOT NULL,\n description TEXT,\n fork_origin TEXT,\n agent TEXT NOT NULL DEFAULT 'claude',\n mcp_enabled TEXT,\n mcp_extra TEXT,\n last_shown_model TEXT,\n last_shown_effort TEXT,\n last_shown_agent TEXT,\n PRIMARY KEY (user_id, name),\n UNIQUE (user_id, message_thread_id)\n );\n\n CREATE INDEX IF NOT EXISTS idx_topics_lookup ON topics(user_id, message_thread_id);\n`);\n\n function tryMigrate(sql: string, expectedMsg?: string): void {\n try {\n db.exec(sql);\n } catch (e) {\n if (expectedMsg && errMsg(e).includes(expectedMsg)) return;\n logger.error({ err: e, sql }, \"DB migration failed\");\n throw e;\n }\n }\n\n type SqlValue = string | number | bigint | boolean | null | Uint8Array;\n\n function sqlValue(value: unknown): SqlValue {\n if (value === undefined || value === null) return null;\n if (\n typeof value === \"string\" ||\n typeof value === \"number\" ||\n typeof value === \"bigint\" ||\n typeof value === \"boolean\"\n ) {\n return value;\n }\n if (value instanceof Uint8Array) return value;\n return String(value);\n }\n\n function columnExists(table: string, column: string): boolean {\n return db\n .query<{ name: string }, []>(`PRAGMA table_info(${table})`)\n .all()\n .some((c) => c.name === column);\n }\n\n function tableSql(table: string): string | null {\n return (\n db\n .query<{ sql: string }, string>(\n \"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?\",\n )\n .get(table)?.sql ?? null\n );\n }\n\n function dropColumnIfExists(table: string, column: string): void {\n if (columnExists(table, column)) {\n tryMigrate(`ALTER TABLE ${table} DROP COLUMN ${column}`);\n }\n }\n\n function topicTableNeedsRebuild(): boolean {\n const sql = tableSql(\"topics\");\n if (!sql) return false;\n const cols = db.query<{ name: string }, []>(\"PRAGMA table_info(topics)\").all();\n const names = new Set(cols.map((c) => c.name));\n return (\n names.has(\"forum_group_id\") ||\n names.has(\"system_prompt_extra\") ||\n names.has(\"model\") ||\n names.has(\"effort\") ||\n names.has(\"model_pinned\") ||\n names.has(\"effort_pinned\") ||\n names.has(\"memory_files\") ||\n names.has(\"memory_summary\") ||\n names.has(\"privacy_mode\") ||\n names.has(\"advisor_enabled\") ||\n names.has(\"agent_settings\") ||\n !/PRIMARY KEY \\(\\s*user_id\\s*,\\s*name\\s*\\)/i.test(sql)\n );\n }\n\n function normalizeStoredAgent(value: unknown): AgentKind {\n if (isAgentKind(value)) return value;\n if (value === \"hermes\" || value === \"alpha\") return \"maestro\";\n return \"claude\";\n }\n\n function rebuildTopicsTableIfNeeded(): void {\n if (!topicTableNeedsRebuild()) return;\n\n const cols = db.query<{ name: string }, []>(\"PRAGMA table_info(topics)\").all();\n const has = new Set(cols.map((c) => c.name));\n const pick = (row: Record<string, unknown>, column: string, fallback: unknown = null) =>\n has.has(column) ? (row[column] ?? fallback) : fallback;\n const descriptionFor = (row: Record<string, unknown>) =>\n pick(row, \"description\", pick(row, \"system_prompt_extra\", null));\n\n const rows = db\n .query<Record<string, unknown>, []>(\"SELECT rowid AS __rowid, * FROM topics\")\n .all()\n .sort((a, b) => {\n const aCreated = String(pick(a, \"created_at\", \"\"));\n const bCreated = String(pick(b, \"created_at\", \"\"));\n const byDate = bCreated.localeCompare(aCreated);\n if (byDate !== 0) return byDate;\n return Number(b.__rowid ?? 0) - Number(a.__rowid ?? 0);\n });\n\n const previousForeignKeys = db\n .query<{ foreign_keys: number }, []>(\"PRAGMA foreign_keys\")\n .get()?.foreign_keys;\n db.exec(\"PRAGMA foreign_keys = OFF\");\n try {\n db.transaction(() => {\n db.exec(\"DROP TABLE IF EXISTS topics_new\");\n db.exec(`\n CREATE TABLE topics_new (\n user_id TEXT NOT NULL REFERENCES users(id),\n name TEXT NOT NULL,\n message_thread_id INTEGER NOT NULL,\n session_id TEXT,\n created_at TEXT NOT NULL,\n description TEXT,\n fork_origin TEXT,\n agent TEXT NOT NULL DEFAULT 'claude',\n mcp_enabled TEXT,\n mcp_extra TEXT,\n last_shown_model TEXT,\n last_shown_effort TEXT,\n last_shown_agent TEXT,\n PRIMARY KEY (user_id, name),\n UNIQUE (user_id, message_thread_id)\n )\n `);\n\n const insert = db.query(`\n INSERT OR IGNORE INTO topics_new\n (user_id, name, message_thread_id, session_id, created_at, description,\n fork_origin, agent, mcp_enabled, mcp_extra, last_shown_model,\n last_shown_effort, last_shown_agent)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n `);\n let inserted = 0;\n const fallbackCreatedAt = new Date().toISOString();\n for (const row of rows) {\n const result = insert.run(\n String(pick(row, \"user_id\", \"\")),\n String(pick(row, \"name\", \"\")),\n Number(pick(row, \"message_thread_id\", 0)),\n sqlValue(pick(row, \"session_id\", null)),\n String(pick(row, \"created_at\", fallbackCreatedAt)),\n sqlValue(descriptionFor(row)),\n sqlValue(pick(row, \"fork_origin\", null)),\n normalizeStoredAgent(pick(row, \"agent\", \"claude\")),\n sqlValue(pick(row, \"mcp_enabled\", null)),\n sqlValue(pick(row, \"mcp_extra\", null)),\n sqlValue(pick(row, \"last_shown_model\", null)),\n sqlValue(pick(row, \"last_shown_effort\", null)),\n sqlValue(pick(row, \"last_shown_agent\", null)),\n );\n if (Number(result.changes ?? 0) > 0) inserted += 1;\n }\n\n db.exec(\"DROP TABLE topics\");\n db.exec(\"ALTER TABLE topics_new RENAME TO topics\");\n db.exec(\n \"CREATE INDEX IF NOT EXISTS idx_topics_lookup ON topics(user_id, message_thread_id)\",\n );\n logger.info(\n { migrated: inserted, skippedConflicts: rows.length - inserted },\n \"topics schema migrated to current user-scoped schema\",\n );\n })();\n } finally {\n db.exec(`PRAGMA foreign_keys = ${previousForeignKeys ? \"ON\" : \"OFF\"}`);\n }\n }\n\n rebuildTopicsTableIfNeeded();\n\n tryMigrate(\"ALTER TABLE topics ADD COLUMN fork_origin TEXT\", \"duplicate column\");\n tryMigrate(\n \"ALTER TABLE topics ADD COLUMN agent TEXT NOT NULL DEFAULT 'claude'\",\n \"duplicate column\",\n );\n tryMigrate(\"ALTER TABLE topics ADD COLUMN mcp_enabled TEXT\", \"duplicate column\");\n tryMigrate(\"ALTER TABLE topics ADD COLUMN mcp_extra TEXT\", \"duplicate column\");\n tryMigrate(\"ALTER TABLE topics ADD COLUMN last_shown_model TEXT\", \"duplicate column\");\n tryMigrate(\"ALTER TABLE topics ADD COLUMN last_shown_effort TEXT\", \"duplicate column\");\n tryMigrate(\"ALTER TABLE topics ADD COLUMN last_shown_agent TEXT\", \"duplicate column\");\n\n for (const column of [\"privacy_mode\", \"advisor_enabled\", \"agent_settings\"]) {\n dropColumnIfExists(\"topics\", column);\n }\n dropColumnIfExists(\"users\", \"manager_session_id\");\n\n tryMigrate(\"DROP INDEX idx_topics_lookup\", \"no such index\");\n tryMigrate(\"CREATE INDEX IF NOT EXISTS idx_topics_lookup ON topics(user_id, message_thread_id)\");\n\n function assertColumn(table: string, column: string): void {\n if (!columnExists(table, column)) {\n throw new Error(`Schema migration failed: ${table}.${column} is missing`);\n }\n }\n\n for (const column of [\"id\", \"dm_session_id\", \"communicate_thread_id\"]) {\n assertColumn(\"users\", column);\n }\n for (const column of [\n \"user_id\",\n \"name\",\n \"message_thread_id\",\n \"session_id\",\n \"created_at\",\n \"description\",\n \"fork_origin\",\n \"agent\",\n \"mcp_enabled\",\n \"mcp_extra\",\n \"last_shown_model\",\n \"last_shown_effort\",\n \"last_shown_agent\",\n ]) {\n assertColumn(\"topics\", column);\n }\n}\n\nregisterStorageSchemaInitializer(initializeForumSchema, 10);\n\nexport function rowToTopic(row: TopicRow): ForumTopicInfo {\n if (!isAgentKind(row.agent)) throw new Error(`Invalid agent in DB: ${row.agent}`);\n const agent: AgentKind = row.agent;\n return {\n messageThreadId: row.message_thread_id,\n sessionId: row.session_id ?? \"\",\n createdAt: row.created_at,\n name: row.name,\n agent,\n ...(row.description && { description: row.description }),\n ...(row.fork_origin && { forkOrigin: row.fork_origin }),\n };\n}\n\nexport function flushSessionCache() {\n closeStorageDatabase();\n}\n\nexport { logger };\n",
23
23
  "import { type AgentKind, isAgentKind } from \"#types\";\nimport { db } from \"./schema\";\n\nexport function getTopicAgent(userId: number, topicName: string): AgentKind {\n const row = db\n .query<{ agent: string | null }, [string, string]>(\n \"SELECT agent FROM topics WHERE user_id = ? AND name = ?\",\n )\n .get(String(userId), topicName);\n const value = row?.agent;\n if (!isAgentKind(value)) throw new Error(`Invalid agent in DB: ${value}`);\n return value;\n}\n\nexport function setTopicAgent(userId: number, topicName: string, agent: AgentKind): boolean {\n const result = db\n .query(\"UPDATE topics SET agent = ? WHERE user_id = ? AND name = ?\")\n .run(agent, String(userId), topicName);\n return result.changes > 0;\n}\n",