negotium 0.1.7 → 0.1.8

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 (256) hide show
  1. package/README.md +2 -2
  2. package/dist/chunk-0ay6cjdy.js +1331 -0
  3. package/dist/chunk-0ay6cjdy.js.map +16 -0
  4. package/dist/chunk-1k7t7bnx.js +97 -0
  5. package/dist/chunk-1k7t7bnx.js.map +10 -0
  6. package/dist/chunk-5ahkew9c.js +806 -0
  7. package/dist/chunk-5ahkew9c.js.map +17 -0
  8. package/dist/chunk-6cpn7ht2.js +287 -0
  9. package/dist/chunk-6cpn7ht2.js.map +13 -0
  10. package/dist/chunk-6kctn20e.js +3529 -0
  11. package/dist/chunk-6kctn20e.js.map +20 -0
  12. package/dist/chunk-7xqj7aex.js +77 -0
  13. package/dist/chunk-7xqj7aex.js.map +11 -0
  14. package/dist/chunk-9p0ww3tk.js +41 -0
  15. package/dist/chunk-9p0ww3tk.js.map +10 -0
  16. package/dist/chunk-a92a7nrk.js +252 -0
  17. package/dist/chunk-a92a7nrk.js.map +10 -0
  18. package/dist/chunk-c500xe9z.js +33 -0
  19. package/dist/chunk-c500xe9z.js.map +10 -0
  20. package/dist/chunk-cds1qxsf.js +1644 -0
  21. package/dist/chunk-cds1qxsf.js.map +17 -0
  22. package/dist/chunk-e4407g8x.js +274 -0
  23. package/dist/chunk-e4407g8x.js.map +10 -0
  24. package/dist/chunk-e5pbggsh.js +363 -0
  25. package/dist/chunk-e5pbggsh.js.map +11 -0
  26. package/dist/chunk-edb07sjp.js +567 -0
  27. package/dist/chunk-edb07sjp.js.map +14 -0
  28. package/dist/chunk-exd0v74a.js +3012 -0
  29. package/dist/chunk-exd0v74a.js.map +32 -0
  30. package/dist/chunk-fgrme9tr.js +4398 -0
  31. package/dist/chunk-fgrme9tr.js.map +47 -0
  32. package/dist/chunk-fka9eqe1.js +581 -0
  33. package/dist/chunk-fka9eqe1.js.map +11 -0
  34. package/dist/chunk-g82jygez.js +108 -0
  35. package/dist/chunk-g82jygez.js.map +10 -0
  36. package/dist/chunk-ghs2j1fr.js +312 -0
  37. package/dist/chunk-ghs2j1fr.js.map +10 -0
  38. package/dist/chunk-gmy11efb.js +125 -0
  39. package/dist/chunk-gmy11efb.js.map +10 -0
  40. package/dist/chunk-gxma4c70.js +110 -0
  41. package/dist/chunk-gxma4c70.js.map +10 -0
  42. package/dist/chunk-hkdqhm34.js +61 -0
  43. package/dist/chunk-hkdqhm34.js.map +10 -0
  44. package/dist/chunk-hpa0cpzh.js +134 -0
  45. package/dist/chunk-hpa0cpzh.js.map +10 -0
  46. package/dist/chunk-hwy3n31w.js +21 -0
  47. package/dist/chunk-hwy3n31w.js.map +9 -0
  48. package/dist/chunk-j2behpy7.js +214 -0
  49. package/dist/chunk-j2behpy7.js.map +10 -0
  50. package/dist/chunk-jc0at0tc.js +102 -0
  51. package/dist/chunk-jc0at0tc.js.map +10 -0
  52. package/dist/chunk-jkqq5ef6.js +243 -0
  53. package/dist/chunk-jkqq5ef6.js.map +11 -0
  54. package/dist/chunk-jzkw2f01.js +51 -0
  55. package/dist/chunk-jzkw2f01.js.map +10 -0
  56. package/dist/chunk-jzz91jwh.js +70 -0
  57. package/dist/chunk-jzz91jwh.js.map +10 -0
  58. package/dist/chunk-k9z47mab.js +247 -0
  59. package/dist/chunk-k9z47mab.js.map +12 -0
  60. package/dist/chunk-n17yxpa8.js +23 -0
  61. package/dist/chunk-n17yxpa8.js.map +10 -0
  62. package/dist/chunk-naa09ags.js +2143 -0
  63. package/dist/chunk-naa09ags.js.map +15 -0
  64. package/dist/chunk-ns3ke88n.js +142 -0
  65. package/dist/chunk-ns3ke88n.js.map +10 -0
  66. package/dist/chunk-p3cr2ejg.js +1626 -0
  67. package/dist/chunk-p3cr2ejg.js.map +15 -0
  68. package/dist/chunk-q5bcpf23.js +2925 -0
  69. package/dist/chunk-q5bcpf23.js.map +20 -0
  70. package/dist/chunk-rafc55bs.js +424 -0
  71. package/dist/chunk-rafc55bs.js.map +11 -0
  72. package/dist/chunk-rbrxjvbj.js +2830 -0
  73. package/dist/chunk-rbrxjvbj.js.map +23 -0
  74. package/dist/chunk-sa874j5c.js +9 -0
  75. package/dist/chunk-sa874j5c.js.map +10 -0
  76. package/dist/chunk-sgsz2fpq.js +289 -0
  77. package/dist/chunk-sgsz2fpq.js.map +10 -0
  78. package/dist/chunk-sm6fh5c5.js +569 -0
  79. package/dist/chunk-sm6fh5c5.js.map +14 -0
  80. package/dist/chunk-t9zcg8f1.js +499 -0
  81. package/dist/chunk-t9zcg8f1.js.map +13 -0
  82. package/dist/chunk-vp2db42d.js +121 -0
  83. package/dist/chunk-vp2db42d.js.map +10 -0
  84. package/dist/chunk-w13jjf4a.js +46 -0
  85. package/dist/chunk-w13jjf4a.js.map +10 -0
  86. package/dist/chunk-x1arr7ze.js +63 -0
  87. package/dist/chunk-x1arr7ze.js.map +10 -0
  88. package/dist/chunk-xfq68rch.js +583 -0
  89. package/dist/chunk-xfq68rch.js.map +11 -0
  90. package/dist/main.js +132 -20
  91. package/dist/main.js.map +4 -4
  92. package/dist/runtime/scripts/faster-whisper-wrapper.py +87 -0
  93. package/dist/runtime/scripts/mcp-patchright-http.mjs +349 -0
  94. package/dist/runtime/src/agents/api-topic-agent-switch.ts +178 -0
  95. package/dist/runtime/src/agents/archiver.ts +396 -0
  96. package/dist/runtime/src/agents/auth-check.ts +64 -0
  97. package/dist/runtime/src/agents/claude-provider.ts +737 -0
  98. package/dist/runtime/src/agents/claude-registry.ts +115 -0
  99. package/dist/runtime/src/agents/codex-native-multi-agent.ts +177 -0
  100. package/dist/runtime/src/agents/codex-provider.ts +785 -0
  101. package/dist/runtime/src/agents/codex-registry.ts +115 -0
  102. package/dist/runtime/src/agents/codex-tree-kill.ts +365 -0
  103. package/dist/runtime/src/agents/contracts.ts +69 -0
  104. package/dist/runtime/src/agents/deep-map.ts +13 -0
  105. package/dist/runtime/src/agents/execution-host.ts +91 -0
  106. package/dist/runtime/src/agents/fixtures/claude-attachments.jsonl +2 -0
  107. package/dist/runtime/src/agents/fixtures/codex-shell.jsonl +5 -0
  108. package/dist/runtime/src/agents/fork.ts +65 -0
  109. package/dist/runtime/src/agents/hosted-agent.ts +48 -0
  110. package/dist/runtime/src/agents/idle-archiver.ts +128 -0
  111. package/dist/runtime/src/agents/index.ts +205 -0
  112. package/dist/runtime/src/agents/maestro-provider.ts +206 -0
  113. package/dist/runtime/src/agents/maestro-registry.ts +31 -0
  114. package/dist/runtime/src/agents/mcp-tools/ask-user.ts +280 -0
  115. package/dist/runtime/src/agents/mcp-tools/common.ts +21 -0
  116. package/dist/runtime/src/agents/mcp-tools/self-config.ts +220 -0
  117. package/dist/runtime/src/agents/mcp-tools/spawn-subagent.ts +525 -0
  118. package/dist/runtime/src/agents/mcp-tools/visuals.ts +88 -0
  119. package/dist/runtime/src/agents/model-catalog.ts +151 -0
  120. package/dist/runtime/src/agents/registry.ts +24 -0
  121. package/dist/runtime/src/agents/rollout/claude.ts +331 -0
  122. package/dist/runtime/src/agents/rollout/codex.ts +569 -0
  123. package/dist/runtime/src/agents/rollout/shared.ts +205 -0
  124. package/dist/runtime/src/agents/self-config-core.ts +415 -0
  125. package/dist/runtime/src/agents/task-events.ts +44 -0
  126. package/dist/runtime/src/agents/tool-format.ts +352 -0
  127. package/dist/runtime/src/agents/topic-agent-switch.ts +193 -0
  128. package/dist/runtime/src/agents/topic-cleanup.ts +231 -0
  129. package/dist/runtime/src/agents/vault-tool-policy.ts +33 -0
  130. package/dist/runtime/src/application/execute-external-user-turn.ts +23 -0
  131. package/dist/runtime/src/application/submit-user-message.ts +47 -0
  132. package/dist/runtime/src/application/switch-topic-effort.ts +48 -0
  133. package/dist/runtime/src/application/switch-topic-model.ts +79 -0
  134. package/dist/runtime/src/application/topic-service.ts +139 -0
  135. package/dist/runtime/src/application/vault-command.ts +170 -0
  136. package/dist/runtime/src/bus.ts +319 -0
  137. package/dist/runtime/src/index.ts +333 -0
  138. package/dist/runtime/src/mcp/agent-health-server.ts +309 -0
  139. package/dist/runtime/src/mcp/background-bash-server.ts +346 -0
  140. package/dist/runtime/src/mcp/canonical-bridge-config.ts +84 -0
  141. package/dist/runtime/src/mcp/canonical-proxy-server.ts +171 -0
  142. package/dist/runtime/src/mcp/dm-ipc.ts +60 -0
  143. package/dist/runtime/src/mcp/mcp-helpers.ts +40 -0
  144. package/dist/runtime/src/mcp/paddleocr-server.ts +105 -0
  145. package/dist/runtime/src/mcp/peer-bridge.ts +114 -0
  146. package/dist/runtime/src/mcp/runtime-spec.ts +134 -0
  147. package/dist/runtime/src/mcp/session-comm/bridge-ipc-config.ts +27 -0
  148. package/dist/runtime/src/mcp/session-comm/peer-forward.ts +165 -0
  149. package/dist/runtime/src/mcp/session-comm/runtime.ts +115 -0
  150. package/dist/runtime/src/mcp/session-comm/server.ts +649 -0
  151. package/dist/runtime/src/mcp/session-comm/topic-config.ts +131 -0
  152. package/dist/runtime/src/mcp/session-comm/topics.ts +229 -0
  153. package/dist/runtime/src/mcp/stdio-protect.ts +84 -0
  154. package/dist/runtime/src/mcp/system-health-server.ts +130 -0
  155. package/dist/runtime/src/mcp/task-server.ts +173 -0
  156. package/dist/runtime/src/mcp/token-stats-server.ts +98 -0
  157. package/dist/runtime/src/mcp/vault-http.ts +219 -0
  158. package/dist/runtime/src/mcp/vault-run.ts +153 -0
  159. package/dist/runtime/src/mcp/vault-server.ts +104 -0
  160. package/dist/runtime/src/mcp/wiki-server.ts +755 -0
  161. package/dist/runtime/src/media/file-events.ts +33 -0
  162. package/dist/runtime/src/media/text-extractor.ts +312 -0
  163. package/dist/runtime/src/media/video.ts +92 -0
  164. package/dist/runtime/src/outbox/file-ops.ts +210 -0
  165. package/dist/runtime/src/outbox/index.ts +8 -0
  166. package/dist/runtime/src/outbox/utils.ts +51 -0
  167. package/dist/runtime/src/platform/background-bash/manager.ts +279 -0
  168. package/dist/runtime/src/platform/config.ts +371 -0
  169. package/dist/runtime/src/platform/constants.ts +18 -0
  170. package/dist/runtime/src/platform/debug-users.ts +14 -0
  171. package/dist/runtime/src/platform/delay.ts +1 -0
  172. package/dist/runtime/src/platform/error.ts +9 -0
  173. package/dist/runtime/src/platform/file-utils.ts +16 -0
  174. package/dist/runtime/src/platform/jsonl.ts +202 -0
  175. package/dist/runtime/src/platform/lifecycle.ts +174 -0
  176. package/dist/runtime/src/platform/logger.ts +31 -0
  177. package/dist/runtime/src/platform/maestro-bootstrap-env.ts +3 -0
  178. package/dist/runtime/src/platform/mcp-config.ts +865 -0
  179. package/dist/runtime/src/platform/modules.ts +145 -0
  180. package/dist/runtime/src/platform/node-plugins.ts +46 -0
  181. package/dist/runtime/src/platform/playwright/manager.ts +1233 -0
  182. package/dist/runtime/src/prompts/agents/wiki-archiver.md +261 -0
  183. package/dist/runtime/src/prompts/builders.ts +357 -0
  184. package/dist/runtime/src/prompts/sessions/channel-system.md +48 -0
  185. package/dist/runtime/src/prompts/sessions/manager-system.md +37 -0
  186. package/dist/runtime/src/prompts/sessions/topic-system.md +47 -0
  187. package/dist/runtime/src/prompts/sessions/visual-design.md +86 -0
  188. package/dist/runtime/src/query/active-rooms.ts +532 -0
  189. package/dist/runtime/src/query/control.ts +313 -0
  190. package/dist/runtime/src/query/index.ts +24 -0
  191. package/dist/runtime/src/query/session-inbox-cleanup.ts +44 -0
  192. package/dist/runtime/src/query/session-inbox-path.ts +42 -0
  193. package/dist/runtime/src/query/state.ts +94 -0
  194. package/dist/runtime/src/query/types.ts +83 -0
  195. package/dist/runtime/src/runtime/ask-callbacks.ts +153 -0
  196. package/dist/runtime/src/runtime/attachments.ts +151 -0
  197. package/dist/runtime/src/runtime/background-sessions.ts +83 -0
  198. package/dist/runtime/src/runtime/channel-context.ts +107 -0
  199. package/dist/runtime/src/runtime/errors.ts +69 -0
  200. package/dist/runtime/src/runtime/event-heartbeat.ts +77 -0
  201. package/dist/runtime/src/runtime/file-hooks.ts +81 -0
  202. package/dist/runtime/src/runtime/footer.ts +23 -0
  203. package/dist/runtime/src/runtime/inbox.ts +946 -0
  204. package/dist/runtime/src/runtime/tasks.ts +56 -0
  205. package/dist/runtime/src/runtime/topic-config.ts +29 -0
  206. package/dist/runtime/src/runtime/turn-runner.ts +2286 -0
  207. package/dist/runtime/src/runtime/usage-alert.ts +84 -0
  208. package/dist/runtime/src/runtime/visual-html.ts +109 -0
  209. package/dist/runtime/src/runtime/visual-store.ts +430 -0
  210. package/dist/runtime/src/runtime/visuals.ts +171 -0
  211. package/dist/runtime/src/security/sanitize.ts +27 -0
  212. package/dist/runtime/src/security/sensitive-path.ts +34 -0
  213. package/dist/runtime/src/storage/activity-log.ts +152 -0
  214. package/dist/runtime/src/storage/api-messages.ts +617 -0
  215. package/dist/runtime/src/storage/api-topic-brief.ts +127 -0
  216. package/dist/runtime/src/storage/api-topic-config.ts +192 -0
  217. package/dist/runtime/src/storage/api-topics.ts +844 -0
  218. package/dist/runtime/src/storage/app-settings.ts +44 -0
  219. package/dist/runtime/src/storage/browser-profiles.ts +133 -0
  220. package/dist/runtime/src/storage/conversations.ts +268 -0
  221. package/dist/runtime/src/storage/forum/agent-settings.ts +20 -0
  222. package/dist/runtime/src/storage/forum/index.ts +4 -0
  223. package/dist/runtime/src/storage/forum/repository.ts +283 -0
  224. package/dist/runtime/src/storage/forum/schema.ts +300 -0
  225. package/dist/runtime/src/storage/forum-db.ts +23 -0
  226. package/dist/runtime/src/storage/runtime-events.ts +130 -0
  227. package/dist/runtime/src/storage/runtime-leases.ts +189 -0
  228. package/dist/runtime/src/storage/runtime-process-leases.ts +216 -0
  229. package/dist/runtime/src/storage/runtime-topic-state.ts +148 -0
  230. package/dist/runtime/src/storage/runtime-turn-requests.ts +300 -0
  231. package/dist/runtime/src/storage/self-schedules.ts +298 -0
  232. package/dist/runtime/src/storage/session-asks.ts +347 -0
  233. package/dist/runtime/src/storage/sqlite.ts +129 -0
  234. package/dist/runtime/src/storage/tasks.ts +179 -0
  235. package/dist/runtime/src/storage/token-stats.ts +144 -0
  236. package/dist/runtime/src/storage/topic-archive-state.ts +59 -0
  237. package/dist/runtime/src/storage/topic-archive.ts +74 -0
  238. package/dist/runtime/src/storage/topic-settings.ts +67 -0
  239. package/dist/runtime/src/storage/topic-transcript.ts +98 -0
  240. package/dist/runtime/src/storage/vault-crypto.ts +65 -0
  241. package/dist/runtime/src/storage/vault.ts +225 -0
  242. package/dist/runtime/src/storage/wiki-summary-names.ts +33 -0
  243. package/dist/runtime/src/storage/wiki.ts +85 -0
  244. package/dist/runtime/src/topics/create.ts +123 -0
  245. package/dist/runtime/src/topics/derive.ts +398 -0
  246. package/dist/runtime/src/topics/lifecycle.ts +311 -0
  247. package/dist/runtime/src/topics/links.ts +7 -0
  248. package/dist/runtime/src/topics/personal-general.ts +65 -0
  249. package/dist/runtime/src/topics/session.ts +381 -0
  250. package/dist/runtime/src/types/api.ts +497 -0
  251. package/dist/runtime/src/types.ts +255 -0
  252. package/dist/runtime/src/version.ts +1 -0
  253. package/dist/runtime/tsconfig.json +13 -0
  254. package/package.json +16 -3
  255. package/dist/main.d.ts +0 -3
  256. package/dist/main.d.ts.map +0 -1
@@ -0,0 +1,617 @@
1
+ // Persistent message store for the REST/WS API channel (Otium).
2
+ //
3
+ // Replaces the previous in-memory `Map<topicId, MessageDto[]>` so messages
4
+ // survive server restarts. Backed by the shared sessions SQLite DB — the same
5
+ // database the Telegram channel uses — which is the foundation for unifying
6
+ // conversation state across channels later.
7
+ //
8
+ // Ordering is by the implicit SQLite `rowid` (monotonic insert order). Cursor
9
+ // pagination resolves the cursor id → its rowid and returns rows after it.
10
+
11
+ import { db } from "#storage/forum-db";
12
+ import type { AgentKind } from "#types";
13
+ import type { MessageDto } from "#types/api";
14
+
15
+ db.exec(`
16
+ CREATE TABLE IF NOT EXISTS api_messages (
17
+ id TEXT PRIMARY KEY,
18
+ topic_id TEXT NOT NULL,
19
+ parent_id TEXT,
20
+ author_id TEXT NOT NULL,
21
+ source_adapter TEXT,
22
+ text TEXT NOT NULL,
23
+ query_id TEXT,
24
+ agent_type TEXT,
25
+ model TEXT,
26
+ attachments TEXT,
27
+ usage TEXT,
28
+ deleted INTEGER NOT NULL DEFAULT 0,
29
+ edited_at TEXT,
30
+ reactions TEXT,
31
+ kind TEXT,
32
+ ask_user_question TEXT,
33
+ mentions TEXT,
34
+ thread_root_id TEXT,
35
+ created_at TEXT NOT NULL
36
+ )
37
+ `);
38
+ try {
39
+ db.exec("ALTER TABLE api_messages ADD COLUMN source_adapter TEXT");
40
+ } catch {
41
+ // Column already exists.
42
+ }
43
+ try {
44
+ db.exec("ALTER TABLE api_messages ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0");
45
+ } catch {
46
+ // Column already exists.
47
+ }
48
+ try {
49
+ db.exec("ALTER TABLE api_messages ADD COLUMN edited_at TEXT");
50
+ } catch {
51
+ // Column already exists.
52
+ }
53
+ try {
54
+ db.exec("ALTER TABLE api_messages ADD COLUMN reactions TEXT");
55
+ } catch {
56
+ // Column already exists.
57
+ }
58
+ try {
59
+ db.exec("ALTER TABLE api_messages ADD COLUMN kind TEXT");
60
+ } catch {
61
+ // Column already exists.
62
+ }
63
+ try {
64
+ db.exec("ALTER TABLE api_messages ADD COLUMN ask_user_question TEXT");
65
+ } catch {
66
+ // Column already exists.
67
+ }
68
+ try {
69
+ db.exec("ALTER TABLE api_messages ADD COLUMN mentions TEXT");
70
+ } catch {
71
+ // Column already exists.
72
+ }
73
+ try {
74
+ // Slack-style threads: a reply carries the ROOT message id (flat, no nesting).
75
+ // Distinct from parent_id (which is a Telegram-style inline quote-reply).
76
+ db.exec("ALTER TABLE api_messages ADD COLUMN thread_root_id TEXT");
77
+ } catch {
78
+ // Column already exists.
79
+ }
80
+ try {
81
+ db.exec("ALTER TABLE api_messages ADD COLUMN subagent_card TEXT");
82
+ } catch {
83
+ // Column already exists.
84
+ }
85
+ // Index on topic_id; SQLite appends the implicit rowid to every index entry,
86
+ // so `WHERE topic_id=? AND rowid>? ORDER BY rowid` stays index-driven.
87
+ db.exec("CREATE INDEX IF NOT EXISTS idx_api_messages_topic ON api_messages(topic_id)");
88
+ db.exec("CREATE INDEX IF NOT EXISTS idx_api_messages_thread_root ON api_messages(thread_root_id)");
89
+
90
+ export interface ApiMessageRow {
91
+ id: string;
92
+ topic_id: string;
93
+ parent_id: string | null;
94
+ author_id: string;
95
+ source_adapter: string | null;
96
+ text: string;
97
+ query_id: string | null;
98
+ agent_type: string | null;
99
+ model: string | null;
100
+ attachments: string | null;
101
+ usage: string | null;
102
+ deleted: number;
103
+ edited_at: string | null;
104
+ reactions: string | null;
105
+ kind: string | null;
106
+ ask_user_question: string | null;
107
+ subagent_card: string | null;
108
+ mentions: string | null;
109
+ thread_root_id: string | null;
110
+ created_at: string;
111
+ rowid?: number;
112
+ }
113
+
114
+ type ApiMessageAppendHook = (msg: MessageDto) => void | Promise<void>;
115
+ const appendHooks = new Set<ApiMessageAppendHook>();
116
+
117
+ export interface AppendApiMessageOptions {
118
+ notify?: boolean;
119
+ updateTopicLastMessageAt?: boolean;
120
+ }
121
+
122
+ export function registerApiMessageAppendHook(hook: ApiMessageAppendHook): () => void {
123
+ appendHooks.add(hook);
124
+ return () => {
125
+ appendHooks.delete(hook);
126
+ };
127
+ }
128
+
129
+ function emitApiMessageAppended(msg: MessageDto): void {
130
+ for (const hook of appendHooks) {
131
+ queueMicrotask(() => {
132
+ try {
133
+ void Promise.resolve(hook(msg)).catch(() => undefined);
134
+ } catch {
135
+ // Hooks are post-persist side effects; never make message writes fail.
136
+ }
137
+ });
138
+ }
139
+ }
140
+
141
+ function rowToDto(r: ApiMessageRow): MessageDto {
142
+ return {
143
+ id: r.id,
144
+ topicId: r.topic_id,
145
+ parentId: r.parent_id ?? undefined,
146
+ authorId: r.author_id,
147
+ sourceAdapter: r.source_adapter ?? undefined,
148
+ text: r.text,
149
+ queryId: r.query_id ?? undefined,
150
+ agentType: (r.agent_type as AgentKind | null) ?? undefined,
151
+ model: r.model ?? undefined,
152
+ attachments: r.attachments ? JSON.parse(r.attachments) : undefined,
153
+ usage: r.usage ? JSON.parse(r.usage) : undefined,
154
+ deleted: r.deleted !== 0,
155
+ editedAt: r.edited_at ?? undefined,
156
+ reactions: r.reactions ? JSON.parse(r.reactions) : undefined,
157
+ kind: (r.kind as MessageDto["kind"] | null) ?? undefined,
158
+ askUserQuestion: r.ask_user_question ? JSON.parse(r.ask_user_question) : undefined,
159
+ subagentCard: r.subagent_card ? JSON.parse(r.subagent_card) : undefined,
160
+ mentions: r.mentions ? JSON.parse(r.mentions) : undefined,
161
+ threadRootId: r.thread_root_id ?? undefined,
162
+ createdAt: r.created_at,
163
+ };
164
+ }
165
+
166
+ function attachmentListHasFileId(raw: string | null, fileId: string): boolean {
167
+ if (!raw) return false;
168
+ try {
169
+ const attachments = JSON.parse(raw) as unknown;
170
+ return (
171
+ Array.isArray(attachments) &&
172
+ attachments.some(
173
+ (attachment) =>
174
+ typeof attachment === "object" &&
175
+ attachment !== null &&
176
+ "id" in attachment &&
177
+ attachment.id === fileId,
178
+ )
179
+ );
180
+ } catch {
181
+ return false;
182
+ }
183
+ }
184
+
185
+ /** Persist a single message. Idempotent on id: duplicate appends do not rewrite edited/reaction state. */
186
+ export function appendApiMessage(msg: MessageDto, options: AppendApiMessageOptions = {}): void {
187
+ const notify = options.notify ?? true;
188
+ const updateTopicLastMessageAt = options.updateTopicLastMessageAt ?? true;
189
+ let inserted = false;
190
+ db.transaction(() => {
191
+ const result = db
192
+ .query(
193
+ `INSERT INTO api_messages
194
+ (id, topic_id, parent_id, author_id, source_adapter, text, query_id, agent_type, model, attachments, usage, deleted, edited_at, reactions, kind, ask_user_question, subagent_card, mentions, thread_root_id, created_at)
195
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
196
+ ON CONFLICT(id) DO NOTHING`,
197
+ )
198
+ .run(
199
+ msg.id,
200
+ msg.topicId,
201
+ msg.parentId ?? null,
202
+ msg.authorId,
203
+ msg.sourceAdapter ?? null,
204
+ msg.text,
205
+ msg.queryId ?? null,
206
+ msg.agentType ?? null,
207
+ msg.model ?? null,
208
+ msg.attachments ? JSON.stringify(msg.attachments) : null,
209
+ msg.usage ? JSON.stringify(msg.usage) : null,
210
+ msg.deleted ? 1 : 0,
211
+ msg.editedAt ?? null,
212
+ msg.reactions?.length ? JSON.stringify(msg.reactions) : null,
213
+ msg.kind ?? null,
214
+ msg.askUserQuestion ? JSON.stringify(msg.askUserQuestion) : null,
215
+ msg.subagentCard ? JSON.stringify(msg.subagentCard) : null,
216
+ msg.mentions?.length ? JSON.stringify(msg.mentions) : null,
217
+ msg.threadRootId ?? null,
218
+ msg.createdAt,
219
+ );
220
+ inserted = Number(result.changes ?? 0) > 0;
221
+ if (inserted && updateTopicLastMessageAt && !msg.deleted) {
222
+ db.query(
223
+ `UPDATE api_topics
224
+ SET last_message_at = CASE
225
+ WHEN last_message_at IS NULL OR last_message_at < ? THEN ?
226
+ ELSE last_message_at
227
+ END
228
+ WHERE id = ?`,
229
+ ).run(msg.createdAt, msg.createdAt, msg.topicId);
230
+ }
231
+ })();
232
+ if (inserted && notify && !msg.deleted) emitApiMessageAppended(msg);
233
+ }
234
+
235
+ export function getApiMessage(topicId: string, messageId: string): MessageDto | null {
236
+ const row = db
237
+ .query("SELECT * FROM api_messages WHERE topic_id = ? AND id = ?")
238
+ .get(topicId, messageId) as ApiMessageRow | undefined;
239
+ return row ? rowToDto(row) : null;
240
+ }
241
+
242
+ export function topicHasAttachmentFileId(topicId: string, fileId: string): boolean {
243
+ const rows = db
244
+ .query<{ attachments: string | null }, [string]>(
245
+ "SELECT attachments FROM api_messages WHERE topic_id = ? AND deleted = 0 AND attachments IS NOT NULL",
246
+ )
247
+ .all(topicId);
248
+ return rows.some((row) => attachmentListHasFileId(row.attachments, fileId));
249
+ }
250
+
251
+ export function updateApiMessageText(
252
+ topicId: string,
253
+ messageId: string,
254
+ text: string,
255
+ editedAt = new Date().toISOString(),
256
+ ): MessageDto | null {
257
+ const res = db
258
+ .query(
259
+ `UPDATE api_messages
260
+ SET text = ?, edited_at = ?
261
+ WHERE topic_id = ? AND id = ? AND deleted = 0`,
262
+ )
263
+ .run(text, editedAt, topicId, messageId);
264
+ if (Number(res.changes ?? 0) === 0) return null;
265
+ return getApiMessage(topicId, messageId);
266
+ }
267
+
268
+ /** Attach terminal turn usage to an already-persisted assistant segment. */
269
+ export function updateApiMessageUsage(
270
+ topicId: string,
271
+ messageId: string,
272
+ usage: NonNullable<MessageDto["usage"]>,
273
+ ): MessageDto | null {
274
+ const res = db
275
+ .query(
276
+ `UPDATE api_messages
277
+ SET usage = ?
278
+ WHERE topic_id = ? AND id = ? AND deleted = 0`,
279
+ )
280
+ .run(JSON.stringify(usage), topicId, messageId);
281
+ if (Number(res.changes ?? 0) === 0) return null;
282
+ return getApiMessage(topicId, messageId);
283
+ }
284
+
285
+ export function updateApiMessageAskUserQuestion(
286
+ topicId: string,
287
+ messageId: string,
288
+ askUserQuestion: NonNullable<MessageDto["askUserQuestion"]>,
289
+ editedAt = new Date().toISOString(),
290
+ ): MessageDto | null {
291
+ const res = db
292
+ .query(
293
+ `UPDATE api_messages
294
+ SET ask_user_question = ?, edited_at = ?
295
+ WHERE topic_id = ? AND id = ? AND deleted = 0 AND kind = 'ask_user_question'`,
296
+ )
297
+ .run(JSON.stringify(askUserQuestion), editedAt, topicId, messageId);
298
+ if (Number(res.changes ?? 0) === 0) return null;
299
+ return getApiMessage(topicId, messageId);
300
+ }
301
+
302
+ export function updateApiMessageSubagentCard(
303
+ topicId: string,
304
+ messageId: string,
305
+ subagentCard: NonNullable<MessageDto["subagentCard"]>,
306
+ editedAt = new Date().toISOString(),
307
+ ): MessageDto | null {
308
+ const res = db
309
+ .query(
310
+ `UPDATE api_messages
311
+ SET subagent_card = ?, edited_at = ?
312
+ WHERE topic_id = ? AND id = ? AND deleted = 0 AND kind = 'subagent'`,
313
+ )
314
+ .run(JSON.stringify(subagentCard), editedAt, topicId, messageId);
315
+ if (Number(res.changes ?? 0) === 0) return null;
316
+ return getApiMessage(topicId, messageId);
317
+ }
318
+
319
+ /** All non-deleted messages of one kind, across topics (boot-time sweeps). */
320
+ export function listApiMessagesByKind(kind: NonNullable<MessageDto["kind"]>): MessageDto[] {
321
+ const rows = db
322
+ .query("SELECT * FROM api_messages WHERE kind = ? AND deleted = 0")
323
+ .all(kind) as ApiMessageRow[];
324
+ return rows.map(rowToDto);
325
+ }
326
+
327
+ /** Replace a message's full reactions array. Toggle logic lives in the route. */
328
+ export function setApiMessageReactions(
329
+ topicId: string,
330
+ messageId: string,
331
+ reactions: NonNullable<MessageDto["reactions"]>,
332
+ ): MessageDto | null {
333
+ const res = db
334
+ .query(
335
+ `UPDATE api_messages
336
+ SET reactions = ?
337
+ WHERE topic_id = ? AND id = ? AND deleted = 0`,
338
+ )
339
+ .run(reactions.length ? JSON.stringify(reactions) : null, topicId, messageId);
340
+ if (Number(res.changes ?? 0) === 0) return null;
341
+ return getApiMessage(topicId, messageId);
342
+ }
343
+
344
+ export function softDeleteApiMessage(topicId: string, messageId: string): MessageDto | null {
345
+ const res = db
346
+ .query(
347
+ `UPDATE api_messages
348
+ SET deleted = 1, text = ''
349
+ WHERE topic_id = ? AND id = ? AND deleted = 0`,
350
+ )
351
+ .run(topicId, messageId);
352
+ if (Number(res.changes ?? 0) === 0) return null;
353
+ return getApiMessage(topicId, messageId);
354
+ }
355
+
356
+ export interface MessagePage {
357
+ page: MessageDto[];
358
+ cursor?: string;
359
+ hasMore: boolean;
360
+ }
361
+
362
+ /**
363
+ * List messages for a topic, NEWEST-FIRST by default (bug G).
364
+ *
365
+ * The chat client loads once on mount and renders whatever it gets, so the
366
+ * initial page must be the *latest* N messages, not the oldest. We therefore
367
+ * query newest→oldest (`ORDER BY rowid DESC`) and then reverse the page back to
368
+ * chronological (oldest→newest) order for display.
369
+ *
370
+ * Pagination is BACKWARD (toward older history): pass the returned `cursor`
371
+ * (the oldest id currently loaded) to fetch the page of messages *older* than
372
+ * it. `hasMore` therefore means "there are older messages above this page".
373
+ */
374
+ export function listApiMessages(
375
+ topicId: string,
376
+ options?: { cursor?: string | null; limit?: number },
377
+ ): MessagePage {
378
+ const limit = Math.max(1, Math.min(options?.limit ?? 50, 200));
379
+
380
+ let anchorRowid: number | null = null;
381
+ if (options?.cursor) {
382
+ const cur = db
383
+ .query("SELECT rowid AS rowid FROM api_messages WHERE topic_id = ? AND id = ?")
384
+ .get(topicId, options.cursor) as { rowid: number } | undefined;
385
+ if (cur) anchorRowid = cur.rowid;
386
+ }
387
+
388
+ const rows = (
389
+ anchorRowid !== null
390
+ ? db
391
+ .query(
392
+ `SELECT *, rowid AS rowid FROM api_messages
393
+ WHERE topic_id = ? AND deleted = 0 AND thread_root_id IS NULL AND rowid < ?
394
+ ORDER BY rowid DESC
395
+ LIMIT ?`,
396
+ )
397
+ .all(topicId, anchorRowid, limit + 1)
398
+ : db
399
+ .query(
400
+ `SELECT *, rowid AS rowid FROM api_messages
401
+ WHERE topic_id = ? AND deleted = 0 AND thread_root_id IS NULL
402
+ ORDER BY rowid DESC
403
+ LIMIT ?`,
404
+ )
405
+ .all(topicId, limit + 1)
406
+ ) as (ApiMessageRow & { rowid: number })[];
407
+
408
+ const hasMore = rows.length > limit;
409
+ // rows are newest→oldest; trim the look-ahead row, then flip to chronological.
410
+ const pageRows = (hasMore ? rows.slice(0, limit) : rows).reverse();
411
+ const page = pageRows.map(rowToDto);
412
+
413
+ // Stamp thread reply counts onto the messages that are thread roots.
414
+ const summaries = getThreadSummaries(page.map((m) => m.id));
415
+ if (summaries.size > 0) {
416
+ for (const m of page) {
417
+ const s = summaries.get(m.id);
418
+ if (s) {
419
+ m.threadReplyCount = s.replyCount;
420
+ m.threadLastReplyAt = s.lastReplyAt;
421
+ }
422
+ }
423
+ }
424
+
425
+ return {
426
+ page,
427
+ // Oldest loaded id — pass back as `cursor` to load the previous (older) page.
428
+ cursor: page.length > 0 ? page[0].id : undefined,
429
+ hasMore,
430
+ };
431
+ }
432
+
433
+ /**
434
+ * Reply count + last reply time per thread root, computed on read (no
435
+ * denormalized counters to drift). Only non-deleted replies are counted.
436
+ */
437
+ export function getThreadSummaries(
438
+ rootIds: readonly string[],
439
+ ): Map<string, { replyCount: number; lastReplyAt: string }> {
440
+ const out = new Map<string, { replyCount: number; lastReplyAt: string }>();
441
+ const ids = [...new Set(rootIds)].filter(Boolean);
442
+ if (ids.length === 0) return out;
443
+ const rows = db
444
+ .query(
445
+ `SELECT thread_root_id AS rootId, COUNT(*) AS replyCount, MAX(created_at) AS lastReplyAt
446
+ FROM api_messages
447
+ WHERE thread_root_id IN (${ids.map(() => "?").join(",")}) AND deleted = 0
448
+ GROUP BY thread_root_id`,
449
+ )
450
+ .all(...ids) as { rootId: string; replyCount: number; lastReplyAt: string }[];
451
+ for (const r of rows) {
452
+ out.set(r.rootId, { replyCount: Number(r.replyCount), lastReplyAt: r.lastReplyAt });
453
+ }
454
+ return out;
455
+ }
456
+
457
+ /** The root message plus its thread replies (chronological). */
458
+ export function listThreadMessages(
459
+ topicId: string,
460
+ rootId: string,
461
+ ): { root: MessageDto | null; replies: MessageDto[] } {
462
+ const root = getApiMessage(topicId, rootId);
463
+ const rows = db
464
+ .query(
465
+ `SELECT *, rowid AS rowid FROM api_messages
466
+ WHERE topic_id = ? AND thread_root_id = ? AND deleted = 0
467
+ ORDER BY rowid ASC
468
+ LIMIT 200`,
469
+ )
470
+ .all(topicId, rootId) as ApiMessageRow[];
471
+ return { root, replies: rows.map(rowToDto) };
472
+ }
473
+
474
+ /**
475
+ * Find the newest same-user/same-text message that is recent enough to be the
476
+ * message just posted before a follow-up `/ai` call. This is a compatibility
477
+ * fallback for clients that persist attachments through `/messages` but do not
478
+ * repeat their file ids in the `/ai` request body.
479
+ */
480
+ export function findRecentUserMessage(
481
+ topicId: string,
482
+ authorId: string,
483
+ text: string,
484
+ maxAgeMs: number,
485
+ ): MessageDto | undefined {
486
+ const row = db
487
+ .query(
488
+ `SELECT * FROM api_messages
489
+ WHERE topic_id = ? AND author_id = ? AND text = ?
490
+ AND deleted = 0
491
+ ORDER BY rowid DESC
492
+ LIMIT 1`,
493
+ )
494
+ .get(topicId, authorId, text) as ApiMessageRow | undefined;
495
+ if (!row) return undefined;
496
+
497
+ const createdMs = Date.parse(row.created_at);
498
+ if (!Number.isFinite(createdMs) || Date.now() - createdMs > maxAgeMs) return undefined;
499
+ return rowToDto(row);
500
+ }
501
+
502
+ /**
503
+ * Latest message text per topic, keyed by topic_id. Used to render a preview
504
+ * subtitle in the topic list. One index-driven query (MAX(rowid) per topic)
505
+ * instead of N round-trips. Empty topics are simply absent from the map.
506
+ */
507
+ export function getLastMessagePreviews(topicIds?: readonly string[]): Map<string, string> {
508
+ const uniqueTopicIds = topicIds ? [...new Set(topicIds)] : undefined;
509
+ if (uniqueTopicIds && uniqueTopicIds.length === 0) return new Map();
510
+ const topicFilter = uniqueTopicIds
511
+ ? `AND topic_id IN (${uniqueTopicIds.map(() => "?").join(",")})`
512
+ : "";
513
+ const rows = db
514
+ .query(
515
+ `SELECT m.topic_id AS topic_id, m.text AS text
516
+ FROM api_messages m
517
+ JOIN (
518
+ SELECT topic_id, MAX(rowid) AS mx FROM api_messages
519
+ WHERE deleted = 0 AND thread_root_id IS NULL
520
+ ${topicFilter}
521
+ GROUP BY topic_id
522
+ ) last ON m.topic_id = last.topic_id AND m.rowid = last.mx`,
523
+ )
524
+ .all(...(uniqueTopicIds ?? [])) as { topic_id: string; text: string }[];
525
+
526
+ const out = new Map<string, string>();
527
+ for (const r of rows) {
528
+ const preview = (r.text ?? "").replace(/\s+/g, " ").trim().slice(0, 120);
529
+ if (preview) out.set(r.topic_id, preview);
530
+ }
531
+ return out;
532
+ }
533
+
534
+ /** Hard-delete every message in a topic (used when a topic is deleted). */
535
+ export function deleteMessagesForTopic(topicId: string): number {
536
+ const res = db.query("DELETE FROM api_messages WHERE topic_id = ?").run(topicId);
537
+ return Number(res.changes ?? 0);
538
+ }
539
+
540
+ /** Anonymize all messages authored by a deleted user. */
541
+ export function anonymizeApiMessages(userId: string): void {
542
+ db.query("UPDATE api_messages SET author_id = 'deleted-user' WHERE author_id = ?").run(userId);
543
+ }
544
+
545
+ /** Topic ids whose persisted attachment JSON references this exact upload id.
546
+ * Used only as a compatibility bridge for uploads created before ACL metadata
547
+ * was introduced. JSON UUIDs are quoted, avoiding substring matches. */
548
+ export function listTopicIdsForAttachment(fileId: string): string[] {
549
+ const needle = `%"${fileId.replace(/[\\%_]/g, (char) => `\\${char}`)}"%`;
550
+ // The ESCAPE literal must reach SQLite as a SINGLE backslash. SQLite string
551
+ // literals do no backslash processing, so '\\' there is two characters and
552
+ // fails at row-evaluation time ("ESCAPE expression must be a single
553
+ // character") — which only surfaces once api_messages has any rows.
554
+ const rows = db
555
+ .query<{ topic_id: string }, [string]>(
556
+ "SELECT DISTINCT topic_id FROM api_messages WHERE attachments LIKE ? ESCAPE '\\'",
557
+ )
558
+ .all(needle);
559
+ return rows.map((row) => row.topic_id);
560
+ }
561
+
562
+ /**
563
+ * Copy all messages from one topic to another (used by fork).
564
+ * Each message gets a new UUID but retains original content, author, and timestamps.
565
+ * Returns the number of messages copied.
566
+ */
567
+ export function copyMessagesForTopic(sourceTopicId: string, targetTopicId: string): number {
568
+ const rows = db
569
+ .query("SELECT * FROM api_messages WHERE topic_id = ? AND deleted = 0 ORDER BY rowid ASC")
570
+ .all(sourceTopicId) as ApiMessageRow[];
571
+
572
+ if (rows.length === 0) return 0;
573
+
574
+ const idMap = new Map(rows.map((r) => [r.id, crypto.randomUUID()]));
575
+ let copied = 0;
576
+ for (const r of rows) {
577
+ const newId = idMap.get(r.id);
578
+ if (!newId) continue;
579
+ appendApiMessage(
580
+ {
581
+ ...rowToDto(r),
582
+ id: newId,
583
+ topicId: targetTopicId,
584
+ parentId: r.parent_id ? (idMap.get(r.parent_id) ?? r.parent_id) : undefined,
585
+ threadRootId: r.thread_root_id
586
+ ? (idMap.get(r.thread_root_id) ?? r.thread_root_id)
587
+ : undefined,
588
+ },
589
+ { notify: false, updateTopicLastMessageAt: false },
590
+ );
591
+ copied++;
592
+ }
593
+
594
+ return copied;
595
+ }
596
+
597
+ /**
598
+ * Get all messages for a topic in chronological order (used by export).
599
+ */
600
+ export function getAllMessagesForTopic(topicId: string): ApiMessageRow[] {
601
+ return db
602
+ .query(
603
+ "SELECT *, rowid AS rowid FROM api_messages WHERE topic_id = ? AND deleted = 0 ORDER BY rowid ASC",
604
+ )
605
+ .all(topicId) as ApiMessageRow[];
606
+ }
607
+
608
+ export function getMessagesForTopicAfterRowid(
609
+ topicId: string,
610
+ afterRowid: number,
611
+ ): ApiMessageRow[] {
612
+ return db
613
+ .query(
614
+ "SELECT *, rowid AS rowid FROM api_messages WHERE topic_id = ? AND rowid > ? AND deleted = 0 ORDER BY rowid ASC",
615
+ )
616
+ .all(topicId, afterRowid) as ApiMessageRow[];
617
+ }