@lota-sdk/core 0.1.14 → 0.1.15

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 (95) hide show
  1. package/package.json +5 -5
  2. package/src/ai/embedding-cache.ts +7 -6
  3. package/src/ai/index.ts +1 -0
  4. package/src/bifrost/bifrost.ts +12 -7
  5. package/src/config/agent-defaults.ts +1 -1
  6. package/src/config/logger.ts +7 -9
  7. package/src/{runtime.ts → create-runtime.ts} +6 -6
  8. package/src/db/cursor-pagination.ts +1 -1
  9. package/src/db/memory-store.ts +10 -6
  10. package/src/db/memory.ts +6 -4
  11. package/src/db/schema-fingerprint.ts +1 -0
  12. package/src/db/service.ts +40 -43
  13. package/src/db/startup.ts +3 -3
  14. package/src/index.ts +1 -1
  15. package/src/queues/context-compaction.queue.ts +4 -8
  16. package/src/queues/document-processor.queue.ts +7 -7
  17. package/src/queues/memory-consolidation.queue.ts +7 -8
  18. package/src/queues/post-chat-memory.queue.ts +2 -6
  19. package/src/queues/recent-activity-title-refinement.queue.ts +2 -6
  20. package/src/queues/regular-chat-memory-digest.queue.ts +4 -7
  21. package/src/queues/skill-extraction.queue.ts +4 -7
  22. package/src/queues/workstream-title-generation.queue.ts +2 -6
  23. package/src/redis/connection.ts +6 -3
  24. package/src/redis/index.ts +1 -0
  25. package/src/redis/org-memory-lock.ts +1 -1
  26. package/src/redis/redis-lease-lock.ts +41 -8
  27. package/src/runtime/agent-stream-helpers.ts +2 -1
  28. package/src/runtime/context-compaction-constants.ts +1 -1
  29. package/src/runtime/context-compaction-runtime.ts +6 -4
  30. package/src/runtime/context-compaction.ts +19 -38
  31. package/src/runtime/execution-plan.ts +2 -2
  32. package/src/runtime/helper-model.ts +3 -1
  33. package/src/runtime/index.ts +12 -1
  34. package/src/runtime/memory-block.ts +3 -2
  35. package/src/runtime/memory-pipeline.ts +24 -5
  36. package/src/runtime/plugin-types.ts +1 -1
  37. package/src/runtime/runtime-extensions.ts +89 -13
  38. package/src/runtime/title-helpers.ts +11 -2
  39. package/src/runtime/workstream-chat-helpers.ts +5 -6
  40. package/src/runtime/workstream-routing-policy.ts +0 -30
  41. package/src/runtime/workstream-state.ts +17 -7
  42. package/src/services/attachment.service.ts +1 -1
  43. package/src/services/context-compaction.service.ts +3 -3
  44. package/src/services/document-chunk.service.ts +37 -32
  45. package/src/services/execution-plan.service.ts +2 -0
  46. package/src/services/learned-skill.service.ts +6 -10
  47. package/src/services/{memory.utils.ts → memory-utils.ts} +4 -8
  48. package/src/services/memory.service.ts +21 -18
  49. package/src/services/organization-member.service.ts +1 -1
  50. package/src/services/plan-artifact.service.ts +1 -0
  51. package/src/services/plan-executor.service.ts +2 -18
  52. package/src/services/plan-helpers.ts +15 -0
  53. package/src/services/plan-validator.service.ts +3 -18
  54. package/src/services/recent-activity-title.service.ts +3 -10
  55. package/src/services/recent-activity.service.ts +6 -12
  56. package/src/services/workstream-message.service.ts +26 -16
  57. package/src/services/workstream-title.service.ts +1 -9
  58. package/src/services/{workstream-turn-preparation.ts → workstream-turn-preparation.service.ts} +401 -314
  59. package/src/services/workstream-turn.ts +2 -2
  60. package/src/services/workstream.service.ts +22 -10
  61. package/src/services/workstream.types.ts +7 -16
  62. package/src/storage/attachment-storage.service.ts +4 -4
  63. package/src/storage/{attachments.utils.ts → attachment-utils.ts} +1 -4
  64. package/src/storage/index.ts +2 -2
  65. package/src/system-agents/{context-compacter.agent.ts → context-compaction.agent.ts} +4 -4
  66. package/src/system-agents/delegated-agent-factory.ts +3 -2
  67. package/src/system-agents/index.ts +8 -0
  68. package/src/system-agents/memory-reranker.agent.ts +1 -1
  69. package/src/system-agents/memory.agent.ts +1 -1
  70. package/src/system-agents/recent-activity-title-refiner.agent.ts +1 -1
  71. package/src/tools/execution-plan.tool.ts +6 -2
  72. package/src/tools/fetch-webpage.tool.ts +20 -18
  73. package/src/tools/index.ts +2 -2
  74. package/src/tools/read-file-parts.tool.ts +1 -1
  75. package/src/tools/search-web.tool.ts +18 -15
  76. package/src/tools/{search-tools.ts → search.tool.ts} +1 -1
  77. package/src/tools/team-think.tool.ts +9 -5
  78. package/src/tools/{tool-contract.ts → tool-contracts.ts} +9 -2
  79. package/src/utils/async.ts +1 -1
  80. package/src/utils/errors.ts +15 -0
  81. package/src/utils/hono-error-handler.ts +1 -2
  82. package/src/utils/index.ts +10 -2
  83. package/src/utils/string.ts +14 -0
  84. package/src/workers/bootstrap.ts +2 -2
  85. package/src/workers/memory-consolidation.worker.ts +12 -12
  86. package/src/workers/regular-chat-memory-digest.helpers.ts +2 -7
  87. package/src/workers/regular-chat-memory-digest.runner.ts +9 -103
  88. package/src/workers/skill-extraction.runner.ts +7 -101
  89. package/src/workers/utils/file-section-chunker.ts +5 -3
  90. package/src/workers/utils/workstream-message-query.ts +106 -0
  91. package/src/workers/worker-utils.ts +4 -0
  92. package/src/runtime/retrieval-pipeline.ts +0 -3
  93. package/src/utils/error.ts +0 -10
  94. /package/src/services/{context-compaction-runtime.ts → context-compaction-runtime.singleton.ts} +0 -0
  95. /package/src/storage/{attachments.types.ts → attachment-types.ts} +0 -0
@@ -24,10 +24,12 @@ import {
24
24
  scopedRetrievalToMap,
25
25
  } from '../runtime/retrieval-adapters'
26
26
  import { getRuntimeConfig } from '../runtime/runtime-config'
27
- import { createMemoryRerankerAgent, memoryRerankerPrompt } from '../system-agents/memory-reranker.agent'
28
- import { createOrgMemoryAgent, orgMemoryPrompt } from '../system-agents/memory.agent'
27
+ import { createMemoryRerankerAgent, MEMORY_RERANKER_PROMPT } from '../system-agents/memory-reranker.agent'
28
+ import { createOrgMemoryAgent, ORG_MEMORY_PROMPT } from '../system-agents/memory.agent'
29
+ import { toError } from '../utils/errors'
30
+ import { compactWhitespace } from '../utils/string'
29
31
  import { assessMemoryImportance, clampMemoryImportance } from './memory-assessment.service'
30
- import { formatMemoryResults, formatRerankedResults, getCandidateLimit } from './memory.utils'
32
+ import { formatMemoryResults, formatRerankedResults, getCandidateLimit } from './memory-utils'
31
33
 
32
34
  const ORG_MEMORY_TYPE = 'fact'
33
35
  const RERANK_CANDIDATE_MAX_CHARS = 500
@@ -72,7 +74,7 @@ class MemoryService {
72
74
  const cached = cache.get(cacheKey)
73
75
  if (cached) return cached
74
76
 
75
- const memory = new Memory({ createAgent: createOrgMemoryAgent }, { customPrompt: orgMemoryPrompt })
77
+ const memory = new Memory({ createAgent: createOrgMemoryAgent }, { customPrompt: ORG_MEMORY_PROMPT })
76
78
 
77
79
  cache.set(cacheKey, memory)
78
80
  aiLogger.debug`Memory client created and cached for ${cacheKey}`
@@ -85,7 +87,7 @@ class MemoryService {
85
87
  }
86
88
 
87
89
  private truncateCandidateText(value: string): string {
88
- const normalized = value.replace(/\s+/g, ' ').trim()
90
+ const normalized = compactWhitespace(value)
89
91
  if (normalized.length <= RERANK_CANDIDATE_MAX_CHARS) return normalized
90
92
  return `${normalized.slice(0, RERANK_CANDIDATE_MAX_CHARS)}...`
91
93
  }
@@ -125,7 +127,7 @@ class MemoryService {
125
127
  }
126
128
 
127
129
  private normalizeConversationText(value: string, maxChars: number): string {
128
- const normalized = value.replace(/\s+/g, ' ').trim()
130
+ const normalized = compactWhitespace(value)
129
131
  if (!normalized) return ''
130
132
  if (normalized.length <= maxChars) return normalized
131
133
  return `${normalized.slice(0, maxChars - 3)}...`
@@ -146,7 +148,7 @@ class MemoryService {
146
148
  }
147
149
 
148
150
  private normalizePreSeededMemoryText(value: string): string {
149
- const normalized = value.replace(/\s+/g, ' ').trim()
151
+ const normalized = compactWhitespace(value)
150
152
  if (normalized.length <= PRESEEDED_MEMORY_MAX_CHARS) return normalized
151
153
  return `${normalized.slice(0, PRESEEDED_MEMORY_MAX_CHARS - 3)}...`
152
154
  }
@@ -232,7 +234,7 @@ class MemoryService {
232
234
  return await helperModelRuntime.generateHelperStructured({
233
235
  tag: 'memory-reranker',
234
236
  createAgent: createMemoryRerankerAgent,
235
- defaultSystemPrompt: memoryRerankerPrompt,
237
+ defaultSystemPrompt: MEMORY_RERANKER_PROMPT,
236
238
  messages: [
237
239
  {
238
240
  role: 'user',
@@ -269,7 +271,7 @@ class MemoryService {
269
271
  return await helperModelRuntime.generateHelperStructured({
270
272
  tag: 'memory-reranker-multi-scope',
271
273
  createAgent: createMemoryRerankerAgent,
272
- defaultSystemPrompt: memoryRerankerPrompt,
274
+ defaultSystemPrompt: MEMORY_RERANKER_PROMPT,
273
275
  messages: [
274
276
  {
275
277
  role: 'user',
@@ -334,7 +336,7 @@ class MemoryService {
334
336
  aiLogger.debug`Organization memory search completed (resultLength: ${results.length}, preview: ${results.slice(0, 100)})`
335
337
  return results
336
338
  } catch (error: unknown) {
337
- const normalizedError = error instanceof Error ? error : new Error(String(error))
339
+ const normalizedError = toError(error)
338
340
  aiLogger.error`Organization memory search failed: ${normalizedError}`
339
341
  throw normalizedError
340
342
  }
@@ -348,7 +350,8 @@ class MemoryService {
348
350
  if (stale.length === 0) return ''
349
351
  const items = stale.map((m) => `- [NEEDS REVIEW] ${m.content}`).join('\n')
350
352
  return `Memories flagged for review (parent fact was superseded):\n${items}`
351
- } catch {
353
+ } catch (error) {
354
+ aiLogger.warn`Failed to get stale memories: ${error}`
352
355
  return ''
353
356
  }
354
357
  }
@@ -359,7 +362,7 @@ class MemoryService {
359
362
  options?: { fastMode?: boolean; limit?: number },
360
363
  ): Promise<string> {
361
364
  const orgScopeId = scopeId(ORG_SCOPE_PREFIX, orgId)
362
- aiLogger.info`[MEMORY_DEBUG] searchOrganizationMemoriesRaw - orgId: "${orgId}", scopeId: "${orgScopeId}"`
365
+ aiLogger.debug`searchOrganizationMemoriesRaw - orgId: "${orgId}", scopeId: "${orgScopeId}"`
363
366
  const memory = this.getOrgMemory(orgId)
364
367
  const fastMode = options?.fastMode ?? true
365
368
  const searchK = getRuntimeConfig().memory.searchK
@@ -376,7 +379,7 @@ class MemoryService {
376
379
  aiLogger.debug`Organization memory search (raw) completed (candidates: ${candidates.length})`
377
380
  return formatMemoryResults(candidates)
378
381
  } catch (error: unknown) {
379
- const normalizedError = error instanceof Error ? error : new Error(String(error))
382
+ const normalizedError = toError(error)
380
383
  aiLogger.error`Organization memory search (raw) failed: ${normalizedError}`
381
384
  throw normalizedError
382
385
  }
@@ -397,7 +400,7 @@ class MemoryService {
397
400
  aiLogger.debug`Agent memory search completed (agentName: ${agentName}, resultLength: ${results.length}, preview: ${results.slice(0, 100)})`
398
401
  return results
399
402
  } catch (error: unknown) {
400
- const normalizedError = error instanceof Error ? error : new Error(String(error))
403
+ const normalizedError = toError(error)
401
404
  aiLogger.error`Agent memory search failed: ${normalizedError}`
402
405
  throw normalizedError
403
406
  }
@@ -467,7 +470,7 @@ class MemoryService {
467
470
  const deduped: MemoryRecord[] = []
468
471
  const seen = new Set<string>()
469
472
  for (const memory of combined) {
470
- const normalizedKey = memory.content.replace(/\s+/g, ' ').trim().toLowerCase()
473
+ const normalizedKey = compactWhitespace(memory.content).toLowerCase()
471
474
  if (!normalizedKey || seen.has(normalizedKey)) continue
472
475
  seen.add(normalizedKey)
473
476
  deduped.push(memory)
@@ -594,7 +597,7 @@ class MemoryService {
594
597
  durability?: MemoryRecord['durability']
595
598
  }): Promise<string> {
596
599
  const orgScopeId = scopeId(ORG_SCOPE_PREFIX, orgId)
597
- aiLogger.info`[MEMORY_DEBUG] createOrganizationMemory - orgId: "${orgId}", scopeId: "${orgScopeId}", content preview: "${content.slice(0, 50)}"`
600
+ aiLogger.debug`createOrganizationMemory - orgId: "${orgId}", scopeId: "${orgScopeId}", content preview: "${content.slice(0, 50)}"`
598
601
  const memory = this.getOrgMemory(orgId)
599
602
  try {
600
603
  return await memory.insert(content, {
@@ -762,7 +765,7 @@ class MemoryService {
762
765
  }
763
766
 
764
767
  const orgScopeId = scopeId(ORG_SCOPE_PREFIX, orgId)
765
- aiLogger.info`[MEMORY_DEBUG] addConversationMemories - orgId: "${orgId}", scopeId: "${orgScopeId}", sourceId: ${sourceId ?? 'none'}`
768
+ aiLogger.debug`addConversationMemories - orgId: "${orgId}", scopeId: "${orgScopeId}", sourceId: ${sourceId ?? 'none'}`
766
769
 
767
770
  const orgMemory = this.getOrgMemory(orgId)
768
771
  let assessedImportance: number | undefined
@@ -833,7 +836,7 @@ class MemoryService {
833
836
  })
834
837
  aiLogger.debug`Conversation memories added to ${scopes.length} scope(s) from ${messages.length} message(s)`
835
838
  } catch (error: unknown) {
836
- const normalizedError = error instanceof Error ? error : new Error(String(error))
839
+ const normalizedError = toError(error)
837
840
  aiLogger.error`Memory write failed: ${normalizedError}`
838
841
  throw normalizedError
839
842
  }
@@ -23,7 +23,7 @@ const sdkOrganizationMemberSchema = z.object({
23
23
  createdAt: z.iso.datetime(),
24
24
  })
25
25
 
26
- export type SdkOrganizationMemberRecord = z.infer<typeof organizationMemberRecordSchema>
26
+ type SdkOrganizationMemberRecord = z.infer<typeof organizationMemberRecordSchema>
27
27
  export type SdkOrganizationMember = z.infer<typeof sdkOrganizationMemberSchema>
28
28
 
29
29
  class OrganizationMemberService extends BaseService<typeof organizationMemberRecordSchema> {
@@ -17,6 +17,7 @@ class PlanArtifactService {
17
17
  }): Promise<PlanArtifactRecord[]> {
18
18
  const records: PlanArtifactRecord[] = []
19
19
 
20
+ // Sequential: SurrealDB transactions require ordered operations
20
21
  for (const artifact of params.artifacts) {
21
22
  const artifactId = new RecordId(TABLES.PLAN_ARTIFACT, Bun.randomUUIDv7())
22
23
  const created = await params.tx
@@ -26,9 +26,11 @@ import { ensureRecordId, recordIdToString } from '../db/record-id'
26
26
  import { databaseService } from '../db/service'
27
27
  import type { DatabaseTransaction } from '../db/service'
28
28
  import { TABLES } from '../db/tables'
29
+ import { isRecord } from '../utils/string'
29
30
  import { planApprovalService } from './plan-approval.service'
30
31
  import { planArtifactService } from './plan-artifact.service'
31
32
  import { planCheckpointService } from './plan-checkpoint.service'
33
+ import { readPathValue } from './plan-helpers'
32
34
  import { planRunService } from './plan-run.service'
33
35
  import type { PlanValidationIssueInput } from './plan-validator.service'
34
36
  import { planValidatorService } from './plan-validator.service'
@@ -37,24 +39,6 @@ const SUCCESSFUL_TERMINAL_NODE_STATUSES = new Set(['completed', 'partial', 'skip
37
39
  const HUMAN_NODE_TYPES = new Set(['human-input', 'human-approval', 'human-review-edit', 'human-decision'])
38
40
  const STRUCTURAL_NODE_TYPES = new Set(['switch', 'join'])
39
41
 
40
- function isRecord(value: unknown): value is Record<string, unknown> {
41
- return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
42
- }
43
-
44
- function readPathValue(source: unknown, path: string): unknown {
45
- if (!path.trim()) return source
46
-
47
- let current: unknown = source
48
- for (const segment of path
49
- .split('.')
50
- .map((part) => part.trim())
51
- .filter(Boolean)) {
52
- if (!isRecord(current)) return undefined
53
- current = current[segment]
54
- }
55
- return current
56
- }
57
-
58
42
  function setPathValue(target: Record<string, unknown>, path: string, value: unknown) {
59
43
  const segments = path
60
44
  .split('.')
@@ -0,0 +1,15 @@
1
+ import { isRecord } from '../utils/string'
2
+
3
+ export function readPathValue(source: unknown, path: string): unknown {
4
+ if (!path.trim()) return source
5
+
6
+ let current: unknown = source
7
+ for (const segment of path
8
+ .split('.')
9
+ .map((part) => part.trim())
10
+ .filter(Boolean)) {
11
+ if (!isRecord(current)) return undefined
12
+ current = current[segment]
13
+ }
14
+ return current
15
+ }
@@ -8,6 +8,9 @@ import type {
8
8
  PlanValidationIssueSeverity,
9
9
  } from '@lota-sdk/shared'
10
10
 
11
+ import { isRecord } from '../utils/string'
12
+ import { readPathValue } from './plan-helpers'
13
+
11
14
  const STRUCTURAL_NODE_TYPES = new Set(['switch', 'join'])
12
15
  const HUMAN_NODE_TYPES = new Set(['human-input', 'human-approval', 'human-review-edit', 'human-decision'])
13
16
 
@@ -46,24 +49,6 @@ function createIssue(params: {
46
49
  }
47
50
  }
48
51
 
49
- function isRecord(value: unknown): value is Record<string, unknown> {
50
- return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
51
- }
52
-
53
- function readPathValue(source: unknown, path: string): unknown {
54
- if (!path.trim()) return source
55
-
56
- let current: unknown = source
57
- for (const segment of path
58
- .split('.')
59
- .map((part) => part.trim())
60
- .filter(Boolean)) {
61
- if (!isRecord(current)) return undefined
62
- current = current[segment]
63
- }
64
- return current
65
- }
66
-
67
52
  function hasAllFields(value: unknown, fields: string[]): boolean {
68
53
  if (!isRecord(value)) return false
69
54
  return fields.every((field) => readPathValue(value, field) !== undefined)
@@ -1,20 +1,13 @@
1
1
  import { createHelperModelRuntime } from '../runtime/helper-model'
2
+ import { normalizeTitle } from '../runtime/title-helpers'
2
3
  import {
3
4
  createRecentActivityTitleRefinerAgent,
4
- recentActivityTitleRefinerPrompt,
5
+ RECENT_ACTIVITY_TITLE_REFINER_PROMPT,
5
6
  } from '../system-agents/recent-activity-title-refiner.agent'
6
- import { compactWhitespace } from '../utils/string'
7
7
  import { recentActivityService } from './recent-activity.service'
8
8
 
9
9
  const RECENT_ACTIVITY_TITLE_TIMEOUT_MS = 60_000
10
10
 
11
- function normalizeTitle(value: string): string {
12
- const normalized = compactWhitespace(value)
13
- .replace(/^["'`]+|["'`]+$/g, '')
14
- .replace(/[.!?,;:]+$/g, '')
15
- return normalized.length <= 80 ? normalized : normalized.slice(0, 80).trim()
16
- }
17
-
18
11
  function buildRefinementPromptInput(
19
12
  candidate: Awaited<ReturnType<typeof recentActivityService.getRefinementCandidate>>,
20
13
  ) {
@@ -52,7 +45,7 @@ class RecentActivityTitleService {
52
45
  await this.helperRuntime.generateHelperText({
53
46
  tag: 'recent-activity-title-refinement',
54
47
  createAgent: createRecentActivityTitleRefinerAgent,
55
- defaultSystemPrompt: recentActivityTitleRefinerPrompt,
48
+ defaultSystemPrompt: RECENT_ACTIVITY_TITLE_REFINER_PROMPT,
56
49
  timeoutMs: RECENT_ACTIVITY_TITLE_TIMEOUT_MS,
57
50
  messages: [{ role: 'user', content: promptInput }],
58
51
  }),
@@ -166,18 +166,12 @@ class RecentActivityService {
166
166
  }): Promise<RecentActivity[]> {
167
167
  await databaseService.connect()
168
168
 
169
- const items: RecentActivity[] = []
170
- for (const candidate of params.events) {
171
- const recorded = await this.recordEvent({
172
- orgId: params.orgId,
173
- userId: params.userId,
174
- source: params.source,
175
- event: candidate,
176
- })
177
- items.push(recorded.item)
178
- }
179
-
180
- return items
169
+ const results = await Promise.all(
170
+ params.events.map((candidate) =>
171
+ this.recordEvent({ orgId: params.orgId, userId: params.userId, source: params.source, event: candidate }),
172
+ ),
173
+ )
174
+ return results.map((r) => r.item)
181
175
  }
182
176
 
183
177
  async recordEvent(params: {
@@ -1,3 +1,5 @@
1
+ import { createHash } from 'node:crypto'
2
+
1
3
  import { parseRowMetadata, toTimestamp, withCreatedAtMetadata } from '@lota-sdk/shared'
2
4
  import type { ChatMessage } from '@lota-sdk/shared'
3
5
  import { RecordId, surql } from 'surrealdb'
@@ -33,14 +35,21 @@ function toMessageId(value: string | RecordIdRef): string {
33
35
  return recordIdToString(value, TABLES.WORKSTREAM_MESSAGE)
34
36
  }
35
37
 
38
+ /**
39
+ * Builds a collision-free row id by hashing the workstream + message id pair.
40
+ * Previous implementation replaced non-alphanumeric chars with '_', which was
41
+ * lossy (e.g. "msg:foo" and "msg_foo" mapped to the same row id).
42
+ * Now uses a 32-char SHA-256 hex prefix -- short enough for ergonomic ids,
43
+ * long enough (128 bits) to make collisions negligible.
44
+ */
36
45
  function toWorkstreamMessageRowId(workstreamId: RecordIdRef, messageId: string): RecordId {
37
- const workstreamPart = recordIdToString(workstreamId, TABLES.WORKSTREAM).replace(/[^a-zA-Z0-9_-]/g, '_')
38
- const messagePart = messageId.replace(/[^a-zA-Z0-9_-]/g, '_')
39
- return new RecordId(TABLES.WORKSTREAM_MESSAGE, `${workstreamPart}__${messagePart}`)
46
+ const workstreamStr = recordIdToString(workstreamId, TABLES.WORKSTREAM)
47
+ const digest = createHash('sha256').update(`${workstreamStr}\0${messageId}`).digest('hex').slice(0, 32)
48
+ return new RecordId(TABLES.WORKSTREAM_MESSAGE, digest)
40
49
  }
41
50
 
42
51
  function toChatMessage(row: WorkstreamMessageRow): ChatMessage {
43
- const rowCreatedAt = toTimestamp(row.createdAt)
52
+ const rowCreatedAt = toTimestamp(row.createdAt) ?? Date.now()
44
53
  const metadata = withCreatedAtMetadata(parseRowMetadata(row.metadata), rowCreatedAt)
45
54
 
46
55
  return { id: row.messageId, role: row.role, parts: (row.parts ?? []) as ChatMessage['parts'], metadata }
@@ -74,16 +83,16 @@ class WorkstreamMessageService {
74
83
  async upsertMessages(params: { workstreamId: RecordIdRef; messages: ChatMessage[] }): Promise<void> {
75
84
  const workstreamId = params.workstreamId
76
85
 
77
- for (const message of params.messages) {
86
+ const upsertPromises = params.messages.map(async (message) => {
78
87
  const messageId = message.id.trim()
79
- if (!messageId) continue
88
+ if (!messageId) return
80
89
 
81
90
  const role = message.role
82
91
  const parts = Array.isArray(message.parts)
83
92
  ? message.parts.map((part) => structuredClone(part) as Record<string, unknown>)
84
93
  : []
85
94
  if (parts.length === 0) {
86
- if (role === 'assistant') continue
95
+ if (role === 'assistant') return
87
96
  throw new Error(`Refusing to persist workstream message "${messageId}" with empty parts`)
88
97
  }
89
98
  const rowId = toWorkstreamMessageRowId(workstreamId, messageId)
@@ -93,7 +102,9 @@ class WorkstreamMessageService {
93
102
  WorkstreamMessageExistingRowSchema,
94
103
  )
95
104
  const persistedCreatedAt =
96
- existingRow === null ? toTimestamp(message.metadata?.createdAt) : toTimestamp(existingRow.createdAt)
105
+ existingRow === null
106
+ ? (toTimestamp(message.metadata?.createdAt) ?? Date.now())
107
+ : (toTimestamp(existingRow.createdAt) ?? Date.now())
97
108
  const metadata = withCreatedAtMetadata({ ...message.metadata, createdAt: persistedCreatedAt })
98
109
 
99
110
  await databaseService.upsert(
@@ -105,12 +116,15 @@ class WorkstreamMessageService {
105
116
  role,
106
117
  parts,
107
118
  metadata,
108
- createdAt: existingRow ? new Date(toTimestamp(existingRow.createdAt)) : new Date(persistedCreatedAt),
119
+ createdAt: existingRow
120
+ ? new Date(toTimestamp(existingRow.createdAt) ?? Date.now())
121
+ : new Date(persistedCreatedAt),
109
122
  },
110
123
  WorkstreamMessageRowSchema,
111
124
  { mutation: 'content' },
112
125
  )
113
- }
126
+ })
127
+ await Promise.all(upsertPromises)
114
128
  }
115
129
 
116
130
  async listMessages(workstreamId: RecordIdRef): Promise<ChatMessage[]> {
@@ -151,7 +165,7 @@ class WorkstreamMessageService {
151
165
  throw new Error(`Workstream cursor message not found: ${cursorMessageId}`)
152
166
  }
153
167
 
154
- const cursorCreatedAt = new Date(toTimestamp(cursorRow.createdAt))
168
+ const cursorCreatedAt = new Date(toTimestamp(cursorRow.createdAt) ?? Date.now())
155
169
  const cursorId = toWorkstreamMessageRowId(workstreamId, cursorMessageId)
156
170
  const rows = await databaseService.query<unknown>(surql`
157
171
  SELECT * FROM workstreamMessage
@@ -195,7 +209,7 @@ class WorkstreamMessageService {
195
209
  .map((message) => ({
196
210
  id: message.id,
197
211
  role: message.role as 'user' | 'assistant',
198
- createdAt: new Date(toTimestamp(message.metadata?.createdAt)).toISOString(),
212
+ createdAt: new Date(toTimestamp(message.metadata?.createdAt) ?? Date.now()).toISOString(),
199
213
  content: message.parts
200
214
  .flatMap((part) => (part.type === 'text' && typeof part.text === 'string' ? [part.text] : []))
201
215
  .join('\n')
@@ -273,10 +287,6 @@ class WorkstreamMessageService {
273
287
  async listAllMessages(workstreamId: RecordIdRef): Promise<ChatMessage[]> {
274
288
  return await this.listMessages(workstreamId)
275
289
  }
276
-
277
- async addAttachments(): Promise<void> {
278
- // Attachments are no longer persisted via workstreamMessage service in AI SDK mode.
279
- }
280
290
  }
281
291
 
282
292
  export const workstreamMessageService = new WorkstreamMessageService()
@@ -3,21 +3,13 @@ import { WORKSTREAM } from '@lota-sdk/shared'
3
3
  import { chatLogger } from '../config/logger'
4
4
  import type { RecordIdRef } from '../db/record-id'
5
5
  import { createHelperModelRuntime } from '../runtime/helper-model'
6
- import { deriveTitle, limitTitleWords } from '../runtime/title-helpers'
6
+ import { deriveTitle, limitTitleWords, normalizeTitle } from '../runtime/title-helpers'
7
7
  import {
8
8
  createWorkstreamTitleGeneratorAgent,
9
9
  WORKSTREAM_TITLE_GENERATOR_PROMPT,
10
10
  } from '../system-agents/title-generator.agent'
11
- import { compactWhitespace } from '../utils/string'
12
11
  import { workstreamService } from './workstream.service'
13
12
 
14
- function normalizeTitle(value: string): string {
15
- const normalized = compactWhitespace(value)
16
- .replace(/^["'`]+|["'`]+$/g, '')
17
- .replace(/[.!?,;:]+$/g, '')
18
- return normalized.length <= 80 ? normalized : normalized.slice(0, 80).trim()
19
- }
20
-
21
13
  class WorkstreamTitleService {
22
14
  helperRuntime = createHelperModelRuntime()
23
15