@strav/brain 1.0.0-alpha.17 → 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.
- package/package.json +4 -2
- package/src/agent_generate_result.ts +2 -0
- package/src/agent_result.ts +7 -0
- package/src/agent_runner.ts +80 -4
- package/src/brain_manager.ts +119 -2
- package/src/index.ts +20 -2
- package/src/mcp/client.ts +17 -0
- package/src/mcp/index.ts +1 -0
- package/src/mcp/pool.ts +106 -0
- package/src/mcp/resolve_mcp_tools.ts +25 -7
- package/src/persistence/brain_message.ts +34 -0
- package/src/persistence/brain_message_repository.ts +106 -0
- package/src/persistence/brain_store.ts +166 -0
- package/src/persistence/brain_suspended_run.ts +30 -0
- package/src/persistence/brain_suspended_run_repository.ts +68 -0
- package/src/persistence/brain_thread.ts +30 -0
- package/src/persistence/brain_thread_repository.ts +65 -0
- package/src/persistence/database_brain_store.ts +190 -0
- package/src/persistence/index.ts +48 -0
- package/src/persistence/schema/brain_message_schema.ts +61 -0
- package/src/persistence/schema/brain_suspended_run_schema.ts +58 -0
- package/src/persistence/schema/brain_thread_schema.ts +50 -0
- package/src/persistence/schema/index.ts +3 -0
- package/src/provider.ts +36 -1
- package/src/providers/anthropic_provider.ts +140 -23
- package/src/providers/gemini_provider.ts +55 -32
- package/src/providers/openai_compat_provider.ts +452 -23
- package/src/providers/openai_provider.ts +87 -32
- package/src/providers/openai_responses_provider.ts +365 -50
- package/src/suspended_run.ts +153 -0
- package/src/thread.ts +40 -1
- package/src/types.ts +110 -0
|
@@ -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
|
+
|
|
@@ -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
|
+
}
|