@sixb/pg 0.1.21 → 0.1.22

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 (110) hide show
  1. package/README.md +1 -0
  2. package/dist/agents/agent-storage.d.ts +8 -2
  3. package/dist/agents/agent-storage.d.ts.map +1 -1
  4. package/dist/agents/approvals.d.ts +48 -0
  5. package/dist/agents/approvals.d.ts.map +1 -0
  6. package/dist/agents/checkpoints.d.ts +1 -0
  7. package/dist/agents/checkpoints.d.ts.map +1 -1
  8. package/dist/agents/index.d.ts +1 -0
  9. package/dist/agents/index.d.ts.map +1 -1
  10. package/dist/agents/memory.d.ts +19 -0
  11. package/dist/agents/memory.d.ts.map +1 -0
  12. package/dist/agents/messages.d.ts.map +1 -1
  13. package/dist/agents/rows.d.ts +15 -1
  14. package/dist/agents/rows.d.ts.map +1 -1
  15. package/dist/agents/runs.d.ts +3 -1
  16. package/dist/agents/runs.d.ts.map +1 -1
  17. package/dist/agents/settings.d.ts +29 -0
  18. package/dist/agents/settings.d.ts.map +1 -0
  19. package/dist/agents/threads.d.ts +6 -1
  20. package/dist/agents/threads.d.ts.map +1 -1
  21. package/dist/ai-cost-groups.d.ts.map +1 -1
  22. package/dist/auth-storage/auth-storage.d.ts +3 -2
  23. package/dist/auth-storage/auth-storage.d.ts.map +1 -1
  24. package/dist/auth-storage/users.d.ts +8 -4
  25. package/dist/auth-storage/users.d.ts.map +1 -1
  26. package/dist/index.js +3720 -1594
  27. package/dist/index.js.map +37 -31
  28. package/dist/migrations.d.ts.map +1 -1
  29. package/dist/objects/conditional-reader.d.ts +17 -0
  30. package/dist/objects/conditional-reader.d.ts.map +1 -0
  31. package/dist/objects/query-compiler.d.ts +27 -1
  32. package/dist/objects/query-compiler.d.ts.map +1 -1
  33. package/dist/objects/query-indexes.d.ts.map +1 -1
  34. package/dist/objects/read-scope.d.ts +10 -1
  35. package/dist/objects/read-scope.d.ts.map +1 -1
  36. package/dist/objects/reader.d.ts +2 -0
  37. package/dist/objects/reader.d.ts.map +1 -1
  38. package/dist/objects/scoped-reader.d.ts +12 -0
  39. package/dist/objects/scoped-reader.d.ts.map +1 -0
  40. package/dist/objects/selected-reader.d.ts +1 -0
  41. package/dist/objects/selected-reader.d.ts.map +1 -1
  42. package/dist/objects/storage.d.ts +7 -1
  43. package/dist/objects/storage.d.ts.map +1 -1
  44. package/dist/ontology-storage/index.d.ts +2 -0
  45. package/dist/ontology-storage/index.d.ts.map +1 -1
  46. package/dist/ontology-storage/materialization-session.d.ts +6 -0
  47. package/dist/ontology-storage/materialization-session.d.ts.map +1 -1
  48. package/dist/ontology-storage/materialization-writer.d.ts +15 -0
  49. package/dist/ontology-storage/materialization-writer.d.ts.map +1 -1
  50. package/dist/ontology-storage/materializations.d.ts.map +1 -1
  51. package/dist/ontology-storage/unique-values.d.ts +60 -0
  52. package/dist/ontology-storage/unique-values.d.ts.map +1 -0
  53. package/dist/pg-ai-cost-storage.d.ts.map +1 -1
  54. package/dist/pg-ai-usage-storage.d.ts +8 -1
  55. package/dist/pg-ai-usage-storage.d.ts.map +1 -1
  56. package/dist/pg-artifact-storage.d.ts +8 -1
  57. package/dist/pg-artifact-storage.d.ts.map +1 -1
  58. package/dist/pg-execution-storage.d.ts +2 -1
  59. package/dist/pg-execution-storage.d.ts.map +1 -1
  60. package/dist/storage-errors.d.ts +2 -0
  61. package/dist/storage-errors.d.ts.map +1 -1
  62. package/package.json +3 -3
  63. package/src/agents/agent-storage.ts +11 -2
  64. package/src/agents/approvals.ts +400 -0
  65. package/src/agents/checkpoints.ts +8 -4
  66. package/src/agents/index.ts +1 -0
  67. package/src/agents/memory.ts +175 -0
  68. package/src/agents/messages.ts +14 -7
  69. package/src/agents/rows.ts +46 -1
  70. package/src/agents/runs.ts +154 -22
  71. package/src/agents/settings.ts +105 -0
  72. package/src/agents/threads.ts +356 -3
  73. package/src/ai-cost-groups.ts +11 -1
  74. package/src/auth-storage/auth-storage.ts +45 -13
  75. package/src/auth-storage/users.ts +105 -43
  76. package/src/migrations/069-artifact-kinds.sql +4 -0
  77. package/src/migrations/070-artifact-state.sql +16 -0
  78. package/src/migrations/071-agent-thread-participants.sql +16 -0
  79. package/src/migrations/072-forked-context-checkpoints.sql +23 -0
  80. package/src/migrations/073-unique-values.sql +25 -0
  81. package/src/migrations/074-generated-media-usage.sql +21 -0
  82. package/src/migrations/075-model-call-files.sql +11 -0
  83. package/src/migrations/076-agent-memory.sql +33 -0
  84. package/src/migrations/077-artifact-threads.sql +21 -0
  85. package/src/migrations/078-agent-approvals.sql +58 -0
  86. package/src/migrations/079-agent-approval-resume.sql +20 -0
  87. package/src/migrations/080-subagent-threads.sql +145 -0
  88. package/src/migrations/081-subagent-questions.sql +6 -0
  89. package/src/migrations.ts +34 -0
  90. package/src/objects/conditional-reader.ts +44 -0
  91. package/src/objects/prepare-queries.ts +1 -1
  92. package/src/objects/query-compiler.ts +96 -33
  93. package/src/objects/query-indexes.ts +29 -10
  94. package/src/objects/read-scope.ts +140 -25
  95. package/src/objects/reader.ts +50 -6
  96. package/src/objects/scoped-reader.ts +101 -0
  97. package/src/objects/selected-reader.ts +16 -76
  98. package/src/objects/storage.ts +24 -5
  99. package/src/objects/text-count-index.ts +1 -0
  100. package/src/ontology-storage/index.ts +3 -0
  101. package/src/ontology-storage/materialization-session.ts +8 -0
  102. package/src/ontology-storage/materialization-writer.ts +76 -1
  103. package/src/ontology-storage/materializations.ts +20 -1
  104. package/src/ontology-storage/unique-values.ts +274 -0
  105. package/src/pg-ai-cost-storage.ts +62 -12
  106. package/src/pg-ai-usage-storage.ts +90 -1
  107. package/src/pg-artifact-storage.ts +205 -9
  108. package/src/pg-execution-storage.ts +31 -0
  109. package/src/storage-errors.ts +7 -0
  110. package/src/transactions.ts +1 -1
@@ -1,8 +1,11 @@
1
1
  import type { AgentStorage, ExecutionStorage } from "@sixb/core/storage"
2
2
  import type { PgStoreClient } from "../transactions"
3
+ import { PgAgentApprovalStore } from "./approvals"
3
4
  import { PgAgentContextCheckpointStore } from "./checkpoints"
5
+ import { PgAgentMemoryStore } from "./memory"
4
6
  import { PgAgentMessageStore } from "./messages"
5
7
  import { PgAgentRunStore } from "./runs"
8
+ import { PgAgentApprovalModeStore } from "./settings"
6
9
  import { PgAgentThreadStore } from "./threads"
7
10
 
8
11
  export interface PgAgentStorageOptions {
@@ -11,8 +14,8 @@ export interface PgAgentStorageOptions {
11
14
  }
12
15
 
13
16
  /**
14
- * PostgreSQL-backed agent persistence: thread, run, message, and context-checkpoint sub-stores
15
- * sharing one connection. Cross-table writes run inside `runPgTransaction` with `SELECT ... FOR
17
+ * PostgreSQL-backed agent persistence: thread, run, message, context-checkpoint, memory, approval
18
+ * and approval-mode sub-stores sharing one connection. Cross-table writes run inside `runPgTransaction` with `SELECT ... FOR
16
19
  * UPDATE` row locks for single-flight and checkpoint compare-and-swap safety.
17
20
  */
18
21
  export class PgAgentStorage implements AgentStorage {
@@ -20,11 +23,17 @@ export class PgAgentStorage implements AgentStorage {
20
23
  readonly runs: PgAgentRunStore
21
24
  readonly messages: PgAgentMessageStore
22
25
  readonly checkpoints: PgAgentContextCheckpointStore
26
+ readonly memory: PgAgentMemoryStore
27
+ readonly approvals: PgAgentApprovalStore
28
+ readonly approvalModes: PgAgentApprovalModeStore
23
29
 
24
30
  constructor(options: PgAgentStorageOptions) {
25
31
  this.threads = new PgAgentThreadStore(options.sql)
26
32
  this.runs = new PgAgentRunStore(options.sql, options.executions)
27
33
  this.messages = new PgAgentMessageStore(options.sql)
28
34
  this.checkpoints = new PgAgentContextCheckpointStore(options.sql)
35
+ this.memory = new PgAgentMemoryStore(options.sql)
36
+ this.approvals = new PgAgentApprovalStore(options.sql)
37
+ this.approvalModes = new PgAgentApprovalModeStore(options.sql)
29
38
  }
30
39
  }
@@ -0,0 +1,400 @@
1
+ import type { Principal } from "@sixb/core"
2
+ import {
3
+ agentApprovalCallKey,
4
+ assertAgentApprovalCaller,
5
+ assertRequestAgentApprovalInput,
6
+ newAgentApprovalRecord,
7
+ } from "@sixb/core/internal/agent-run-storage-provider"
8
+ import {
9
+ type AgentApprovalCall,
10
+ type AgentApprovalCursor,
11
+ type AgentApprovalMode,
12
+ type AgentApprovalOutcome,
13
+ type AgentApprovalRecord,
14
+ type AgentApprovalStore,
15
+ AgentStorageError,
16
+ type DecideAgentApprovalInput,
17
+ type DecideAgentApprovalResult,
18
+ type ListAgentApprovalsInput,
19
+ type ListAgentApprovalsResult,
20
+ type MarkAgentApprovalsReportedInput,
21
+ type RequestAgentApprovalInput,
22
+ type RequestAgentApprovalResult,
23
+ type SettleAgentApprovalsInput,
24
+ type StopAgentApprovalsInput,
25
+ type StopAgentApprovalsResult,
26
+ } from "@sixb/core/storage"
27
+ import type { SQLClient } from "../pg-client"
28
+ import { type PgStoreClient, runPgTransaction } from "../transactions"
29
+ import { type AgentRunRow, rowToRunRecord } from "./rows"
30
+
31
+ interface AgentApprovalRow {
32
+ readonly project_id: string
33
+ readonly id: string
34
+ readonly thread_id: string
35
+ readonly run_id: string
36
+ readonly caller_run_id: string
37
+ readonly requested_by_type: Principal["type"]
38
+ readonly requested_by_id: string
39
+ readonly mode: AgentApprovalRecord["mode"]
40
+ readonly call_key: string
41
+ readonly call: AgentApprovalCall | string
42
+ readonly status: AgentApprovalRecord["status"]
43
+ readonly created_at: Date | string
44
+ readonly expires_at: Date | string
45
+ readonly decided_at: Date | string | null
46
+ readonly decided_by_type: Principal["type"] | null
47
+ readonly decided_by_id: string | null
48
+ readonly outcome: AgentApprovalOutcome | string | null
49
+ readonly reported_in_message_id: string | null
50
+ readonly raised_mode: AgentApprovalMode | null
51
+ readonly turn_stopped_at: Date | string | null
52
+ readonly execution_claimed_at: Date | string | null
53
+ }
54
+
55
+ export class PgAgentApprovalStore implements AgentApprovalStore {
56
+ constructor(private readonly sql: PgStoreClient) {}
57
+
58
+ async request(input: RequestAgentApprovalInput): Promise<RequestAgentApprovalResult> {
59
+ assertRequestAgentApprovalInput(input, "SixbPg")
60
+
61
+ return runPgTransaction(this.sql, async (tx) => {
62
+ // The caller's row lock serializes its requests with its `finish`, which cancels them.
63
+ const turn = await lockRun(tx, input.projectId, input.runId)
64
+ const caller =
65
+ input.callerRunId === input.runId
66
+ ? turn
67
+ : await lockRun(tx, input.projectId, input.callerRunId)
68
+ const threadId = assertAgentApprovalCaller({ request: input, turn, caller, prefix: "SixbPg" })
69
+
70
+ const live = await selectLive(tx, input.projectId, threadId, agentApprovalCallKey(input.call))
71
+ if (live) return { approval: rowToApproval(live), created: false }
72
+
73
+ const approval = newAgentApprovalRecord(input, threadId)
74
+ const [inserted] = await tx<AgentApprovalRow[]>`
75
+ INSERT INTO agent_approvals (
76
+ project_id, id, thread_id, run_id, caller_run_id, requested_by_type, requested_by_id,
77
+ mode, call_key, call, status, created_at, expires_at
78
+ ) VALUES (
79
+ ${approval.projectId}, ${approval.id}, ${approval.threadId}, ${approval.runId},
80
+ ${approval.callerRunId}, ${approval.requestedBy.type}, ${approval.requestedBy.id},
81
+ ${approval.mode}, ${approval.callKey}, ${JSON.stringify(approval.call)}::text::jsonb,
82
+ 'pending', ${approval.createdAt}, ${approval.expiresAt}
83
+ )
84
+ ON CONFLICT DO NOTHING
85
+ RETURNING *
86
+ `
87
+ if (inserted) return { approval: rowToApproval(inserted), created: true }
88
+
89
+ // Another caller of the same turn held the call first.
90
+ const raced = await selectLive(tx, input.projectId, threadId, approval.callKey)
91
+ if (raced) return { approval: rowToApproval(raced), created: false }
92
+ throw new AgentStorageError(
93
+ "duplicate_id",
94
+ `[SixbPg] Agent approval '${input.id}' already exists for project '${input.projectId}'.`
95
+ )
96
+ })
97
+ }
98
+
99
+ async decide(input: DecideAgentApprovalInput): Promise<DecideAgentApprovalResult | null> {
100
+ const decidedAt = input.decidedAt ?? new Date()
101
+ const [row] = await this.sql<AgentApprovalRow[]>`
102
+ UPDATE agent_approvals
103
+ SET
104
+ status = CASE WHEN expires_at <= ${decidedAt} THEN 'expired' ELSE ${input.decision} END,
105
+ decided_at = CASE WHEN expires_at <= ${decidedAt} THEN NULL ELSE ${decidedAt}::timestamptz END,
106
+ decided_by_type = CASE WHEN expires_at <= ${decidedAt} THEN NULL ELSE ${input.decidedBy.type} END,
107
+ decided_by_id = CASE WHEN expires_at <= ${decidedAt} THEN NULL ELSE ${input.decidedBy.id} END,
108
+ raised_mode = CASE WHEN expires_at <= ${decidedAt} OR ${input.decision} <> 'approved'
109
+ THEN NULL ELSE ${input.raisedMode ?? null}::text END
110
+ WHERE project_id = ${input.projectId} AND id = ${input.id} AND status = 'pending'
111
+ RETURNING *
112
+ `
113
+ if (row) {
114
+ const approval = rowToApproval(row)
115
+ return { approval, decided: approval.status === input.decision }
116
+ }
117
+
118
+ const current = await this.getById(input)
119
+ return current ? { approval: current, decided: false } : null
120
+ }
121
+
122
+ async recordOutcome(input: {
123
+ readonly projectId: string
124
+ readonly id: string
125
+ readonly outcome: AgentApprovalOutcome
126
+ }): Promise<AgentApprovalRecord> {
127
+ const [row] = await this.sql<AgentApprovalRow[]>`
128
+ UPDATE agent_approvals
129
+ SET outcome = ${JSON.stringify(input.outcome)}::text::jsonb
130
+ WHERE project_id = ${input.projectId} AND id = ${input.id}
131
+ AND status = 'approved' AND outcome IS NULL
132
+ RETURNING *
133
+ `
134
+ if (row) return rowToApproval(row)
135
+
136
+ const current = await this.getById(input)
137
+ if (current?.status === "approved") return current
138
+ throw new AgentStorageError(
139
+ "invalid_state",
140
+ current
141
+ ? `[SixbPg] Agent approval '${input.id}' is not approved (status '${current.status}').`
142
+ : `[SixbPg] Agent approval '${input.id}' not found for project '${input.projectId}'.`
143
+ )
144
+ }
145
+
146
+ async settle(input: SettleAgentApprovalsInput): Promise<readonly AgentApprovalRecord[]> {
147
+ const rows = await this.sql<AgentApprovalRow[]>`
148
+ UPDATE agent_approvals SET status = ${input.status}
149
+ WHERE project_id = ${input.projectId} AND thread_id = ${input.threadId}
150
+ AND status = 'pending'
151
+ RETURNING *
152
+ `
153
+ return rows.map(rowToApproval).sort(compareApprovals)
154
+ }
155
+
156
+ async stop(input: StopAgentApprovalsInput): Promise<StopAgentApprovalsResult> {
157
+ const runId = input.runId ?? null
158
+ const requesterType = input.requestedBy?.type ?? null
159
+ const requesterId = input.requestedBy?.id ?? null
160
+ const stoppedAt = input.stoppedAt ?? new Date()
161
+
162
+ // What each row was before the update tells what this stop changed.
163
+ const rows = await this.sql<
164
+ (AgentApprovalRow & { readonly was_pending: boolean; readonly newly_stopped: boolean })[]
165
+ >`
166
+ WITH matched AS (
167
+ SELECT id, status = 'pending' AS was_pending, turn_stopped_at IS NULL AS newly_stopped
168
+ FROM agent_approvals
169
+ WHERE project_id = ${input.projectId} AND thread_id = ${input.threadId}
170
+ AND reported_in_message_id IS NULL
171
+ AND (status = 'pending' OR turn_stopped_at IS NULL)
172
+ AND (${runId}::text IS NULL OR run_id = ${runId})
173
+ AND (${requesterType}::text IS NULL
174
+ OR (requested_by_type = ${requesterType} AND requested_by_id = ${requesterId}))
175
+ FOR UPDATE
176
+ )
177
+ UPDATE agent_approvals AS approval
178
+ SET
179
+ status = CASE WHEN matched.was_pending THEN 'cancelled' ELSE approval.status END,
180
+ turn_stopped_at = COALESCE(approval.turn_stopped_at, ${stoppedAt}::timestamptz)
181
+ FROM matched
182
+ WHERE approval.project_id = ${input.projectId} AND approval.id = matched.id
183
+ RETURNING approval.*, matched.was_pending, matched.newly_stopped
184
+ `
185
+ const cancelled = rows.filter((row) => row.was_pending).map(rowToApproval)
186
+ const stopped = rows.filter((row) => row.newly_stopped).length
187
+ return { cancelled: cancelled.sort(compareApprovals), stopped }
188
+ }
189
+
190
+ async holdsRun(input: { readonly projectId: string; readonly runId: string }): Promise<boolean> {
191
+ return pgHoldsRun(this.sql, input.projectId, input.runId)
192
+ }
193
+
194
+ async claimExecution(input: {
195
+ readonly projectId: string
196
+ readonly id: string
197
+ readonly claimedAt: Date
198
+ readonly staleBefore: Date
199
+ }): Promise<AgentApprovalRecord | null> {
200
+ const [row] = await this.sql<AgentApprovalRow[]>`
201
+ UPDATE agent_approvals SET execution_claimed_at = ${input.claimedAt}
202
+ WHERE project_id = ${input.projectId} AND id = ${input.id}
203
+ AND status = 'approved' AND outcome IS NULL
204
+ AND COALESCE(execution_claimed_at, decided_at) <= ${input.staleBefore}
205
+ RETURNING *
206
+ `
207
+ return row ? rowToApproval(row) : null
208
+ }
209
+
210
+ async listRepairable(input: {
211
+ readonly projectId: string
212
+ readonly claimableBefore: Date
213
+ readonly after?: AgentApprovalCursor
214
+ readonly limit: number
215
+ }): Promise<readonly AgentApprovalRecord[]> {
216
+ const afterAt = input.after?.createdAt ?? null
217
+ const afterId = input.after?.id ?? ""
218
+ // Each half of the OR matches one partial index.
219
+ const rows = await this.sql<AgentApprovalRow[]>`
220
+ SELECT * FROM agent_approvals
221
+ WHERE project_id = ${input.projectId}
222
+ AND (
223
+ (status = 'approved' AND outcome IS NULL
224
+ AND COALESCE(execution_claimed_at, decided_at) <= ${input.claimableBefore})
225
+ OR (status IN ('approved', 'denied') AND reported_in_message_id IS NULL
226
+ AND turn_stopped_at IS NULL)
227
+ )
228
+ AND (${afterAt}::timestamptz IS NULL OR (created_at, id) > (${afterAt}::timestamptz, ${afterId}))
229
+ ORDER BY created_at, id
230
+ LIMIT ${input.limit}
231
+ `
232
+ return rows.map(rowToApproval)
233
+ }
234
+
235
+ async expireDue(input: {
236
+ readonly projectId: string
237
+ readonly now: Date
238
+ readonly limit?: number
239
+ }): Promise<readonly AgentApprovalRecord[]> {
240
+ // The CTE picks the rows once, under the limit; an `IN` subquery did not keep to it.
241
+ const rows = await this.sql<AgentApprovalRow[]>`
242
+ WITH due AS (
243
+ SELECT id FROM agent_approvals
244
+ WHERE project_id = ${input.projectId} AND status = 'pending'
245
+ AND expires_at <= ${input.now}
246
+ ORDER BY created_at, id
247
+ LIMIT ${input.limit ?? null}
248
+ FOR UPDATE SKIP LOCKED
249
+ )
250
+ UPDATE agent_approvals AS approval SET status = 'expired'
251
+ FROM due
252
+ WHERE approval.project_id = ${input.projectId} AND approval.id = due.id
253
+ RETURNING approval.*
254
+ `
255
+ return rows.map(rowToApproval).sort(compareApprovals)
256
+ }
257
+
258
+ async markReported(input: MarkAgentApprovalsReportedInput): Promise<readonly string[]> {
259
+ const ids = [...new Set(input.ids)]
260
+ if (ids.length === 0) return []
261
+
262
+ const rows = await this.sql<{ id: string }[]>`
263
+ UPDATE agent_approvals SET reported_in_message_id = ${input.messageId}
264
+ WHERE project_id = ${input.projectId} AND thread_id = ${input.threadId}
265
+ AND id = ANY(${this.sql.array(ids)}::text[]) AND reported_in_message_id IS NULL
266
+ AND (${input.resuming !== true} OR turn_stopped_at IS NULL)
267
+ RETURNING id
268
+ `
269
+ const marked = new Set(rows.map((row) => row.id))
270
+ return ids.filter((id) => marked.has(id))
271
+ }
272
+
273
+ async getById(params: {
274
+ readonly projectId: string
275
+ readonly id: string
276
+ }): Promise<AgentApprovalRecord | null> {
277
+ const [row] = await this.sql<AgentApprovalRow[]>`
278
+ SELECT * FROM agent_approvals WHERE project_id = ${params.projectId} AND id = ${params.id}
279
+ `
280
+ return row ? rowToApproval(row) : null
281
+ }
282
+
283
+ async list(input: ListAgentApprovalsInput): Promise<ListAgentApprovalsResult> {
284
+ if (input.statuses?.length === 0) return { approvals: [], hasMore: false, total: 0 }
285
+
286
+ const offset = input.offset ?? 0
287
+ return runPgTransaction(
288
+ this.sql,
289
+ async (tx) => {
290
+ const [counted] = await tx<{ total: number }[]>`
291
+ SELECT COUNT(*)::int AS total FROM agent_approvals WHERE ${listFilter(tx, input)}
292
+ `
293
+ const rows = await tx<AgentApprovalRow[]>`
294
+ SELECT * FROM agent_approvals WHERE ${listFilter(tx, input)}
295
+ ORDER BY created_at, id
296
+ LIMIT ${input.limit ?? null} OFFSET ${offset}
297
+ `
298
+ const total = counted?.total ?? 0
299
+ return { approvals: rows.map(rowToApproval), hasMore: offset + rows.length < total, total }
300
+ },
301
+ { isolation: "repeatableRead" }
302
+ )
303
+ }
304
+ }
305
+
306
+ /** The `list` predicate, as the in-memory store's `agentApprovalMatches` reads it. */
307
+ function listFilter(sql: SQLClient, input: ListAgentApprovalsInput) {
308
+ const threadId = input.threadId ?? null
309
+ const runId = input.runId ?? null
310
+ const requesterType = input.requestedBy?.type ?? null
311
+ const requesterId = input.requestedBy?.id ?? null
312
+ const reported = input.reported ?? null
313
+ const statuses = input.statuses ?? null
314
+
315
+ return sql`
316
+ project_id = ${input.projectId}
317
+ AND (${threadId}::text IS NULL OR thread_id = ${threadId})
318
+ AND (${runId}::text IS NULL OR run_id = ${runId})
319
+ AND (${requesterType}::text IS NULL
320
+ OR (requested_by_type = ${requesterType} AND requested_by_id = ${requesterId}))
321
+ AND (${statuses === null}::boolean OR status = ANY(${sql.array([...(statuses ?? [])])}::text[]))
322
+ AND (${reported}::boolean IS NULL OR (reported_in_message_id IS NOT NULL) = ${reported})
323
+ `
324
+ }
325
+
326
+ async function lockRun(sql: SQLClient, projectId: string, id: string) {
327
+ const [row] = await sql<AgentRunRow[]>`
328
+ SELECT * FROM agent_runs WHERE project_id = ${projectId} AND id = ${id} FOR UPDATE
329
+ `
330
+ return row ? rowToRunRecord(row) : null
331
+ }
332
+
333
+ function selectLive(
334
+ sql: SQLClient,
335
+ projectId: string,
336
+ threadId: string,
337
+ callKey: string
338
+ ): Promise<AgentApprovalRow | undefined> {
339
+ return sql<AgentApprovalRow[]>`
340
+ SELECT * FROM agent_approvals
341
+ WHERE project_id = ${projectId} AND thread_id = ${threadId} AND call_key = ${callKey}
342
+ AND status IN ('pending', 'approved', 'denied')
343
+ `.then((rows) => rows[0])
344
+ }
345
+
346
+ function rowToApproval(row: AgentApprovalRow): AgentApprovalRecord {
347
+ return {
348
+ id: row.id,
349
+ projectId: row.project_id,
350
+ threadId: row.thread_id,
351
+ runId: row.run_id,
352
+ callerRunId: row.caller_run_id,
353
+ requestedBy: { type: row.requested_by_type, id: row.requested_by_id },
354
+ mode: row.mode,
355
+ callKey: row.call_key,
356
+ call: parseJson<AgentApprovalCall>(row.call),
357
+ status: row.status,
358
+ createdAt: new Date(row.created_at),
359
+ expiresAt: new Date(row.expires_at),
360
+ ...(row.decided_at === null ? {} : { decidedAt: new Date(row.decided_at) }),
361
+ ...(row.decided_by_type === null || row.decided_by_id === null
362
+ ? {}
363
+ : { decidedBy: { type: row.decided_by_type, id: row.decided_by_id } }),
364
+ ...(row.outcome === null ? {} : { outcome: parseJson<AgentApprovalOutcome>(row.outcome) }),
365
+ ...(row.reported_in_message_id === null
366
+ ? {}
367
+ : { reportedInMessageId: row.reported_in_message_id }),
368
+ ...(row.raised_mode === null ? {} : { raisedMode: row.raised_mode }),
369
+ ...(row.turn_stopped_at === null ? {} : { turnStoppedAt: new Date(row.turn_stopped_at) }),
370
+ ...(row.execution_claimed_at === null
371
+ ? {}
372
+ : { executionClaimedAt: new Date(row.execution_claimed_at) }),
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Whether a run holds a call: an approval of its, or one it made for its turn, pending or decided
378
+ * and not reported yet. `holdsRun` and the paused finish of `runs.finish` both ask this.
379
+ */
380
+ export async function pgHoldsRun(
381
+ sql: PgStoreClient,
382
+ projectId: string,
383
+ runId: string
384
+ ): Promise<boolean> {
385
+ const [held] = await sql<{ id: string }[]>`
386
+ SELECT id FROM agent_approvals
387
+ WHERE project_id = ${projectId} AND (run_id = ${runId} OR caller_run_id = ${runId})
388
+ AND status IN ('pending', 'approved', 'denied') AND reported_in_message_id IS NULL
389
+ LIMIT 1
390
+ `
391
+ return held !== undefined
392
+ }
393
+
394
+ function parseJson<T>(value: T | string): T {
395
+ return typeof value === "string" ? (JSON.parse(value) as T) : value
396
+ }
397
+
398
+ function compareApprovals(left: AgentApprovalRecord, right: AgentApprovalRecord): number {
399
+ return left.createdAt.getTime() - right.createdAt.getTime() || left.id.localeCompare(right.id)
400
+ }
@@ -67,7 +67,10 @@ export class PgAgentContextCheckpointStore implements AgentContextCheckpointStor
67
67
 
68
68
  const [existingForRunRow] = await tx<AgentContextCheckpointRow[]>`
69
69
  SELECT * FROM agent_context_checkpoints
70
- WHERE project_id = ${input.projectId} AND created_by_run_id = ${input.createdByRunId}
70
+ WHERE
71
+ project_id = ${input.projectId}
72
+ AND thread_id = ${input.threadId}
73
+ AND created_by_run_id = ${input.createdByRunId}
71
74
  `
72
75
  if (existingForRunRow) {
73
76
  const record = rowToContextCheckpointRecord(existingForRunRow)
@@ -177,6 +180,7 @@ export class PgAgentContextCheckpointStore implements AgentContextCheckpointStor
177
180
 
178
181
  async getByRunIds(input: {
179
182
  readonly projectId: string
183
+ readonly threadId: string
180
184
  readonly runIds: readonly string[]
181
185
  }): Promise<readonly AgentContextCheckpointRecord[]> {
182
186
  const runIds = [...new Set(input.runIds)]
@@ -185,11 +189,11 @@ export class PgAgentContextCheckpointStore implements AgentContextCheckpointStor
185
189
  const rows: AgentContextCheckpointRow[] = []
186
190
  for (let offset = 0; offset < runIds.length; offset += PG_CHECKPOINT_RUN_ID_BATCH_SIZE) {
187
191
  const batch = runIds.slice(offset, offset + PG_CHECKPOINT_RUN_ID_BATCH_SIZE)
188
- const placeholders = batch.map((_, index) => `$${index + 2}`).join(", ")
192
+ const placeholders = batch.map((_, index) => `$${index + 3}`).join(", ")
189
193
  rows.push(
190
194
  ...(await this.sql.unsafe<AgentContextCheckpointRow[]>(
191
- `SELECT * FROM agent_context_checkpoints WHERE project_id = $1 AND created_by_run_id IN (${placeholders})`,
192
- [input.projectId, ...batch]
195
+ `SELECT * FROM agent_context_checkpoints WHERE project_id = $1 AND thread_id = $2 AND created_by_run_id IN (${placeholders})`,
196
+ [input.projectId, input.threadId, ...batch]
193
197
  ))
194
198
  )
195
199
  }
@@ -1,6 +1,7 @@
1
1
  export type { PgAgentStorageOptions } from "./agent-storage"
2
2
  export { PgAgentStorage } from "./agent-storage"
3
3
  export { PgAgentContextCheckpointStore } from "./checkpoints"
4
+ export { PgAgentMemoryStore } from "./memory"
4
5
  export { PgAgentMessageStore } from "./messages"
5
6
  export { PgAgentRunStore } from "./runs"
6
7
  export { PgAgentThreadStore } from "./threads"
@@ -0,0 +1,175 @@
1
+ import type { Principal } from "@sixb/core"
2
+ import {
3
+ agentMemoryConflicts,
4
+ agentMemoryHistoryCutoff,
5
+ assertSaveAgentMemoryInput,
6
+ } from "@sixb/core/internal/agent-run-storage-provider"
7
+ import type {
8
+ AgentMemoryRepoRecord,
9
+ AgentMemoryRevisionRecord,
10
+ AgentMemoryStore,
11
+ ListAgentMemoryRevisionsInput,
12
+ ListAgentMemoryRevisionsResult,
13
+ SaveAgentMemoryInput,
14
+ SaveAgentMemoryResult,
15
+ } from "@sixb/core/storage"
16
+ import { lockAdvisoryKeys, type PgStoreClient, runPgTransaction } from "../transactions"
17
+
18
+ interface RevisionRow {
19
+ readonly revision: number
20
+ readonly message: string
21
+ readonly author_principal_type: Principal["type"]
22
+ readonly author_principal_id: string
23
+ readonly source: string | null
24
+ readonly created_at: Date
25
+ }
26
+
27
+ export class PgAgentMemoryStore implements AgentMemoryStore {
28
+ constructor(private readonly sql: PgStoreClient) {}
29
+
30
+ async get(input: {
31
+ projectId: string
32
+ repoId: string
33
+ asOf?: Date
34
+ revision?: number
35
+ }): Promise<AgentMemoryRepoRecord> {
36
+ const asOf = input.asOf ?? null
37
+ const upTo = input.revision ?? null
38
+ // One statement, so the head and the files come from one snapshot.
39
+ const rows = await this.sql<{ head: number; path: string | null; contents: string | null }[]>`
40
+ WITH head AS (
41
+ SELECT COALESCE(MAX(revision), 0)::int AS revision FROM agent_memory_revisions
42
+ WHERE project_id = ${input.projectId} AND repo_id = ${input.repoId}
43
+ AND (${asOf}::timestamptz IS NULL OR created_at <= ${asOf}::timestamptz)
44
+ AND (${upTo}::int IS NULL OR revision <= ${upTo}::int)
45
+ ), latest AS (
46
+ SELECT DISTINCT ON (f.path) f.path, f.contents FROM agent_memory_files f, head
47
+ WHERE f.project_id = ${input.projectId} AND f.repo_id = ${input.repoId}
48
+ AND f.revision <= head.revision
49
+ ORDER BY f.path, f.revision DESC
50
+ )
51
+ SELECT head.revision AS head, latest.path, latest.contents
52
+ FROM head
53
+ LEFT JOIN latest ON latest.contents IS NOT NULL
54
+ ORDER BY latest.path
55
+ `
56
+ return {
57
+ revision: rows[0]?.head ?? 0,
58
+ files: rows.flatMap(({ path, contents }) =>
59
+ path === null || contents === null ? [] : [{ path, contents }]
60
+ ),
61
+ }
62
+ }
63
+
64
+ async save(input: SaveAgentMemoryInput): Promise<SaveAgentMemoryResult> {
65
+ assertSaveAgentMemoryInput(input, "SixbPg")
66
+ return runPgTransaction(this.sql, async (tx) => {
67
+ // A repo's first save has no row to lock, so saves to one repo take its advisory lock.
68
+ await lockAdvisoryKeys(tx, [`agent-memory:${input.projectId}:${input.repoId}`])
69
+ const [{ head } = { head: 0 }] = await tx<{ head: number }[]>`
70
+ SELECT COALESCE(MAX(revision), 0)::int AS head FROM agent_memory_revisions
71
+ WHERE project_id = ${input.projectId} AND repo_id = ${input.repoId}
72
+ `
73
+ const paths = input.changes.map((change) => change.path)
74
+ const latest = await tx<{ path: string; revision: number; contents: string | null }[]>`
75
+ SELECT DISTINCT ON (path) path, revision, contents FROM agent_memory_files
76
+ WHERE project_id = ${input.projectId} AND repo_id = ${input.repoId}
77
+ AND path IN ${tx(paths)}
78
+ ORDER BY path, revision DESC
79
+ `
80
+ const conflicts = agentMemoryConflicts(
81
+ input,
82
+ head,
83
+ new Map(latest.map((file) => [file.path, file]))
84
+ )
85
+ if (conflicts.length > 0) return { saved: false, revision: head, conflicts }
86
+
87
+ const revision = head + 1
88
+ const createdAt = input.createdAt ?? new Date()
89
+ await tx`
90
+ INSERT INTO agent_memory_revisions (
91
+ project_id, repo_id, revision, message, author_principal_type, author_principal_id,
92
+ source, created_at
93
+ ) VALUES (
94
+ ${input.projectId}, ${input.repoId}, ${revision}, ${input.message},
95
+ ${input.author.type}, ${input.author.id}, ${input.source ?? null}, ${createdAt}
96
+ )
97
+ `
98
+ await tx`
99
+ INSERT INTO agent_memory_files (project_id, repo_id, path, revision, contents)
100
+ SELECT ${input.projectId}, ${input.repoId}, change.path, ${revision}, change.contents
101
+ FROM jsonb_to_recordset(${JSON.stringify(input.changes)}::text::jsonb)
102
+ AS change(path TEXT, contents TEXT)
103
+ `
104
+ await tx`
105
+ DELETE FROM agent_memory_files AS f
106
+ WHERE f.project_id = ${input.projectId} AND f.repo_id = ${input.repoId}
107
+ AND f.revision IN (
108
+ SELECT revision FROM agent_memory_revisions
109
+ WHERE project_id = ${input.projectId} AND repo_id = ${input.repoId}
110
+ AND created_at < ${agentMemoryHistoryCutoff(createdAt)}
111
+ )
112
+ AND EXISTS (
113
+ SELECT 1 FROM agent_memory_files n
114
+ WHERE n.project_id = f.project_id AND n.repo_id = f.repo_id AND n.path = f.path
115
+ AND n.revision > f.revision
116
+ )
117
+ `
118
+ return { saved: true, revision }
119
+ })
120
+ }
121
+
122
+ async listRevisions(
123
+ input: ListAgentMemoryRevisionsInput
124
+ ): Promise<ListAgentMemoryRevisionsResult> {
125
+ return runPgTransaction(
126
+ this.sql,
127
+ async (tx) => {
128
+ const [counted] = await tx<{ total: number }[]>`
129
+ SELECT COUNT(*)::int AS total FROM agent_memory_revisions
130
+ WHERE project_id = ${input.projectId} AND repo_id = ${input.repoId}
131
+ `
132
+ const offset = input.offset ?? 0
133
+ const rows = await tx<RevisionRow[]>`
134
+ SELECT revision, message, author_principal_type, author_principal_id, source, created_at
135
+ FROM agent_memory_revisions
136
+ WHERE project_id = ${input.projectId} AND repo_id = ${input.repoId}
137
+ ORDER BY revision DESC
138
+ LIMIT ${input.limit ?? null} OFFSET ${offset}
139
+ `
140
+ const changes =
141
+ rows.length === 0
142
+ ? []
143
+ : await tx<{ revision: number; path: string; deleted: boolean }[]>`
144
+ SELECT revision, path, contents IS NULL AS deleted FROM agent_memory_files
145
+ WHERE project_id = ${input.projectId} AND repo_id = ${input.repoId}
146
+ AND revision IN ${tx(rows.map((row) => row.revision))}
147
+ ORDER BY path
148
+ `
149
+ const total = counted?.total ?? 0
150
+ const revisions = rows.map(
151
+ (row): AgentMemoryRevisionRecord => ({
152
+ revision: row.revision,
153
+ message: row.message,
154
+ author: { type: row.author_principal_type, id: row.author_principal_id },
155
+ ...(row.source === null ? {} : { source: row.source }),
156
+ changes: changes
157
+ .filter((change) => change.revision === row.revision)
158
+ .map((change) => ({ path: change.path, deleted: change.deleted })),
159
+ createdAt: new Date(row.created_at),
160
+ })
161
+ )
162
+ return { revisions, hasMore: offset + rows.length < total, total }
163
+ },
164
+ { isolation: "repeatableRead" }
165
+ )
166
+ }
167
+
168
+ async delete(input: { projectId: string; repoId: string }): Promise<void> {
169
+ // File versions go with their revisions through ON DELETE CASCADE.
170
+ await this.sql`
171
+ DELETE FROM agent_memory_revisions
172
+ WHERE project_id = ${input.projectId} AND repo_id = ${input.repoId}
173
+ `
174
+ }
175
+ }