negotium 0.1.7 → 0.1.9

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 (270) hide show
  1. package/README.md +26 -2
  2. package/dist/canonical-mcp-bridge.js +14 -0
  3. package/dist/canonical-mcp-bridge.js.map +9 -0
  4. package/dist/chunk-10ct8814.js +429 -0
  5. package/dist/chunk-10ct8814.js.map +11 -0
  6. package/dist/chunk-18fzftjf.js +214 -0
  7. package/dist/chunk-18fzftjf.js.map +10 -0
  8. package/dist/chunk-1k7t7bnx.js +97 -0
  9. package/dist/chunk-1k7t7bnx.js.map +10 -0
  10. package/dist/chunk-1msje2zf.js +23 -0
  11. package/dist/chunk-1msje2zf.js.map +10 -0
  12. package/dist/chunk-2dgkah1z.js +706 -0
  13. package/dist/chunk-2dgkah1z.js.map +14 -0
  14. package/dist/chunk-3t4dtm26.js +61 -0
  15. package/dist/chunk-3t4dtm26.js.map +10 -0
  16. package/dist/chunk-3vn2vnj1.js +51 -0
  17. package/dist/chunk-3vn2vnj1.js.map +10 -0
  18. package/dist/chunk-6hknphtc.js +2054 -0
  19. package/dist/chunk-6hknphtc.js.map +29 -0
  20. package/dist/chunk-6tfzpcvk.js +2149 -0
  21. package/dist/chunk-6tfzpcvk.js.map +15 -0
  22. package/dist/chunk-8g0c4as0.js +23 -0
  23. package/dist/chunk-8g0c4as0.js.map +10 -0
  24. package/dist/chunk-9spx3h9n.js +279 -0
  25. package/dist/chunk-9spx3h9n.js.map +10 -0
  26. package/dist/chunk-c500xe9z.js +33 -0
  27. package/dist/chunk-c500xe9z.js.map +10 -0
  28. package/dist/chunk-e9b2xpff.js +147 -0
  29. package/dist/chunk-e9b2xpff.js.map +10 -0
  30. package/dist/chunk-edb07sjp.js +567 -0
  31. package/dist/chunk-edb07sjp.js.map +14 -0
  32. package/dist/chunk-eexqsee7.js +368 -0
  33. package/dist/chunk-eexqsee7.js.map +11 -0
  34. package/dist/chunk-efyxk97v.js +500 -0
  35. package/dist/chunk-efyxk97v.js.map +13 -0
  36. package/dist/chunk-epqxwkvh.js +3534 -0
  37. package/dist/chunk-epqxwkvh.js.map +20 -0
  38. package/dist/chunk-f1pzh3xh.js +115 -0
  39. package/dist/chunk-f1pzh3xh.js.map +10 -0
  40. package/dist/chunk-f5xgprg1.js +59 -0
  41. package/dist/chunk-f5xgprg1.js.map +10 -0
  42. package/dist/chunk-f97qybwk.js +584 -0
  43. package/dist/chunk-f97qybwk.js.map +11 -0
  44. package/dist/chunk-fvc7ncxd.js +252 -0
  45. package/dist/chunk-fvc7ncxd.js.map +10 -0
  46. package/dist/chunk-g7pvfj5e.js +574 -0
  47. package/dist/chunk-g7pvfj5e.js.map +14 -0
  48. package/dist/chunk-h8vmb9gf.js +315 -0
  49. package/dist/chunk-h8vmb9gf.js.map +10 -0
  50. package/dist/chunk-hgw7n8cm.js +43 -0
  51. package/dist/chunk-hgw7n8cm.js.map +10 -0
  52. package/dist/chunk-hsq7j9jk.js +126 -0
  53. package/dist/chunk-hsq7j9jk.js.map +10 -0
  54. package/dist/chunk-hwy3n31w.js +21 -0
  55. package/dist/chunk-hwy3n31w.js.map +9 -0
  56. package/dist/chunk-j0kjscb7.js +53 -0
  57. package/dist/chunk-j0kjscb7.js.map +10 -0
  58. package/dist/chunk-jzz91jwh.js +70 -0
  59. package/dist/chunk-jzz91jwh.js.map +10 -0
  60. package/dist/chunk-k9z47mab.js +247 -0
  61. package/dist/chunk-k9z47mab.js.map +12 -0
  62. package/dist/chunk-kbm35t7w.js +1331 -0
  63. package/dist/chunk-kbm35t7w.js.map +16 -0
  64. package/dist/chunk-m4veth0c.js +1649 -0
  65. package/dist/chunk-m4veth0c.js.map +17 -0
  66. package/dist/chunk-mhty3nna.js +150 -0
  67. package/dist/chunk-mhty3nna.js.map +11 -0
  68. package/dist/chunk-mmgh670x.js +248 -0
  69. package/dist/chunk-mmgh670x.js.map +11 -0
  70. package/dist/chunk-n0kk5xbq.js +2358 -0
  71. package/dist/chunk-n0kk5xbq.js.map +26 -0
  72. package/dist/chunk-nmv41qx9.js +2847 -0
  73. package/dist/chunk-nmv41qx9.js.map +23 -0
  74. package/dist/chunk-nqdp52aj.js +113 -0
  75. package/dist/chunk-nqdp52aj.js.map +12 -0
  76. package/dist/chunk-ns2m25dz.js +139 -0
  77. package/dist/chunk-ns2m25dz.js.map +10 -0
  78. package/dist/chunk-pkdmypdd.js +147 -0
  79. package/dist/chunk-pkdmypdd.js.map +11 -0
  80. package/dist/chunk-rmyfmrrr.js +1634 -0
  81. package/dist/chunk-rmyfmrrr.js.map +15 -0
  82. package/dist/chunk-sa874j5c.js +9 -0
  83. package/dist/chunk-sa874j5c.js.map +10 -0
  84. package/dist/chunk-sgsz2fpq.js +289 -0
  85. package/dist/chunk-sgsz2fpq.js.map +10 -0
  86. package/dist/chunk-sn51wnf1.js +3020 -0
  87. package/dist/chunk-sn51wnf1.js.map +32 -0
  88. package/dist/chunk-sqhr1a7e.js +587 -0
  89. package/dist/chunk-sqhr1a7e.js.map +11 -0
  90. package/dist/chunk-tmj5r2zv.js +2933 -0
  91. package/dist/chunk-tmj5r2zv.js.map +20 -0
  92. package/dist/chunk-vefj3880.js +56 -0
  93. package/dist/chunk-vefj3880.js.map +10 -0
  94. package/dist/chunk-vp2db42d.js +121 -0
  95. package/dist/chunk-vp2db42d.js.map +10 -0
  96. package/dist/chunk-wry79q55.js +108 -0
  97. package/dist/chunk-wry79q55.js.map +10 -0
  98. package/dist/chunk-x1arr7ze.js +63 -0
  99. package/dist/chunk-x1arr7ze.js.map +10 -0
  100. package/dist/chunk-z2xy5n2q.js +103 -0
  101. package/dist/chunk-z2xy5n2q.js.map +10 -0
  102. package/dist/hosted-agent.js +53 -0
  103. package/dist/hosted-agent.js.map +10 -0
  104. package/dist/main.js +132 -20
  105. package/dist/main.js.map +4 -4
  106. package/dist/runtime/scripts/faster-whisper-wrapper.py +87 -0
  107. package/dist/runtime/scripts/mcp-patchright-http.mjs +349 -0
  108. package/dist/runtime/src/agents/api-topic-agent-switch.ts +178 -0
  109. package/dist/runtime/src/agents/archiver.ts +396 -0
  110. package/dist/runtime/src/agents/auth-check.ts +64 -0
  111. package/dist/runtime/src/agents/claude-provider.ts +737 -0
  112. package/dist/runtime/src/agents/claude-registry.ts +115 -0
  113. package/dist/runtime/src/agents/codex-native-multi-agent.ts +177 -0
  114. package/dist/runtime/src/agents/codex-provider.ts +785 -0
  115. package/dist/runtime/src/agents/codex-registry.ts +115 -0
  116. package/dist/runtime/src/agents/codex-tree-kill.ts +365 -0
  117. package/dist/runtime/src/agents/contracts.ts +69 -0
  118. package/dist/runtime/src/agents/deep-map.ts +13 -0
  119. package/dist/runtime/src/agents/execution-host.ts +91 -0
  120. package/dist/runtime/src/agents/fixtures/claude-attachments.jsonl +2 -0
  121. package/dist/runtime/src/agents/fixtures/codex-shell.jsonl +5 -0
  122. package/dist/runtime/src/agents/fork.ts +65 -0
  123. package/dist/runtime/src/agents/hosted-agent.ts +49 -0
  124. package/dist/runtime/src/agents/idle-archiver.ts +128 -0
  125. package/dist/runtime/src/agents/index.ts +205 -0
  126. package/dist/runtime/src/agents/maestro-provider.ts +206 -0
  127. package/dist/runtime/src/agents/maestro-registry.ts +31 -0
  128. package/dist/runtime/src/agents/mcp-tools/ask-user.ts +280 -0
  129. package/dist/runtime/src/agents/mcp-tools/common.ts +21 -0
  130. package/dist/runtime/src/agents/mcp-tools/self-config.ts +220 -0
  131. package/dist/runtime/src/agents/mcp-tools/spawn-subagent.ts +525 -0
  132. package/dist/runtime/src/agents/mcp-tools/visuals.ts +88 -0
  133. package/dist/runtime/src/agents/model-catalog.ts +151 -0
  134. package/dist/runtime/src/agents/registry.ts +24 -0
  135. package/dist/runtime/src/agents/rollout/claude.ts +331 -0
  136. package/dist/runtime/src/agents/rollout/codex.ts +569 -0
  137. package/dist/runtime/src/agents/rollout/shared.ts +205 -0
  138. package/dist/runtime/src/agents/self-config-core.ts +415 -0
  139. package/dist/runtime/src/agents/task-events.ts +44 -0
  140. package/dist/runtime/src/agents/tool-format.ts +352 -0
  141. package/dist/runtime/src/agents/topic-agent-switch.ts +193 -0
  142. package/dist/runtime/src/agents/topic-cleanup.ts +231 -0
  143. package/dist/runtime/src/agents/vault-tool-policy.ts +33 -0
  144. package/dist/runtime/src/application/execute-external-user-turn.ts +23 -0
  145. package/dist/runtime/src/application/submit-user-message.ts +50 -0
  146. package/dist/runtime/src/application/switch-topic-effort.ts +48 -0
  147. package/dist/runtime/src/application/switch-topic-model.ts +79 -0
  148. package/dist/runtime/src/application/topic-service.ts +139 -0
  149. package/dist/runtime/src/application/vault-command.ts +170 -0
  150. package/dist/runtime/src/bus.ts +319 -0
  151. package/dist/runtime/src/index.ts +333 -0
  152. package/dist/runtime/src/mcp/agent-health-server.ts +309 -0
  153. package/dist/runtime/src/mcp/background-bash-server.ts +346 -0
  154. package/dist/runtime/src/mcp/canonical-bridge-config.ts +84 -0
  155. package/dist/runtime/src/mcp/canonical-proxy-server.ts +171 -0
  156. package/dist/runtime/src/mcp/dm-ipc.ts +60 -0
  157. package/dist/runtime/src/mcp/mcp-helpers.ts +40 -0
  158. package/dist/runtime/src/mcp/paddleocr-server.ts +105 -0
  159. package/dist/runtime/src/mcp/peer-bridge.ts +114 -0
  160. package/dist/runtime/src/mcp/runtime-spec.ts +137 -0
  161. package/dist/runtime/src/mcp/session-comm/bridge-ipc-config.ts +27 -0
  162. package/dist/runtime/src/mcp/session-comm/peer-forward.ts +165 -0
  163. package/dist/runtime/src/mcp/session-comm/runtime.ts +115 -0
  164. package/dist/runtime/src/mcp/session-comm/server.ts +649 -0
  165. package/dist/runtime/src/mcp/session-comm/topic-config.ts +131 -0
  166. package/dist/runtime/src/mcp/session-comm/topics.ts +229 -0
  167. package/dist/runtime/src/mcp/stdio-protect.ts +84 -0
  168. package/dist/runtime/src/mcp/system-health-server.ts +130 -0
  169. package/dist/runtime/src/mcp/task-server.ts +173 -0
  170. package/dist/runtime/src/mcp/token-stats-server.ts +98 -0
  171. package/dist/runtime/src/mcp/vault-http.ts +219 -0
  172. package/dist/runtime/src/mcp/vault-run.ts +153 -0
  173. package/dist/runtime/src/mcp/vault-server.ts +104 -0
  174. package/dist/runtime/src/mcp/wiki-server.ts +755 -0
  175. package/dist/runtime/src/media/file-events.ts +33 -0
  176. package/dist/runtime/src/media/text-extractor.ts +312 -0
  177. package/dist/runtime/src/media/video.ts +92 -0
  178. package/dist/runtime/src/outbox/file-ops.ts +210 -0
  179. package/dist/runtime/src/outbox/index.ts +8 -0
  180. package/dist/runtime/src/outbox/utils.ts +51 -0
  181. package/dist/runtime/src/platform/background-bash/manager.ts +279 -0
  182. package/dist/runtime/src/platform/config.ts +371 -0
  183. package/dist/runtime/src/platform/constants.ts +18 -0
  184. package/dist/runtime/src/platform/debug-users.ts +14 -0
  185. package/dist/runtime/src/platform/delay.ts +1 -0
  186. package/dist/runtime/src/platform/error.ts +9 -0
  187. package/dist/runtime/src/platform/file-utils.ts +16 -0
  188. package/dist/runtime/src/platform/jsonl.ts +202 -0
  189. package/dist/runtime/src/platform/lifecycle.ts +174 -0
  190. package/dist/runtime/src/platform/logger.ts +31 -0
  191. package/dist/runtime/src/platform/maestro-bootstrap-env.ts +3 -0
  192. package/dist/runtime/src/platform/mcp-config.ts +878 -0
  193. package/dist/runtime/src/platform/modules.ts +145 -0
  194. package/dist/runtime/src/platform/node-plugins.ts +46 -0
  195. package/dist/runtime/src/platform/playwright/manager.ts +1233 -0
  196. package/dist/runtime/src/prompts/agents/wiki-archiver.md +261 -0
  197. package/dist/runtime/src/prompts/builders.ts +368 -0
  198. package/dist/runtime/src/prompts/sessions/channel-system.md +48 -0
  199. package/dist/runtime/src/prompts/sessions/manager-system.md +37 -0
  200. package/dist/runtime/src/prompts/sessions/topic-system.md +47 -0
  201. package/dist/runtime/src/prompts/sessions/visual-design.md +86 -0
  202. package/dist/runtime/src/query/active-rooms.ts +534 -0
  203. package/dist/runtime/src/query/control.ts +313 -0
  204. package/dist/runtime/src/query/index.ts +24 -0
  205. package/dist/runtime/src/query/session-inbox-cleanup.ts +44 -0
  206. package/dist/runtime/src/query/session-inbox-path.ts +42 -0
  207. package/dist/runtime/src/query/state.ts +94 -0
  208. package/dist/runtime/src/query/types.ts +83 -0
  209. package/dist/runtime/src/runtime/ask-callbacks.ts +153 -0
  210. package/dist/runtime/src/runtime/attachments.ts +151 -0
  211. package/dist/runtime/src/runtime/background-sessions.ts +83 -0
  212. package/dist/runtime/src/runtime/channel-context.ts +107 -0
  213. package/dist/runtime/src/runtime/errors.ts +69 -0
  214. package/dist/runtime/src/runtime/event-heartbeat.ts +77 -0
  215. package/dist/runtime/src/runtime/file-hooks.ts +81 -0
  216. package/dist/runtime/src/runtime/footer.ts +23 -0
  217. package/dist/runtime/src/runtime/inbox.ts +946 -0
  218. package/dist/runtime/src/runtime/tasks.ts +56 -0
  219. package/dist/runtime/src/runtime/topic-config.ts +29 -0
  220. package/dist/runtime/src/runtime/turn-runner.ts +2298 -0
  221. package/dist/runtime/src/runtime/usage-alert.ts +84 -0
  222. package/dist/runtime/src/runtime/visual-html.ts +109 -0
  223. package/dist/runtime/src/runtime/visual-store.ts +430 -0
  224. package/dist/runtime/src/runtime/visuals.ts +171 -0
  225. package/dist/runtime/src/security/sanitize.ts +27 -0
  226. package/dist/runtime/src/security/sensitive-path.ts +34 -0
  227. package/dist/runtime/src/storage/activity-log.ts +152 -0
  228. package/dist/runtime/src/storage/api-messages.ts +617 -0
  229. package/dist/runtime/src/storage/api-topic-brief.ts +127 -0
  230. package/dist/runtime/src/storage/api-topic-config.ts +192 -0
  231. package/dist/runtime/src/storage/api-topics.ts +844 -0
  232. package/dist/runtime/src/storage/app-settings.ts +44 -0
  233. package/dist/runtime/src/storage/browser-profiles.ts +133 -0
  234. package/dist/runtime/src/storage/conversations.ts +268 -0
  235. package/dist/runtime/src/storage/forum/agent-settings.ts +20 -0
  236. package/dist/runtime/src/storage/forum/index.ts +4 -0
  237. package/dist/runtime/src/storage/forum/repository.ts +283 -0
  238. package/dist/runtime/src/storage/forum/schema.ts +300 -0
  239. package/dist/runtime/src/storage/forum-db.ts +23 -0
  240. package/dist/runtime/src/storage/runtime-events.ts +130 -0
  241. package/dist/runtime/src/storage/runtime-leases.ts +189 -0
  242. package/dist/runtime/src/storage/runtime-process-leases.ts +216 -0
  243. package/dist/runtime/src/storage/runtime-topic-state.ts +148 -0
  244. package/dist/runtime/src/storage/runtime-turn-requests.ts +301 -0
  245. package/dist/runtime/src/storage/self-schedules.ts +298 -0
  246. package/dist/runtime/src/storage/session-asks.ts +347 -0
  247. package/dist/runtime/src/storage/sqlite.ts +129 -0
  248. package/dist/runtime/src/storage/tasks.ts +179 -0
  249. package/dist/runtime/src/storage/token-stats.ts +144 -0
  250. package/dist/runtime/src/storage/topic-archive-state.ts +59 -0
  251. package/dist/runtime/src/storage/topic-archive.ts +74 -0
  252. package/dist/runtime/src/storage/topic-settings.ts +67 -0
  253. package/dist/runtime/src/storage/topic-transcript.ts +98 -0
  254. package/dist/runtime/src/storage/vault-crypto.ts +65 -0
  255. package/dist/runtime/src/storage/vault.ts +225 -0
  256. package/dist/runtime/src/storage/wiki-summary-names.ts +33 -0
  257. package/dist/runtime/src/storage/wiki.ts +85 -0
  258. package/dist/runtime/src/topics/create.ts +123 -0
  259. package/dist/runtime/src/topics/derive.ts +398 -0
  260. package/dist/runtime/src/topics/lifecycle.ts +311 -0
  261. package/dist/runtime/src/topics/links.ts +7 -0
  262. package/dist/runtime/src/topics/personal-general.ts +65 -0
  263. package/dist/runtime/src/topics/session.ts +381 -0
  264. package/dist/runtime/src/types/api.ts +497 -0
  265. package/dist/runtime/src/types.ts +257 -0
  266. package/dist/runtime/src/version.ts +1 -0
  267. package/dist/runtime/tsconfig.json +13 -0
  268. package/package.json +28 -3
  269. package/dist/main.d.ts +0 -3
  270. package/dist/main.d.ts.map +0 -1
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Global (workspace-wide) application settings — a single shared record, not
3
+ * per-user and not per-topic.
4
+ *
5
+ * Currently holds the global AI name (default "Otium"): the AI is one named
6
+ * entity for the whole workspace. Changing it is an admin-only action (see the
7
+ * settings route). Loaded synchronously at import time so `getGlobalAiName()`
8
+ * is always populated for callers like the system-prompt builder.
9
+ */
10
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { DATA_DIR } from "#platform/config";
13
+
14
+ export const DEFAULT_AI_NAME = "Otium";
15
+
16
+ const SETTINGS_FILE = join(DATA_DIR, "otium-settings.json");
17
+
18
+ let aiName = DEFAULT_AI_NAME;
19
+
20
+ try {
21
+ if (existsSync(SETTINGS_FILE)) {
22
+ const data = JSON.parse(readFileSync(SETTINGS_FILE, "utf8")) as { aiName?: unknown };
23
+ if (typeof data.aiName === "string" && data.aiName.trim()) {
24
+ aiName = data.aiName.trim();
25
+ }
26
+ }
27
+ } catch {
28
+ // Corrupt/missing file → keep the default.
29
+ }
30
+
31
+ export function getGlobalAiName(): string {
32
+ return aiName || DEFAULT_AI_NAME;
33
+ }
34
+
35
+ /** Set the global AI name (empty → reset to default). Persists to disk. */
36
+ export function setGlobalAiName(name: string): string {
37
+ aiName = name.trim() || DEFAULT_AI_NAME;
38
+ try {
39
+ writeFileSync(SETTINGS_FILE, JSON.stringify({ aiName }, null, 2));
40
+ } catch {
41
+ // Best-effort persistence; the in-memory value still updates.
42
+ }
43
+ return aiName;
44
+ }
@@ -0,0 +1,133 @@
1
+ import { db } from "#storage/forum-db";
2
+
3
+ export const DEFAULT_BROWSER_PROFILE = "default";
4
+
5
+ export interface BrowserProfileRecord {
6
+ name: string;
7
+ createdAt: string | null;
8
+ topics: Array<{ id: string; title: string }>;
9
+ }
10
+
11
+ export function normalizeBrowserProfileName(name: string): string {
12
+ const value = name.trim().toLowerCase();
13
+ if (!/^[a-z0-9][a-z0-9_-]{0,47}$/.test(value)) {
14
+ throw new Error("Profile name must be 1-48 lowercase letters, numbers, '-' or '_'.");
15
+ }
16
+ return value;
17
+ }
18
+
19
+ db.exec(`
20
+ CREATE TABLE IF NOT EXISTS browser_profiles (
21
+ owner_id TEXT NOT NULL,
22
+ name TEXT NOT NULL,
23
+ created_at TEXT NOT NULL,
24
+ PRIMARY KEY (owner_id, name)
25
+ )
26
+ `);
27
+
28
+ export function getBrowserProfileOwner(topicId: string, fallbackUserId: string): string {
29
+ return (
30
+ db
31
+ .query<{ browser_profile_owner: string | null }, string>(
32
+ "SELECT browser_profile_owner FROM api_topics WHERE id = ?",
33
+ )
34
+ .get(topicId)?.browser_profile_owner ?? fallbackUserId
35
+ );
36
+ }
37
+
38
+ export function isTopicBrowserProfileOwner(topicId: string, userId: string): boolean {
39
+ return getBrowserProfileOwner(topicId, "") === userId;
40
+ }
41
+
42
+ export function getTopicBrowserProfile(topicId: string): string {
43
+ const value = db
44
+ .query<{ browser_profile: string | null }, string>(
45
+ "SELECT browser_profile FROM api_topics WHERE id = ?",
46
+ )
47
+ .get(topicId)?.browser_profile;
48
+ return value || DEFAULT_BROWSER_PROFILE;
49
+ }
50
+
51
+ export function hasBrowserProfileTopic(topicId: string): boolean {
52
+ return Boolean(
53
+ db.query<{ id: string }, string>("SELECT id FROM api_topics WHERE id = ?").get(topicId),
54
+ );
55
+ }
56
+
57
+ export function createBrowserProfile(ownerId: string, rawName: string): string {
58
+ const name = normalizeBrowserProfileName(rawName);
59
+ if (name === DEFAULT_BROWSER_PROFILE) return name;
60
+ db.query(
61
+ "INSERT OR IGNORE INTO browser_profiles (owner_id, name, created_at) VALUES (?, ?, ?)",
62
+ ).run(ownerId, name, new Date().toISOString());
63
+ return name;
64
+ }
65
+
66
+ export function assignTopicBrowserProfile(opts: {
67
+ topicId: string;
68
+ actorUserId: string;
69
+ profile: string;
70
+ }): { previous: string; profile: string } {
71
+ const actualOwner = getBrowserProfileOwner(opts.topicId, "");
72
+ if (!actualOwner) throw new Error(`Topic "${opts.topicId}" not found or has no owner.`);
73
+ if (actualOwner !== opts.actorUserId) {
74
+ throw new Error(`Only the topic owner can change its browser profile.`);
75
+ }
76
+ const profile = normalizeBrowserProfileName(opts.profile);
77
+ if (profile !== DEFAULT_BROWSER_PROFILE) createBrowserProfile(actualOwner, profile);
78
+ const previous = getTopicBrowserProfile(opts.topicId);
79
+ const result = db
80
+ .query("UPDATE api_topics SET browser_profile = ? WHERE id = ?")
81
+ .run(profile, opts.topicId);
82
+ if (Number(result.changes ?? 0) !== 1) throw new Error(`Topic "${opts.topicId}" not found.`);
83
+ return { previous, profile };
84
+ }
85
+
86
+ export function listBrowserProfiles(ownerId: string): BrowserProfileRecord[] {
87
+ const records = db
88
+ .query<{ name: string; created_at: string }, string>(
89
+ "SELECT name, created_at FROM browser_profiles WHERE owner_id = ? ORDER BY name",
90
+ )
91
+ .all(ownerId);
92
+ const topics = db
93
+ .query<{ id: string; title: string; browser_profile: string | null }, string>(
94
+ `SELECT t.id, t.title, t.browser_profile FROM api_topics t
95
+ WHERE t.browser_profile_owner = ?
96
+ ORDER BY t.title`,
97
+ )
98
+ .all(ownerId);
99
+
100
+ const byName = new Map<string, BrowserProfileRecord>();
101
+ byName.set(DEFAULT_BROWSER_PROFILE, {
102
+ name: DEFAULT_BROWSER_PROFILE,
103
+ createdAt: null,
104
+ topics: [],
105
+ });
106
+ for (const row of records) {
107
+ byName.set(row.name, { name: row.name, createdAt: row.created_at, topics: [] });
108
+ }
109
+ for (const topic of topics) {
110
+ const name = topic.browser_profile || DEFAULT_BROWSER_PROFILE;
111
+ const record = byName.get(name) ?? { name, createdAt: null, topics: [] };
112
+ record.topics.push({ id: topic.id, title: topic.title });
113
+ byName.set(name, record);
114
+ }
115
+ return [...byName.values()].sort((a, b) => {
116
+ if (a.name === DEFAULT_BROWSER_PROFILE) return -1;
117
+ if (b.name === DEFAULT_BROWSER_PROFILE) return 1;
118
+ return a.name.localeCompare(b.name);
119
+ });
120
+ }
121
+
122
+ export function deleteBrowserProfile(ownerId: string, rawName: string): void {
123
+ const name = normalizeBrowserProfileName(rawName);
124
+ if (name === DEFAULT_BROWSER_PROFILE) throw new Error("The default profile cannot be deleted.");
125
+ const used = db
126
+ .query<{ count: number }, [string, string]>(
127
+ `SELECT COUNT(*) AS count FROM api_topics t
128
+ WHERE t.browser_profile = ? AND t.browser_profile_owner = ?`,
129
+ )
130
+ .get(name, ownerId)?.count;
131
+ if (used) throw new Error(`Profile "${name}" is assigned to ${used} topic(s).`);
132
+ db.query("DELETE FROM browser_profiles WHERE owner_id = ? AND name = ?").run(ownerId, name);
133
+ }
@@ -0,0 +1,268 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ renameSync,
6
+ unlinkSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { DATA_DIR } from "#platform/config";
11
+ import { appendJsonlLine } from "#platform/jsonl";
12
+ import { logger } from "#platform/logger";
13
+ import { sanitizeTopicName } from "#security/sanitize";
14
+ import type { AgentKind, UnifiedEvent } from "#types";
15
+
16
+ /**
17
+ * Per-topic conversation log (UnifiedEvent stream) used as the
18
+ * **provider-agnostic source of truth** for cross-agent portability.
19
+ *
20
+ * Storage layout:
21
+ * {DATA_DIR}/conversations/{userId}/{sanitizedTopicName}.jsonl
22
+ *
23
+ * Each line is a `ConversationEntry` JSON object. Append-only: every yielded
24
+ * UnifiedEvent during a `runAgent()` turn is captured here by the recording
25
+ * wrapper, so on agent switch we can rebuild a synthetic native rollout for
26
+ * the target SDK and preserve the conversation across providers.
27
+ *
28
+ * The Claude/Codex SDK rollouts (~/.claude/projects/, ~/.codex/sessions/) are
29
+ * intentionally treated as opaque side-effects of the SDKs only this file
30
+ * is canonical.
31
+ */
32
+ export interface ConversationEntry {
33
+ ts: string;
34
+ /**
35
+ * Agent that produced this event, frozen at write time. A topic that has
36
+ * been switched (via `set_agent`) will have a mixed-agent log: earlier
37
+ * entries keep the agent that originally generated them. Replay code
38
+ * (rollout-codec `extractChatPairs`) intentionally ignores this field —
39
+ * the cross-agent rollout's whole point is to feed past dialogue, no
40
+ * matter who produced it, into the *new* SDK as if it were native.
41
+ */
42
+ agent: AgentKind;
43
+ event: UnifiedEvent;
44
+ }
45
+
46
+ /**
47
+ * Validate that a userId stringifies to a safe path component. Rejects empties,
48
+ * slashes, and traversal sequences before they touch the filesystem. The
49
+ * conversation log is local-only so we keep this strict but simple if a
50
+ * caller sends something unexpected it is their bug to fix, not ours to coerce.
51
+ */
52
+ function safeUserIdComponent(userId: number | string): string {
53
+ const str = String(userId);
54
+ if (!str || /[/\\]|\.\./.test(str)) {
55
+ throw new Error(`conversations: refusing unsafe userId path component: ${str}`);
56
+ }
57
+ return str;
58
+ }
59
+
60
+ function conversationDir(userId: number | string): string {
61
+ return join(DATA_DIR, "conversations", safeUserIdComponent(userId));
62
+ }
63
+
64
+ function topicFilename(topicName: string): string {
65
+ const t = sanitizeTopicName(topicName, true);
66
+ return `${t}.jsonl`;
67
+ }
68
+
69
+ /** Compute the absolute path for a given user/topic conversation log. */
70
+ export function getConversationPath(userId: number | string, topicName: string): string {
71
+ return join(conversationDir(userId), topicFilename(topicName));
72
+ }
73
+
74
+ /**
75
+ * Append a single UnifiedEvent for the given topic. Creates the parent
76
+ * directory and the file as needed. Best-effort: I/O failures are logged but
77
+ * never throw, since recording must not break the live stream to Telegram.
78
+ *
79
+ * **Concurrency note (review item M2, revised):** within the bot process the
80
+ * single-threaded event loop already serializes writes — but this module is
81
+ * NOT single-process: the self-config MCP server (a separate stdio process,
82
+ * via `topic-agent-switch`) and provider bridge helpers append to the same
83
+ * topic logs.
84
+ * Cross-process interleaving on macOS is real for lines beyond PIPE_BUF
85
+ * (512B), and a torn line is silently dropped by `readConversation` —
86
+ * corrupting the canonical source for cross-agent rollout reconstruction.
87
+ * Writes therefore go through `appendJsonlLine` (sidecar `.lock` via O_EXCL,
88
+ * stale-lock reclaim, unlocked-append fallback on timeout — interleave is
89
+ * accepted over dropping the entry).
90
+ *
91
+ * The append stays SYNCHRONOUS on purpose: a previous attempt at a
92
+ * Promise-chained per-topic queue made writes async (durability gap before a
93
+ * synchronous `readConversation`) and broke `set_agent` in the bridge tests.
94
+ */
95
+ export function appendConversationEvent(
96
+ userId: number | string,
97
+ topicName: string,
98
+ agent: AgentKind,
99
+ event: UnifiedEvent,
100
+ ): void {
101
+ try {
102
+ appendConversationEventStrict(userId, topicName, agent, event);
103
+ } catch (err) {
104
+ logger.warn(
105
+ { err, userId, topicName, eventType: event.type },
106
+ "appendConversationEvent: write failed",
107
+ );
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Strict variant for state transitions where the conversation log is a manifest
113
+ * rather than telemetry. Throws on I/O failure so callers can avoid committing
114
+ * DB state that points at an unmanifested SDK session.
115
+ */
116
+ export function appendConversationEventStrict(
117
+ userId: number | string,
118
+ topicName: string,
119
+ agent: AgentKind,
120
+ event: UnifiedEvent,
121
+ ): void {
122
+ const path = getConversationPath(userId, topicName);
123
+ const entry: ConversationEntry = {
124
+ ts: new Date().toISOString(),
125
+ agent,
126
+ event,
127
+ };
128
+ mkdirSync(dirname(path), { recursive: true });
129
+ appendJsonlLine(path, JSON.stringify(entry));
130
+ }
131
+
132
+ /**
133
+ * Read all entries for a topic in chronological order. Returns an empty array
134
+ * if the file does not exist. Malformed lines are skipped with a warning so a
135
+ * single corrupted entry does not poison the whole conversation.
136
+ *
137
+ * NOTE(perf): reads the whole file each call. Topics in the kilobyte range
138
+ * are fine; if a single topic ever grows into multi-megabyte territory,
139
+ * consider a streaming reader (`readline`/`Bun.file().stream()`) and a
140
+ * size-bounded tail.
141
+ */
142
+ export function readConversation(userId: number | string, topicName: string): ConversationEntry[] {
143
+ const path = getConversationPath(userId, topicName);
144
+ const out: ConversationEntry[] = [];
145
+ if (!existsSync(path)) return out;
146
+ let raw: string;
147
+ try {
148
+ raw = readFileSync(path, "utf8");
149
+ } catch (err) {
150
+ logger.warn({ err, path }, "readConversation: read failed");
151
+ return out;
152
+ }
153
+ for (const line of raw.split("\n")) {
154
+ if (!line.trim()) continue;
155
+ try {
156
+ out.push(JSON.parse(line) as ConversationEntry);
157
+ } catch (err) {
158
+ logger.warn(
159
+ { err, line: line.slice(0, 200) },
160
+ "readConversation: malformed JSONL line skipped",
161
+ );
162
+ }
163
+ }
164
+ return out;
165
+ }
166
+
167
+ /** Atomically replace a provider-neutral topic log with an explicit event set. */
168
+ export function replaceConversationStrict(
169
+ userId: number | string,
170
+ topicName: string,
171
+ entries: ConversationEntry[],
172
+ ): void {
173
+ const path = getConversationPath(userId, topicName);
174
+ const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
175
+ mkdirSync(dirname(path), { recursive: true });
176
+ try {
177
+ writeFileSync(
178
+ tempPath,
179
+ entries.length > 0 ? `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n` : "",
180
+ { flag: "wx" },
181
+ );
182
+ renameSync(tempPath, path);
183
+ } catch (error) {
184
+ try {
185
+ unlinkSync(tempPath);
186
+ } catch {}
187
+ throw error;
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Copy the unified conversation log of `srcTopic` into the file path that
193
+ * `dstTopic` will read from. Used by `/fork` so a forked child topic inherits
194
+ * the parent's full cross-agent history, not just the agent's native SDK
195
+ * rollout. `/spawn` intentionally does not call this; it starts with no
196
+ * conversation history. Without this copy, a fork followed immediately by
197
+ * `/agent <other>` would feed the empty child log to `switchTopicAgent` and
198
+ * the new agent would start from zero — see the bug report from 2026-05-24.
199
+ *
200
+ * Semantics:
201
+ * - Reads the parent via `readConversation`, matching the history an in-place
202
+ * `set_agent` on the parent would have seen.
203
+ * - Writes a single file at the child's canonical path.
204
+ * - Refuses to overwrite a non-empty destination (returns `{copied:false}`).
205
+ * `/fork`'s only caller runs this immediately after topic creation when
206
+ * the dst file is guaranteed empty, so a non-empty dst means a programmer
207
+ * error somewhere upstream — fail loud rather than silently merging.
208
+ * - On any I/O error: throws. The caller (`createChildTopic`) already has
209
+ * a rollback path that wraps this call.
210
+ */
211
+ export function cloneConversationLog(opts: {
212
+ userId: number | string;
213
+ srcTopic: string;
214
+ dstTopic: string;
215
+ }): { copied: boolean; entries: number } {
216
+ const { userId, srcTopic, dstTopic } = opts;
217
+ const dstPath = getConversationPath(userId, dstTopic);
218
+ if (existsSync(dstPath) && readFileSync(dstPath, "utf8").trim().length > 0) {
219
+ logger.warn(
220
+ { userId, srcTopic, dstTopic, dstPath },
221
+ "cloneConversationLog: dst already non-empty — refusing to overwrite",
222
+ );
223
+ return { copied: false, entries: 0 };
224
+ }
225
+ const entries = readConversation(userId, srcTopic);
226
+ if (entries.length === 0) {
227
+ return { copied: false, entries: 0 };
228
+ }
229
+ const body = `${entries.map((e) => JSON.stringify(e)).join("\n")}\n`;
230
+ mkdirSync(dirname(dstPath), { recursive: true });
231
+ writeFileSync(dstPath, body);
232
+ return { copied: true, entries: entries.length };
233
+ }
234
+
235
+ /**
236
+ * Walk the unified log backwards and return the most recent SDK-emitted
237
+ * session id for the given agent — or null if that agent has never run on
238
+ * this topic.
239
+ *
240
+ * Used by `set_agent` to round-trip the SAME native rollout file across
241
+ * agent switches: when the user does claude → codex → claude, we reuse
242
+ * the original claude sessionId so the synthetic rollout lands at the
243
+ * same path the SDK already manages, preserving prompt-cache continuity
244
+ * and avoiding orphan `~/.claude/projects/<dir>/<id>.jsonl` files.
245
+ *
246
+ * Behavior:
247
+ * - First-ever switch to an agent (no prior session events) → null,
248
+ * caller falls back to a fresh randomUUID/uuidv7. Same as before.
249
+ * - Roundtrip switch (prior session exists) → that sessionId.
250
+ *
251
+ * `session` events are emitted on every turn by claude-/codex-provider and
252
+ * captured into the unified log by runAgent's append wrapper. The most
253
+ * recent one for the target agent is by definition the current SDK-side
254
+ * resume key for that agent.
255
+ */
256
+ export function findLastSessionIdForAgent(
257
+ entries: ConversationEntry[],
258
+ agent: AgentKind,
259
+ ): string | null {
260
+ for (let i = entries.length - 1; i >= 0; i--) {
261
+ const entry = entries[i];
262
+ if (entry.agent !== agent) continue;
263
+ if (entry.event.type === "session") {
264
+ return entry.event.sessionId;
265
+ }
266
+ }
267
+ return null;
268
+ }
@@ -0,0 +1,20 @@
1
+ import { type AgentKind, isAgentKind } from "#types";
2
+ import { db } from "./schema";
3
+
4
+ export function getTopicAgent(userId: number, topicName: string): AgentKind {
5
+ const row = db
6
+ .query<{ agent: string | null }, [string, string]>(
7
+ "SELECT agent FROM topics WHERE user_id = ? AND name = ?",
8
+ )
9
+ .get(String(userId), topicName);
10
+ const value = row?.agent;
11
+ if (!isAgentKind(value)) throw new Error(`Invalid agent in DB: ${value}`);
12
+ return value;
13
+ }
14
+
15
+ export function setTopicAgent(userId: number, topicName: string, agent: AgentKind): boolean {
16
+ const result = db
17
+ .query("UPDATE topics SET agent = ? WHERE user_id = ? AND name = ?")
18
+ .run(agent, String(userId), topicName);
19
+ return result.changes > 0;
20
+ }
@@ -0,0 +1,4 @@
1
+ export * from "#storage/forum/agent-settings";
2
+ export * from "#storage/forum/repository";
3
+ export type { ForumTopicInfo, TopicRow, UserForumConfig, UserRow } from "#storage/forum/schema";
4
+ export { flushSessionCache } from "#storage/forum/schema";