@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,166 @@
1
+ /**
2
+ * `BrainStore` — the storage abstraction for `@strav/brain`
3
+ * conversation state.
4
+ *
5
+ * Apps call into `BrainStore` to persist threads, append turns,
6
+ * and track human-in-the-loop suspended runs. The default
7
+ * implementation (`DatabaseBrainStore`) is backed by the three
8
+ * shipped schemas + repositories against Postgres. Apps that want
9
+ * a different backend (Redis / Mongo / in-memory for tests)
10
+ * implement this interface directly — the schemas + repositories
11
+ * stay optional.
12
+ *
13
+ * Multitenancy: `BrainStore` itself is tenant-agnostic. Tenant
14
+ * scoping is the caller's responsibility — wrap calls in
15
+ * `tenants.withTenant(...)` and the backend (RLS, app-level
16
+ * filters, separate keyspaces) does its thing.
17
+ *
18
+ * Integration with `Thread`: this abstraction is intentionally
19
+ * parallel to `Thread`. Apps don't call `Thread.send()` and
20
+ * `store.appendTurn()` simultaneously by default — pick the side
21
+ * that fits the request lifecycle:
22
+ *
23
+ * - **Stateless request handlers** (one request = one turn):
24
+ * load the thread state via `store.loadThread(id)`, build a
25
+ * fresh `Thread` from it, call `thread.send(text)`, persist
26
+ * the user + assistant turns via `store.appendTurn(...)`,
27
+ * return the response. The `Thread` instance dies with the
28
+ * request.
29
+ *
30
+ * - **Long-lived workers** (e.g., chat-streaming connections):
31
+ * keep the `Thread` in memory for the connection's lifetime
32
+ * and call `store.appendTurn(...)` after each `send()`.
33
+ */
34
+
35
+ import type { SuspendedState } from '../suspended_run.ts'
36
+ import type {
37
+ ChatOptions,
38
+ ChatUsage,
39
+ ContentBlock,
40
+ SystemPrompt,
41
+ ToolUseBlock,
42
+ } from '../types.ts'
43
+
44
+ // ─── Thread inputs / outputs ─────────────────────────────────────────────
45
+
46
+ export interface CreateThreadInput {
47
+ /** App-defined owner. Optional — anonymous / shared threads are fine. */
48
+ userId?: string
49
+ /** Human label. Apps set it from the first user turn or via a "rename" UI. */
50
+ title?: string
51
+ /** Thread-owned system prompt — applied on every `send()`. */
52
+ system?: SystemPrompt
53
+ /** Per-thread defaults merged with per-call options on every `send()`. */
54
+ options?: ChatOptions
55
+ }
56
+
57
+ /**
58
+ * What `loadThread(id)` returns. `state` is the shape `Thread.fromJSON`
59
+ * accepts directly; metadata fields surface alongside for app code
60
+ * that wants the row's bookkeeping (timestamps, title, user id) too.
61
+ */
62
+ export interface LoadedThread {
63
+ id: string
64
+ state: {
65
+ messages: Array<{ role: 'user' | 'assistant'; content: string | ContentBlock[] }>
66
+ system?: SystemPrompt
67
+ options?: ChatOptions
68
+ lastResponseId?: string
69
+ }
70
+ metadata: {
71
+ userId: string | null
72
+ title: string | null
73
+ createdAt: Date
74
+ updatedAt: Date
75
+ }
76
+ }
77
+
78
+ export interface TurnInput {
79
+ role: 'user' | 'assistant'
80
+ content: string | ContentBlock[]
81
+ /** Model used for assistant turns. Omitted for user turns. */
82
+ model?: string
83
+ /** Token usage from the model call. Assistant turns only. */
84
+ usage?: ChatUsage
85
+ /** Provider terminal reason — `end_turn`, `max_iterations`, etc. */
86
+ stopReason?: string
87
+ /**
88
+ * Provider response id when surfaced — OpenAI Responses API today.
89
+ * Also bumps `last_response_id` on the parent thread so subsequent
90
+ * sends auto-thread it via `previousResponseId`.
91
+ */
92
+ responseId?: string
93
+ }
94
+
95
+ export interface ThreadFilter {
96
+ userId?: string
97
+ limit?: number
98
+ offset?: number
99
+ }
100
+
101
+ export interface ThreadSummary {
102
+ id: string
103
+ userId: string | null
104
+ title: string | null
105
+ lastResponseId: string | null
106
+ createdAt: Date
107
+ updatedAt: Date
108
+ }
109
+
110
+ // ─── Suspended-run inputs / outputs ──────────────────────────────────────
111
+
112
+ export interface SaveSuspendedRunInput {
113
+ /** Optional link to the thread the run came from. */
114
+ threadId?: string
115
+ /** App-defined approver. */
116
+ userId?: string
117
+ pendingToolCalls: ToolUseBlock[]
118
+ state: SuspendedState
119
+ }
120
+
121
+ export interface LoadedSuspendedRun {
122
+ id: string
123
+ threadId: string | null
124
+ userId: string | null
125
+ pendingToolCalls: ToolUseBlock[]
126
+ state: SuspendedState
127
+ status: 'pending' | 'resumed' | 'cancelled'
128
+ createdAt: Date
129
+ updatedAt: Date
130
+ }
131
+
132
+ export interface SuspendedFilter {
133
+ userId?: string
134
+ threadId?: string
135
+ limit?: number
136
+ offset?: number
137
+ }
138
+
139
+ export interface SuspendedSummary {
140
+ id: string
141
+ threadId: string | null
142
+ userId: string | null
143
+ status: 'pending' | 'resumed' | 'cancelled'
144
+ createdAt: Date
145
+ }
146
+
147
+ // ─── The interface ───────────────────────────────────────────────────────
148
+
149
+ export interface BrainStore {
150
+ // ── Threads ───────────────────────────────────────────────────────────
151
+ createThread(input: CreateThreadInput): Promise<{ id: string }>
152
+ loadThread(id: string): Promise<LoadedThread | null>
153
+ appendTurn(threadId: string, turn: TurnInput): Promise<void>
154
+ updateThreadResponseId(threadId: string, responseId: string): Promise<void>
155
+ listThreads(filter: ThreadFilter): Promise<ThreadSummary[]>
156
+ deleteThread(id: string): Promise<void>
157
+
158
+ // ── Suspended runs ────────────────────────────────────────────────────
159
+ saveSuspendedRun(run: SaveSuspendedRunInput): Promise<{ id: string }>
160
+ loadSuspendedRun(id: string): Promise<LoadedSuspendedRun | null>
161
+ markSuspendedRunStatus(
162
+ id: string,
163
+ status: 'resumed' | 'cancelled',
164
+ ): Promise<void>
165
+ listPendingSuspendedRuns(filter: SuspendedFilter): Promise<SuspendedSummary[]>
166
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * `BrainSuspendedRun` — the typed row of `brain_suspended_run`.
3
+ *
4
+ * `pending_tool_calls` and `state` round-trip the framework's
5
+ * `SuspendedRun` shape verbatim — apps load this row, take the
6
+ * model's `pending_tool_calls`, gather human approvals, and call
7
+ * `brain.resumeTools(state, results, tools, options)` with the
8
+ * `state` field unchanged.
9
+ */
10
+
11
+ import { Model } from '@strav/database'
12
+ import type { SuspendedState } from '../suspended_run.ts'
13
+ import type { ToolUseBlock } from '../types.ts'
14
+ import { brainSuspendedRunSchema } from './schema/brain_suspended_run_schema.ts'
15
+
16
+ export type BrainSuspendedRunStatus = 'pending' | 'resumed' | 'cancelled'
17
+
18
+ export class BrainSuspendedRun extends Model {
19
+ static override readonly schema = brainSuspendedRunSchema
20
+
21
+ id!: string
22
+ tenant_id!: string
23
+ thread_id!: string | null
24
+ user_id!: string | null
25
+ pending_tool_calls!: ToolUseBlock[]
26
+ state!: SuspendedState
27
+ status!: BrainSuspendedRunStatus
28
+ created_at!: Date
29
+ updated_at!: Date
30
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * `BrainSuspendedRunRepository` — data-access for paused agentic
3
+ * runs.
4
+ *
5
+ * Adds two domain helpers on top of generic CRUD:
6
+ *
7
+ * - `markResumed(id)` / `markCancelled(id)` — flip the status
8
+ * enum once the human approval has been processed. Apps can
9
+ * filter `listPending(...)` on `status = 'pending'` to see
10
+ * what's still waiting.
11
+ * - `listPending(filter?)` — paginate pending runs, optionally
12
+ * filtered by `user_id` or `thread_id`.
13
+ */
14
+
15
+ // biome-ignore lint/style/useImportType: PostgresDatabase value import for @inject().
16
+ import { PostgresDatabase, Repository } from '@strav/database'
17
+ // biome-ignore lint/style/useImportType: EventBus value import for @inject().
18
+ import { EventBus, inject } from '@strav/kernel'
19
+ import { BrainSuspendedRun, type BrainSuspendedRunStatus } from './brain_suspended_run.ts'
20
+ import { brainSuspendedRunSchema } from './schema/brain_suspended_run_schema.ts'
21
+
22
+ export interface ListPendingOptions {
23
+ /** Filter by app-defined user — useful when an app has per-user approval queues. */
24
+ userId?: string
25
+ /** Filter by the linked thread (when set). */
26
+ threadId?: string
27
+ /** Pagination — defaults to 50. */
28
+ limit?: number
29
+ offset?: number
30
+ }
31
+
32
+ @inject()
33
+ export class BrainSuspendedRunRepository extends Repository<BrainSuspendedRun> {
34
+ static override readonly schema = brainSuspendedRunSchema
35
+ static override readonly model = BrainSuspendedRun
36
+
37
+ // biome-ignore lint/complexity/noUselessConstructor: explicit constructor for @inject() metadata emission.
38
+ constructor(db: PostgresDatabase, events: EventBus) {
39
+ super(db, events)
40
+ }
41
+
42
+ /** Flip status to `resumed` after `brain.resumeTools(state, ...)` succeeds. */
43
+ async markResumed(run: BrainSuspendedRun): Promise<BrainSuspendedRun> {
44
+ return this.markStatus(run, 'resumed')
45
+ }
46
+
47
+ /** Flip status to `cancelled` when the human approver declined the run. */
48
+ async markCancelled(run: BrainSuspendedRun): Promise<BrainSuspendedRun> {
49
+ return this.markStatus(run, 'cancelled')
50
+ }
51
+
52
+ /** List pending runs, newest-first by default. */
53
+ async listPending(opts: ListPendingOptions = {}): Promise<BrainSuspendedRun[]> {
54
+ let q = this.query().where('status', 'pending')
55
+ if (opts.userId !== undefined) q = q.where('user_id', opts.userId)
56
+ if (opts.threadId !== undefined) q = q.where('thread_id', opts.threadId)
57
+ q = q.orderBy('created_at', 'desc').limit(opts.limit ?? 50)
58
+ if (opts.offset !== undefined) q = q.offset(opts.offset)
59
+ return q.get()
60
+ }
61
+
62
+ private markStatus(
63
+ run: BrainSuspendedRun,
64
+ status: BrainSuspendedRunStatus,
65
+ ): Promise<BrainSuspendedRun> {
66
+ return this.update(run, { status } as Partial<BrainSuspendedRun>)
67
+ }
68
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * `BrainThread` — the typed row of `brain_thread`. One per
3
+ * conversation.
4
+ *
5
+ * The model's `system` / `options` / `last_response_id` columns
6
+ * mirror `ThreadState` so apps that hydrate a thread can rebuild a
7
+ * `Thread` instance via `BrainStore.loadThread(...)`.
8
+ *
9
+ * `user_id` is app-defined — the framework doesn't constrain user
10
+ * shapes. Apps that want FK enforcement add it in a follow-up
11
+ * migration (same pattern as `@strav/auth`'s `session.user_id`).
12
+ */
13
+
14
+ import { Model } from '@strav/database'
15
+ import type { ChatOptions, SystemPrompt } from '../types.ts'
16
+ import { brainThreadSchema } from './schema/brain_thread_schema.ts'
17
+
18
+ export class BrainThread extends Model {
19
+ static override readonly schema = brainThreadSchema
20
+
21
+ id!: string
22
+ tenant_id!: string
23
+ user_id!: string | null
24
+ title!: string | null
25
+ system!: SystemPrompt | null
26
+ options!: ChatOptions | null
27
+ last_response_id!: string | null
28
+ created_at!: Date
29
+ updated_at!: Date
30
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * `BrainThreadRepository` — data-access object for `BrainThread`.
3
+ *
4
+ * Adds thread-specific helpers on top of the generic CRUD surface:
5
+ *
6
+ * - `listForUser(userId, opts?)` — paginate threads for a user,
7
+ * ordered by `updated_at DESC` (most-recently-active first).
8
+ * - `updateResponseId(thread, id)` — small helper that wraps the
9
+ * `update()` call apps make when threading
10
+ * `previousResponseId` forward.
11
+ *
12
+ * Multitenancy: every query auto-scopes to the current tenant via
13
+ * RLS when wrapped in `tenants.withTenant(...)`. No tenant filter
14
+ * shows up in this code — the database enforces isolation.
15
+ */
16
+
17
+ // biome-ignore lint/style/useImportType: PostgresDatabase needs to be a value import — the @inject() decorator below resolves the constructor param via reflect-metadata, which requires the runtime class reference.
18
+ import { PostgresDatabase, Repository } from '@strav/database'
19
+ // biome-ignore lint/style/useImportType: EventBus has the same constraint as PostgresDatabase.
20
+ import { EventBus, inject } from '@strav/kernel'
21
+ import { BrainThread } from './brain_thread.ts'
22
+ import { brainThreadSchema } from './schema/brain_thread_schema.ts'
23
+
24
+ export interface ListThreadsOptions {
25
+ /** Pagination — defaults to 50. */
26
+ limit?: number
27
+ offset?: number
28
+ }
29
+
30
+ @inject()
31
+ export class BrainThreadRepository extends Repository<BrainThread> {
32
+ static override readonly schema = brainThreadSchema
33
+ static override readonly model = BrainThread
34
+
35
+ // biome-ignore lint/complexity/noUselessConstructor: explicit constructor forces TypeScript to emit `design:paramtypes` metadata on the subclass for the @inject() decorator above.
36
+ constructor(db: PostgresDatabase, events: EventBus) {
37
+ super(db, events)
38
+ }
39
+
40
+ /**
41
+ * List threads for a given app-defined user, newest-first. Empty
42
+ * `userId` lists every thread visible under RLS — useful for
43
+ * admin views or tenant-wide audits.
44
+ */
45
+ async listForUser(
46
+ userId: string | null,
47
+ opts: ListThreadsOptions = {},
48
+ ): Promise<BrainThread[]> {
49
+ let q = this.query()
50
+ if (userId !== null) q = q.where('user_id', userId)
51
+ q = q.orderBy('updated_at', 'desc').limit(opts.limit ?? 50)
52
+ if (opts.offset !== undefined) q = q.offset(opts.offset)
53
+ return q.get()
54
+ }
55
+
56
+ /**
57
+ * Update a thread's `last_response_id`. Wraps `update()` so the
58
+ * standard `updated_at` bump + repository lifecycle events still
59
+ * fire — apps that watch `brain_thread.updated` see the
60
+ * transition.
61
+ */
62
+ async updateResponseId(thread: BrainThread, responseId: string): Promise<BrainThread> {
63
+ return this.update(thread, { last_response_id: responseId } as Partial<BrainThread>)
64
+ }
65
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * `DatabaseBrainStore` — Postgres-backed implementation of
3
+ * `BrainStore`. Composes the three shipped repositories
4
+ * (`BrainThreadRepository`, `BrainMessageRepository`,
5
+ * `BrainSuspendedRunRepository`) into a single store surface.
6
+ *
7
+ * All multitenancy is transparent here — the repositories scope
8
+ * via RLS when the call is wrapped in `tenants.withTenant(...)`.
9
+ * Apps wire this once via `app.resolve(DatabaseBrainStore)` and
10
+ * inject it where conversations need to be persisted.
11
+ *
12
+ * Apps that need a different backend implement `BrainStore`
13
+ * directly against Redis / Mongo / in-memory / etc. — the
14
+ * schemas + repositories are framework conveniences, not
15
+ * obligations.
16
+ */
17
+
18
+ // biome-ignore lint/style/useImportType: classes are value imports for @inject() param-type metadata.
19
+ import { inject } from '@strav/kernel'
20
+ // biome-ignore lint/style/useImportType: repository classes value imports for @inject().
21
+ import { BrainMessageRepository } from './brain_message_repository.ts'
22
+ // biome-ignore lint/style/useImportType: repository classes value imports for @inject().
23
+ import { BrainSuspendedRunRepository } from './brain_suspended_run_repository.ts'
24
+ // biome-ignore lint/style/useImportType: repository classes value imports for @inject().
25
+ import { BrainThreadRepository } from './brain_thread_repository.ts'
26
+ import type {
27
+ BrainStore,
28
+ CreateThreadInput,
29
+ LoadedSuspendedRun,
30
+ LoadedThread,
31
+ SaveSuspendedRunInput,
32
+ SuspendedFilter,
33
+ SuspendedSummary,
34
+ ThreadFilter,
35
+ ThreadSummary,
36
+ TurnInput,
37
+ } from './brain_store.ts'
38
+
39
+ @inject()
40
+ export class DatabaseBrainStore implements BrainStore {
41
+ constructor(
42
+ private readonly threads: BrainThreadRepository,
43
+ private readonly messages: BrainMessageRepository,
44
+ private readonly suspendedRuns: BrainSuspendedRunRepository,
45
+ ) {}
46
+
47
+ // ── Threads ────────────────────────────────────────────────────────────
48
+
49
+ async createThread(input: CreateThreadInput): Promise<{ id: string }> {
50
+ const created = await this.threads.create({
51
+ user_id: input.userId ?? null,
52
+ title: input.title ?? null,
53
+ system: input.system ?? null,
54
+ options: input.options ?? null,
55
+ last_response_id: null,
56
+ })
57
+ return { id: created.id }
58
+ }
59
+
60
+ async loadThread(id: string): Promise<LoadedThread | null> {
61
+ const thread = await this.threads.find(id)
62
+ if (!thread) return null
63
+ const rows = await this.messages.loadForThread(id)
64
+ const result: LoadedThread = {
65
+ id: thread.id,
66
+ state: {
67
+ messages: rows.map((m) => ({ role: m.role, content: m.content })),
68
+ },
69
+ metadata: {
70
+ userId: thread.user_id,
71
+ title: thread.title,
72
+ createdAt: thread.created_at,
73
+ updatedAt: thread.updated_at,
74
+ },
75
+ }
76
+ if (thread.system !== null) result.state.system = thread.system
77
+ if (thread.options !== null) result.state.options = thread.options
78
+ if (thread.last_response_id !== null) {
79
+ result.state.lastResponseId = thread.last_response_id
80
+ }
81
+ return result
82
+ }
83
+
84
+ async appendTurn(threadId: string, turn: TurnInput): Promise<void> {
85
+ await this.messages.appendTurn({
86
+ threadId,
87
+ role: turn.role,
88
+ content: turn.content,
89
+ ...(turn.model !== undefined ? { model: turn.model } : {}),
90
+ ...(turn.usage !== undefined ? { usage: turn.usage } : {}),
91
+ ...(turn.stopReason !== undefined ? { stopReason: turn.stopReason } : {}),
92
+ ...(turn.responseId !== undefined ? { responseId: turn.responseId } : {}),
93
+ })
94
+ // When the model surfaced a new response id, also bump the
95
+ // thread-level pointer so subsequent loads + sends thread it via
96
+ // `previousResponseId` automatically.
97
+ if (turn.responseId !== undefined) {
98
+ const thread = await this.threads.find(threadId)
99
+ if (thread) {
100
+ await this.threads.updateResponseId(thread, turn.responseId)
101
+ }
102
+ }
103
+ }
104
+
105
+ async updateThreadResponseId(threadId: string, responseId: string): Promise<void> {
106
+ const thread = await this.threads.find(threadId)
107
+ if (!thread) return
108
+ await this.threads.updateResponseId(thread, responseId)
109
+ }
110
+
111
+ async listThreads(filter: ThreadFilter): Promise<ThreadSummary[]> {
112
+ const list = await this.threads.listForUser(filter.userId ?? null, {
113
+ ...(filter.limit !== undefined ? { limit: filter.limit } : {}),
114
+ ...(filter.offset !== undefined ? { offset: filter.offset } : {}),
115
+ })
116
+ return list.map((t) => ({
117
+ id: t.id,
118
+ userId: t.user_id,
119
+ title: t.title,
120
+ lastResponseId: t.last_response_id,
121
+ createdAt: t.created_at,
122
+ updatedAt: t.updated_at,
123
+ }))
124
+ }
125
+
126
+ async deleteThread(id: string): Promise<void> {
127
+ const thread = await this.threads.find(id)
128
+ if (!thread) return
129
+ await this.threads.delete(thread)
130
+ }
131
+
132
+ // ── Suspended runs ─────────────────────────────────────────────────────
133
+
134
+ async saveSuspendedRun(input: SaveSuspendedRunInput): Promise<{ id: string }> {
135
+ const created = await this.suspendedRuns.create({
136
+ thread_id: input.threadId ?? null,
137
+ user_id: input.userId ?? null,
138
+ pending_tool_calls: input.pendingToolCalls,
139
+ state: input.state,
140
+ status: 'pending',
141
+ })
142
+ return { id: created.id }
143
+ }
144
+
145
+ async loadSuspendedRun(id: string): Promise<LoadedSuspendedRun | null> {
146
+ const row = await this.suspendedRuns.find(id)
147
+ if (!row) return null
148
+ return {
149
+ id: row.id,
150
+ threadId: row.thread_id,
151
+ userId: row.user_id,
152
+ pendingToolCalls: row.pending_tool_calls,
153
+ state: row.state,
154
+ status: row.status,
155
+ createdAt: row.created_at,
156
+ updatedAt: row.updated_at,
157
+ }
158
+ }
159
+
160
+ async markSuspendedRunStatus(
161
+ id: string,
162
+ status: 'resumed' | 'cancelled',
163
+ ): Promise<void> {
164
+ const row = await this.suspendedRuns.find(id)
165
+ if (!row) return
166
+ if (status === 'resumed') {
167
+ await this.suspendedRuns.markResumed(row)
168
+ } else {
169
+ await this.suspendedRuns.markCancelled(row)
170
+ }
171
+ }
172
+
173
+ async listPendingSuspendedRuns(
174
+ filter: SuspendedFilter,
175
+ ): Promise<SuspendedSummary[]> {
176
+ const rows = await this.suspendedRuns.listPending({
177
+ ...(filter.userId !== undefined ? { userId: filter.userId } : {}),
178
+ ...(filter.threadId !== undefined ? { threadId: filter.threadId } : {}),
179
+ ...(filter.limit !== undefined ? { limit: filter.limit } : {}),
180
+ ...(filter.offset !== undefined ? { offset: filter.offset } : {}),
181
+ })
182
+ return rows.map((r) => ({
183
+ id: r.id,
184
+ threadId: r.thread_id,
185
+ userId: r.user_id,
186
+ status: r.status,
187
+ createdAt: r.created_at,
188
+ }))
189
+ }
190
+ }
@@ -0,0 +1,48 @@
1
+ // Public API of `@strav/brain/persistence` — recommended schema +
2
+ // repositories for persisting conversations (threads + turns) and
3
+ // human-in-the-loop suspended runs to Postgres via `@strav/database`.
4
+ //
5
+ // Apps that need a different backend implement `BrainStore`
6
+ // directly — the schemas + repositories are conveniences, not
7
+ // obligations.
8
+
9
+ export {
10
+ BrainMessage,
11
+ type BrainMessageRole,
12
+ } from './brain_message.ts'
13
+ export {
14
+ type AppendTurnInput,
15
+ BrainMessageRepository,
16
+ type LoadMessagesOptions,
17
+ } from './brain_message_repository.ts'
18
+ export type {
19
+ BrainStore,
20
+ CreateThreadInput,
21
+ LoadedSuspendedRun,
22
+ LoadedThread,
23
+ SaveSuspendedRunInput,
24
+ SuspendedFilter,
25
+ SuspendedSummary,
26
+ ThreadFilter,
27
+ ThreadSummary,
28
+ TurnInput,
29
+ } from './brain_store.ts'
30
+ export {
31
+ BrainSuspendedRun,
32
+ type BrainSuspendedRunStatus,
33
+ } from './brain_suspended_run.ts'
34
+ export {
35
+ type ListPendingOptions,
36
+ BrainSuspendedRunRepository,
37
+ } from './brain_suspended_run_repository.ts'
38
+ export { BrainThread } from './brain_thread.ts'
39
+ export {
40
+ BrainThreadRepository,
41
+ type ListThreadsOptions,
42
+ } from './brain_thread_repository.ts'
43
+ export { DatabaseBrainStore } from './database_brain_store.ts'
44
+ export {
45
+ brainMessageSchema,
46
+ brainSuspendedRunSchema,
47
+ brainThreadSchema,
48
+ } from './schema/index.ts'
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `brainMessageSchema` — one row per assistant or user turn within
3
+ * a thread. Append-only; rows are inserted in `turn_index` order
4
+ * and never updated (compaction blocks live as a regular assistant
5
+ * row whose `content` includes a `CompactionBlock`).
6
+ *
7
+ * Why per-turn rather than a JSONB blob on `brain_thread`:
8
+ *
9
+ * - **Pagination.** UIs render the latest N turns; queries select
10
+ * by `(thread_id, turn_index)` instead of parsing a JSON array.
11
+ * - **Per-turn metadata.** `model` / `usage` / `stop_reason` /
12
+ * `response_id` are indexed and queryable for cost analytics,
13
+ * audit, and routing (e.g., "which threads used gpt-5?").
14
+ * - **Append cost.** Each `send()` is a single INSERT, not a
15
+ * rewrite of the entire array.
16
+ *
17
+ * Columns:
18
+ *
19
+ * - `id` ULID primary key.
20
+ * - `thread_id` FK → `brain_thread`. `onDelete: cascade` —
21
+ * deleting a thread drops its history.
22
+ * - `turn_index` 0-based ordinal. Unique with `thread_id` (app
23
+ * migration adds the index).
24
+ * - `role` `user` or `assistant`. The framework's
25
+ * `Message.role` union; tool_result blocks land
26
+ * on user turns per the assistant ↔ user
27
+ * handshake, so `role` reflects that.
28
+ * - `content` JSONB — `string | ContentBlock[]`. Carries
29
+ * every typed block: text, image, document,
30
+ * audio, tool_use, tool_result, mcp_*, compaction.
31
+ * - `model` Model identifier used for assistant turns
32
+ * (NULL for user turns).
33
+ * - `usage` JSONB — `ChatUsage` for assistant turns.
34
+ * - `stop_reason` Provider terminal reason (`end_turn`, etc.).
35
+ * - `response_id` OpenAI Responses API id when surfaced. Indexed
36
+ * via partial index in the recommended migration.
37
+ * - `created_at` Timestamp.
38
+ *
39
+ * Archetype.Event — append-only semantics; no `updated_at`.
40
+ */
41
+
42
+ import { Archetype, defineSchema } from '@strav/database'
43
+ import { brainThreadSchema } from './brain_thread_schema.ts'
44
+
45
+ export const brainMessageSchema = defineSchema(
46
+ 'brain_message',
47
+ Archetype.Event,
48
+ (t) => {
49
+ t.id()
50
+ t.reference('thread_id').to(brainThreadSchema).onDelete('cascade').notNull()
51
+ t.integer('turn_index').notNull()
52
+ t.enum('role', ['user', 'assistant']).notNull()
53
+ t.json('content').notNull()
54
+ t.string('model').max(128).nullable()
55
+ t.json('usage').nullable()
56
+ t.string('stop_reason').max(64).nullable()
57
+ t.string('response_id').max(128).nullable()
58
+ t.timestamp('created_at').notNull()
59
+ },
60
+ { tenanted: true },
61
+ )