@lota-sdk/core 0.2.2 → 0.3.0

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 (102) hide show
  1. package/infrastructure/schema/00_identity.surql +2 -2
  2. package/infrastructure/schema/00_thread.surql +75 -0
  3. package/infrastructure/schema/02_execution_plan.surql +10 -11
  4. package/infrastructure/schema/10_autonomous_job.surql +3 -3
  5. package/package.json +2 -2
  6. package/src/ai/definitions.ts +1 -1
  7. package/src/config/agent-defaults.ts +5 -5
  8. package/src/config/index.ts +1 -1
  9. package/src/config/thread-defaults.ts +72 -0
  10. package/src/create-runtime.ts +89 -93
  11. package/src/db/tables.ts +3 -3
  12. package/src/db/{workstream-message-row.ts → thread-message-row.ts} +3 -3
  13. package/src/queues/context-compaction.queue.ts +6 -6
  14. package/src/queues/plan-agent-heartbeat.queue.ts +3 -3
  15. package/src/queues/post-chat-memory.queue.ts +1 -1
  16. package/src/queues/title-generation.queue.ts +10 -13
  17. package/src/redis/index.ts +1 -1
  18. package/src/redis/stream-context.ts +1 -1
  19. package/src/runtime/agent-identity-overrides.ts +1 -1
  20. package/src/runtime/agent-runtime-policy.ts +19 -21
  21. package/src/runtime/chat-request-routing.ts +1 -1
  22. package/src/runtime/context-compaction-constants.ts +1 -1
  23. package/src/runtime/context-compaction.ts +1 -1
  24. package/src/runtime/execution-plan.ts +1 -1
  25. package/src/runtime/index.ts +1 -1
  26. package/src/runtime/memory-digest-policy.ts +1 -1
  27. package/src/runtime/plugin-types.ts +1 -1
  28. package/src/runtime/post-turn-side-effects.ts +35 -35
  29. package/src/runtime/runtime-config.ts +12 -12
  30. package/src/runtime/runtime-extensions.ts +11 -11
  31. package/src/runtime/social-chat-agent-runner.ts +3 -3
  32. package/src/runtime/social-chat-history.ts +1 -1
  33. package/src/runtime/social-chat.ts +6 -6
  34. package/src/runtime/team-consultation-orchestrator.ts +1 -1
  35. package/src/runtime/{workstream-chat-helpers.ts → thread-chat-helpers.ts} +7 -7
  36. package/src/runtime/{workstream-plan-turn.ts → thread-plan-turn.ts} +11 -17
  37. package/src/runtime/{workstream-turn-context.ts → thread-turn-context.ts} +10 -10
  38. package/src/services/agent-activity.service.ts +39 -44
  39. package/src/services/agent-executor.service.ts +17 -19
  40. package/src/services/attachment.service.ts +4 -8
  41. package/src/services/autonomous-job.service.ts +29 -28
  42. package/src/services/context-compaction.service.ts +19 -29
  43. package/src/services/execution-plan.service.ts +58 -70
  44. package/src/services/global-orchestrator.service.ts +5 -5
  45. package/src/services/index.ts +6 -6
  46. package/src/services/memory.service.ts +1 -1
  47. package/src/services/monitoring-window.service.ts +2 -2
  48. package/src/services/mutating-approval.service.ts +7 -10
  49. package/src/services/node-workspace.service.ts +8 -7
  50. package/src/services/notification.service.ts +1 -1
  51. package/src/services/organization.service.ts +9 -9
  52. package/src/services/ownership-dispatcher.service.ts +13 -19
  53. package/src/services/plan-agent-heartbeat.service.ts +13 -13
  54. package/src/services/plan-agent-query.service.ts +7 -7
  55. package/src/services/plan-artifact.service.ts +1 -2
  56. package/src/services/plan-coordination.service.ts +4 -4
  57. package/src/services/plan-cycle.service.ts +7 -7
  58. package/src/services/plan-deadline.service.ts +4 -4
  59. package/src/services/plan-event-delivery.service.ts +8 -12
  60. package/src/services/plan-executor.service.ts +16 -37
  61. package/src/services/plan-run-data.ts +27 -8
  62. package/src/services/plan-run.service.ts +7 -9
  63. package/src/services/plan-scheduler.service.ts +4 -4
  64. package/src/services/plan-template.service.ts +2 -2
  65. package/src/services/plan-validator.service.ts +0 -11
  66. package/src/services/plugin-executor.service.ts +1 -1
  67. package/src/services/queue-job.service.ts +1 -1
  68. package/src/services/recent-activity-title.service.ts +1 -1
  69. package/src/services/recent-activity.service.ts +4 -4
  70. package/src/services/system-executor.service.ts +2 -2
  71. package/src/services/{workstream-message.service.ts → thread-message.service.ts} +72 -76
  72. package/src/services/thread-plan-registry.service.ts +22 -0
  73. package/src/services/thread-title.service.ts +39 -0
  74. package/src/services/{workstream-turn-preparation.service.ts → thread-turn-preparation.service.ts} +131 -143
  75. package/src/services/{workstream-turn.ts → thread-turn.ts} +27 -31
  76. package/src/services/thread.service.ts +707 -0
  77. package/src/services/thread.types.ts +17 -0
  78. package/src/storage/attachment-storage.service.ts +4 -4
  79. package/src/system-agents/index.ts +1 -1
  80. package/src/system-agents/memory.agent.ts +1 -1
  81. package/src/system-agents/recent-activity-title-refiner.agent.ts +2 -2
  82. package/src/system-agents/regular-chat-memory-digest.agent.ts +1 -1
  83. package/src/system-agents/researcher.agent.ts +3 -3
  84. package/src/system-agents/{workstream-router.agent.ts → thread-router.agent.ts} +21 -21
  85. package/src/system-agents/title-generator.agent.ts +8 -8
  86. package/src/tools/execution-plan.tool.ts +39 -40
  87. package/src/tools/memory-block.tool.ts +4 -4
  88. package/src/tools/research-topic.tool.ts +1 -0
  89. package/src/tools/search-web.tool.ts +1 -1
  90. package/src/tools/search.tool.ts +4 -4
  91. package/src/tools/team-think.tool.ts +9 -9
  92. package/src/workers/regular-chat-memory-digest.helpers.ts +1 -1
  93. package/src/workers/regular-chat-memory-digest.runner.ts +43 -43
  94. package/src/workers/skill-extraction.runner.ts +9 -13
  95. package/src/workers/utils/{workstream-message-query.ts → thread-message-query.ts} +21 -21
  96. package/infrastructure/schema/00_workstream.surql +0 -64
  97. package/src/config/workstream-defaults.ts +0 -72
  98. package/src/services/workstream-plan-registry.service.ts +0 -22
  99. package/src/services/workstream-title.service.ts +0 -42
  100. package/src/services/workstream.service.ts +0 -803
  101. package/src/services/workstream.types.ts +0 -17
  102. /package/src/services/{workstream-constants.ts → thread-constants.ts} +0 -0
@@ -0,0 +1,707 @@
1
+ import { THREAD, sdkThreadStatusSchema } from '@lota-sdk/shared'
2
+ import { BoundQuery, RecordId, StringRecordId, surql } from 'surrealdb'
3
+
4
+ import { agentDisplayNames, agentRoster, getCoreThreadProfile, isAgentName } from '../config/agent-defaults'
5
+ import { serverLogger } from '../config/logger'
6
+ import { getThreadBootstrapConfig } from '../config/thread-defaults'
7
+ import { BaseService } from '../db/base.service'
8
+ import { ensureRecordId, recordIdToString } from '../db/record-id'
9
+ import type { RecordIdInput, RecordIdRef } from '../db/record-id'
10
+ import { databaseService } from '../db/service'
11
+ import type { DatabaseTable } from '../db/tables'
12
+ import { TABLES } from '../db/tables'
13
+ import { getRedisConnection, withRedisLeaseLock } from '../redis'
14
+ import {
15
+ appendToMemoryBlock,
16
+ compactMemoryBlockEntries,
17
+ formatPersistedMemoryBlockForPrompt,
18
+ parseMemoryBlock,
19
+ serializeMemoryBlock,
20
+ } from '../runtime/memory-block'
21
+ import { toIsoDateTimeString } from '../utils/date-time'
22
+ import { chatRunRegistry } from './chat-run-registry.service'
23
+ import { contextCompactionService } from './context-compaction.service'
24
+ import { MEMORY_BLOCK_COMPACTION_CHUNK_ENTRIES, MEMORY_BLOCK_COMPACTION_TRIGGER_ENTRIES } from './thread-constants'
25
+ import { threadMessageService } from './thread-message.service'
26
+ import { NormalizedThreadSchema, PublicThreadSchema, ThreadSchema } from './thread.types'
27
+ import type { NormalizedThread, PublicThread, ThreadRecord } from './thread.types'
28
+
29
+ const THREAD_ACTIVE_RUN_LOCK_TTL_MS = 90_000
30
+ const THREAD_ACTIVE_RUN_LOCK_MAX_WAIT_MS = 750
31
+ const THREAD_ACTIVE_RUN_LOCK_RETRY_DELAY_MS = 75
32
+
33
+ function isRecordIdInput(value: unknown): value is RecordIdInput {
34
+ if (typeof value === 'string' || value instanceof RecordId || value instanceof StringRecordId) {
35
+ return true
36
+ }
37
+
38
+ if (!value || typeof value !== 'object') {
39
+ return false
40
+ }
41
+
42
+ const record = value as { tb?: unknown; id?: unknown }
43
+ return typeof record.tb === 'string' && record.id !== undefined
44
+ }
45
+
46
+ function getAgentDisplayName(agentId: string): string {
47
+ return agentDisplayNames[agentId] ?? agentId
48
+ }
49
+
50
+ function buildActiveRunLockKey(threadId: RecordIdRef): string {
51
+ return `thread-active-run:${recordIdToString(ensureRecordId(threadId, TABLES.THREAD), TABLES.THREAD)}`
52
+ }
53
+
54
+ function buildListThreadsQuery(options: {
55
+ includeArchived: boolean
56
+ paginate: boolean
57
+ type?: string
58
+ types?: string[]
59
+ }): string {
60
+ const clauses = [`SELECT * FROM ${TABLES.THREAD}`, 'WHERE userId = $userId', ' AND organizationId = $orgId']
61
+
62
+ if (options.types) {
63
+ clauses.push(' AND type IN $types')
64
+ } else if (options.type) {
65
+ clauses.push(' AND type = $type')
66
+ }
67
+
68
+ if (!options.includeArchived) {
69
+ clauses.push(' AND status = "active"')
70
+ }
71
+ clauses.push('ORDER BY updatedAt DESC')
72
+ if (options.paginate) {
73
+ clauses.push('LIMIT $limit START $offset')
74
+ }
75
+ return clauses.join('\n')
76
+ }
77
+
78
+ function normalizeActiveTurnValue(value: unknown): string | null {
79
+ if (typeof value !== 'string') {
80
+ return null
81
+ }
82
+
83
+ const normalized = value.trim()
84
+ return normalized.length > 0 ? normalized : null
85
+ }
86
+
87
+ function assertMutableThread(thread: ThreadRecord): void {
88
+ if (thread.type === 'default') throw new Error('Default threads cannot be modified')
89
+ if (thread.type === 'thread') throw new Error('Thread threads cannot be modified')
90
+ }
91
+
92
+ function isUniqueConstraintError(error: unknown): boolean {
93
+ if (!(error instanceof Error)) return false
94
+ return error.message.includes('already contains')
95
+ }
96
+
97
+ export class ActiveThreadRunConflictError extends Error {
98
+ constructor() {
99
+ super('A chat run is already active.')
100
+ this.name = 'ActiveThreadRunConflictError'
101
+ }
102
+ }
103
+
104
+ class ThreadService extends BaseService<typeof ThreadSchema> {
105
+ constructor() {
106
+ super(TABLES.THREAD, ThreadSchema)
107
+ }
108
+
109
+ async createThread(input: {
110
+ userId: RecordIdRef
111
+ organizationId: RecordIdRef
112
+ type: string
113
+ agentId?: string
114
+ threadType?: string
115
+ members?: string[]
116
+ title?: string
117
+ }): Promise<NormalizedThread> {
118
+ switch (input.type) {
119
+ case 'default':
120
+ if (!input.agentId) throw new Error('Default threads require agentId')
121
+ if (input.threadType) throw new Error('Default threads cannot have threadType')
122
+ break
123
+ case 'topic':
124
+ if (!input.agentId) throw new Error('Topic threads require agentId')
125
+ if (input.threadType) throw new Error('Topic threads cannot have threadType')
126
+ break
127
+ case 'thread':
128
+ if (!input.threadType) throw new Error('Thread threads require threadType')
129
+ if (input.agentId) throw new Error('Thread threads cannot have agentId')
130
+ break
131
+ case 'group':
132
+ if (input.agentId) throw new Error('Group threads cannot have agentId')
133
+ if (input.threadType) throw new Error('Group threads cannot have threadType')
134
+ break
135
+ }
136
+
137
+ const title = input.title ?? THREAD.DEFAULT_TITLE
138
+ const thread = await this.create({
139
+ userId: input.userId,
140
+ organizationId: input.organizationId,
141
+ type: input.type,
142
+ agentId: input.agentId,
143
+ threadType: input.threadType,
144
+ members: input.members ?? [...agentRoster],
145
+ title,
146
+ status: 'active',
147
+ nameGenerated: input.title !== undefined && input.title !== THREAD.DEFAULT_TITLE,
148
+ isCompacting: false,
149
+ turnCount: 0,
150
+ })
151
+
152
+ return await this.toNormalizedThread(thread)
153
+ }
154
+
155
+ async getOrCreateDefault(
156
+ orgId: RecordIdRef,
157
+ userId: RecordIdRef,
158
+ agentId: string,
159
+ ): Promise<{ record: ThreadRecord; created: boolean }> {
160
+ const existing = await this.databaseService.findOne(
161
+ this.table,
162
+ { type: 'default', organizationId: orgId, userId, agentId },
163
+ ThreadSchema,
164
+ )
165
+ if (existing) return { record: existing, created: false }
166
+
167
+ try {
168
+ const record = await this.create({
169
+ type: 'default',
170
+ organizationId: orgId,
171
+ userId,
172
+ agentId,
173
+ members: [agentId],
174
+ title: getAgentDisplayName(agentId),
175
+ status: 'active',
176
+ nameGenerated: false,
177
+ isCompacting: false,
178
+ turnCount: 0,
179
+ })
180
+ return { record, created: true }
181
+ } catch (e) {
182
+ if (isUniqueConstraintError(e)) {
183
+ const retried = await this.databaseService.findOne(
184
+ this.table,
185
+ { type: 'default', organizationId: orgId, userId, agentId },
186
+ ThreadSchema,
187
+ )
188
+ return { record: retried!, created: false }
189
+ }
190
+ throw e
191
+ }
192
+ }
193
+
194
+ async getOrCreateThread(
195
+ orgId: RecordIdRef,
196
+ userId: RecordIdRef,
197
+ threadType: string,
198
+ config: { members: string[]; title: string },
199
+ ): Promise<{ record: ThreadRecord; created: boolean }> {
200
+ const existing = await this.databaseService.findOne(
201
+ this.table,
202
+ { type: 'thread', organizationId: orgId, userId, threadType },
203
+ ThreadSchema,
204
+ )
205
+ if (existing) return { record: existing, created: false }
206
+
207
+ try {
208
+ const record = await this.create({
209
+ type: 'thread',
210
+ organizationId: orgId,
211
+ userId,
212
+ threadType,
213
+ members: config.members,
214
+ title: config.title,
215
+ status: 'active',
216
+ nameGenerated: false,
217
+ isCompacting: false,
218
+ turnCount: 0,
219
+ })
220
+ return { record, created: true }
221
+ } catch (e) {
222
+ if (isUniqueConstraintError(e)) {
223
+ const retried = await this.databaseService.findOne(
224
+ this.table,
225
+ { type: 'thread', organizationId: orgId, userId, threadType },
226
+ ThreadSchema,
227
+ )
228
+ return { record: retried!, created: false }
229
+ }
230
+ throw e
231
+ }
232
+ }
233
+
234
+ async ensureBootstrapThreads(
235
+ userId: RecordIdRef,
236
+ orgId: RecordIdRef,
237
+ options?: { onboardStatus?: string; userName?: string | null },
238
+ ): Promise<void> {
239
+ const onboardStatus = options?.onboardStatus ?? 'completed'
240
+ const onboardingCompleted = onboardStatus === 'completed'
241
+ const bootstrapConfig = getThreadBootstrapConfig()
242
+
243
+ const existingThreads = await databaseService.findMany(
244
+ TABLES.THREAD,
245
+ { userId, organizationId: orgId },
246
+ ThreadSchema,
247
+ )
248
+
249
+ const hasGroupThread = existingThreads.some((t) => t.type === 'group')
250
+ const defaultThreadsByAgent = new Map<string, ThreadRecord>()
251
+ const threadThreadsByType = new Map<string, ThreadRecord>()
252
+
253
+ for (const thread of existingThreads) {
254
+ if (thread.type === 'default' && thread.agentId) {
255
+ defaultThreadsByAgent.set(thread.agentId, thread)
256
+ }
257
+ if (thread.type === 'thread' && typeof thread.threadType === 'string') {
258
+ threadThreadsByType.set(thread.threadType, thread)
259
+ }
260
+ }
261
+
262
+ const requiredDefaultAgents = onboardingCompleted
263
+ ? bootstrapConfig.completedDefaultAgents
264
+ : bootstrapConfig.onboardingDefaultAgents
265
+
266
+ const creations: Promise<{ record: ThreadRecord; created: boolean }>[] = []
267
+
268
+ for (const agentId of requiredDefaultAgents) {
269
+ if (defaultThreadsByAgent.has(agentId)) continue
270
+ creations.push(this.getOrCreateDefault(orgId, userId, agentId))
271
+ }
272
+
273
+ if (onboardingCompleted && bootstrapConfig.ensureDefaultGroupOnCompleted && !hasGroupThread) {
274
+ creations.push(
275
+ this.createThread({ userId, organizationId: orgId, type: 'group', title: THREAD.DEFAULT_TITLE }).then(
276
+ (normalized) => ({ record: normalized as unknown as ThreadRecord, created: true }),
277
+ ),
278
+ )
279
+ }
280
+
281
+ if (onboardingCompleted) {
282
+ for (const wsType of bootstrapConfig.threadTypesAfterOnboarding) {
283
+ if (threadThreadsByType.has(wsType)) continue
284
+ const profile = getCoreThreadProfile(wsType)
285
+ creations.push(
286
+ this.getOrCreateThread(orgId, userId, wsType, { members: [...profile.members], title: profile.config.title }),
287
+ )
288
+ }
289
+ }
290
+
291
+ let createdResults: { record: ThreadRecord; created: boolean }[] = []
292
+ if (creations.length > 0) {
293
+ createdResults = await Promise.all(creations)
294
+ }
295
+
296
+ const onboardingWelcome = bootstrapConfig.onboardingWelcome
297
+ if (!onboardingCompleted && onboardingWelcome) {
298
+ const createdOwnerThread = createdResults.find(
299
+ (r) => r.created && r.record.type === 'default' && r.record.agentId === onboardingWelcome.defaultAgentId,
300
+ )
301
+ const existingOwnerThread = defaultThreadsByAgent.get(onboardingWelcome.defaultAgentId)
302
+
303
+ const ownerThreadId = createdOwnerThread?.record.id ?? existingOwnerThread?.id
304
+
305
+ if (ownerThreadId) {
306
+ const ownerThreadRef = ensureRecordId(ownerThreadId, TABLES.THREAD)
307
+ await threadMessageService.ensureBootstrapWelcomeMessage({
308
+ threadId: ownerThreadRef,
309
+ agentId: onboardingWelcome.defaultAgentId,
310
+ text: onboardingWelcome.buildMessageText({ userName: options?.userName }),
311
+ })
312
+ }
313
+ }
314
+ }
315
+
316
+ async listThreads(
317
+ userId: RecordIdRef,
318
+ orgId: RecordIdRef,
319
+ options: { type?: string; types?: string[]; take?: number; page?: number; includeArchived?: boolean },
320
+ ): Promise<{ threads: NormalizedThread[]; hasMore: boolean }> {
321
+ const includeArchived = options.includeArchived ?? false
322
+ const type = options.type
323
+ const types = options.types
324
+
325
+ if (type === 'default' || type === 'thread') {
326
+ const vars: Record<string, unknown> = { userId, orgId, type }
327
+ const threads = await databaseService.queryMany<typeof ThreadSchema>(
328
+ new BoundQuery(buildListThreadsQuery({ includeArchived, paginate: false, type }), vars),
329
+ ThreadSchema,
330
+ )
331
+ return { threads: await this.toNormalizedThreads(threads, { checkLease: false }), hasMore: false }
332
+ }
333
+
334
+ const take = options.take ?? THREAD.DEFAULT_PAGE_LIMIT
335
+ const page = options.page ?? 1
336
+ const vars: Record<string, unknown> = { userId, orgId, limit: take + 1, offset: (page - 1) * take }
337
+
338
+ if (types) {
339
+ vars.types = types
340
+ } else if (type) {
341
+ vars.type = type
342
+ }
343
+
344
+ const threads = await databaseService.queryMany<typeof ThreadSchema>(
345
+ new BoundQuery(buildListThreadsQuery({ includeArchived, paginate: true, type, types }), vars),
346
+ ThreadSchema,
347
+ )
348
+
349
+ const hasMore = threads.length > take
350
+ const sliced = hasMore ? threads.slice(0, take) : threads
351
+
352
+ return { threads: await this.toNormalizedThreads(sliced, { checkLease: false }), hasMore }
353
+ }
354
+
355
+ async listOrganizationThreads(params: {
356
+ orgId: RecordIdRef
357
+ type?: string
358
+ agentId?: string
359
+ includeArchived?: boolean
360
+ }): Promise<NormalizedThread[]> {
361
+ const whereClauses = ['organizationId = $orgId']
362
+ const variables: Record<string, unknown> = { orgId: params.orgId }
363
+
364
+ if (params.type) {
365
+ whereClauses.push('type = $type')
366
+ variables.type = params.type
367
+ }
368
+
369
+ if (params.agentId) {
370
+ whereClauses.push('agentId = $agentId')
371
+ variables.agentId = params.agentId
372
+ }
373
+
374
+ if (params.includeArchived !== true) {
375
+ whereClauses.push('status = "active"')
376
+ }
377
+
378
+ const threads = await databaseService.queryMany<typeof ThreadSchema>(
379
+ new BoundQuery(
380
+ `SELECT * FROM ${TABLES.THREAD}
381
+ WHERE ${whereClauses.join('\n AND ')}
382
+ ORDER BY createdAt ASC, id ASC`,
383
+ variables,
384
+ ),
385
+ ThreadSchema,
386
+ )
387
+
388
+ return await this.toNormalizedThreads(threads, { checkLease: false })
389
+ }
390
+
391
+ async getThread(threadId: RecordIdRef): Promise<NormalizedThread> {
392
+ const thread = await this.getById(threadId)
393
+ return await this.toNormalizedThread(thread)
394
+ }
395
+
396
+ async updateTitle(threadId: RecordIdRef, title: string): Promise<NormalizedThread> {
397
+ const existing = await this.getById(threadId)
398
+ assertMutableThread(existing)
399
+ const thread = await this.update(threadId, { title, nameGenerated: true })
400
+ return await this.toNormalizedThread(thread)
401
+ }
402
+
403
+ async updateStatus(threadId: RecordIdRef, status: string): Promise<NormalizedThread> {
404
+ const validStatus = sdkThreadStatusSchema.parse(status)
405
+ const existing = await this.getById(threadId)
406
+ assertMutableThread(existing)
407
+ const thread = await this.update(threadId, { status: validStatus })
408
+ return await this.toNormalizedThread(thread)
409
+ }
410
+
411
+ async setActiveTurn(threadId: RecordIdRef, runId: string, streamId?: string | null): Promise<void> {
412
+ const threadRef = ensureRecordId(threadId, TABLES.THREAD)
413
+ if (streamId === null || streamId === undefined) {
414
+ await databaseService.query<unknown>(surql`
415
+ UPDATE ONLY ${threadRef}
416
+ SET activeRunId = ${runId},
417
+ activeStreamId = NONE
418
+ `)
419
+ return
420
+ }
421
+
422
+ await databaseService.query<unknown>(surql`
423
+ UPDATE ONLY ${threadRef}
424
+ SET activeRunId = ${runId},
425
+ activeStreamId = ${streamId}
426
+ `)
427
+ }
428
+
429
+ async getActiveTurn(threadId: RecordIdRef): Promise<{ runId: string | null; streamId: string | null }> {
430
+ const thread = await this.getById(threadId)
431
+ return {
432
+ runId: normalizeActiveTurnValue(thread.activeRunId),
433
+ streamId: normalizeActiveTurnValue(thread.activeStreamId),
434
+ }
435
+ }
436
+
437
+ async getActiveRunId(threadId: RecordIdRef): Promise<string | null> {
438
+ const { runId } = await this.getActiveTurn(threadId)
439
+ return runId
440
+ }
441
+
442
+ async hasActiveRunLease(threadId: RecordIdRef): Promise<boolean> {
443
+ const count = await getRedisConnection().exists(buildActiveRunLockKey(threadId))
444
+ return count > 0
445
+ }
446
+
447
+ async withActiveRunLease<T>(threadId: RecordIdRef, fn: (signal: AbortSignal) => Promise<T>): Promise<T> {
448
+ try {
449
+ return await withRedisLeaseLock(
450
+ {
451
+ redis: getRedisConnection(),
452
+ lockKey: buildActiveRunLockKey(threadId),
453
+ lockTtlMs: THREAD_ACTIVE_RUN_LOCK_TTL_MS,
454
+ retryDelayMs: THREAD_ACTIVE_RUN_LOCK_RETRY_DELAY_MS,
455
+ maxWaitMs: THREAD_ACTIVE_RUN_LOCK_MAX_WAIT_MS,
456
+ label: 'thread active run',
457
+ logger: serverLogger,
458
+ },
459
+ fn,
460
+ )
461
+ } catch (error) {
462
+ if (error instanceof Error && error.message.startsWith('Timed out waiting for thread active run')) {
463
+ throw new ActiveThreadRunConflictError()
464
+ }
465
+ throw error
466
+ }
467
+ }
468
+
469
+ async getActiveStreamId(threadId: RecordIdRef): Promise<string | null> {
470
+ const { streamId } = await this.getActiveTurn(threadId)
471
+ return streamId
472
+ }
473
+
474
+ async clearActiveTurn(threadId: RecordIdRef, params: { runId: string; streamId?: string | null }): Promise<void> {
475
+ const threadRef = ensureRecordId(threadId, TABLES.THREAD)
476
+ const currentStreamId = params.streamId ?? null
477
+ if (currentStreamId === null) {
478
+ await databaseService.query(
479
+ surql`UPDATE ONLY ${threadRef} SET activeRunId = NONE, activeStreamId = NONE WHERE activeRunId = ${params.runId}`,
480
+ )
481
+ return
482
+ }
483
+
484
+ await databaseService.query(surql`
485
+ UPDATE ONLY ${threadRef}
486
+ SET activeRunId = NONE,
487
+ activeStreamId = NONE
488
+ WHERE activeRunId = ${params.runId} AND activeStreamId = ${currentStreamId}
489
+ `)
490
+ }
491
+
492
+ async clearStaleActiveRunIfMissingFromRegistry(threadId: RecordIdRef): Promise<boolean> {
493
+ const { runId: activeRunId, streamId: activeStreamId } = await this.getActiveTurn(threadId)
494
+ if (!activeRunId || (await this.hasActiveRunLease(threadId))) {
495
+ return false
496
+ }
497
+
498
+ await this.clearActiveTurn(threadId, { runId: activeRunId, streamId: activeStreamId })
499
+
500
+ serverLogger.warn`Cleared stale thread run after lease expired: thread=${recordIdToString(ensureRecordId(threadId, TABLES.THREAD), TABLES.THREAD)} run=${activeRunId}`
501
+ return true
502
+ }
503
+
504
+ async stopActiveRun(threadId: RecordIdRef): Promise<boolean> {
505
+ const { runId: activeRunId } = await this.getActiveTurn(threadId)
506
+ if (!activeRunId) return false
507
+
508
+ const stopped = chatRunRegistry.stop(activeRunId, new DOMException('Run stopped by user.', 'AbortError'))
509
+ if (stopped) {
510
+ return true
511
+ }
512
+
513
+ await this.clearStaleActiveRunIfMissingFromRegistry(threadId)
514
+ return false
515
+ }
516
+
517
+ async setCompacting(threadId: RecordIdRef, value: boolean): Promise<void> {
518
+ const threadRef = ensureRecordId(threadId, TABLES.THREAD)
519
+ await databaseService.query<unknown>(surql`
520
+ UPDATE ONLY ${threadRef}
521
+ SET isCompacting = ${value}
522
+ `)
523
+ }
524
+
525
+ async appendMemoryBlock(threadId: RecordIdRef, entry: string): Promise<string> {
526
+ const threadRef = ensureRecordId(threadId, TABLES.THREAD)
527
+ const thread = await this.getById(threadRef)
528
+ const entries = parseMemoryBlock(thread.memoryBlock)
529
+
530
+ const labelMatch = entry.match(/^(\w+):\s*/i)
531
+ const role = labelMatch ? labelMatch[1].toLowerCase() : 'system'
532
+ const content = labelMatch ? entry.slice(labelMatch[0].length).trim() : entry.trim()
533
+
534
+ const updatedEntries = appendToMemoryBlock(entries, role, content)
535
+ const serialized = serializeMemoryBlock(updatedEntries)
536
+
537
+ await this.update(threadRef, { memoryBlock: serialized })
538
+
539
+ if (updatedEntries.length >= MEMORY_BLOCK_COMPACTION_TRIGGER_ENTRIES) {
540
+ void this.compactMemoryBlock(threadRef).catch((err: unknown) => {
541
+ serverLogger.warn`Memory block compaction failed for ${threadRef}: ${err}`
542
+ })
543
+ }
544
+
545
+ return this.formatMemoryBlockForPrompt({ memoryBlock: serialized, memoryBlockSummary: thread.memoryBlockSummary })
546
+ }
547
+
548
+ async compactMemoryBlock(threadId: RecordIdRef): Promise<boolean> {
549
+ const threadRef = ensureRecordId(threadId, TABLES.THREAD)
550
+ const thread = await this.getById(threadRef)
551
+ const result = await compactMemoryBlockEntries({
552
+ previousSummary: thread.memoryBlockSummary,
553
+ entries: parseMemoryBlock(thread.memoryBlock),
554
+ triggerEntries: MEMORY_BLOCK_COMPACTION_TRIGGER_ENTRIES,
555
+ chunkEntries: MEMORY_BLOCK_COMPACTION_CHUNK_ENTRIES,
556
+ compact: (params) => contextCompactionService.compactMemoryBlock(params),
557
+ })
558
+
559
+ if (!result.compacted) return false
560
+
561
+ await this.update(threadRef, {
562
+ memoryBlockSummary: result.summary || '',
563
+ memoryBlock: serializeMemoryBlock(result.entries),
564
+ })
565
+
566
+ return true
567
+ }
568
+
569
+ async deleteThread(threadId: RecordIdRef): Promise<void> {
570
+ const existing = await this.getById(threadId)
571
+ assertMutableThread(existing)
572
+ await this.delete(threadId)
573
+ }
574
+
575
+ async listRecentThreads({
576
+ userId,
577
+ orgId,
578
+ excludeThreadId,
579
+ limit,
580
+ }: {
581
+ userId: RecordIdRef
582
+ orgId: RecordIdRef
583
+ excludeThreadId?: RecordIdRef
584
+ limit: number
585
+ }): Promise<NormalizedThread[]> {
586
+ let excludeCondition = ''
587
+ const vars: Record<string, unknown> = { userId, orgId, limit }
588
+
589
+ if (excludeThreadId) {
590
+ excludeCondition = 'AND id != $excludeThreadId'
591
+ vars.excludeThreadId = excludeThreadId
592
+ }
593
+
594
+ const threads = await databaseService.queryMany<typeof ThreadSchema>(
595
+ new BoundQuery(
596
+ `SELECT * FROM ${TABLES.THREAD}
597
+ WHERE userId = $userId
598
+ AND organizationId = $orgId
599
+ ${excludeCondition}
600
+ AND status != "archived"
601
+ ORDER BY updatedAt DESC
602
+ LIMIT $limit`,
603
+ vars,
604
+ ),
605
+ ThreadSchema,
606
+ )
607
+
608
+ return await this.toNormalizedThreads(threads, { checkLease: false })
609
+ }
610
+
611
+ private normalizeRecordIdString(id: unknown, table: DatabaseTable): string {
612
+ if (!isRecordIdInput(id)) {
613
+ throw new Error(`Invalid record id for table ${table}`)
614
+ }
615
+
616
+ return recordIdToString(id, table)
617
+ }
618
+
619
+ formatMemoryBlockForPrompt(thread: Pick<ThreadRecord, 'memoryBlock' | 'memoryBlockSummary'>): string {
620
+ return formatPersistedMemoryBlockForPrompt({
621
+ summary: thread.memoryBlockSummary,
622
+ entries: parseMemoryBlock(thread.memoryBlock),
623
+ })
624
+ }
625
+
626
+ private getDefaultTitle(thread: Pick<ThreadRecord, 'type' | 'threadType'>): string {
627
+ if (thread.type === 'thread' && typeof thread.threadType === 'string') {
628
+ return getCoreThreadProfile(thread.threadType).config.title
629
+ }
630
+
631
+ return THREAD.DEFAULT_TITLE
632
+ }
633
+
634
+ private async computeIsRunning(
635
+ thread: Pick<ThreadRecord, 'id' | 'activeRunId'>,
636
+ options: { checkLease: boolean },
637
+ ): Promise<boolean> {
638
+ const activeRunId =
639
+ typeof thread.activeRunId === 'string' && thread.activeRunId.trim().length > 0 ? thread.activeRunId : null
640
+
641
+ if (activeRunId === null) {
642
+ return false
643
+ }
644
+
645
+ if (chatRunRegistry.has(activeRunId)) {
646
+ return true
647
+ }
648
+
649
+ if (!options.checkLease) {
650
+ return true
651
+ }
652
+
653
+ return await this.hasActiveRunLease(ensureRecordId(thread.id, TABLES.THREAD))
654
+ }
655
+
656
+ private async toNormalizedThread(
657
+ thread: ThreadRecord,
658
+ options: { checkLease?: boolean } = {},
659
+ ): Promise<NormalizedThread> {
660
+ const isRunning = await this.computeIsRunning(thread, { checkLease: options.checkLease ?? true })
661
+ const isCompacting = thread.isCompacting === true
662
+ const type = thread.type
663
+ const threadType = type === 'thread' && typeof thread.threadType === 'string' ? thread.threadType : undefined
664
+ const status = thread.status
665
+ return NormalizedThreadSchema.parse({
666
+ id: this.normalizeRecordIdString(thread.id, TABLES.THREAD),
667
+ userId: this.normalizeRecordIdString(thread.userId, TABLES.USER),
668
+ organizationId: this.normalizeRecordIdString(thread.organizationId, TABLES.ORGANIZATION),
669
+ type,
670
+ ...(threadType ? { threadType } : {}),
671
+ nameGenerated: thread.nameGenerated,
672
+ isRunning,
673
+ isCompacting,
674
+ ...(isAgentName(thread.agentId) ? { agentId: thread.agentId } : {}),
675
+ title: thread.title ?? this.getDefaultTitle(thread),
676
+ status,
677
+ memoryBlock: this.formatMemoryBlockForPrompt(thread),
678
+ members: thread.members ?? [],
679
+ createdAt: toIsoDateTimeString(thread.createdAt),
680
+ updatedAt: toIsoDateTimeString(thread.updatedAt),
681
+ })
682
+ }
683
+
684
+ private async toNormalizedThreads(
685
+ threads: ThreadRecord[],
686
+ options: { checkLease?: boolean } = {},
687
+ ): Promise<NormalizedThread[]> {
688
+ return await Promise.all(threads.map(async (thread) => await this.toNormalizedThread(thread, options)))
689
+ }
690
+
691
+ toPublicThread(thread: NormalizedThread): PublicThread {
692
+ const { organizationId: _organizationId, userId: _userId, memoryBlock: _memoryBlock, ...publicThread } = thread
693
+ return PublicThreadSchema.parse(publicThread)
694
+ }
695
+
696
+ async incrementTurnCount(threadId: RecordIdRef): Promise<number> {
697
+ const threadRef = ensureRecordId(threadId, TABLES.THREAD)
698
+ const result = await databaseService.query<{ turnCount: number }>(surql`
699
+ UPDATE ONLY ${threadRef}
700
+ SET turnCount += 1
701
+ RETURN turnCount
702
+ `)
703
+ return result[0].turnCount
704
+ }
705
+ }
706
+
707
+ export const threadService = new ThreadService()