@strav/brain 1.0.0-alpha.16 → 1.0.0-alpha.18

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 (42) hide show
  1. package/package.json +4 -2
  2. package/src/agent.ts +34 -5
  3. package/src/agent_generate_result.ts +2 -0
  4. package/src/agent_result.ts +7 -0
  5. package/src/agent_runner.ts +134 -15
  6. package/src/agent_stream_event.ts +100 -0
  7. package/src/brain_config.ts +91 -1
  8. package/src/brain_manager.ts +287 -6
  9. package/src/brain_provider.ts +25 -1
  10. package/src/index.ts +37 -2
  11. package/src/mcp/client.ts +99 -13
  12. package/src/mcp/index.ts +7 -0
  13. package/src/mcp/oauth.ts +227 -0
  14. package/src/mcp/pool.ts +106 -0
  15. package/src/mcp/resolve_mcp_tools.ts +31 -9
  16. package/src/mcp_server.ts +16 -0
  17. package/src/persistence/brain_message.ts +34 -0
  18. package/src/persistence/brain_message_repository.ts +106 -0
  19. package/src/persistence/brain_store.ts +166 -0
  20. package/src/persistence/brain_suspended_run.ts +30 -0
  21. package/src/persistence/brain_suspended_run_repository.ts +68 -0
  22. package/src/persistence/brain_thread.ts +30 -0
  23. package/src/persistence/brain_thread_repository.ts +65 -0
  24. package/src/persistence/database_brain_store.ts +190 -0
  25. package/src/persistence/index.ts +48 -0
  26. package/src/persistence/schema/brain_message_schema.ts +61 -0
  27. package/src/persistence/schema/brain_suspended_run_schema.ts +58 -0
  28. package/src/persistence/schema/brain_thread_schema.ts +50 -0
  29. package/src/persistence/schema/index.ts +3 -0
  30. package/src/provider.ts +145 -1
  31. package/src/providers/anthropic_provider.ts +723 -38
  32. package/src/providers/deepseek_provider.ts +117 -0
  33. package/src/providers/gemini_provider.ts +625 -33
  34. package/src/providers/ollama_provider.ts +86 -0
  35. package/src/providers/openai_compat_provider.ts +616 -0
  36. package/src/providers/openai_provider.ts +801 -43
  37. package/src/providers/openai_responses_provider.ts +1015 -0
  38. package/src/suspended_run.ts +153 -0
  39. package/src/thread.ts +40 -1
  40. package/src/tool.ts +7 -0
  41. package/src/tool_runner.ts +81 -0
  42. package/src/types.ts +343 -0
@@ -0,0 +1,227 @@
1
+ /**
2
+ * OAuth support for the local MCP client.
3
+ *
4
+ * Most real-world MCP servers (Linear, Notion, GitHub, Asana,
5
+ * Atlassian) are OAuth-protected. The local client at
6
+ * `@strav/brain/mcp` already supports static bearer tokens via
7
+ * `MCPServer.authorizationToken` — fine for self-hosted servers,
8
+ * useless against any commercial server. This module closes the
9
+ * gap.
10
+ *
11
+ * The flow apps see:
12
+ *
13
+ * ```ts
14
+ * const store = new MemoryOAuthStore()
15
+ * const linear: MCPServer = {
16
+ * name: 'linear',
17
+ * url: 'https://mcp.linear.app',
18
+ * oauth: {
19
+ * redirectUri: 'https://myapp.com/mcp/linear/callback',
20
+ * scope: 'read',
21
+ * store,
22
+ * },
23
+ * }
24
+ *
25
+ * try {
26
+ * const client = new MCPClient(linear)
27
+ * await client.connect()
28
+ * } catch (err) {
29
+ * if (err instanceof MCPAuthRequiredError) {
30
+ * // Redirect the user to err.authorizationUrl and remember
31
+ * // who they were (so the callback handler can rebuild the
32
+ * // store with the right per-user state).
33
+ * }
34
+ * }
35
+ *
36
+ * // Later, in the callback handler:
37
+ * const client = new MCPClient(linear)
38
+ * await client.completeAuthorization(req.query.code)
39
+ * // The store now has tokens; subsequent connect()s succeed.
40
+ * ```
41
+ *
42
+ * The framework is server-side and headless — it can't redirect
43
+ * the user inline. So instead of blocking on `connect()`, we
44
+ * surface `MCPAuthRequiredError` carrying the authorization URL.
45
+ * Apps redirect the user themselves, then call
46
+ * `MCPClient.completeAuthorization(code)` from their callback
47
+ * route.
48
+ *
49
+ * Multi-tenancy: build a fresh `MCPOAuthStore` per `(user, server)`
50
+ * with the user id baked into the storage keys. The store
51
+ * interface is intentionally per-server (no `userId` arg) so apps
52
+ * pick the boundary that matches their data model.
53
+ */
54
+
55
+ import type {
56
+ OAuthClientInformation,
57
+ OAuthClientInformationFull,
58
+ OAuthClientMetadata,
59
+ OAuthTokens,
60
+ } from '@modelcontextprotocol/sdk/shared/auth.js'
61
+ import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
62
+ import { BrainError } from '../brain_error.ts'
63
+
64
+ /**
65
+ * Persistence contract for one MCP server's OAuth state.
66
+ *
67
+ * Methods may be sync or async — implementations are free to call
68
+ * a DB, a Redis cache, a file, or hold the state in memory. The
69
+ * framework awaits whichever shape returns.
70
+ *
71
+ * Per-(server) only by design — apps that need per-(user, server)
72
+ * scoping construct a fresh store per request with the user id
73
+ * baked into the underlying storage keys.
74
+ */
75
+ export interface MCPOAuthStore {
76
+ /** Load the dynamic-client-registration record, or undefined if not registered. */
77
+ clientInformation():
78
+ | OAuthClientInformation
79
+ | undefined
80
+ | Promise<OAuthClientInformation | undefined>
81
+ /** Persist the dynamic-client-registration record after registration succeeds. */
82
+ saveClientInformation(info: OAuthClientInformationFull): void | Promise<void>
83
+ /** Load the active token set, or undefined if the user hasn't authorized. */
84
+ tokens(): OAuthTokens | undefined | Promise<OAuthTokens | undefined>
85
+ /** Persist tokens after authorization completes or a refresh succeeds. */
86
+ saveTokens(tokens: OAuthTokens): void | Promise<void>
87
+ /** Load the PKCE code verifier saved during the authorize step. */
88
+ codeVerifier(): string | Promise<string>
89
+ /** Persist the PKCE code verifier before redirecting to authorize. */
90
+ saveCodeVerifier(verifier: string): void | Promise<void>
91
+ }
92
+
93
+ /** Per-server OAuth configuration on `MCPServer.oauth`. */
94
+ export interface MCPOAuthConfig {
95
+ /** Where the user comes back after authorizing. Must match a registered redirect URI on the OAuth server. */
96
+ redirectUri: string
97
+ /** Optional OAuth scopes to request. Some servers require specific scopes. */
98
+ scope?: string
99
+ /** Per-server token + client-info storage. */
100
+ store: MCPOAuthStore
101
+ /** Optional client metadata for dynamic client registration. Defaults to a minimal sane shape. */
102
+ clientMetadata?: Partial<OAuthClientMetadata>
103
+ }
104
+
105
+ /**
106
+ * `MemoryOAuthStore` — in-memory `MCPOAuthStore` implementation.
107
+ *
108
+ * Fine for tests and single-process dev. Production apps with
109
+ * multiple processes or restarts persist to a DB / Redis / KV.
110
+ */
111
+ export class MemoryOAuthStore implements MCPOAuthStore {
112
+ private _clientInfo: OAuthClientInformationFull | undefined
113
+ private _tokens: OAuthTokens | undefined
114
+ private _verifier: string | undefined
115
+
116
+ clientInformation(): OAuthClientInformation | undefined {
117
+ return this._clientInfo
118
+ }
119
+ saveClientInformation(info: OAuthClientInformationFull): void {
120
+ this._clientInfo = info
121
+ }
122
+ tokens(): OAuthTokens | undefined {
123
+ return this._tokens
124
+ }
125
+ saveTokens(tokens: OAuthTokens): void {
126
+ this._tokens = tokens
127
+ }
128
+ codeVerifier(): string {
129
+ if (this._verifier === undefined) {
130
+ throw new BrainError(
131
+ 'MemoryOAuthStore.codeVerifier(): no PKCE verifier saved. The authorization flow must call saveCodeVerifier before requesting the verifier.',
132
+ )
133
+ }
134
+ return this._verifier
135
+ }
136
+ saveCodeVerifier(verifier: string): void {
137
+ this._verifier = verifier
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Thrown when an MCP server requires the user to authorize before
143
+ * the framework can connect. Apps catch this on `MCPClient.connect()`,
144
+ * redirect the user to `authorizationUrl`, and on the OAuth callback
145
+ * route call `MCPClient.completeAuthorization(code)` to finish the
146
+ * flow.
147
+ *
148
+ * `BrainError` subclass so the typed-exception handler renders it
149
+ * cleanly through the standard pathway.
150
+ */
151
+ export class MCPAuthRequiredError extends BrainError {
152
+ /** URL the user should be redirected to in order to authorize. */
153
+ readonly authorizationUrl: string
154
+
155
+ constructor(serverName: string, authorizationUrl: string) {
156
+ super(
157
+ `MCPClient(${serverName}): authorization required. Redirect the user to authorizationUrl and call completeAuthorization(code) from your callback route.`,
158
+ {
159
+ context: { server: serverName, authorizationUrl },
160
+ },
161
+ )
162
+ this.authorizationUrl = authorizationUrl
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Default client metadata used when dynamic client registration
168
+ * fires. Apps override per-server via
169
+ * `MCPOAuthConfig.clientMetadata`.
170
+ */
171
+ function defaultClientMetadata(redirectUri: string): OAuthClientMetadata {
172
+ return {
173
+ redirect_uris: [redirectUri],
174
+ token_endpoint_auth_method: 'none',
175
+ grant_types: ['authorization_code', 'refresh_token'],
176
+ response_types: ['code'],
177
+ client_name: 'strav-brain',
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Internal — implements the SDK's `OAuthClientProvider` against an
183
+ * `MCPOAuthStore`. Holds the auth URL captured from the SDK so
184
+ * `MCPClient.connect()` can surface it on `MCPAuthRequiredError`.
185
+ */
186
+ export class StoreBackedOAuthProvider implements OAuthClientProvider {
187
+ /** Captured auth URL — populated by the SDK when authorization is needed. */
188
+ capturedAuthorizationUrl: URL | undefined
189
+
190
+ constructor(
191
+ private readonly config: MCPOAuthConfig,
192
+ ) {}
193
+
194
+ get redirectUrl(): string {
195
+ return this.config.redirectUri
196
+ }
197
+
198
+ get clientMetadata(): OAuthClientMetadata {
199
+ return {
200
+ ...defaultClientMetadata(this.config.redirectUri),
201
+ ...(this.config.scope !== undefined ? { scope: this.config.scope } : {}),
202
+ ...(this.config.clientMetadata ?? {}),
203
+ }
204
+ }
205
+
206
+ async clientInformation(): Promise<OAuthClientInformation | undefined> {
207
+ return await this.config.store.clientInformation()
208
+ }
209
+ async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
210
+ await this.config.store.saveClientInformation(info)
211
+ }
212
+ async tokens(): Promise<OAuthTokens | undefined> {
213
+ return await this.config.store.tokens()
214
+ }
215
+ async saveTokens(tokens: OAuthTokens): Promise<void> {
216
+ await this.config.store.saveTokens(tokens)
217
+ }
218
+ async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
219
+ this.capturedAuthorizationUrl = authorizationUrl
220
+ }
221
+ async saveCodeVerifier(verifier: string): Promise<void> {
222
+ await this.config.store.saveCodeVerifier(verifier)
223
+ }
224
+ async codeVerifier(): Promise<string> {
225
+ return await this.config.store.codeVerifier()
226
+ }
227
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * `MCPClientPool` — long-lived, per-server `MCPClient` cache.
3
+ *
4
+ * Default `resolveMcpTools` flow constructs a fresh `MCPClient` per
5
+ * call to `runTools` / `runWithTools` / etc., handshakes the
6
+ * Streamable HTTP transport, lists tools, executes them, then
7
+ * closes the transport in a `finally`. For one-shot calls that's
8
+ * fine. For long-running agent workers — chat servers, background
9
+ * job processors — the per-call handshake adds noticeable
10
+ * latency and burns connection slots upstream.
11
+ *
12
+ * The pool keeps one connected `MCPClient` per `(server.name,
13
+ * server.url)` pair for the lifetime of the pool. `borrow(server)`
14
+ * returns the pooled client (lazily creating + connecting on
15
+ * first use). When the pool is in play, `resolveMcpTools` skips
16
+ * the per-call `close()` — the pool owns the lifetime — so
17
+ * subsequent calls reuse the existing transport.
18
+ *
19
+ * Apps own the pool's lifetime. Construct one at app boot, hand it
20
+ * to every provider (or to `BrainProvider` if using the DI
21
+ * helper), and call `pool.close()` on shutdown.
22
+ *
23
+ * ```ts
24
+ * const pool = new MCPClientPool()
25
+ *
26
+ * const openai = new OpenAIProvider(
27
+ * 'openai',
28
+ * { driver: 'openai', apiKey: ... },
29
+ * { mcpPool: pool },
30
+ * )
31
+ *
32
+ * // ... many runTools calls later, on graceful shutdown:
33
+ * await pool.close()
34
+ * ```
35
+ *
36
+ * Concurrency: `borrow()` is synchronous; `MCPClient.connect()`
37
+ * itself dedupes concurrent calls. Two parallel `runTools` calls
38
+ * sharing the same pooled client both await one handshake.
39
+ *
40
+ * Re-auth: when a borrowed client throws `MCPAuthRequiredError`,
41
+ * the pool keeps the (still un-authorized) client. Apps call
42
+ * `pool.evict(server)` after running `completeAuthorization` on
43
+ * a fresh client so subsequent borrows see the renewed state —
44
+ * or just reuse the same client the app authorized via the
45
+ * standard `MCPClient.completeAuthorization` flow.
46
+ */
47
+
48
+ import type { MCPServer } from '../mcp_server.ts'
49
+ import { MCPClient } from './client.ts'
50
+
51
+ /** Internal — factory injection for tests. Defaults to `new MCPClient(server)`. */
52
+ export type MCPClientFactory = (server: MCPServer) => MCPClient
53
+
54
+ export class MCPClientPool {
55
+ private readonly clients: Map<string, MCPClient> = new Map()
56
+ private readonly factory: MCPClientFactory
57
+
58
+ constructor(factory: MCPClientFactory = (s) => new MCPClient(s)) {
59
+ this.factory = factory
60
+ }
61
+
62
+ /**
63
+ * Return the pooled client for `server`, constructing + caching it on
64
+ * first call. The client is NOT eagerly connected — the first
65
+ * `listTools` / `callTool` invocation triggers `connect()` once.
66
+ */
67
+ borrow(server: MCPServer): MCPClient {
68
+ const key = poolKey(server)
69
+ const existing = this.clients.get(key)
70
+ if (existing) return existing
71
+ const client = this.factory(server)
72
+ this.clients.set(key, client)
73
+ return client
74
+ }
75
+
76
+ /**
77
+ * Drop the cached client for `server` and close its transport.
78
+ * Useful after the app re-authorizes an OAuth server, or after a
79
+ * transient failure where the connection state is suspect and a
80
+ * fresh handshake on next borrow is preferable.
81
+ */
82
+ async evict(server: MCPServer): Promise<void> {
83
+ const key = poolKey(server)
84
+ const client = this.clients.get(key)
85
+ if (!client) return
86
+ this.clients.delete(key)
87
+ await client.close()
88
+ }
89
+
90
+ /** Close every pooled client. Call on app shutdown. */
91
+ async close(): Promise<void> {
92
+ const all = [...this.clients.values()]
93
+ this.clients.clear()
94
+ await Promise.all(all.map((c) => c.close()))
95
+ }
96
+
97
+ /** Whether the pool currently holds a client for `server`. Used by tests. */
98
+ has(server: MCPServer): boolean {
99
+ return this.clients.has(poolKey(server))
100
+ }
101
+ }
102
+
103
+ /** Pool key: name + url, so two `MCPServer`s with the same name but different URLs don't collide. */
104
+ function poolKey(server: MCPServer): string {
105
+ return `${server.name}|${server.url}`
106
+ }
@@ -21,6 +21,7 @@
21
21
  import type { MCPServer } from '../mcp_server.ts'
22
22
  import type { Tool, ToolContext } from '../tool.ts'
23
23
  import { MCPClient } from './client.ts'
24
+ import type { MCPClientPool } from './pool.ts'
24
25
 
25
26
  export interface ResolvedMcpTools {
26
27
  tools: Tool[]
@@ -30,6 +31,14 @@ export interface ResolvedMcpTools {
30
31
  export interface ResolveMcpToolsOptions {
31
32
  /** Override the client factory — tests inject mock clients per server here. */
32
33
  clientFactory?(server: MCPServer): MCPClient
34
+ /**
35
+ * When set, clients are borrowed from the pool instead of being
36
+ * constructed fresh per call, and the returned `close` becomes a
37
+ * no-op — the pool owns the lifetime, and apps call
38
+ * `pool.close()` on shutdown. Mutually beneficial with
39
+ * `clientFactory` (tests pass a factory to the pool itself).
40
+ */
41
+ pool?: MCPClientPool
33
42
  }
34
43
 
35
44
  const NAME_SEPARATOR = '__'
@@ -40,13 +49,16 @@ export async function resolveMcpTools(
40
49
  ): Promise<ResolvedMcpTools> {
41
50
  const clients: MCPClient[] = []
42
51
  const tools: Tool[] = []
52
+ const pooled = options.pool !== undefined
43
53
 
44
54
  for (const server of servers) {
45
55
  if (server.tools?.enabled === false) continue
46
- const client = options.clientFactory
47
- ? options.clientFactory(server)
48
- : new MCPClient(server)
49
- clients.push(client)
56
+ const client = options.pool
57
+ ? options.pool.borrow(server)
58
+ : options.clientFactory
59
+ ? options.clientFactory(server)
60
+ : new MCPClient(server)
61
+ if (!pooled) clients.push(client)
50
62
 
51
63
  const allowed = server.tools?.allowedTools
52
64
  const allowedSet = allowed ? new Set(allowed) : null
@@ -60,9 +72,15 @@ export async function resolveMcpTools(
60
72
 
61
73
  return {
62
74
  tools,
63
- close: async () => {
64
- await Promise.all(clients.map((c) => c.close()))
65
- },
75
+ // Pooled clients live across calls — `close` becomes a no-op
76
+ // and the pool owns the lifetime. Non-pooled clients close
77
+ // here so each `runWithTools` invocation cleans up its own
78
+ // transports.
79
+ close: pooled
80
+ ? async () => {}
81
+ : async () => {
82
+ await Promise.all(clients.map((c) => c.close()))
83
+ },
66
84
  }
67
85
  }
68
86
 
@@ -75,8 +93,12 @@ function buildTool(
75
93
  name: `${serverName}${NAME_SEPARATOR}${descriptor.name}`,
76
94
  description: descriptor.description,
77
95
  inputSchema: descriptor.inputSchema,
78
- async execute(input: unknown, _ctx: ToolContext): Promise<string> {
79
- const result = await client.callTool(descriptor.name, input)
96
+ async execute(input: unknown, ctx: ToolContext): Promise<string> {
97
+ const result = await client.callTool(
98
+ descriptor.name,
99
+ input,
100
+ ctx.signal !== undefined ? { signal: ctx.signal } : {},
101
+ )
80
102
  if (result.isError) {
81
103
  return `MCP tool error: ${result.content}`
82
104
  }
package/src/mcp_server.ts CHANGED
@@ -40,8 +40,24 @@ export interface MCPServer {
40
40
  * Optional bearer token. Apps source from env vars / secrets
41
41
  * managers — never hardcode. The framework forwards this verbatim
42
42
  * to the provider's `authorization_token` field.
43
+ *
44
+ * Mutually exclusive with `oauth`. Use `authorizationToken` for
45
+ * self-hosted servers where you control the token; use `oauth`
46
+ * for commercial servers (Linear, Notion, GitHub, ...).
43
47
  */
44
48
  authorizationToken?: string
49
+ /**
50
+ * OAuth configuration for servers that require it. When set, the
51
+ * local MCP client (`@strav/brain/mcp`) drives the
52
+ * authorization-code-with-PKCE flow against the server's OAuth
53
+ * endpoints, storing tokens via the supplied `store`. The
54
+ * Anthropic server-side path doesn't use this — Anthropic's
55
+ * connector handles its own auth.
56
+ *
57
+ * Mutually exclusive with `authorizationToken`. See the OAuth
58
+ * section of `docs/brain/guides/mcp.md`.
59
+ */
60
+ oauth?: import('./mcp/oauth.ts').MCPOAuthConfig
45
61
  /** Per-server tool config (allowlist / enable flag). */
46
62
  tools?: MCPServerToolConfig
47
63
  }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * `BrainMessage` — the typed row of `brain_message`. One per turn.
3
+ *
4
+ * `content` mirrors `Message.content` — string for plain text or
5
+ * `ContentBlock[]` when the turn carries structured blocks
6
+ * (tool_use, tool_result, image, compaction, ...). JSONB hydration
7
+ * is automatic.
8
+ *
9
+ * Assistant turns carry `model` / `usage` / `stop_reason` /
10
+ * `response_id`; user turns leave them NULL. The repository's
11
+ * `appendTurn` helper writes the right shape per role.
12
+ */
13
+
14
+ import { Model } from '@strav/database'
15
+ import type { ChatUsage, ContentBlock } from '../types.ts'
16
+ import { brainMessageSchema } from './schema/brain_message_schema.ts'
17
+
18
+ export type BrainMessageRole = 'user' | 'assistant'
19
+
20
+ export class BrainMessage extends Model {
21
+ static override readonly schema = brainMessageSchema
22
+
23
+ id!: string
24
+ tenant_id!: string
25
+ thread_id!: string
26
+ turn_index!: number
27
+ role!: BrainMessageRole
28
+ content!: string | ContentBlock[]
29
+ model!: string | null
30
+ usage!: ChatUsage | null
31
+ stop_reason!: string | null
32
+ response_id!: string | null
33
+ created_at!: Date
34
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * `BrainMessageRepository` — data-access object for `BrainMessage`.
3
+ *
4
+ * Append-only by design: messages get inserted and never updated.
5
+ * `appendTurn` handles the next-`turn_index` lookup + INSERT in a
6
+ * single round-trip via `INSERT ... SELECT COALESCE(MAX, -1) + 1`
7
+ * so concurrent appends on the same thread don't race.
8
+ *
9
+ * Reads:
10
+ * - `loadForThread(threadId, opts?)` — paginated history,
11
+ * ordered by `turn_index ASC`.
12
+ * - `countForThread(threadId)` — total turn count, useful for
13
+ * pagination UIs.
14
+ */
15
+
16
+ // biome-ignore lint/style/useImportType: PostgresDatabase needs to be a value import for @inject().
17
+ import { PostgresDatabase, quoteIdent, Repository, type RepositoryScope } from '@strav/database'
18
+ // biome-ignore lint/style/useImportType: EventBus value import for @inject().
19
+ import { EventBus, inject, ulid } from '@strav/kernel'
20
+ import type { ChatUsage, ContentBlock } from '../types.ts'
21
+ import { BrainMessage, type BrainMessageRole } from './brain_message.ts'
22
+ import { brainMessageSchema } from './schema/brain_message_schema.ts'
23
+
24
+ export interface AppendTurnInput {
25
+ threadId: string
26
+ role: BrainMessageRole
27
+ content: string | ContentBlock[]
28
+ model?: string
29
+ usage?: ChatUsage
30
+ stopReason?: string
31
+ responseId?: string
32
+ }
33
+
34
+ export interface LoadMessagesOptions {
35
+ /** Pagination — defaults to no limit (full history). */
36
+ limit?: number
37
+ offset?: number
38
+ }
39
+
40
+ @inject()
41
+ export class BrainMessageRepository extends Repository<BrainMessage> {
42
+ static override readonly schema = brainMessageSchema
43
+ static override readonly model = BrainMessage
44
+
45
+ // biome-ignore lint/complexity/noUselessConstructor: explicit constructor for @inject() metadata emission.
46
+ constructor(db: PostgresDatabase, events: EventBus) {
47
+ super(db, events)
48
+ }
49
+
50
+ /**
51
+ * Insert a new turn at the next `turn_index` for the thread. The
52
+ * `turn_index` is computed in-SQL so two concurrent appends
53
+ * don't collide — the unique `(thread_id, turn_index)` index on
54
+ * the table catches any race that slips through.
55
+ *
56
+ * Lifecycle: routes through `create()` so `brain_message.created`
57
+ * events fire. The `turn_index` is filled in by the SELECT side
58
+ * of an explicit INSERT here rather than `create()` because the
59
+ * value isn't known client-side.
60
+ */
61
+ async appendTurn(input: AppendTurnInput, opts?: RepositoryScope): Promise<BrainMessage> {
62
+ const table = quoteIdent(brainMessageSchema.name)
63
+ const sql = `
64
+ INSERT INTO ${table}
65
+ ("id", "thread_id", "turn_index", "role", "content",
66
+ "model", "usage", "stop_reason", "response_id", "created_at")
67
+ SELECT
68
+ $1, $2,
69
+ COALESCE((SELECT MAX("turn_index") FROM ${table} WHERE "thread_id" = $2), -1) + 1,
70
+ $3, $4::jsonb, $5, $6::jsonb, $7, $8, NOW()
71
+ RETURNING *
72
+ `
73
+ const params = [
74
+ ulid(),
75
+ input.threadId,
76
+ input.role,
77
+ JSON.stringify(input.content),
78
+ input.model ?? null,
79
+ input.usage !== undefined ? JSON.stringify(input.usage) : null,
80
+ input.stopReason ?? null,
81
+ input.responseId ?? null,
82
+ ]
83
+ const rows = await this.executor(opts).query<Record<string, unknown>>(sql, params)
84
+ if (rows.length === 0) {
85
+ throw new Error('BrainMessageRepository.appendTurn: INSERT returned no rows.')
86
+ }
87
+ return this.hydrate(rows[0]!)
88
+ }
89
+
90
+ /** Load every turn for a thread, ordered by `turn_index ASC`. */
91
+ async loadForThread(
92
+ threadId: string,
93
+ opts: LoadMessagesOptions = {},
94
+ ): Promise<BrainMessage[]> {
95
+ let q = this.query().where('thread_id', threadId).orderBy('turn_index', 'asc')
96
+ if (opts.limit !== undefined) q = q.limit(opts.limit)
97
+ if (opts.offset !== undefined) q = q.offset(opts.offset)
98
+ return q.get()
99
+ }
100
+
101
+ /** Total turn count for a thread — useful for pagination UIs. */
102
+ async countForThread(threadId: string): Promise<number> {
103
+ return this.count({ thread_id: threadId } as Partial<BrainMessage>)
104
+ }
105
+ }
106
+