@corgelabs/corge 0.0.1 → 1.0.1

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 (341) hide show
  1. package/dist/ai/index.d.ts +3 -0
  2. package/dist/ai/index.d.ts.map +1 -0
  3. package/dist/ai/index.js +3 -0
  4. package/dist/ai/index.js.map +1 -0
  5. package/dist/ai/schema.d.ts +83 -0
  6. package/dist/ai/schema.d.ts.map +1 -0
  7. package/dist/ai/schema.js +19 -0
  8. package/dist/ai/schema.js.map +1 -0
  9. package/dist/ai/service.d.ts +14 -0
  10. package/dist/ai/service.d.ts.map +1 -0
  11. package/dist/ai/service.js +266 -0
  12. package/dist/ai/service.js.map +1 -0
  13. package/dist/ai/utils.d.ts +10 -0
  14. package/dist/ai/utils.d.ts.map +1 -0
  15. package/dist/ai/utils.js +27 -0
  16. package/dist/ai/utils.js.map +1 -0
  17. package/dist/attachments/index.d.ts +4 -0
  18. package/dist/attachments/index.d.ts.map +1 -0
  19. package/dist/attachments/index.js +4 -0
  20. package/dist/attachments/index.js.map +1 -0
  21. package/dist/attachments/routes.d.ts +75 -0
  22. package/dist/attachments/routes.d.ts.map +1 -0
  23. package/dist/attachments/routes.js +18 -0
  24. package/dist/attachments/routes.js.map +1 -0
  25. package/dist/attachments/schema.d.ts +61 -0
  26. package/dist/attachments/schema.d.ts.map +1 -0
  27. package/dist/attachments/schema.js +30 -0
  28. package/dist/attachments/schema.js.map +1 -0
  29. package/dist/attachments/service.d.ts +27 -0
  30. package/dist/attachments/service.d.ts.map +1 -0
  31. package/dist/attachments/service.js +224 -0
  32. package/dist/attachments/service.js.map +1 -0
  33. package/dist/attachments/utils.d.ts +9 -0
  34. package/dist/attachments/utils.d.ts.map +1 -0
  35. package/dist/attachments/utils.js +80 -0
  36. package/dist/attachments/utils.js.map +1 -0
  37. package/dist/auth/index.d.ts +4 -0
  38. package/dist/auth/index.d.ts.map +1 -0
  39. package/dist/auth/index.js +4 -0
  40. package/dist/auth/index.js.map +1 -0
  41. package/dist/auth/oidc.d.ts +25 -0
  42. package/dist/auth/oidc.d.ts.map +1 -0
  43. package/dist/auth/oidc.js +298 -0
  44. package/dist/auth/oidc.js.map +1 -0
  45. package/dist/auth/recovery.d.ts +41 -0
  46. package/dist/auth/recovery.d.ts.map +1 -0
  47. package/dist/auth/recovery.js +250 -0
  48. package/dist/auth/recovery.js.map +1 -0
  49. package/dist/auth/routes.d.ts +226 -0
  50. package/dist/auth/routes.d.ts.map +1 -0
  51. package/dist/auth/routes.js +96 -0
  52. package/dist/auth/routes.js.map +1 -0
  53. package/dist/auth/schema.d.ts +158 -0
  54. package/dist/auth/schema.d.ts.map +1 -0
  55. package/dist/auth/schema.js +82 -0
  56. package/dist/auth/schema.js.map +1 -0
  57. package/dist/auth/service.d.ts +96 -0
  58. package/dist/auth/service.d.ts.map +1 -0
  59. package/dist/auth/service.js +129 -0
  60. package/dist/auth/service.js.map +1 -0
  61. package/dist/auth/session.d.ts +51 -0
  62. package/dist/auth/session.d.ts.map +1 -0
  63. package/dist/auth/session.js +344 -0
  64. package/dist/auth/session.js.map +1 -0
  65. package/dist/auth/utils.d.ts +17 -0
  66. package/dist/auth/utils.d.ts.map +1 -0
  67. package/dist/auth/utils.js +96 -0
  68. package/dist/auth/utils.js.map +1 -0
  69. package/dist/bin/corge.js +542 -0
  70. package/dist/chat/context.d.ts +10 -0
  71. package/dist/chat/context.d.ts.map +1 -0
  72. package/dist/chat/context.js +221 -0
  73. package/dist/chat/context.js.map +1 -0
  74. package/dist/chat/index.d.ts +4 -0
  75. package/dist/chat/index.d.ts.map +1 -0
  76. package/dist/chat/index.js +4 -0
  77. package/dist/chat/index.js.map +1 -0
  78. package/dist/chat/routes.d.ts +160 -0
  79. package/dist/chat/routes.d.ts.map +1 -0
  80. package/dist/chat/routes.js +210 -0
  81. package/dist/chat/routes.js.map +1 -0
  82. package/dist/chat/schema.d.ts +285 -0
  83. package/dist/chat/schema.d.ts.map +1 -0
  84. package/dist/chat/schema.js +74 -0
  85. package/dist/chat/schema.js.map +1 -0
  86. package/dist/chat/service.d.ts +64 -0
  87. package/dist/chat/service.d.ts.map +1 -0
  88. package/dist/chat/service.js +279 -0
  89. package/dist/chat/service.js.map +1 -0
  90. package/dist/chat/stream.d.ts +53 -0
  91. package/dist/chat/stream.d.ts.map +1 -0
  92. package/dist/chat/stream.js +339 -0
  93. package/dist/chat/stream.js.map +1 -0
  94. package/dist/chat/utils.d.ts +61 -0
  95. package/dist/chat/utils.d.ts.map +1 -0
  96. package/dist/chat/utils.js +285 -0
  97. package/dist/chat/utils.js.map +1 -0
  98. package/dist/cli/builder.d.ts +6 -0
  99. package/dist/cli/builder.d.ts.map +1 -0
  100. package/dist/cli/builder.js +91 -0
  101. package/dist/cli/builder.js.map +1 -0
  102. package/dist/cli/deployer.d.ts +2 -0
  103. package/dist/cli/deployer.d.ts.map +1 -0
  104. package/dist/cli/deployer.js +152 -0
  105. package/dist/cli/deployer.js.map +1 -0
  106. package/dist/cli/dev-server.d.ts +2 -0
  107. package/dist/cli/dev-server.d.ts.map +1 -0
  108. package/dist/cli/dev-server.js +70 -0
  109. package/dist/cli/dev-server.js.map +1 -0
  110. package/dist/cli/env.d.ts +4 -0
  111. package/dist/cli/env.d.ts.map +1 -0
  112. package/dist/cli/env.js +68 -0
  113. package/dist/cli/env.js.map +1 -0
  114. package/dist/cli/init.d.ts +6 -0
  115. package/dist/cli/init.d.ts.map +1 -0
  116. package/dist/cli/init.js +97 -0
  117. package/dist/cli/init.js.map +1 -0
  118. package/dist/config.d.ts +262 -0
  119. package/dist/config.d.ts.map +1 -0
  120. package/dist/config.js +212 -0
  121. package/dist/config.js.map +1 -0
  122. package/dist/db/index.d.ts +3 -0
  123. package/dist/db/index.d.ts.map +1 -0
  124. package/dist/db/index.js +3 -0
  125. package/dist/db/index.js.map +1 -0
  126. package/dist/db/schema.d.ts +410 -0
  127. package/dist/db/schema.d.ts.map +1 -0
  128. package/dist/db/schema.js +294 -0
  129. package/dist/db/schema.js.map +1 -0
  130. package/dist/db/service.d.ts +42 -0
  131. package/dist/db/service.d.ts.map +1 -0
  132. package/dist/db/service.js +372 -0
  133. package/dist/db/service.js.map +1 -0
  134. package/dist/db/utils.d.ts +25 -0
  135. package/dist/db/utils.d.ts.map +1 -0
  136. package/dist/db/utils.js +188 -0
  137. package/dist/db/utils.js.map +1 -0
  138. package/dist/emails/index.d.ts +4 -0
  139. package/dist/emails/index.d.ts.map +1 -0
  140. package/dist/emails/index.js +4 -0
  141. package/dist/emails/index.js.map +1 -0
  142. package/dist/emails/routes.d.ts +191 -0
  143. package/dist/emails/routes.d.ts.map +1 -0
  144. package/dist/emails/routes.js +39 -0
  145. package/dist/emails/routes.js.map +1 -0
  146. package/dist/emails/schema.d.ts +237 -0
  147. package/dist/emails/schema.d.ts.map +1 -0
  148. package/dist/emails/schema.js +98 -0
  149. package/dist/emails/schema.js.map +1 -0
  150. package/dist/emails/service.d.ts +22 -0
  151. package/dist/emails/service.d.ts.map +1 -0
  152. package/dist/emails/service.js +337 -0
  153. package/dist/emails/service.js.map +1 -0
  154. package/dist/emails/utils.d.ts +49 -0
  155. package/dist/emails/utils.d.ts.map +1 -0
  156. package/dist/emails/utils.js +341 -0
  157. package/dist/emails/utils.js.map +1 -0
  158. package/dist/files/index.d.ts +4 -0
  159. package/dist/files/index.d.ts.map +1 -0
  160. package/dist/files/index.js +4 -0
  161. package/dist/files/index.js.map +1 -0
  162. package/dist/files/routes.d.ts +72 -0
  163. package/dist/files/routes.d.ts.map +1 -0
  164. package/dist/files/routes.js +38 -0
  165. package/dist/files/routes.js.map +1 -0
  166. package/dist/files/schema.d.ts +57 -0
  167. package/dist/files/schema.d.ts.map +1 -0
  168. package/dist/files/schema.js +39 -0
  169. package/dist/files/schema.js.map +1 -0
  170. package/dist/files/service.d.ts +27 -0
  171. package/dist/files/service.d.ts.map +1 -0
  172. package/dist/files/service.js +175 -0
  173. package/dist/files/service.js.map +1 -0
  174. package/dist/files/utils.d.ts +3 -0
  175. package/dist/files/utils.d.ts.map +1 -0
  176. package/dist/files/utils.js +63 -0
  177. package/dist/files/utils.js.map +1 -0
  178. package/dist/index.d.ts +875 -0
  179. package/dist/index.d.ts.map +1 -0
  180. package/dist/index.js +9919 -0
  181. package/dist/index.js.map +7 -0
  182. package/dist/payments/billing.d.ts +29 -0
  183. package/dist/payments/billing.d.ts.map +1 -0
  184. package/dist/payments/billing.js +378 -0
  185. package/dist/payments/billing.js.map +1 -0
  186. package/dist/payments/index.d.ts +4 -0
  187. package/dist/payments/index.d.ts.map +1 -0
  188. package/dist/payments/index.js +4 -0
  189. package/dist/payments/index.js.map +1 -0
  190. package/dist/payments/routes.d.ts +340 -0
  191. package/dist/payments/routes.d.ts.map +1 -0
  192. package/dist/payments/routes.js +56 -0
  193. package/dist/payments/routes.js.map +1 -0
  194. package/dist/payments/schema.d.ts +412 -0
  195. package/dist/payments/schema.d.ts.map +1 -0
  196. package/dist/payments/schema.js +260 -0
  197. package/dist/payments/schema.js.map +1 -0
  198. package/dist/payments/service.d.ts +31 -0
  199. package/dist/payments/service.d.ts.map +1 -0
  200. package/dist/payments/service.js +322 -0
  201. package/dist/payments/service.js.map +1 -0
  202. package/dist/payments/utils.d.ts +61 -0
  203. package/dist/payments/utils.d.ts.map +1 -0
  204. package/dist/payments/utils.js +369 -0
  205. package/dist/payments/utils.js.map +1 -0
  206. package/dist/router/guard.d.ts +19 -0
  207. package/dist/router/guard.d.ts.map +1 -0
  208. package/dist/router/guard.js +243 -0
  209. package/dist/router/guard.js.map +1 -0
  210. package/dist/router/index.d.ts +3 -0
  211. package/dist/router/index.d.ts.map +1 -0
  212. package/dist/router/index.js +3 -0
  213. package/dist/router/index.js.map +1 -0
  214. package/dist/router/schema.d.ts +103 -0
  215. package/dist/router/schema.d.ts.map +1 -0
  216. package/dist/router/schema.js +282 -0
  217. package/dist/router/schema.js.map +1 -0
  218. package/dist/router/service.d.ts +25 -0
  219. package/dist/router/service.d.ts.map +1 -0
  220. package/dist/router/service.js +339 -0
  221. package/dist/router/service.js.map +1 -0
  222. package/dist/tasks/index.d.ts +4 -0
  223. package/dist/tasks/index.d.ts.map +1 -0
  224. package/dist/tasks/index.js +4 -0
  225. package/dist/tasks/index.js.map +1 -0
  226. package/dist/tasks/routes.d.ts +54 -0
  227. package/dist/tasks/routes.d.ts.map +1 -0
  228. package/dist/tasks/routes.js +195 -0
  229. package/dist/tasks/routes.js.map +1 -0
  230. package/dist/tasks/schema.d.ts +261 -0
  231. package/dist/tasks/schema.d.ts.map +1 -0
  232. package/dist/tasks/schema.js +35 -0
  233. package/dist/tasks/schema.js.map +1 -0
  234. package/dist/tasks/service.d.ts +32 -0
  235. package/dist/tasks/service.d.ts.map +1 -0
  236. package/dist/tasks/service.js +201 -0
  237. package/dist/tasks/service.js.map +1 -0
  238. package/dist/tasks/steps.d.ts +18 -0
  239. package/dist/tasks/steps.d.ts.map +1 -0
  240. package/dist/tasks/steps.js +365 -0
  241. package/dist/tasks/steps.js.map +1 -0
  242. package/dist/tasks/utils.d.ts +75 -0
  243. package/dist/tasks/utils.d.ts.map +1 -0
  244. package/dist/tasks/utils.js +311 -0
  245. package/dist/tasks/utils.js.map +1 -0
  246. package/dist/tasks/watcher.d.ts +13 -0
  247. package/dist/tasks/watcher.d.ts.map +1 -0
  248. package/dist/tasks/watcher.js +255 -0
  249. package/dist/tasks/watcher.js.map +1 -0
  250. package/dist/tasks/worker.d.ts +28 -0
  251. package/dist/tasks/worker.d.ts.map +1 -0
  252. package/dist/tasks/worker.js +326 -0
  253. package/dist/tasks/worker.js.map +1 -0
  254. package/dist/utils/index.d.ts +3 -0
  255. package/dist/utils/index.d.ts.map +1 -0
  256. package/dist/utils/index.js +3 -0
  257. package/dist/utils/index.js.map +1 -0
  258. package/dist/utils/schema.d.ts +79 -0
  259. package/dist/utils/schema.d.ts.map +1 -0
  260. package/dist/utils/schema.js +41 -0
  261. package/dist/utils/schema.js.map +1 -0
  262. package/dist/utils/service.d.ts +32 -0
  263. package/dist/utils/service.d.ts.map +1 -0
  264. package/dist/utils/service.js +384 -0
  265. package/dist/utils/service.js.map +1 -0
  266. package/docs/SDK_SPEC.md +1645 -0
  267. package/infra/firestore.base.indexes.json +138 -0
  268. package/infra/firestore.base.rules +152 -0
  269. package/infra/storage.base.rules +8 -0
  270. package/package.json +58 -13
  271. package/src/ai/index.ts +2 -0
  272. package/src/ai/schema.ts +64 -0
  273. package/src/ai/service.ts +317 -0
  274. package/src/ai/utils.ts +38 -0
  275. package/src/attachments/index.ts +3 -0
  276. package/src/attachments/routes.ts +28 -0
  277. package/src/attachments/schema.ts +45 -0
  278. package/src/attachments/service.ts +315 -0
  279. package/src/attachments/utils.ts +115 -0
  280. package/src/auth/index.ts +3 -0
  281. package/src/auth/oidc.ts +419 -0
  282. package/src/auth/recovery.ts +365 -0
  283. package/src/auth/routes.ts +142 -0
  284. package/src/auth/schema.ts +181 -0
  285. package/src/auth/service.ts +183 -0
  286. package/src/auth/session.ts +414 -0
  287. package/src/auth/utils.ts +106 -0
  288. package/src/chat/context.ts +310 -0
  289. package/src/chat/index.ts +3 -0
  290. package/src/chat/routes.ts +264 -0
  291. package/src/chat/schema.ts +295 -0
  292. package/src/chat/service.ts +360 -0
  293. package/src/chat/stream.ts +481 -0
  294. package/src/chat/utils.ts +370 -0
  295. package/src/cli/builder.ts +93 -0
  296. package/src/cli/deployer.ts +184 -0
  297. package/src/cli/dev-server.ts +77 -0
  298. package/src/cli/env.ts +89 -0
  299. package/src/cli/init.ts +121 -0
  300. package/src/config.ts +447 -0
  301. package/src/db/index.ts +2 -0
  302. package/src/db/schema.ts +409 -0
  303. package/src/db/service.ts +477 -0
  304. package/src/db/utils.ts +250 -0
  305. package/src/emails/index.ts +3 -0
  306. package/src/emails/routes.ts +64 -0
  307. package/src/emails/schema.ts +239 -0
  308. package/src/emails/service.ts +488 -0
  309. package/src/emails/utils.ts +449 -0
  310. package/src/files/index.ts +3 -0
  311. package/src/files/routes.ts +44 -0
  312. package/src/files/schema.ts +76 -0
  313. package/src/files/service.ts +238 -0
  314. package/src/files/utils.ts +77 -0
  315. package/src/index.ts +425 -0
  316. package/src/payments/billing.ts +488 -0
  317. package/src/payments/index.ts +3 -0
  318. package/src/payments/routes.ts +92 -0
  319. package/src/payments/schema.ts +401 -0
  320. package/src/payments/service.ts +445 -0
  321. package/src/payments/utils.ts +472 -0
  322. package/src/router/guard.ts +319 -0
  323. package/src/router/index.ts +2 -0
  324. package/src/router/schema.ts +458 -0
  325. package/src/router/service.ts +453 -0
  326. package/src/tasks/index.ts +11 -0
  327. package/src/tasks/routes.ts +230 -0
  328. package/src/tasks/schema.ts +310 -0
  329. package/src/tasks/service.ts +254 -0
  330. package/src/tasks/steps.ts +472 -0
  331. package/src/tasks/utils.ts +445 -0
  332. package/src/tasks/watcher.ts +307 -0
  333. package/src/tasks/worker.ts +461 -0
  334. package/src/utils/index.ts +2 -0
  335. package/src/utils/schema.ts +69 -0
  336. package/src/utils/service.ts +461 -0
  337. package/tests/e2e.test.ts +2531 -0
  338. package/tsconfig.base.json +12 -0
  339. package/README.md +0 -2
  340. package/index.d.ts +0 -1
  341. package/index.js +0 -1
@@ -0,0 +1,481 @@
1
+ import { type ModelMessage, type streamText } from 'ai'
2
+ import { type DocumentReference } from 'firebase-admin/firestore'
3
+ import type { Context } from 'hono'
4
+ import type { z } from 'zod'
5
+ import type { AttachmentTypesMap, ProcessedAttachment } from '../attachments'
6
+ import { CORE_CONFIG, type AppEnv, type DbRole } from '../config'
7
+ import { entryId, type EntryId, type SelectModel } from '../db'
8
+ import type { FileRef, FileType } from '../files'
9
+ import { Core } from '../index'
10
+ import {
11
+ saveChatBody,
12
+ streamChatBody,
13
+ type AgentConfig,
14
+ type ChatMessagePublic,
15
+ type ListChatMessagesOptions,
16
+ type PersistTurnCompletionOpts,
17
+ type RawAttachmentPart
18
+ } from './schema'
19
+ import {
20
+ buildCompletedTurnWriteBatch,
21
+ buildUIMessageResponse,
22
+ deriveTitle,
23
+ extractMessageText,
24
+ extractToolInvocations,
25
+ extractTurnAttachmentParts,
26
+ formatAttachmentLine,
27
+ mapFirestoreMessageDoc,
28
+ PUBLIC_MESSAGE_FIELDS
29
+ } from './utils'
30
+
31
+ interface ActiveGenerationEntry {
32
+ generationId: string
33
+ controller: AbortController
34
+ partialText: () => string
35
+ completionPromise?: Promise<void>
36
+ }
37
+
38
+ const activeGenerations = new Map<string, ActiveGenerationEntry>()
39
+ const genKey = (isDev: boolean, chatId: string): string => `${isDev ? 'dev' : 'prod'}:${chatId}`
40
+
41
+ export class ChatStreamEngine {
42
+ readonly #core: Core
43
+ readonly #agentRegistry: Map<string, AgentConfig>
44
+
45
+ constructor(coreClient: Core, agentRegistry: Map<string, AgentConfig>) {
46
+ this.#core = coreClient
47
+ this.#agentRegistry = agentRegistry
48
+ }
49
+
50
+ #assertChatAccess = async (chatId: EntryId): Promise<void> => {
51
+ const caller = this.#core.caller
52
+ if (caller.role !== 'admin' && caller.userId !== CORE_CONFIG.auth.adminUserId) {
53
+ const chatDoc = await this.#core.db.read('chat', chatId, false, {
54
+ userId: caller.userId,
55
+ userRole: caller.role
56
+ })
57
+ if (!chatDoc) this.#core.errors.httpError(404, `Chat not found: ${chatId}`, 'NOT_FOUND')
58
+ }
59
+ }
60
+
61
+ enforceAgentAccess = async (c: Context<AppEnv>, agent: AgentConfig, scopeKey: string): Promise<void> => {
62
+ if (agent.auth) {
63
+ await this.#core.internals.context.authenticateRequest(c, agent.auth)
64
+ }
65
+ if (agent.rateLimit !== undefined) {
66
+ this.#core.internals.context.applyRateLimit(c, agent.rateLimit, `${scopeKey}:${agent.name ?? 'agent'}`)
67
+ }
68
+ }
69
+
70
+ stageAndScanAttachments = async (
71
+ userId: EntryId,
72
+ parts: RawAttachmentPart[],
73
+ types?: AttachmentTypesMap
74
+ ): Promise<{ staged: { ref: FileRef; filename: string }[]; attachments: ProcessedAttachment[] }> => {
75
+ if (parts.length === 0) return { staged: [], attachments: [] }
76
+ if (parts.length > CORE_CONFIG.limits.maxChatAttachments) {
77
+ this.#core.errors.httpError(
78
+ 400,
79
+ `Too many attachments (max ${CORE_CONFIG.limits.maxChatAttachments})`,
80
+ 'TOO_MANY_ATTACHMENTS'
81
+ )
82
+ }
83
+ const staged = await Promise.all(
84
+ parts.map(async (part) => {
85
+ if (part.ref) {
86
+ if (!this.#core.files.canAccess(part.ref, userId)) {
87
+ this.#core.errors.httpError(403, 'Forbidden', 'FORBIDDEN')
88
+ }
89
+ return { ref: part.ref, filename: part.filename }
90
+ }
91
+ if ((part.dataUrl?.length ?? 0) > 28 * 1024 * 1024) {
92
+ this.#core.errors.httpError(413, 'Attachment exceeds 20MiB limit', 'PAYLOAD_TOO_LARGE')
93
+ }
94
+ const match = /^data:([^;,]+);base64,(.+)$/.exec(part.dataUrl ?? '')
95
+ if (!match?.[1] || !match[2]) {
96
+ return this.#core.errors.httpError(400, 'Invalid base64 data URL for attachment', 'INVALID_FILE')
97
+ }
98
+ const contentType = match[1] as FileType
99
+ const bytes = new Uint8Array(Buffer.from(match[2], 'base64'))
100
+ const { ref } = await this.#core.files.upload({ type: contentType, name: part.filename, bytes }, userId)
101
+ return { ref, filename: part.filename }
102
+ })
103
+ )
104
+ const { attachments } = await this.#core.attachments.process({ refs: staged.map((s) => s.ref) }, { types })
105
+ return { staged, attachments }
106
+ }
107
+
108
+ saveChat = async ({
109
+ userId = this.#core.userId,
110
+ input
111
+ }: {
112
+ userId?: EntryId
113
+ input: z.infer<typeof saveChatBody>
114
+ }): Promise<SelectModel<'chat'>> => {
115
+ const targetId = input.id ?? input.chatId
116
+ const existing = targetId ? await this.#core.db.read('chat', targetId, true, { bypassRbac: true }) : undefined
117
+ if (existing && userId !== CORE_CONFIG.auth.adminUserId && existing.userId !== userId) {
118
+ return this.#core.errors.httpError(403, 'Forbidden', 'FORBIDDEN')
119
+ }
120
+ const defaultAgentId = this.#agentRegistry.keys().next().value ?? 'chat'
121
+ if (existing && input.agentId && input.agentId !== existing.agentId) {
122
+ return this.#core.errors.httpError(400, 'Cannot switch agentId on an existing chat', 'AGENT_IMMUTABLE')
123
+ }
124
+ const title = input.title ?? existing?.title ?? 'New Chat'
125
+ const agentId = existing ? existing.agentId : (input.agentId ?? defaultAgentId)
126
+ if (!this.#agentRegistry.has(agentId)) {
127
+ return this.#core.errors.httpError(404, `Unknown agent: ${agentId}`, 'NOT_FOUND')
128
+ }
129
+ const model = input.model ?? existing?.model ?? CORE_CONFIG.models.vertex
130
+
131
+ if (existing && targetId) {
132
+ const patch = { title, model, ...(input.summary !== undefined ? { summary: input.summary } : {}), deleted: false }
133
+ await this.#core.db.update('chat', targetId, patch, { bypassRbac: true })
134
+ return { ...existing, ...patch, updatedAt: new Date() }
135
+ }
136
+
137
+ return this.#core.db.create(
138
+ 'chat',
139
+ {
140
+ ...(targetId ? { id: targetId } : {}),
141
+ userId,
142
+ agentId,
143
+ title,
144
+ model,
145
+ messageCount: 0,
146
+ summary: input.summary ?? null,
147
+ activeGenerationId: null
148
+ },
149
+ { bypassRbac: true }
150
+ )
151
+ }
152
+
153
+ messages = async (chatId: EntryId, opts?: ListChatMessagesOptions): Promise<ChatMessagePublic[]> => {
154
+ await this.#assertChatAccess(chatId)
155
+ const limit = Math.min(
156
+ Math.max(opts?.limit ?? CORE_CONFIG.limits.chatMessagesLimit, 1),
157
+ CORE_CONFIG.limits.defaultListLimit
158
+ )
159
+ const db = this.#core.db.firestore()
160
+ const messagesCol = db.collection('chat').doc(chatId).collection('messages')
161
+
162
+ await this.#core.internals.resilience.acquireLimit('firestore')
163
+ let query = messagesCol.select(...PUBLIC_MESSAGE_FIELDS).orderBy('createdAt', 'desc').limit(limit)
164
+ if (opts?.before) {
165
+ const beforeSnap = await messagesCol.doc(opts.before).get()
166
+ if (beforeSnap.exists) query = query.startAfter(beforeSnap)
167
+ }
168
+ const snap = await query.get()
169
+ return snap.docs.map((d) => mapFirestoreMessageDoc(d.id, d.data() ?? {})).reverse()
170
+ }
171
+
172
+ cancel = async (chatId: EntryId): Promise<{ chatId: string; cancelled: true }> => {
173
+ await this.#assertChatAccess(chatId)
174
+ const key = genKey(Boolean(this.#core.env.isDev), chatId)
175
+ const localGen = activeGenerations.get(key)
176
+
177
+ await this.#core.db.update('chat', chatId, { activeGenerationId: null }, { bypassRbac: true }).catch(() => {})
178
+
179
+ if (localGen) {
180
+ localGen.controller.abort(new Error('Cancelled by user'))
181
+ if (localGen.completionPromise) {
182
+ await Promise.race([localGen.completionPromise, new Promise((r) => setTimeout(r, 2_500))])
183
+ }
184
+ }
185
+
186
+ return { chatId, cancelled: true }
187
+ }
188
+
189
+ #resolveStreamTurnTarget = async (c: Context<AppEnv>, userId: EntryId, body: z.infer<typeof streamChatBody>) => {
190
+ const paramValue = c.req.param('chatId')
191
+ const paramIsAgent = Boolean(
192
+ paramValue && this.#agentRegistry.has(paramValue) && !body.agentId && !body.chatId && !body.id
193
+ )
194
+ const defaultAgentId = this.#agentRegistry.keys().next().value ?? 'chat'
195
+ const db = this.#core.db.firestore()
196
+ const candidateChatId = !paramIsAgent ? (paramValue ?? body.chatId ?? body.id) : (body.chatId ?? body.id)
197
+ const chatId =
198
+ candidateChatId && entryId.safeParse(candidateChatId).success
199
+ ? candidateChatId
200
+ : db.collection('chat').doc().id
201
+
202
+ const existing = await this.#core.db.read('chat', chatId, true, { bypassRbac: true })
203
+ if (existing && userId !== CORE_CONFIG.auth.adminUserId && existing.userId !== userId) {
204
+ this.#core.errors.httpError(403, 'Forbidden', 'FORBIDDEN')
205
+ }
206
+
207
+ const requestedAgentId = body.agentId ?? (paramIsAgent ? paramValue : undefined)
208
+ if (existing && requestedAgentId && requestedAgentId !== existing.agentId) {
209
+ this.#core.errors.httpError(400, 'Cannot switch agentId on an existing chat', 'AGENT_IMMUTABLE')
210
+ }
211
+ const resolvedAgentId = existing ? existing.agentId : (requestedAgentId ?? defaultAgentId)
212
+ const agent = this.#agentRegistry.get(resolvedAgentId)
213
+ if (!agent) return this.#core.errors.httpError(404, `Unknown agent: ${resolvedAgentId}`, 'NOT_FOUND')
214
+ await this.enforceAgentAccess(c, agent, 'chat.stream')
215
+
216
+ const attachmentParts = extractTurnAttachmentParts(body.message)
217
+ const rawUserText = extractMessageText(body.message)
218
+ if (!rawUserText && attachmentParts.length === 0) {
219
+ this.#core.errors.httpError(400, 'message must contain non-empty text or attachments', 'VALIDATION_ERROR')
220
+ }
221
+
222
+ const key = genKey(Boolean(this.#core.env.isDev), chatId)
223
+ const activeTimeoutMs = CORE_CONFIG.cloudRun.http.timeoutSeconds * 1000
224
+ const hasFreshRemoteLock =
225
+ Boolean(existing?.activeGenerationId) &&
226
+ Date.now() - (existing?.updatedAt instanceof Date ? existing.updatedAt.getTime() : 0) < activeTimeoutMs
227
+
228
+ if (activeGenerations.has(key) || hasFreshRemoteLock) {
229
+ this.#core.errors.httpError(409, 'A generation is already in progress for this chat', 'GENERATION_IN_PROGRESS')
230
+ }
231
+
232
+ return { chatId, existing, resolvedAgentId, agent, attachmentParts, rawUserText, key }
233
+ }
234
+
235
+ #persistTurnCompletion = async (opts: PersistTurnCompletionOpts): Promise<void> => {
236
+ let assistantText = ''
237
+ let totalTokens: number | undefined
238
+ const toolInvocations: unknown[] = []
239
+ try {
240
+ try {
241
+ const [text, response, usage] = await Promise.all([opts.turn.text, opts.turn.response, opts.turn.usage])
242
+ assistantText = text || opts.getAccumulatedText()
243
+ totalTokens = usage?.totalTokens ?? ((usage?.inputTokens ?? 0) + (usage?.outputTokens ?? 0) || undefined)
244
+ toolInvocations.push(...extractToolInvocations(response.messages))
245
+ } catch {
246
+ assistantText = opts.getAccumulatedText()
247
+ }
248
+
249
+ const finalAssistantText = assistantText.trim()
250
+ const [userVector, assistantVector] = await Promise.all([
251
+ opts.userVecPromise,
252
+ this.#core.ai.embed(finalAssistantText).catch(() => null)
253
+ ])
254
+
255
+ const batch = buildCompletedTurnWriteBatch(this.#core.db.firestore(), {
256
+ ...opts,
257
+ finalAssistantText,
258
+ toolInvocations,
259
+ ...(totalTokens !== undefined ? { totalTokens } : {}),
260
+ cancelled: opts.signal.aborted,
261
+ userVector,
262
+ assistantVector
263
+ })
264
+
265
+ await this.#core.internals.resilience.acquireLimit('firestore')
266
+ await batch.commit().catch((err) => console.error('[chat] failed to commit turn batch:', err))
267
+ } finally {
268
+ try {
269
+ opts.unsubCancelWatch?.()
270
+ } catch {}
271
+ activeGenerations.delete(opts.key)
272
+ }
273
+ }
274
+
275
+ #upsertChatTurnLock = async (
276
+ existing: SelectModel<'chat'> | undefined,
277
+ chatDocRef: DocumentReference,
278
+ chatId: string,
279
+ userId: EntryId,
280
+ resolvedAgentId: string,
281
+ title: string,
282
+ selectedModel: string,
283
+ generationId: string,
284
+ generationController: AbortController
285
+ ): Promise<() => void> => {
286
+ if (existing) {
287
+ await this.#core.db.update(
288
+ 'chat',
289
+ chatId,
290
+ { title, agentId: resolvedAgentId, model: selectedModel, activeGenerationId: generationId, deleted: false },
291
+ { bypassRbac: true }
292
+ )
293
+ } else {
294
+ await this.#core.db.create(
295
+ 'chat',
296
+ {
297
+ id: chatId,
298
+ userId,
299
+ agentId: resolvedAgentId,
300
+ title,
301
+ model: selectedModel,
302
+ messageCount: 0,
303
+ summary: null,
304
+ activeGenerationId: generationId
305
+ },
306
+ { bypassRbac: true }
307
+ )
308
+ }
309
+
310
+ return chatDocRef.onSnapshot(
311
+ (snap) => {
312
+ if (snap.data()?.activeGenerationId !== generationId && !generationController.signal.aborted) {
313
+ generationController.abort(new Error('Generation cancelled via Firestore state'))
314
+ }
315
+ },
316
+ () => {}
317
+ )
318
+ }
319
+
320
+ startChatStreamTurn = async (opts: {
321
+ userId: EntryId
322
+ role: DbRole
323
+ agent: AgentConfig
324
+ attachmentParts: RawAttachmentPart[]
325
+ rawUserText: string
326
+ signal: AbortSignal
327
+ chatId?: string
328
+ messageCount?: number
329
+ selectedModel?: string
330
+ priorMessages?: ModelMessage[]
331
+ }) => {
332
+ const { userId, role, agent, attachmentParts, rawUserText, signal, chatId, messageCount, selectedModel, priorMessages } =
333
+ opts
334
+ const [{ staged, attachments }, hybrid] = await Promise.all([
335
+ this.stageAndScanAttachments(userId, attachmentParts, agent.attachmentTypes ?? {}),
336
+ this.#core.chat.retrieveContext({
337
+ userId,
338
+ ...(chatId ? { chatId } : {}),
339
+ ...(messageCount !== undefined ? { messageCount } : {}),
340
+ prompt: rawUserText,
341
+ useContext: agent.useContext !== false,
342
+ agent,
343
+ role,
344
+ abortSignal: signal
345
+ })
346
+ ])
347
+
348
+ const stagedAttachmentSummaries = attachments.map((item, idx) =>
349
+ formatAttachmentLine(staged[idx]?.filename ?? 'file', item).trim()
350
+ )
351
+ const userCreatedAtMs = Date.now()
352
+ const messagesForTurn = priorMessages
353
+ ? [
354
+ ...priorMessages,
355
+ ...(stagedAttachmentSummaries.length > 0
356
+ ? [{ role: 'user' as const, content: [{ type: 'text' as const, text: stagedAttachmentSummaries.join('\n\n') }] }]
357
+ : [])
358
+ ].slice(-CORE_CONFIG.limits.chatMessagesLimit)
359
+ : [
360
+ ...hybrid.slidingWindowMessages,
361
+ {
362
+ role: 'user' as const,
363
+ content: [{ type: 'text' as const, text: [rawUserText, ...stagedAttachmentSummaries].filter(Boolean).join('\n\n') || 'Attached files.' }]
364
+ }
365
+ ]
366
+
367
+ const turn = await this.#core.chat.stream(agent, messagesForTurn, userId, {
368
+ ...(selectedModel ? { modelOverride: selectedModel } : {}),
369
+ ...(chatId ? { chatId } : {}),
370
+ abortSignal: signal,
371
+ core: new Core({ env: this.#core.env, userId, role }),
372
+ dynamicContext: {
373
+ currentTimeIso: new Date(userCreatedAtMs).toISOString(),
374
+ recalledMemories: hybrid.recalledMemories,
375
+ infobaseContextEntries: hybrid.infobaseContextEntries,
376
+ stagedAttachmentSummaries,
377
+ userContext: hybrid.userContext
378
+ }
379
+ })
380
+ return { turn, stagedRefs: staged.map((s) => s.ref), stagedAttachmentSummaries, userCreatedAtMs }
381
+ }
382
+
383
+ streamChatSse = async ({
384
+ c,
385
+ userId = this.#core.userId,
386
+ input
387
+ }: {
388
+ c: Context<AppEnv>
389
+ userId?: EntryId
390
+ input?: z.infer<typeof streamChatBody>
391
+ }): Promise<Response> => {
392
+ const parsedBody = streamChatBody.safeParse(input ?? (await c.req.json().catch(() => ({}))))
393
+ if (!parsedBody.success) {
394
+ return this.#core.errors.httpError(
395
+ 400,
396
+ parsedBody.error.issues.map((i) => `${i.path.join('.') || 'input'}: ${i.message}`).join('; '),
397
+ 'VALIDATION_ERROR'
398
+ )
399
+ }
400
+ const body = parsedBody.data
401
+ const { chatId, existing, resolvedAgentId, agent, attachmentParts, rawUserText, key } =
402
+ await this.#resolveStreamTurnTarget(c, userId, body)
403
+
404
+ const generationId = `gen_${crypto.randomUUID().replace(/-/g, '').slice(0, 20)}`
405
+ const generationController = new AbortController()
406
+ let accumulatedText = ''
407
+ const activeEntry: ActiveGenerationEntry = {
408
+ generationId,
409
+ controller: generationController,
410
+ partialText: () => accumulatedText
411
+ }
412
+ activeGenerations.set(key, activeEntry)
413
+
414
+ const title = body.title ?? existing?.title ?? deriveTitle(body.message)
415
+ const selectedModel = body.model ?? existing?.model ?? agent.model ?? CORE_CONFIG.models.vertex
416
+ const chatDocRef = this.#core.db.firestore().collection('chat').doc(chatId)
417
+ let unsubCancelWatch: (() => void) | undefined
418
+
419
+ try {
420
+ unsubCancelWatch = await this.#upsertChatTurnLock(
421
+ existing,
422
+ chatDocRef,
423
+ chatId,
424
+ userId,
425
+ resolvedAgentId,
426
+ title,
427
+ selectedModel,
428
+ generationId,
429
+ generationController
430
+ )
431
+ const role = this.#core.internals.auth.resolveUserRole(userId, c.get('userRole'))
432
+ const { turn, stagedRefs, stagedAttachmentSummaries, userCreatedAtMs } = await this.startChatStreamTurn({
433
+ userId,
434
+ role,
435
+ chatId,
436
+ messageCount: existing?.messageCount ?? 0,
437
+ agent,
438
+ selectedModel,
439
+ attachmentParts,
440
+ rawUserText,
441
+ signal: generationController.signal
442
+ })
443
+
444
+ const finalUserText = rawUserText || stagedAttachmentSummaries.join('\n')
445
+ const msgCol = chatDocRef.collection('messages')
446
+ const userMsgId = body.message.id && entryId.safeParse(body.message.id).success ? body.message.id : msgCol.doc().id
447
+ const userPartsForStorage = body.message.parts?.length ? body.message.parts : [{ type: 'text', text: rawUserText }]
448
+ const completionPromise = this.#persistTurnCompletion({
449
+ turn,
450
+ getAccumulatedText: () => accumulatedText,
451
+ signal: generationController.signal,
452
+ userVecPromise: this.#core.ai.embed(finalUserText).catch(() => null),
453
+ chatDocRef,
454
+ chatId,
455
+ userId,
456
+ resolvedAgentId,
457
+ title,
458
+ selectedModel,
459
+ userMsgId,
460
+ assistantMsgId: msgCol.doc().id,
461
+ userCreatedAtMs,
462
+ finalUserText,
463
+ userPartsForStorage,
464
+ stagedRefs,
465
+ unsubCancelWatch,
466
+ key
467
+ })
468
+ activeEntry.completionPromise = completionPromise
469
+ return buildUIMessageResponse(turn, chatId, generationId, completionPromise, (d) => {
470
+ accumulatedText += d
471
+ })
472
+ } catch (err) {
473
+ try {
474
+ unsubCancelWatch?.()
475
+ } catch {}
476
+ activeGenerations.delete(key)
477
+ await this.#core.db.update('chat', chatId, { activeGenerationId: null }, { bypassRbac: true }).catch(() => {})
478
+ throw err
479
+ }
480
+ }
481
+ }