@lota-sdk/core 0.1.5

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 (153) hide show
  1. package/infrastructure/schema/00_workstream.surql +55 -0
  2. package/infrastructure/schema/01_memory.surql +47 -0
  3. package/infrastructure/schema/02_execution_plan.surql +62 -0
  4. package/infrastructure/schema/03_learned_skill.surql +32 -0
  5. package/infrastructure/schema/04_runtime_bootstrap.surql +8 -0
  6. package/package.json +128 -0
  7. package/src/ai/definitions.ts +308 -0
  8. package/src/bifrost/bifrost.ts +256 -0
  9. package/src/config/agent-defaults.ts +99 -0
  10. package/src/config/constants.ts +33 -0
  11. package/src/config/env-shapes.ts +122 -0
  12. package/src/config/logger.ts +29 -0
  13. package/src/config/model-constants.ts +31 -0
  14. package/src/config/search.ts +17 -0
  15. package/src/config/workstream-defaults.ts +68 -0
  16. package/src/db/base.service.ts +55 -0
  17. package/src/db/cursor-pagination.ts +73 -0
  18. package/src/db/memory-query-builder.ts +207 -0
  19. package/src/db/memory-store.helpers.ts +118 -0
  20. package/src/db/memory-store.rows.ts +29 -0
  21. package/src/db/memory-store.ts +974 -0
  22. package/src/db/memory-types.ts +193 -0
  23. package/src/db/memory.ts +505 -0
  24. package/src/db/record-id.ts +78 -0
  25. package/src/db/service.ts +932 -0
  26. package/src/db/startup.ts +152 -0
  27. package/src/db/tables.ts +20 -0
  28. package/src/document/org-document-chunking.ts +224 -0
  29. package/src/document/parsing.ts +40 -0
  30. package/src/embeddings/provider.ts +76 -0
  31. package/src/index.ts +302 -0
  32. package/src/queues/context-compaction.queue.ts +82 -0
  33. package/src/queues/document-processor.queue.ts +118 -0
  34. package/src/queues/memory-consolidation.queue.ts +65 -0
  35. package/src/queues/post-chat-memory.queue.ts +128 -0
  36. package/src/queues/recent-activity-title-refinement.queue.ts +69 -0
  37. package/src/queues/regular-chat-memory-digest.config.ts +12 -0
  38. package/src/queues/regular-chat-memory-digest.queue.ts +73 -0
  39. package/src/queues/skill-extraction.config.ts +9 -0
  40. package/src/queues/skill-extraction.queue.ts +62 -0
  41. package/src/redis/connection.ts +176 -0
  42. package/src/redis/index.ts +30 -0
  43. package/src/redis/org-memory-lock.ts +43 -0
  44. package/src/redis/redis-lease-lock.ts +158 -0
  45. package/src/runtime/agent-contract.ts +1 -0
  46. package/src/runtime/agent-prompt-context.ts +119 -0
  47. package/src/runtime/agent-runtime-policy.ts +192 -0
  48. package/src/runtime/agent-stream-helpers.ts +117 -0
  49. package/src/runtime/agent-types.ts +22 -0
  50. package/src/runtime/approval-continuation.ts +16 -0
  51. package/src/runtime/chat-attachments.ts +46 -0
  52. package/src/runtime/chat-message.ts +10 -0
  53. package/src/runtime/chat-request-routing.ts +21 -0
  54. package/src/runtime/chat-run-orchestration.ts +25 -0
  55. package/src/runtime/chat-run-registry.ts +20 -0
  56. package/src/runtime/chat-types.ts +18 -0
  57. package/src/runtime/context-compaction-constants.ts +11 -0
  58. package/src/runtime/context-compaction-runtime.ts +86 -0
  59. package/src/runtime/context-compaction.ts +909 -0
  60. package/src/runtime/execution-plan.ts +59 -0
  61. package/src/runtime/helper-model.ts +405 -0
  62. package/src/runtime/indexed-repositories-policy.ts +28 -0
  63. package/src/runtime/instruction-sections.ts +8 -0
  64. package/src/runtime/llm-content.ts +71 -0
  65. package/src/runtime/memory-block.ts +264 -0
  66. package/src/runtime/memory-digest-policy.ts +14 -0
  67. package/src/runtime/memory-format.ts +8 -0
  68. package/src/runtime/memory-pipeline.ts +570 -0
  69. package/src/runtime/memory-prompts-fact.ts +47 -0
  70. package/src/runtime/memory-prompts-parse.ts +3 -0
  71. package/src/runtime/memory-prompts-update.ts +37 -0
  72. package/src/runtime/memory-scope.ts +43 -0
  73. package/src/runtime/plugin-types.ts +10 -0
  74. package/src/runtime/retrieval-adapters.ts +25 -0
  75. package/src/runtime/retrieval-pipeline.ts +3 -0
  76. package/src/runtime/runtime-extensions.ts +154 -0
  77. package/src/runtime/skill-extraction-policy.ts +3 -0
  78. package/src/runtime/team-consultation-orchestrator.ts +245 -0
  79. package/src/runtime/team-consultation-prompts.ts +32 -0
  80. package/src/runtime/title-helpers.ts +12 -0
  81. package/src/runtime/turn-lifecycle.ts +28 -0
  82. package/src/runtime/workstream-chat-helpers.ts +187 -0
  83. package/src/runtime/workstream-routing-policy.ts +301 -0
  84. package/src/runtime/workstream-state.ts +261 -0
  85. package/src/services/attachment.service.ts +159 -0
  86. package/src/services/chat-attachments.service.ts +17 -0
  87. package/src/services/chat-run-registry.service.ts +3 -0
  88. package/src/services/context-compaction-runtime.ts +13 -0
  89. package/src/services/context-compaction.service.ts +115 -0
  90. package/src/services/document-chunk.service.ts +141 -0
  91. package/src/services/execution-plan.service.ts +890 -0
  92. package/src/services/learned-skill.service.ts +328 -0
  93. package/src/services/memory-assessment.service.ts +43 -0
  94. package/src/services/memory.service.ts +807 -0
  95. package/src/services/memory.utils.ts +84 -0
  96. package/src/services/mutating-approval.service.ts +110 -0
  97. package/src/services/recent-activity-title.service.ts +74 -0
  98. package/src/services/recent-activity.service.ts +397 -0
  99. package/src/services/workstream-change-tracker.service.ts +313 -0
  100. package/src/services/workstream-message.service.ts +283 -0
  101. package/src/services/workstream-title.service.ts +58 -0
  102. package/src/services/workstream-turn-preparation.ts +1340 -0
  103. package/src/services/workstream-turn.ts +37 -0
  104. package/src/services/workstream.service.ts +854 -0
  105. package/src/services/workstream.types.ts +118 -0
  106. package/src/storage/attachment-parser.ts +101 -0
  107. package/src/storage/attachment-storage.service.ts +391 -0
  108. package/src/storage/attachments.types.ts +11 -0
  109. package/src/storage/attachments.utils.ts +58 -0
  110. package/src/storage/generated-document-storage.service.ts +55 -0
  111. package/src/system-agents/agent-result.ts +27 -0
  112. package/src/system-agents/context-compacter.agent.ts +46 -0
  113. package/src/system-agents/delegated-agent-factory.ts +177 -0
  114. package/src/system-agents/helper-agent-options.ts +20 -0
  115. package/src/system-agents/memory-reranker.agent.ts +38 -0
  116. package/src/system-agents/memory.agent.ts +58 -0
  117. package/src/system-agents/recent-activity-title-refiner.agent.ts +53 -0
  118. package/src/system-agents/regular-chat-memory-digest.agent.ts +75 -0
  119. package/src/system-agents/researcher.agent.ts +34 -0
  120. package/src/system-agents/skill-extractor.agent.ts +88 -0
  121. package/src/system-agents/skill-manager.agent.ts +80 -0
  122. package/src/system-agents/title-generator.agent.ts +42 -0
  123. package/src/system-agents/workstream-tracker.agent.ts +58 -0
  124. package/src/tools/execution-plan.tool.ts +163 -0
  125. package/src/tools/fetch-webpage.tool.ts +132 -0
  126. package/src/tools/firecrawl-client.ts +12 -0
  127. package/src/tools/memory-block.tool.ts +55 -0
  128. package/src/tools/read-file-parts.tool.ts +80 -0
  129. package/src/tools/remember-memory.tool.ts +85 -0
  130. package/src/tools/research-topic.tool.ts +15 -0
  131. package/src/tools/search-tools.ts +55 -0
  132. package/src/tools/search-web.tool.ts +175 -0
  133. package/src/tools/team-think.tool.ts +125 -0
  134. package/src/tools/tool-contract.ts +21 -0
  135. package/src/tools/user-questions.tool.ts +18 -0
  136. package/src/utils/async.ts +50 -0
  137. package/src/utils/date-time.ts +34 -0
  138. package/src/utils/error.ts +10 -0
  139. package/src/utils/errors.ts +28 -0
  140. package/src/utils/hono-error-handler.ts +71 -0
  141. package/src/utils/string.ts +51 -0
  142. package/src/workers/bootstrap.ts +44 -0
  143. package/src/workers/memory-consolidation.worker.ts +318 -0
  144. package/src/workers/regular-chat-memory-digest.helpers.ts +100 -0
  145. package/src/workers/regular-chat-memory-digest.runner.ts +363 -0
  146. package/src/workers/regular-chat-memory-digest.worker.ts +22 -0
  147. package/src/workers/skill-extraction.runner.ts +331 -0
  148. package/src/workers/skill-extraction.worker.ts +22 -0
  149. package/src/workers/utils/repo-indexer-chunker.ts +331 -0
  150. package/src/workers/utils/repo-structure-extractor.ts +645 -0
  151. package/src/workers/utils/repomix-process-concurrency.ts +65 -0
  152. package/src/workers/utils/sandbox-error.ts +5 -0
  153. package/src/workers/worker-utils.ts +182 -0
@@ -0,0 +1,313 @@
1
+ import { z } from 'zod'
2
+
3
+ import { aiLogger } from '../config/logger'
4
+ import type { RecordIdRef } from '../db/record-id'
5
+ import { mergeStateDelta } from '../runtime/context-compaction'
6
+ import { createHelperModelRuntime, extractJsonObjectCandidates } from '../runtime/helper-model'
7
+ import { toOptionalTrimmedString } from '../runtime/workstream-chat-helpers'
8
+ import {
9
+ StructuredWorkstreamStateDeltaSchema,
10
+ createEmptyStructuredWorkstreamStateDelta,
11
+ createEmptyWorkstreamState,
12
+ parseStructuredWorkstreamStateDelta,
13
+ } from '../runtime/workstream-state'
14
+ import type { WorkstreamState, WorkstreamStateDelta } from '../runtime/workstream-state'
15
+ import { createWorkstreamTrackerAgent } from '../system-agents/workstream-tracker.agent'
16
+ import { compactWhitespace, isRecord, truncateText } from '../utils/string'
17
+ import { workstreamService } from './workstream.service'
18
+
19
+ const helperModelRuntime = createHelperModelRuntime()
20
+
21
+ const TrackerOutputSchema = z.object({
22
+ summary: z.string().trim().min(1).max(1_200),
23
+ stateDelta: StructuredWorkstreamStateDeltaSchema,
24
+ })
25
+
26
+ type TrackerMessage = { label: string; text: string }
27
+
28
+ const TRACKER_JSON_WRAPPER_KEYS = ['output', 'result', 'data'] as const
29
+ const TRACKER_SUMMARY_KEYS = ['summary', 'chatSummary', 'summaryText', 'sidebarSummary', 'message'] as const
30
+ const TRACKER_STATE_DELTA_KEYS = ['stateDelta', 'delta', 'workstreamStateDelta', 'trackerStateDelta', 'state'] as const
31
+ const TRACKER_FALLBACK_SECTION_PREFIX =
32
+ /^(?:#{1,6}\s*)?(?:\*\*)?(?:state delta|current plan|new decisions|resolved questions|new questions|new constraints|new risks|task updates|artifacts|agent note|conflicts|approved by|approved at|approval message id|approval note)(?:\*\*)?\s*:?\s*/i
33
+
34
+ function renderMessages(messages: TrackerMessage[]): string {
35
+ if (messages.length === 0) return '- None'
36
+ return messages
37
+ .map((message, index) => `### Message ${index + 1}\n- Actor: ${message.label}\n- Content: ${message.text}`)
38
+ .join('\n\n')
39
+ }
40
+
41
+ function getRecordStringField(record: Record<string, unknown>, keys: readonly string[]): string | null {
42
+ for (const key of keys) {
43
+ const field = toOptionalTrimmedString(typeof record[key] === 'string' ? record[key] : null)
44
+ if (field) return field
45
+ }
46
+
47
+ return null
48
+ }
49
+
50
+ function getTrackerJsonObjects(value: unknown): Record<string, unknown>[] {
51
+ if (!isRecord(value)) return []
52
+
53
+ const candidates: Record<string, unknown>[] = [value]
54
+ for (const key of TRACKER_JSON_WRAPPER_KEYS) {
55
+ const nested = value[key]
56
+ if (isRecord(nested)) {
57
+ candidates.push(nested)
58
+ }
59
+ }
60
+
61
+ return candidates
62
+ }
63
+
64
+ function parseTrackerJsonFallback(text: string): z.infer<typeof TrackerOutputSchema> | null {
65
+ for (const candidateText of extractJsonObjectCandidates(text)) {
66
+ let parsedJson: unknown
67
+
68
+ try {
69
+ parsedJson = JSON.parse(candidateText) as unknown
70
+ } catch {
71
+ continue
72
+ }
73
+
74
+ for (const candidate of getTrackerJsonObjects(parsedJson)) {
75
+ const summary = getRecordStringField(candidate, TRACKER_SUMMARY_KEYS)
76
+ if (!summary) continue
77
+
78
+ const stateDeltaValue = TRACKER_STATE_DELTA_KEYS.map((key) => candidate[key]).find((value) => value !== undefined)
79
+ const parsedStateDelta = StructuredWorkstreamStateDeltaSchema.safeParse(stateDeltaValue)
80
+
81
+ return {
82
+ summary: truncateText(summary, 1_200),
83
+ stateDelta: parsedStateDelta.success ? parsedStateDelta.data : createEmptyStructuredWorkstreamStateDelta(),
84
+ }
85
+ }
86
+ }
87
+
88
+ return null
89
+ }
90
+
91
+ function extractTrackerFallbackSummary(text: string): string | null {
92
+ const normalized = text.replace(/\r/g, '').trim()
93
+ if (!normalized) return null
94
+
95
+ const summaryLineMatch = normalized.match(/(?:^|\n)(?:#{1,6}\s*)?(?:\*\*)?summary(?:\*\*)?\s*:?\s*(.+?)(?=\n|$)/i)
96
+ const summaryLine = summaryLineMatch?.[1]?.trim()
97
+ if (summaryLine && !summaryLine.startsWith('{') && !summaryLine.startsWith('```')) {
98
+ return truncateText(summaryLine, 1_200)
99
+ }
100
+
101
+ const summarySectionMatch = normalized.match(
102
+ /(?:^|\n)(?:#{1,6}\s*)?(?:\*\*)?summary(?:\*\*)?\s*:?\s*\n([\s\S]*?)(?=\n(?:#{1,6}\s*)?(?:\*\*)?(?:state delta|summary)(?:\*\*)?\s*:?\s*\n|$)/i,
103
+ )
104
+ const summarySource = summarySectionMatch?.[1]?.trim() || normalized
105
+ const paragraphs = summarySource
106
+ .split(/\n\s*\n/)
107
+ .map((paragraph) => paragraph.trim())
108
+ .filter((paragraph) => paragraph.length > 0)
109
+
110
+ for (const paragraph of paragraphs) {
111
+ const candidate = paragraph.replace(/^(?:#{1,6}\s*)?(?:\*\*)?summary(?:\*\*)?\s*:?\s*/i, '').trim()
112
+ if (
113
+ !candidate ||
114
+ candidate.startsWith('{') ||
115
+ candidate.startsWith('```') ||
116
+ TRACKER_FALLBACK_SECTION_PREFIX.test(candidate)
117
+ ) {
118
+ continue
119
+ }
120
+
121
+ return truncateText(candidate, 1_200)
122
+ }
123
+
124
+ return null
125
+ }
126
+
127
+ export function parseTrackerTextFallback(text: string): z.infer<typeof TrackerOutputSchema> | null {
128
+ const jsonFallback = parseTrackerJsonFallback(text)
129
+ if (jsonFallback) {
130
+ return jsonFallback
131
+ }
132
+
133
+ const summary = extractTrackerFallbackSummary(text)
134
+ if (!summary) return null
135
+
136
+ return { summary, stateDelta: createEmptyStructuredWorkstreamStateDelta() }
137
+ }
138
+
139
+ function extractHeuristicSummaryCandidate(text: string | null | undefined): string | null {
140
+ const normalized = compactWhitespace(toOptionalTrimmedString(text) ?? '')
141
+ if (!normalized) return null
142
+
143
+ const match = /^(.+?[.!?])(?:\s|$)/.exec(normalized)
144
+ const candidate = match ? String(match[1]) : normalized
145
+ return truncateText(candidate, 1_200)
146
+ }
147
+
148
+ export function buildHeuristicTrackerSummary(params: {
149
+ previousSummary: string | null
150
+ userMessageText: string | null
151
+ assistantMessages: TrackerMessage[]
152
+ }): string | null {
153
+ for (const message of params.assistantMessages) {
154
+ const summary = extractHeuristicSummaryCandidate(message.text)
155
+ if (summary) {
156
+ return summary
157
+ }
158
+ }
159
+
160
+ const userSummary = extractHeuristicSummaryCandidate(params.userMessageText)
161
+ if (userSummary) {
162
+ return truncateText(`User request: ${userSummary}`, 1_200)
163
+ }
164
+
165
+ return extractHeuristicSummaryCandidate(params.previousSummary)
166
+ }
167
+
168
+ function formatTrackerPrompt(params: {
169
+ title: string
170
+ mode: 'direct' | 'group'
171
+ coreType?: string
172
+ visibleAgentId?: string
173
+ hasActiveExecutionPlan: boolean
174
+ previousSummary: string | null
175
+ existingState: WorkstreamState
176
+ userMessageText: string | null
177
+ assistantMessages: TrackerMessage[]
178
+ }): string {
179
+ return [
180
+ '# Workstream Turn',
181
+ '',
182
+ `- Title: ${params.title}`,
183
+ `- Mode: ${params.mode}`,
184
+ `- Visible agent: ${params.visibleAgentId ?? 'none'}`,
185
+ `- Active execution plan: ${params.hasActiveExecutionPlan ? 'yes' : 'no'}`,
186
+ ...(params.coreType ? [`- Core type: ${params.coreType}`] : []),
187
+ '',
188
+ '## Previous Summary',
189
+ params.previousSummary ?? 'None',
190
+ '',
191
+ '## Existing State',
192
+ JSON.stringify(params.existingState),
193
+ '',
194
+ '## User Message',
195
+ params.userMessageText ?? 'None',
196
+ '',
197
+ '## Assistant Messages',
198
+ renderMessages(params.assistantMessages),
199
+ ...(params.hasActiveExecutionPlan
200
+ ? [
201
+ '',
202
+ '## Tracker Constraint',
203
+ 'An active execution plan exists. Do not update currentPlan or taskUpdates. Track only decisions, questions, risks, artifacts, approvals, and agent notes.',
204
+ ]
205
+ : []),
206
+ ].join('\n')
207
+ }
208
+
209
+ export function applyTrackedStateDelta(params: {
210
+ existingState: WorkstreamState
211
+ delta: WorkstreamStateDelta
212
+ hasActiveExecutionPlan: boolean
213
+ now: () => number
214
+ }): WorkstreamState {
215
+ const mergedState = mergeStateDelta(params.existingState, params.delta, params.now)
216
+ if (!params.hasActiveExecutionPlan) {
217
+ return mergedState
218
+ }
219
+
220
+ return { ...mergedState, currentPlan: null, tasks: [] }
221
+ }
222
+
223
+ export async function updateWorkstreamChangeTracker(params: {
224
+ workstreamId: RecordIdRef
225
+ title: string
226
+ mode: 'direct' | 'group'
227
+ coreType?: string
228
+ visibleAgentId?: string
229
+ hasActiveExecutionPlan: boolean
230
+ previousSummary: string | null
231
+ existingState: WorkstreamState | null
232
+ userMessageText: string | null
233
+ assistantMessages: TrackerMessage[]
234
+ }): Promise<boolean> {
235
+ const assistantMessages = params.assistantMessages
236
+ .map((message) => ({
237
+ label: toOptionalTrimmedString(message.label) ?? 'Assistant',
238
+ text: truncateText(message.text.trim(), 2_400),
239
+ }))
240
+ .filter((message) => message.text.length > 0)
241
+ .slice(0, 6)
242
+
243
+ if (!toOptionalTrimmedString(params.userMessageText) && assistantMessages.length === 0) {
244
+ return false
245
+ }
246
+
247
+ const existingState = params.existingState ?? createEmptyWorkstreamState()
248
+
249
+ try {
250
+ const output = await helperModelRuntime.generateHelperStructured({
251
+ tag: 'workstream-change-tracker',
252
+ createAgent: createWorkstreamTrackerAgent,
253
+ schema: TrackerOutputSchema,
254
+ maxOutputTokens: 1_400,
255
+ textFallbackParser: parseTrackerTextFallback,
256
+ messages: [
257
+ {
258
+ role: 'user',
259
+ content: formatTrackerPrompt({
260
+ title: params.title,
261
+ mode: params.mode,
262
+ ...(params.coreType ? { coreType: params.coreType } : {}),
263
+ ...(params.visibleAgentId ? { visibleAgentId: params.visibleAgentId } : {}),
264
+ hasActiveExecutionPlan: params.hasActiveExecutionPlan,
265
+ previousSummary: params.previousSummary,
266
+ existingState,
267
+ userMessageText: toOptionalTrimmedString(params.userMessageText),
268
+ assistantMessages,
269
+ }),
270
+ },
271
+ ],
272
+ })
273
+
274
+ const sparseStateDelta = parseStructuredWorkstreamStateDelta(output.stateDelta)
275
+ const nextState = applyTrackedStateDelta({
276
+ existingState,
277
+ delta: sparseStateDelta,
278
+ hasActiveExecutionPlan: params.hasActiveExecutionPlan,
279
+ now: () => Date.now(),
280
+ })
281
+ await workstreamService.persistChangeTracker(params.workstreamId, { chatSummary: output.summary, state: nextState })
282
+ return true
283
+ } catch (error) {
284
+ const fallbackSummary = buildHeuristicTrackerSummary({
285
+ previousSummary: params.previousSummary,
286
+ userMessageText: toOptionalTrimmedString(params.userMessageText),
287
+ assistantMessages,
288
+ })
289
+
290
+ if (!fallbackSummary) {
291
+ aiLogger.warn`Workstream change tracker update failed: ${error}`
292
+ return false
293
+ }
294
+
295
+ try {
296
+ const nextState = applyTrackedStateDelta({
297
+ existingState,
298
+ delta: {},
299
+ hasActiveExecutionPlan: params.hasActiveExecutionPlan,
300
+ now: () => Date.now(),
301
+ })
302
+ await workstreamService.persistChangeTracker(params.workstreamId, {
303
+ chatSummary: fallbackSummary,
304
+ state: nextState,
305
+ })
306
+ aiLogger.info`Workstream change tracker used heuristic fallback after helper failure`
307
+ return true
308
+ } catch (persistError) {
309
+ aiLogger.warn`Workstream change tracker update failed: ${error}; fallback_persist=${persistError}`
310
+ return false
311
+ }
312
+ }
313
+ }
@@ -0,0 +1,283 @@
1
+ import { toTimestamp, withCreatedAtMetadata } from '@lota-sdk/shared/runtime/chat-message-metadata'
2
+ import { parseRowMetadata } from '@lota-sdk/shared/schemas/chat-message'
3
+ import type { ChatMessage } from '@lota-sdk/shared/schemas/chat-message'
4
+ import { RecordId, surql } from 'surrealdb'
5
+ import { z } from 'zod'
6
+
7
+ import { agentDisplayNames } from '../config/agent-defaults'
8
+ import { CursorRowSchema, listMessageHistoryPage } from '../db/cursor-pagination'
9
+ import type { CursorPaginationConfig, MessageHistoryPage } from '../db/cursor-pagination'
10
+ import { recordIdToString } from '../db/record-id'
11
+ import type { RecordIdRef } from '../db/record-id'
12
+ import { databaseService } from '../db/service'
13
+ import { TABLES } from '../db/tables'
14
+
15
+ const WorkstreamMessageRowSchema = z.object({
16
+ id: z.unknown(),
17
+ workstreamId: z.unknown(),
18
+ messageId: z.string(),
19
+ role: z.enum(['system', 'user', 'assistant']),
20
+ parts: z.array(z.record(z.string(), z.unknown())).optional(),
21
+ metadata: z.record(z.string(), z.unknown()).nullish(),
22
+ createdAt: z.union([z.date(), z.string(), z.number()]),
23
+ updatedAt: z.union([z.date(), z.string(), z.number()]).optional(),
24
+ })
25
+
26
+ type WorkstreamMessageRow = z.infer<typeof WorkstreamMessageRowSchema>
27
+
28
+ const WorkstreamMessageExistingRowSchema = z.object({
29
+ id: z.unknown(),
30
+ createdAt: z.union([z.date(), z.string(), z.number()]),
31
+ })
32
+
33
+ function toMessageId(value: string | RecordIdRef): string {
34
+ return recordIdToString(value, TABLES.WORKSTREAM_MESSAGE)
35
+ }
36
+
37
+ function toWorkstreamMessageRowId(workstreamId: RecordIdRef, messageId: string): RecordId {
38
+ const workstreamPart = recordIdToString(workstreamId, TABLES.WORKSTREAM).replace(/[^a-zA-Z0-9_-]/g, '_')
39
+ const messagePart = messageId.replace(/[^a-zA-Z0-9_-]/g, '_')
40
+ return new RecordId(TABLES.WORKSTREAM_MESSAGE, `${workstreamPart}__${messagePart}`)
41
+ }
42
+
43
+ function toChatMessage(row: WorkstreamMessageRow): ChatMessage {
44
+ const rowCreatedAt = toTimestamp(row.createdAt)
45
+ const metadata = withCreatedAtMetadata(parseRowMetadata(row.metadata), rowCreatedAt)
46
+
47
+ return { id: row.messageId, role: row.role, parts: (row.parts ?? []) as ChatMessage['parts'], metadata }
48
+ }
49
+
50
+ const workstreamPaginationConfig: CursorPaginationConfig = {
51
+ table: TABLES.WORKSTREAM_MESSAGE,
52
+ parentFilterField: 'workstreamId',
53
+ toRowId: toWorkstreamMessageRowId,
54
+ parseRow: (row: unknown) => WorkstreamMessageRowSchema.parse(row),
55
+ toMessage: (row: unknown) => toChatMessage(WorkstreamMessageRowSchema.parse(row)),
56
+ queryLatest: (parentId, limit) => surql`
57
+ SELECT * FROM workstreamMessage
58
+ WHERE workstreamId = ${parentId}
59
+ ORDER BY createdAt DESC, id DESC
60
+ LIMIT ${limit}
61
+ `,
62
+ queryBefore: (parentId, cursorCreatedAt, cursorId, limit) => surql`
63
+ SELECT * FROM workstreamMessage
64
+ WHERE workstreamId = ${parentId}
65
+ AND (
66
+ createdAt < ${cursorCreatedAt}
67
+ OR (createdAt = ${cursorCreatedAt} AND id < ${cursorId})
68
+ )
69
+ ORDER BY createdAt DESC, id DESC
70
+ LIMIT ${limit}
71
+ `,
72
+ }
73
+
74
+ class WorkstreamMessageService {
75
+ async upsertMessages(params: { workstreamId: RecordIdRef; messages: ChatMessage[] }): Promise<void> {
76
+ const workstreamId = params.workstreamId
77
+
78
+ for (const message of params.messages) {
79
+ const messageId = message.id.trim()
80
+ if (!messageId) continue
81
+
82
+ const role = message.role
83
+ const parts = Array.isArray(message.parts)
84
+ ? message.parts.map((part) => structuredClone(part) as Record<string, unknown>)
85
+ : []
86
+ if (parts.length === 0) {
87
+ if (role === 'assistant') continue
88
+ throw new Error(`Refusing to persist workstream message "${messageId}" with empty parts`)
89
+ }
90
+ const rowId = toWorkstreamMessageRowId(workstreamId, messageId)
91
+ const existingRow = await databaseService.findOne(
92
+ TABLES.WORKSTREAM_MESSAGE,
93
+ { workstreamId, messageId },
94
+ WorkstreamMessageExistingRowSchema,
95
+ )
96
+ const persistedCreatedAt =
97
+ existingRow === null ? toTimestamp(message.metadata?.createdAt) : toTimestamp(existingRow.createdAt)
98
+ const metadata = withCreatedAtMetadata({ ...message.metadata, createdAt: persistedCreatedAt })
99
+
100
+ await databaseService.upsert(
101
+ TABLES.WORKSTREAM_MESSAGE,
102
+ rowId,
103
+ {
104
+ workstreamId,
105
+ messageId,
106
+ role,
107
+ parts,
108
+ metadata,
109
+ createdAt: existingRow ? new Date(toTimestamp(existingRow.createdAt)) : new Date(persistedCreatedAt),
110
+ },
111
+ WorkstreamMessageRowSchema,
112
+ { mutation: 'content' },
113
+ )
114
+ }
115
+ }
116
+
117
+ async listMessages(workstreamId: RecordIdRef): Promise<ChatMessage[]> {
118
+ const rows = await databaseService.query<unknown>(surql`
119
+ SELECT * FROM workstreamMessage
120
+ WHERE workstreamId = ${workstreamId}
121
+ ORDER BY createdAt ASC, id ASC
122
+ `)
123
+
124
+ return rows.map((row) => WorkstreamMessageRowSchema.parse(row)).map((row) => toChatMessage(row))
125
+ }
126
+
127
+ async listMessageHistoryPage(params: {
128
+ workstreamId: RecordIdRef
129
+ take: number
130
+ beforeMessageId?: string
131
+ }): Promise<MessageHistoryPage> {
132
+ return listMessageHistoryPage(workstreamPaginationConfig, {
133
+ parentId: params.workstreamId,
134
+ take: params.take,
135
+ beforeMessageId: params.beforeMessageId,
136
+ })
137
+ }
138
+
139
+ async listMessagesAfterCursor(workstreamId: RecordIdRef, afterMessageId?: string): Promise<ChatMessage[]> {
140
+ const cursorMessageId = afterMessageId?.trim()
141
+ if (!cursorMessageId) {
142
+ return await this.listMessages(workstreamId)
143
+ }
144
+
145
+ const cursorRow = await databaseService.findOne(
146
+ TABLES.WORKSTREAM_MESSAGE,
147
+ { workstreamId, messageId: cursorMessageId },
148
+ CursorRowSchema,
149
+ )
150
+
151
+ if (!cursorRow) {
152
+ throw new Error(`Workstream cursor message not found: ${cursorMessageId}`)
153
+ }
154
+
155
+ const cursorCreatedAt = new Date(toTimestamp(cursorRow.createdAt))
156
+ const cursorId = toWorkstreamMessageRowId(workstreamId, cursorMessageId)
157
+ const rows = await databaseService.query<unknown>(surql`
158
+ SELECT * FROM workstreamMessage
159
+ WHERE workstreamId = ${workstreamId}
160
+ AND (
161
+ createdAt > ${cursorCreatedAt}
162
+ OR (createdAt = ${cursorCreatedAt} AND id > ${cursorId})
163
+ )
164
+ ORDER BY createdAt ASC, id ASC
165
+ `)
166
+
167
+ return rows.map((row) => WorkstreamMessageRowSchema.parse(row)).map((row) => toChatMessage(row))
168
+ }
169
+
170
+ async listRecentMessages(workstreamId: RecordIdRef, limit: number): Promise<ChatMessage[]> {
171
+ const rows = await databaseService.query<unknown>(surql`
172
+ SELECT * FROM workstreamMessage
173
+ WHERE workstreamId = ${workstreamId}
174
+ ORDER BY createdAt DESC, id DESC
175
+ LIMIT ${Math.max(1, limit)}
176
+ `)
177
+
178
+ return rows
179
+ .map((row) => WorkstreamMessageRowSchema.parse(row))
180
+ .reverse()
181
+ .map((row) => toChatMessage(row))
182
+ }
183
+
184
+ async searchMessages(params: {
185
+ workstreamId: RecordIdRef
186
+ role: 'user' | 'assistant'
187
+ query: string
188
+ limit: number
189
+ }): Promise<Array<{ id: string; role: 'user' | 'assistant'; createdAt: string; content: string }>> {
190
+ const normalizedQuery = params.query.trim().toLowerCase()
191
+ if (!normalizedQuery) return []
192
+
193
+ const messages = await this.listMessages(params.workstreamId)
194
+ return messages
195
+ .filter((message) => message.role === params.role)
196
+ .map((message) => ({
197
+ id: message.id,
198
+ role: message.role as 'user' | 'assistant',
199
+ createdAt: new Date(toTimestamp(message.metadata?.createdAt)).toISOString(),
200
+ content: message.parts
201
+ .flatMap((part) => (part.type === 'text' && typeof part.text === 'string' ? [part.text] : []))
202
+ .join('\n')
203
+ .trim(),
204
+ }))
205
+ .filter((item) => item.content.length > 0 && item.content.toLowerCase().includes(normalizedQuery))
206
+ .slice(-Math.max(1, params.limit))
207
+ }
208
+
209
+ async addUserMessage(params: {
210
+ messageId: RecordIdRef
211
+ workstreamId: RecordIdRef
212
+ content: string
213
+ }): Promise<ChatMessage> {
214
+ const message: ChatMessage = {
215
+ id: toMessageId(params.messageId),
216
+ role: 'user',
217
+ parts: [{ type: 'text', text: params.content }],
218
+ metadata: { createdAt: Date.now() },
219
+ }
220
+
221
+ await this.upsertMessages({ workstreamId: params.workstreamId, messages: [message] })
222
+ return message
223
+ }
224
+
225
+ async addAgentMessage(params: {
226
+ messageId: RecordIdRef
227
+ workstreamId: RecordIdRef
228
+ parts: ChatMessage['parts']
229
+ metadata?: ChatMessage['metadata']
230
+ }): Promise<ChatMessage> {
231
+ const message: ChatMessage = {
232
+ id: toMessageId(params.messageId),
233
+ role: 'assistant',
234
+ parts: params.parts,
235
+ metadata: withCreatedAtMetadata(params.metadata),
236
+ }
237
+
238
+ await this.upsertMessages({ workstreamId: params.workstreamId, messages: [message] })
239
+ return message
240
+ }
241
+
242
+ async ensureBootstrapWelcomeMessage(params: {
243
+ workstreamId: RecordIdRef
244
+ agentId: string
245
+ text: string
246
+ }): Promise<void> {
247
+ const existingRow = await databaseService.findOne(
248
+ TABLES.WORKSTREAM_MESSAGE,
249
+ { workstreamId: params.workstreamId },
250
+ WorkstreamMessageExistingRowSchema,
251
+ )
252
+ if (existingRow) return
253
+
254
+ const messageText = params.text.trim()
255
+ if (!messageText) return
256
+
257
+ await this.upsertMessages({
258
+ workstreamId: params.workstreamId,
259
+ messages: [
260
+ {
261
+ id: Bun.randomUUIDv7(),
262
+ role: 'assistant',
263
+ parts: [{ type: 'text', text: messageText }],
264
+ metadata: {
265
+ agentId: params.agentId,
266
+ agentName: agentDisplayNames[params.agentId] ?? params.agentId,
267
+ createdAt: Date.now(),
268
+ },
269
+ },
270
+ ],
271
+ })
272
+ }
273
+
274
+ async listAllMessages(workstreamId: RecordIdRef): Promise<ChatMessage[]> {
275
+ return await this.listMessages(workstreamId)
276
+ }
277
+
278
+ async addAttachments(): Promise<void> {
279
+ // Attachments are no longer persisted via workstreamMessage service in AI SDK mode.
280
+ }
281
+ }
282
+
283
+ export const workstreamMessageService = new WorkstreamMessageService()
@@ -0,0 +1,58 @@
1
+ import { WORKSTREAM } from '@lota-sdk/shared/constants/workstream'
2
+
3
+ import { chatLogger } from '../config/logger'
4
+ import type { RecordIdRef } from '../db/record-id'
5
+ import { recordIdToString } from '../db/record-id'
6
+ import { TABLES } from '../db/tables'
7
+ import type { HelperAgent } from '../runtime/helper-model'
8
+ import { llmHelperService } from '../runtime/helper-model'
9
+ import { deriveTitle, limitTitleWords } from '../runtime/title-helpers'
10
+ import { workstreamService } from './workstream.service'
11
+
12
+ const titlePromises = new Map<string, Promise<string>>()
13
+
14
+ class WorkstreamTitleService {
15
+ async ensureTitle(workstreamId: RecordIdRef, existingTitle: string, sourceText: string): Promise<string> {
16
+ const trimmedSource = sourceText.trim()
17
+ if (!trimmedSource) return existingTitle
18
+ if (existingTitle && existingTitle.trim() !== WORKSTREAM.DEFAULT_TITLE) {
19
+ return existingTitle
20
+ }
21
+
22
+ const key = recordIdToString(workstreamId, TABLES.WORKSTREAM)
23
+ const existingPromise = titlePromises.get(key)
24
+ if (existingPromise) {
25
+ return existingPromise
26
+ }
27
+
28
+ const promise = this.generateAndPersistTitle(workstreamId, trimmedSource).finally(() => {
29
+ titlePromises.delete(key)
30
+ })
31
+ titlePromises.set(key, promise)
32
+
33
+ return promise
34
+ }
35
+
36
+ private async generateAndPersistTitle(workstreamId: RecordIdRef, sourceText: string): Promise<string> {
37
+ let title = ''
38
+ try {
39
+ title = await llmHelperService.generateHelperText({
40
+ tag: 'workstream-title',
41
+ createAgent: (_opts: unknown) => ({ generate: async () => ({ text: '' }) }) as unknown as HelperAgent,
42
+ messages: [{ role: 'user' as const, content: `Generate a concise 4-5 word title for: ${sourceText}` }],
43
+ })
44
+ } catch (error) {
45
+ chatLogger.warn`Failed to generate workstream title (non-fatal): ${error}`
46
+ title = ''
47
+ }
48
+
49
+ if (!title) {
50
+ title = limitTitleWords(deriveTitle(sourceText || WORKSTREAM.DEFAULT_TITLE))
51
+ }
52
+
53
+ await workstreamService.updateTitle(workstreamId, title)
54
+ return title
55
+ }
56
+ }
57
+
58
+ export const workstreamTitleService = new WorkstreamTitleService()