@lota-sdk/core 0.1.5 → 0.1.6

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.
@@ -0,0 +1,26 @@
1
+ # SDK identity tables.
2
+ DEFINE TABLE IF NOT EXISTS organization SCHEMAFULL;
3
+ DEFINE FIELD IF NOT EXISTS name ON TABLE organization TYPE string;
4
+ DEFINE FIELD IF NOT EXISTS regularChatDigestLastWorkstreamCursorCreatedAt ON TABLE organization TYPE option<datetime>;
5
+ DEFINE FIELD IF NOT EXISTS regularChatDigestLastWorkstreamCursorId ON TABLE organization TYPE option<string>;
6
+ DEFINE FIELD IF NOT EXISTS skillExtractionLastCursorId ON TABLE organization TYPE option<string>;
7
+ DEFINE FIELD IF NOT EXISTS skillExtractionLastCursorCreatedAt ON TABLE organization TYPE option<datetime>;
8
+ DEFINE FIELD IF NOT EXISTS createdAt ON TABLE organization TYPE datetime DEFAULT time::now() READONLY;
9
+ DEFINE FIELD IF NOT EXISTS updatedAt ON TABLE organization TYPE datetime VALUE time::now();
10
+
11
+ DEFINE TABLE IF NOT EXISTS user SCHEMAFULL;
12
+ DEFINE FIELD IF NOT EXISTS name ON TABLE user TYPE string;
13
+ DEFINE FIELD IF NOT EXISTS email ON TABLE user TYPE string;
14
+ DEFINE FIELD IF NOT EXISTS createdAt ON TABLE user TYPE datetime DEFAULT time::now() READONLY;
15
+ DEFINE FIELD IF NOT EXISTS updatedAt ON TABLE user TYPE datetime VALUE time::now();
16
+
17
+ DEFINE INDEX IF NOT EXISTS sdkUserEmailIdx ON TABLE user COLUMNS email;
18
+
19
+ DEFINE TABLE IF NOT EXISTS organizationMember SCHEMAFULL TYPE RELATION IN user OUT organization;
20
+ DEFINE FIELD IF NOT EXISTS in ON TABLE organizationMember TYPE record<user>;
21
+ DEFINE FIELD IF NOT EXISTS out ON TABLE organizationMember TYPE record<organization>;
22
+ DEFINE FIELD IF NOT EXISTS role ON TABLE organizationMember TYPE string;
23
+ DEFINE FIELD IF NOT EXISTS createdAt ON TABLE organizationMember TYPE datetime DEFAULT time::now() READONLY;
24
+
25
+ DEFINE INDEX IF NOT EXISTS organizationMemberUniqueIdx ON TABLE organizationMember COLUMNS in, out UNIQUE;
26
+ DEFINE INDEX IF NOT EXISTS organizationMemberOutIdx ON TABLE organizationMember COLUMNS out, createdAt;
@@ -15,10 +15,16 @@ DEFINE FIELD IF NOT EXISTS memoryBlockSummary ON TABLE workstream TYPE option<st
15
15
  DEFINE FIELD IF NOT EXISTS activeRunId ON TABLE workstream TYPE option<string>;
16
16
  DEFINE FIELD IF NOT EXISTS chatSummary ON TABLE workstream TYPE option<string>;
17
17
  DEFINE FIELD IF NOT EXISTS lastCompactedMessageId ON TABLE workstream TYPE option<string>;
18
+ DEFINE FIELD IF NOT EXISTS nameGenerated ON TABLE workstream TYPE bool DEFAULT false;
18
19
  DEFINE FIELD IF NOT EXISTS isCompacting ON TABLE workstream TYPE bool DEFAULT false;
19
20
  DEFINE FIELD IF NOT EXISTS state ON TABLE workstream TYPE option<object> FLEXIBLE;
20
21
 
21
22
  DEFINE INDEX IF NOT EXISTS workstreamOrgIdx ON TABLE workstream COLUMNS organizationId;
23
+ DEFINE INDEX IF NOT EXISTS workstreamUserIdx ON TABLE workstream COLUMNS userId;
24
+ DEFINE INDEX IF NOT EXISTS workstreamUserOrgModeUpdatedIdx ON TABLE workstream COLUMNS userId, organizationId, mode, updatedAt;
25
+ DEFINE INDEX IF NOT EXISTS workstreamUserOrgStatusModeUpdatedIdx ON TABLE workstream COLUMNS userId, organizationId, status, mode, updatedAt;
26
+ DEFINE INDEX IF NOT EXISTS workstreamUserOrgCoreModeUpdatedIdx ON TABLE workstream COLUMNS userId, organizationId, core, mode, updatedAt;
27
+ DEFINE INDEX IF NOT EXISTS workstreamUserOrgStatusCoreModeUpdatedIdx ON TABLE workstream COLUMNS userId, organizationId, status, core, mode, updatedAt;
22
28
 
23
29
  # Workstream Message table (AI SDK UIMessage persistence).
24
30
  # parts uses OVERWRITE on the wildcard to override the implicit non-FLEXIBLE
@@ -53,3 +59,4 @@ DEFINE FIELD IF NOT EXISTS updatedAt ON TABLE workstreamAttachment TYPE datetime
53
59
 
54
60
  DEFINE INDEX IF NOT EXISTS workstreamAttachmentWorkstreamIdx ON TABLE workstreamAttachment COLUMNS workstreamId;
55
61
  DEFINE INDEX IF NOT EXISTS workstreamAttachmentMessageIdx ON TABLE workstreamAttachment COLUMNS messageId;
62
+ DEFINE INDEX IF NOT EXISTS workstreamAttachmentWorkstreamMessageIdx ON TABLE workstreamAttachment COLUMNS workstreamId, messageId;
@@ -0,0 +1,48 @@
1
+ # SDK recent activity tables.
2
+ DEFINE TABLE IF NOT EXISTS recentActivityEvent SCHEMAFULL;
3
+ DEFINE FIELD IF NOT EXISTS organizationId ON TABLE recentActivityEvent TYPE record<organization>;
4
+ DEFINE FIELD IF NOT EXISTS userId ON TABLE recentActivityEvent TYPE record<user>;
5
+ DEFINE FIELD IF NOT EXISTS sourceEventId ON TABLE recentActivityEvent TYPE string;
6
+ DEFINE FIELD IF NOT EXISTS source ON TABLE recentActivityEvent TYPE string;
7
+ DEFINE FIELD IF NOT EXISTS kind ON TABLE recentActivityEvent TYPE string;
8
+ DEFINE FIELD IF NOT EXISTS targetKind ON TABLE recentActivityEvent TYPE string;
9
+ DEFINE FIELD IF NOT EXISTS targetId ON TABLE recentActivityEvent TYPE option<string>;
10
+ DEFINE FIELD IF NOT EXISTS mergeKey ON TABLE recentActivityEvent TYPE string;
11
+ DEFINE FIELD IF NOT EXISTS title ON TABLE recentActivityEvent TYPE string;
12
+ DEFINE FIELD IF NOT EXISTS sourceLabel ON TABLE recentActivityEvent TYPE string;
13
+ DEFINE FIELD IF NOT EXISTS deepLink ON TABLE recentActivityEvent TYPE object FLEXIBLE;
14
+ DEFINE FIELD IF NOT EXISTS metadata ON TABLE recentActivityEvent TYPE option<object> FLEXIBLE;
15
+ DEFINE FIELD IF NOT EXISTS occurredAt ON TABLE recentActivityEvent TYPE datetime;
16
+ DEFINE FIELD IF NOT EXISTS createdAt ON TABLE recentActivityEvent TYPE datetime DEFAULT time::now() READONLY;
17
+
18
+ DEFINE INDEX IF NOT EXISTS recentActivityEventSourceUniqueIdx
19
+ ON TABLE recentActivityEvent COLUMNS organizationId, userId, sourceEventId UNIQUE;
20
+ DEFINE INDEX IF NOT EXISTS recentActivityEventRecentIdx
21
+ ON TABLE recentActivityEvent COLUMNS organizationId, userId, occurredAt;
22
+ DEFINE INDEX IF NOT EXISTS recentActivityEventMergeIdx
23
+ ON TABLE recentActivityEvent COLUMNS organizationId, userId, mergeKey, occurredAt;
24
+
25
+ DEFINE TABLE IF NOT EXISTS recentActivity SCHEMAFULL;
26
+ DEFINE FIELD IF NOT EXISTS organizationId ON TABLE recentActivity TYPE record<organization>;
27
+ DEFINE FIELD IF NOT EXISTS userId ON TABLE recentActivity TYPE record<user>;
28
+ DEFINE FIELD IF NOT EXISTS mergeKey ON TABLE recentActivity TYPE string;
29
+ DEFINE FIELD IF NOT EXISTS kind ON TABLE recentActivity TYPE string;
30
+ DEFINE FIELD IF NOT EXISTS targetKind ON TABLE recentActivity TYPE string;
31
+ DEFINE FIELD IF NOT EXISTS targetId ON TABLE recentActivity TYPE option<string>;
32
+ DEFINE FIELD IF NOT EXISTS title ON TABLE recentActivity TYPE string;
33
+ DEFINE FIELD IF NOT EXISTS systemTitle ON TABLE recentActivity TYPE string;
34
+ DEFINE FIELD IF NOT EXISTS titleSource ON TABLE recentActivity TYPE string;
35
+ DEFINE FIELD IF NOT EXISTS sourceLabel ON TABLE recentActivity TYPE string;
36
+ DEFINE FIELD IF NOT EXISTS deepLink ON TABLE recentActivity TYPE object FLEXIBLE;
37
+ DEFINE FIELD IF NOT EXISTS metadata ON TABLE recentActivity TYPE option<object> FLEXIBLE;
38
+ DEFINE FIELD IF NOT EXISTS latestEventId ON TABLE recentActivity TYPE option<record<recentActivityEvent>>;
39
+ DEFINE FIELD IF NOT EXISTS latestSourceEventId ON TABLE recentActivity TYPE option<string>;
40
+ DEFINE FIELD IF NOT EXISTS latestEventAt ON TABLE recentActivity TYPE datetime;
41
+ DEFINE FIELD IF NOT EXISTS titleRefinedAt ON TABLE recentActivity TYPE option<datetime>;
42
+ DEFINE FIELD IF NOT EXISTS createdAt ON TABLE recentActivity TYPE datetime DEFAULT time::now() READONLY;
43
+ DEFINE FIELD IF NOT EXISTS updatedAt ON TABLE recentActivity TYPE datetime VALUE time::now();
44
+
45
+ DEFINE INDEX IF NOT EXISTS recentActivityMergeUniqueIdx
46
+ ON TABLE recentActivity COLUMNS organizationId, userId, mergeKey UNIQUE;
47
+ DEFINE INDEX IF NOT EXISTS recentActivityRecentIdx
48
+ ON TABLE recentActivity COLUMNS organizationId, userId, latestEventAt;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lota-sdk/core",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -104,7 +104,8 @@
104
104
  "lint": "node ../node_modules/oxlint/bin/oxlint --fix -c ../oxlint.config.ts src",
105
105
  "format": "bunx oxfmt src",
106
106
  "typecheck": "bunx tsgo --noEmit",
107
- "test:unit": "bun test ../tests/unit/core"
107
+ "test:unit": "bun test ../tests/unit/core",
108
+ "test:coverage": "bun test --coverage ../tests/unit/core"
108
109
  },
109
110
  "publishConfig": {
110
111
  "access": "public",
@@ -113,7 +114,7 @@
113
114
  "dependencies": {
114
115
  "@ai-sdk/openai": "^3.0.41",
115
116
  "@logtape/logtape": "^2.0.4",
116
- "@lota-sdk/shared": "0.1.0",
117
+ "@lota-sdk/shared": "file:../shared",
117
118
  "@mendable/firecrawl-js": "^4.16.0",
118
119
  "@surrealdb/node": "^3.0.3",
119
120
  "ai": "^6.0.116",
@@ -14,7 +14,6 @@ export const surrealDbEnvShape = {
14
14
  SURREALDB_USER: z.string().min(1, 'SurrealDB user is required'),
15
15
  SURREALDB_PASSWORD: z.string().min(1, 'SurrealDB password is required'),
16
16
  SURREALDB_NAMESPACE: z.string().min(1, 'SurrealDB namespace is required'),
17
- SURREALDB_DATABASE: z.string().min(1, 'SurrealDB database is required'),
18
17
  } as const satisfies Record<string, ZodTypeAny>
19
18
 
20
19
  export const betterAuthEnvShape = {
@@ -0,0 +1,21 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ function toSchemaFilePath(value: string | URL): string {
4
+ return value instanceof URL ? value.pathname : value
5
+ }
6
+
7
+ export async function computeSchemaFingerprint(schemaFiles: readonly (string | URL)[]): Promise<string> {
8
+ const hash = createHash('sha256')
9
+
10
+ for (const schemaFile of schemaFiles) {
11
+ const sortKey = toSchemaFilePath(schemaFile)
12
+ const file = schemaFile instanceof URL ? Bun.file(schemaFile.pathname) : Bun.file(schemaFile)
13
+
14
+ hash.update(sortKey)
15
+ hash.update('\0')
16
+ hash.update((await file.text()).trim())
17
+ hash.update('\0')
18
+ }
19
+
20
+ return hash.digest('hex')
21
+ }
@@ -0,0 +1 @@
1
+ export const LOTA_SDK_DATABASE_NAME = 'lotasdk'
package/src/db/service.ts CHANGED
@@ -926,7 +926,3 @@ export function setDatabaseService(db: SurrealDBService): void {
926
926
 
927
927
  currentDatabaseService = db
928
928
  }
929
-
930
- export function getDatabaseService(): SurrealDBService | undefined {
931
- return currentDatabaseService
932
- }
package/src/db/tables.ts CHANGED
@@ -11,8 +11,8 @@ export const TABLES = {
11
11
  PLAN_TASK: 'planTask',
12
12
  PLAN_EVENT: 'planEvent',
13
13
  ORGANIZATION: 'organization',
14
+ ORGANIZATION_MEMBER: 'organizationMember',
14
15
  USER: 'user',
15
- ORG_ACTION: 'orgAction',
16
16
  RECENT_ACTIVITY_EVENT: 'recentActivityEvent',
17
17
  RECENT_ACTIVITY: 'recentActivity',
18
18
  } as const
package/src/index.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { CoreWorkstreamProfile } from './config/agent-defaults'
2
2
  import type { LotaWorkstreamConfig } from './config/workstream-defaults'
3
+ import { ensureRecordId } from './db/record-id'
3
4
  import type { SurrealDBService } from './db/service'
5
+ import { TABLES } from './db/tables'
4
6
  import type { startContextCompactionWorker } from './queues/context-compaction.queue'
5
7
  import type {
6
8
  scheduleRecurringConsolidation,
@@ -10,16 +12,21 @@ import type { startPostChatMemoryWorker } from './queues/post-chat-memory.queue'
10
12
  import type { startRecentActivityTitleRefinementWorker } from './queues/recent-activity-title-refinement.queue'
11
13
  import type { startRegularChatMemoryDigestWorker } from './queues/regular-chat-memory-digest.queue'
12
14
  import type { startSkillExtractionWorker } from './queues/skill-extraction.queue'
15
+ import type { startWorkstreamTitleGenerationWorker } from './queues/workstream-title-generation.queue'
13
16
  import type { RedisConnectionManager } from './redis/connection'
14
17
  import type { isApprovalContinuationRequest } from './runtime/approval-continuation'
18
+ import type { routeWorkstreamChatMessages } from './runtime/chat-request-routing'
15
19
  import type { LotaPlugin } from './runtime/plugin-types'
16
20
  import type { LotaRuntimeAdapters, LotaRuntimeTurnHooks } from './runtime/runtime-extensions'
17
21
  import type { attachmentService } from './services/attachment.service'
18
22
  import type { executionPlanService } from './services/execution-plan.service'
19
23
  import type { memoryService } from './services/memory.service'
20
24
  import type { verifyMutatingApproval } from './services/mutating-approval.service'
25
+ import type { organizationMemberService } from './services/organization-member.service'
26
+ import type { organizationService } from './services/organization.service'
21
27
  import type { recentActivityTitleService } from './services/recent-activity-title.service'
22
28
  import type { recentActivityService } from './services/recent-activity.service'
29
+ import type { userService } from './services/user.service'
23
30
  import type { workstreamMessageService } from './services/workstream-message.service'
24
31
  import type { workstreamTitleService } from './services/workstream-title.service'
25
32
  import type {
@@ -39,11 +46,22 @@ interface LotaRuntimeBuiltInWorkers {
39
46
  startRecentActivityTitleRefinementWorker: typeof startRecentActivityTitleRefinementWorker
40
47
  startRegularChatMemoryDigestWorker: typeof startRegularChatMemoryDigestWorker
41
48
  startSkillExtractionWorker: typeof startSkillExtractionWorker
49
+ startWorkstreamTitleGenerationWorker: typeof startWorkstreamTitleGenerationWorker
42
50
  scheduleRecurringConsolidation: typeof scheduleRecurringConsolidation
43
51
  }
44
52
 
53
+ type ArchiveSdkWorkstream = (
54
+ workstreamId: Parameters<typeof workstreamService.updateStatus>[0],
55
+ status?: 'archived',
56
+ ) => ReturnType<typeof workstreamService.updateStatus>
57
+
58
+ type UnarchiveSdkWorkstream = (
59
+ workstreamId: Parameters<typeof workstreamService.updateStatus>[0],
60
+ status?: 'regular',
61
+ ) => ReturnType<typeof workstreamService.updateStatus>
62
+
45
63
  export interface LotaRuntimeConfig {
46
- database: { url: string; namespace: string; database: string; username: string; password: string }
64
+ database: { url: string; namespace: string; username: string; password: string }
47
65
  redis: { url: string }
48
66
  aiGateway: { url: string; key: string; admin?: string; pass?: string; embeddingModel?: string }
49
67
  s3: {
@@ -88,6 +106,9 @@ export interface LotaRuntime {
88
106
  generatedDocumentStorageService: typeof generatedDocumentStorageService
89
107
  memoryService: typeof memoryService
90
108
  verifyMutatingApproval: typeof verifyMutatingApproval
109
+ organizationService: typeof organizationService
110
+ organizationMemberService: typeof organizationMemberService
111
+ userService: typeof userService
91
112
  recentActivityService: typeof recentActivityService
92
113
  recentActivityTitleService: typeof recentActivityTitleService
93
114
  executionPlanService: typeof executionPlanService
@@ -99,6 +120,57 @@ export interface LotaRuntime {
99
120
  isApprovalContinuationRequest: typeof isApprovalContinuationRequest
100
121
  runWorkstreamTurnInBackground: typeof runWorkstreamTurnInBackground
101
122
  }
123
+ lota: {
124
+ organizations: {
125
+ create: typeof organizationService.createOrganization
126
+ upsert: typeof organizationService.upsertOrganization
127
+ get: typeof organizationService.getOrganization
128
+ list: typeof organizationService.listOrganizations
129
+ update: typeof organizationService.updateOrganization
130
+ delete: typeof organizationService.deleteOrganization
131
+ }
132
+ users: {
133
+ upsert: typeof userService.upsertUser
134
+ get: typeof userService.getUser
135
+ list: typeof userService.listUsers
136
+ update: typeof userService.updateUser
137
+ delete: typeof userService.deleteUser
138
+ }
139
+ memberships: {
140
+ add: typeof organizationMemberService.addMembership
141
+ listForOrganization: typeof organizationMemberService.listMembershipsForOrganization
142
+ listForUser: typeof organizationMemberService.listMembershipsForUser
143
+ remove: typeof organizationMemberService.removeMembership
144
+ isMember: typeof organizationMemberService.isMember
145
+ }
146
+ workstreams: {
147
+ create: typeof workstreamService.createWorkstream
148
+ list: typeof workstreamService.listWorkstreams
149
+ get: typeof workstreamService.getWorkstream
150
+ update: typeof workstreamService.updateTitle
151
+ archive: ArchiveSdkWorkstream
152
+ unarchive: UnarchiveSdkWorkstream
153
+ delete: typeof workstreamService.deleteWorkstream
154
+ stop: typeof workstreamService.stopActiveRun
155
+ listMessages: typeof workstreamMessageService.listMessageHistoryPage
156
+ getMessage: (params: { workstreamId: string; messageId: string }) => Promise<unknown>
157
+ sendMessage: (params: {
158
+ workstreamId: string
159
+ organizationId: string
160
+ userId: string
161
+ userName: string
162
+ messages: Parameters<typeof routeWorkstreamChatMessages>[0]
163
+ }) => Promise<Awaited<ReturnType<typeof createWorkstreamTurnStream>>>
164
+ continueApproval: (params: {
165
+ workstreamId: string
166
+ organizationId: string
167
+ userId: string
168
+ userName: string
169
+ messages: Parameters<typeof routeWorkstreamChatMessages>[0]
170
+ }) => Promise<Awaited<ReturnType<typeof createWorkstreamApprovalContinuationStream>>>
171
+ uploadAttachment: typeof attachmentService.uploadWorkstreamAttachment
172
+ }
173
+ }
102
174
  redis: {
103
175
  manager: RedisConnectionManager
104
176
  getConnection: () => ReturnType<RedisConnectionManager['getConnection']>
@@ -118,12 +190,16 @@ export interface LotaRuntime {
118
190
  export async function createLotaRuntime(config: LotaRuntimeConfig): Promise<LotaRuntime> {
119
191
  const { lotaSdkEnvKeys, setEnv } = await import('./config/env-shapes')
120
192
  const { configureLogger } = await import('./config/logger')
193
+ const { publishDatabaseBootstrap } = await import('./db/startup')
194
+ const { computeSchemaFingerprint } = await import('./db/schema-fingerprint')
195
+ const { LOTA_SDK_DATABASE_NAME } = await import('./db/sdk-database')
121
196
  const { SurrealDBService: SurrealDBServiceClass, setDatabaseService } = await import('./db/service')
122
197
  const { createRedisConnectionManager } = await import('./redis/connection')
123
198
  const { setRedisConnectionManager } = await import('./redis/index')
124
199
  const { configureAgents, configureAgentFactory } = await import('./config/agent-defaults')
125
200
  const { configureWorkstreams } = await import('./config/workstream-defaults')
126
201
  const { configureRuntimeExtensions } = await import('./runtime/runtime-extensions')
202
+ const { routeWorkstreamChatMessages } = await import('./runtime/chat-request-routing')
127
203
 
128
204
  setEnv({
129
205
  AI_GATEWAY_URL: config.aiGateway.url,
@@ -149,7 +225,7 @@ export async function createLotaRuntime(config: LotaRuntimeConfig): Promise<Lota
149
225
  const db = new SurrealDBServiceClass({
150
226
  url: config.database.url,
151
227
  namespace: config.database.namespace,
152
- database: config.database.database,
228
+ database: LOTA_SDK_DATABASE_NAME,
153
229
  username: config.database.username,
154
230
  password: config.database.password,
155
231
  })
@@ -182,6 +258,9 @@ export async function createLotaRuntime(config: LotaRuntimeConfig): Promise<Lota
182
258
  const { executionPlanService } = await import('./services/execution-plan.service')
183
259
  const { memoryService } = await import('./services/memory.service')
184
260
  const { verifyMutatingApproval } = await import('./services/mutating-approval.service')
261
+ const { organizationMemberService } = await import('./services/organization-member.service')
262
+ const { organizationService } = await import('./services/organization.service')
263
+ const { userService } = await import('./services/user.service')
185
264
  const { workstreamMessageService } = await import('./services/workstream-message.service')
186
265
  const { workstreamService } = await import('./services/workstream.service')
187
266
  const { workstreamTitleService } = await import('./services/workstream-title.service')
@@ -199,6 +278,7 @@ export async function createLotaRuntime(config: LotaRuntimeConfig): Promise<Lota
199
278
  const { startRecentActivityTitleRefinementWorker } = await import('./queues/recent-activity-title-refinement.queue')
200
279
  const { startRegularChatMemoryDigestWorker } = await import('./queues/regular-chat-memory-digest.queue')
201
280
  const { startSkillExtractionWorker } = await import('./queues/skill-extraction.queue')
281
+ const { startWorkstreamTitleGenerationWorker } = await import('./queues/workstream-title-generation.queue')
202
282
 
203
283
  configureRuntimeExtensions({
204
284
  adapters: config.runtimeAdapters,
@@ -209,11 +289,8 @@ export async function createLotaRuntime(config: LotaRuntimeConfig): Promise<Lota
209
289
 
210
290
  const pluginRuntime = config.pluginRuntime ?? {}
211
291
  const pluginContributions = Object.values(pluginRuntime).map((plugin) => plugin.contributions)
212
- const schemaFiles = [
213
- ...getBuiltInSchemaFiles(),
214
- ...pluginContributions.flatMap((plugin) => plugin.schemaFiles),
215
- ...(config.extraSchemaFiles ?? []),
216
- ]
292
+ const schemaFiles = [...getBuiltInSchemaFiles(), ...(config.extraSchemaFiles ?? [])]
293
+ const hostContributionSchemaFiles = pluginContributions.flatMap((plugin) => plugin.schemaFiles)
217
294
  const contributionEnvKeys = [...lotaSdkEnvKeys, ...pluginContributions.flatMap((plugin) => plugin.envKeys)]
218
295
  const connectPluginDatabases = createPluginDatabaseConnector(pluginRuntime)
219
296
  const builtInWorkers = {
@@ -223,9 +300,91 @@ export async function createLotaRuntime(config: LotaRuntimeConfig): Promise<Lota
223
300
  startRecentActivityTitleRefinementWorker,
224
301
  startRegularChatMemoryDigestWorker,
225
302
  startSkillExtractionWorker,
303
+ startWorkstreamTitleGenerationWorker,
226
304
  scheduleRecurringConsolidation,
227
305
  } satisfies LotaRuntimeBuiltInWorkers
228
306
 
307
+ const lota = {
308
+ organizations: {
309
+ create: organizationService.createOrganization.bind(organizationService),
310
+ upsert: organizationService.upsertOrganization.bind(organizationService),
311
+ get: organizationService.getOrganization.bind(organizationService),
312
+ list: organizationService.listOrganizations.bind(organizationService),
313
+ update: organizationService.updateOrganization.bind(organizationService),
314
+ delete: organizationService.deleteOrganization.bind(organizationService),
315
+ },
316
+ users: {
317
+ upsert: userService.upsertUser.bind(userService),
318
+ get: userService.getUser.bind(userService),
319
+ list: userService.listUsers.bind(userService),
320
+ update: userService.updateUser.bind(userService),
321
+ delete: userService.deleteUser.bind(userService),
322
+ },
323
+ memberships: {
324
+ add: organizationMemberService.addMembership.bind(organizationMemberService),
325
+ listForOrganization: organizationMemberService.listMembershipsForOrganization.bind(organizationMemberService),
326
+ listForUser: organizationMemberService.listMembershipsForUser.bind(organizationMemberService),
327
+ remove: organizationMemberService.removeMembership.bind(organizationMemberService),
328
+ isMember: organizationMemberService.isMember.bind(organizationMemberService),
329
+ },
330
+ workstreams: {
331
+ create: workstreamService.createWorkstream.bind(workstreamService),
332
+ list: workstreamService.listWorkstreams.bind(workstreamService),
333
+ get: workstreamService.getWorkstream.bind(workstreamService),
334
+ update: workstreamService.updateTitle.bind(workstreamService),
335
+ archive: async (workstreamId, status = 'archived') => await workstreamService.updateStatus(workstreamId, status),
336
+ unarchive: async (workstreamId, status = 'regular') => await workstreamService.updateStatus(workstreamId, status),
337
+ delete: workstreamService.deleteWorkstream.bind(workstreamService),
338
+ stop: workstreamService.stopActiveRun.bind(workstreamService),
339
+ listMessages: workstreamMessageService.listMessageHistoryPage.bind(workstreamMessageService),
340
+ getMessage: async ({ workstreamId, messageId }) => {
341
+ const messages = await workstreamMessageService.listMessages(ensureRecordId(workstreamId, TABLES.WORKSTREAM))
342
+ const message = messages.find((candidate) => candidate.id === messageId)
343
+ if (!message) {
344
+ throw new Error(`Workstream message not found: ${messageId}`)
345
+ }
346
+ return message
347
+ },
348
+ sendMessage: async ({ workstreamId, organizationId, userId, userName, messages }) => {
349
+ const workstreamRef = ensureRecordId(workstreamId, TABLES.WORKSTREAM)
350
+ const workstream = await workstreamService.getWorkstream(workstreamRef)
351
+ const routed = routeWorkstreamChatMessages(messages)
352
+ if (routed.kind !== 'turn') {
353
+ throw new Error(routed.kind === 'invalid' ? routed.message : 'Expected a user turn payload.')
354
+ }
355
+
356
+ return await createWorkstreamTurnStream({
357
+ workstream,
358
+ workstreamRef,
359
+ orgRef: ensureRecordId(organizationId, TABLES.ORGANIZATION),
360
+ userRef: ensureRecordId(userId, TABLES.USER),
361
+ userName,
362
+ inputMessage: routed.inputMessage,
363
+ })
364
+ },
365
+ continueApproval: async ({ workstreamId, organizationId, userId, userName, messages }) => {
366
+ const workstreamRef = ensureRecordId(workstreamId, TABLES.WORKSTREAM)
367
+ const workstream = await workstreamService.getWorkstream(workstreamRef)
368
+ const routed = routeWorkstreamChatMessages(messages)
369
+ if (routed.kind !== 'approval-continuation') {
370
+ throw new Error(
371
+ routed.kind === 'invalid' ? routed.message : 'Expected approval continuation messages payload.',
372
+ )
373
+ }
374
+
375
+ return await createWorkstreamApprovalContinuationStream({
376
+ workstream,
377
+ workstreamRef,
378
+ orgRef: ensureRecordId(organizationId, TABLES.ORGANIZATION),
379
+ userRef: ensureRecordId(userId, TABLES.USER),
380
+ userName,
381
+ approvalMessages: routed.approvalMessages,
382
+ })
383
+ },
384
+ uploadAttachment: attachmentService.uploadWorkstreamAttachment.bind(attachmentService),
385
+ },
386
+ } satisfies LotaRuntime['lota']
387
+
229
388
  return {
230
389
  services: {
231
390
  database: db,
@@ -236,6 +395,9 @@ export async function createLotaRuntime(config: LotaRuntimeConfig): Promise<Lota
236
395
  generatedDocumentStorageService,
237
396
  memoryService,
238
397
  verifyMutatingApproval,
398
+ organizationService,
399
+ organizationMemberService,
400
+ userService,
239
401
  recentActivityService,
240
402
  recentActivityTitleService,
241
403
  executionPlanService,
@@ -247,6 +409,7 @@ export async function createLotaRuntime(config: LotaRuntimeConfig): Promise<Lota
247
409
  isApprovalContinuationRequest,
248
410
  runWorkstreamTurnInBackground,
249
411
  },
412
+ lota,
250
413
  redis: {
251
414
  manager: redisManager,
252
415
  getConnection: () => redisManager.getConnection(),
@@ -255,7 +418,7 @@ export async function createLotaRuntime(config: LotaRuntimeConfig): Promise<Lota
255
418
  },
256
419
  workers: { ...builtInWorkers, ...config.extraWorkers },
257
420
  schemaFiles,
258
- contributions: { envKeys: [...new Set(contributionEnvKeys)], schemaFiles },
421
+ contributions: { envKeys: [...new Set(contributionEnvKeys)], schemaFiles: hostContributionSchemaFiles },
259
422
  config,
260
423
  plugins: pluginRuntime,
261
424
  async connectPluginDatabases() {
@@ -263,6 +426,12 @@ export async function createLotaRuntime(config: LotaRuntimeConfig): Promise<Lota
263
426
  },
264
427
  async connect() {
265
428
  await db.connect()
429
+ const bunFiles = schemaFiles.map((schemaFile) =>
430
+ schemaFile instanceof URL ? Bun.file(schemaFile.pathname) : Bun.file(schemaFile),
431
+ )
432
+ await db.applySchemaAndMigrations(bunFiles)
433
+ const schemaFingerprint = await computeSchemaFingerprint(schemaFiles)
434
+ await publishDatabaseBootstrap({ databaseService: db, schemaFingerprint })
266
435
  },
267
436
  async disconnect() {
268
437
  await db.disconnect()
@@ -273,10 +442,12 @@ export async function createLotaRuntime(config: LotaRuntimeConfig): Promise<Lota
273
442
 
274
443
  function getBuiltInSchemaFiles(): URL[] {
275
444
  return [
445
+ new URL('../infrastructure/schema/00_identity.surql', import.meta.url),
276
446
  new URL('../infrastructure/schema/00_workstream.surql', import.meta.url),
277
447
  new URL('../infrastructure/schema/01_memory.surql', import.meta.url),
278
448
  new URL('../infrastructure/schema/02_execution_plan.surql', import.meta.url),
279
449
  new URL('../infrastructure/schema/03_learned_skill.surql', import.meta.url),
450
+ new URL('../infrastructure/schema/05_recent_activity.surql', import.meta.url),
280
451
  new URL('../infrastructure/schema/04_runtime_bootstrap.surql', import.meta.url),
281
452
  ]
282
453
  }
@@ -0,0 +1,69 @@
1
+ import { Queue, Worker } from 'bullmq'
2
+ import type { Job } from 'bullmq'
3
+
4
+ import { serverLogger } from '../config/logger'
5
+ import { ensureRecordId } from '../db/record-id'
6
+ import { databaseService } from '../db/service'
7
+ import { getRedisConnectionForBullMQ } from '../redis'
8
+ import { workstreamTitleService } from '../services/workstream-title.service'
9
+ import {
10
+ attachWorkerEvents,
11
+ createTracedWorkerProcessor,
12
+ createWorkerShutdown,
13
+ registerShutdownSignals,
14
+ } from '../workers/worker-utils'
15
+ import type { WorkerHandle } from '../workers/worker-utils'
16
+
17
+ interface WorkstreamTitleGenerationJob {
18
+ workstreamId: string
19
+ sourceText: string
20
+ }
21
+
22
+ const WORKSTREAM_TITLE_GENERATION_QUEUE = 'workstream-title-generation'
23
+
24
+ let _workstreamTitleGenerationQueue: Queue<WorkstreamTitleGenerationJob> | null = null
25
+ function getWorkstreamTitleGenerationQueue(): Queue<WorkstreamTitleGenerationJob> {
26
+ if (!_workstreamTitleGenerationQueue) {
27
+ _workstreamTitleGenerationQueue = new Queue<WorkstreamTitleGenerationJob>(WORKSTREAM_TITLE_GENERATION_QUEUE, {
28
+ connection: getRedisConnectionForBullMQ(),
29
+ defaultJobOptions: {
30
+ removeOnComplete: 200,
31
+ removeOnFail: 200,
32
+ attempts: 2,
33
+ backoff: { type: 'exponential', delay: 2_000 },
34
+ },
35
+ })
36
+ }
37
+ return _workstreamTitleGenerationQueue
38
+ }
39
+
40
+ export async function enqueueWorkstreamTitleGeneration(job: WorkstreamTitleGenerationJob) {
41
+ return await getWorkstreamTitleGenerationQueue().add('generate-workstream-title', job, {
42
+ jobId: `workstream-title:${job.workstreamId}`,
43
+ })
44
+ }
45
+
46
+ async function processWorkstreamTitleGenerationJob(job: Job<WorkstreamTitleGenerationJob>): Promise<void> {
47
+ await databaseService.connect()
48
+ const workstreamRef = ensureRecordId(job.data.workstreamId)
49
+ await workstreamTitleService.generateAndPersistTitle(workstreamRef, job.data.sourceText)
50
+ }
51
+
52
+ export function startWorkstreamTitleGenerationWorker(options: { registerSignals?: boolean } = {}): WorkerHandle {
53
+ const { registerSignals = import.meta.main } = options
54
+ const worker = new Worker(
55
+ WORKSTREAM_TITLE_GENERATION_QUEUE,
56
+ createTracedWorkerProcessor(WORKSTREAM_TITLE_GENERATION_QUEUE, processWorkstreamTitleGenerationJob),
57
+ { connection: getRedisConnectionForBullMQ(), concurrency: 2, lockDuration: 60_000 },
58
+ )
59
+
60
+ attachWorkerEvents(worker, 'Workstream title generation', serverLogger)
61
+
62
+ const shutdown = createWorkerShutdown(worker, 'Workstream title generation', serverLogger)
63
+
64
+ if (registerSignals) {
65
+ registerShutdownSignals({ name: 'Workstream title generation', shutdown, logger: serverLogger })
66
+ }
67
+
68
+ return { worker, shutdown }
69
+ }
@@ -48,6 +48,7 @@ export interface GenerateHelperStructuredParams<T> extends Omit<GenerateHelperTe
48
48
  tag: string
49
49
  schema: ZodSchema<T>
50
50
  textFallbackParser?: (text: string) => T | null
51
+ normalizeCandidate?: (candidate: unknown) => unknown
51
52
  }
52
53
 
53
54
  function isObject(value: unknown): value is Record<string, unknown> {
@@ -358,6 +359,14 @@ export function createHelperModelRuntime() {
358
359
  return parsed.data
359
360
  }
360
361
 
362
+ if (params.normalizeCandidate && isObject(result.output)) {
363
+ const normalized = params.normalizeCandidate(result.output)
364
+ const normalizedParsed = parseStructuredCandidate({ schema: params.schema, candidate: normalized })
365
+ if (normalizedParsed) {
366
+ return normalizedParsed.data
367
+ }
368
+ }
369
+
361
370
  if (typeof result.text === 'string' && result.text.trim().length > 0) {
362
371
  return parseStructuredTextWithFallbacks({
363
372
  schema: params.schema,
@@ -401,5 +410,3 @@ export function createHelperModelRuntime() {
401
410
 
402
411
  return { generateHelperText, generateHelperStructured }
403
412
  }
404
-
405
- export const llmHelperService = createHelperModelRuntime()
@@ -0,0 +1,114 @@
1
+ import { recordIdStringSchema } from '@lota-sdk/shared/schemas/common'
2
+ import { z } from 'zod'
3
+
4
+ import { BaseService } from '../db/base.service'
5
+ import { ensureRecordId, recordIdToString } from '../db/record-id'
6
+ import type { RecordIdInput, RecordIdRef } from '../db/record-id'
7
+ import { TABLES } from '../db/tables'
8
+ import { toIsoDateTimeString } from '../utils/date-time'
9
+
10
+ const organizationMemberRecordSchema = z.object({
11
+ id: z.unknown(),
12
+ in: z.unknown(),
13
+ out: z.unknown(),
14
+ role: z.string(),
15
+ createdAt: z.coerce.date(),
16
+ })
17
+
18
+ const sdkOrganizationMemberSchema = z.object({
19
+ id: recordIdStringSchema,
20
+ userId: recordIdStringSchema,
21
+ organizationId: recordIdStringSchema,
22
+ role: z.string(),
23
+ createdAt: z.iso.datetime(),
24
+ })
25
+
26
+ export type SdkOrganizationMemberRecord = z.infer<typeof organizationMemberRecordSchema>
27
+ export type SdkOrganizationMember = z.infer<typeof sdkOrganizationMemberSchema>
28
+
29
+ class OrganizationMemberService extends BaseService<typeof organizationMemberRecordSchema> {
30
+ constructor() {
31
+ super(TABLES.ORGANIZATION_MEMBER, organizationMemberRecordSchema)
32
+ }
33
+
34
+ toPublic(record: SdkOrganizationMemberRecord): SdkOrganizationMember {
35
+ return sdkOrganizationMemberSchema.parse({
36
+ id: recordIdToString(
37
+ ensureRecordId(record.id as RecordIdInput, TABLES.ORGANIZATION_MEMBER),
38
+ TABLES.ORGANIZATION_MEMBER,
39
+ ),
40
+ userId: recordIdToString(ensureRecordId(record.in as RecordIdInput, TABLES.USER), TABLES.USER),
41
+ organizationId: recordIdToString(
42
+ ensureRecordId(record.out as RecordIdInput, TABLES.ORGANIZATION),
43
+ TABLES.ORGANIZATION,
44
+ ),
45
+ role: record.role,
46
+ createdAt: toIsoDateTimeString(record.createdAt),
47
+ })
48
+ }
49
+
50
+ async addMembership(params: {
51
+ userId: RecordIdInput
52
+ organizationId: RecordIdInput
53
+ role: 'owner' | 'member'
54
+ }): Promise<SdkOrganizationMember> {
55
+ const userRef = ensureRecordId(params.userId, TABLES.USER)
56
+ const organizationRef = ensureRecordId(params.organizationId, TABLES.ORGANIZATION)
57
+ const existing = (await this.findAll({ in: userRef, out: organizationRef }, { limit: 1 })).at(0)
58
+
59
+ if (existing) {
60
+ return this.toPublic(await this.update(existing.id, { role: params.role }))
61
+ }
62
+
63
+ const related = await this.databaseService.relate(userRef, TABLES.ORGANIZATION_MEMBER, organizationRef, {
64
+ role: params.role,
65
+ })
66
+ if (!related) {
67
+ throw new Error(
68
+ `Failed to create membership for ${recordIdToString(userRef, TABLES.USER)} in ${recordIdToString(
69
+ organizationRef,
70
+ TABLES.ORGANIZATION,
71
+ )}`,
72
+ )
73
+ }
74
+
75
+ const record = organizationMemberRecordSchema.parse(related)
76
+
77
+ return this.toPublic(record)
78
+ }
79
+
80
+ async listMembershipsForOrganization(organizationId: RecordIdRef): Promise<SdkOrganizationMember[]> {
81
+ const organizationRef = ensureRecordId(organizationId, TABLES.ORGANIZATION)
82
+ return (await this.findAll({ out: organizationRef }, { orderBy: 'createdAt', orderDir: 'ASC' })).map((record) =>
83
+ this.toPublic(record),
84
+ )
85
+ }
86
+
87
+ async listMembershipsForUser(userId: RecordIdRef): Promise<SdkOrganizationMember[]> {
88
+ const userRef = ensureRecordId(userId, TABLES.USER)
89
+ return (await this.findAll({ in: userRef }, { orderBy: 'createdAt', orderDir: 'ASC' })).map((record) =>
90
+ this.toPublic(record),
91
+ )
92
+ }
93
+
94
+ async isMember(userId: RecordIdInput, organizationId: RecordIdInput): Promise<boolean> {
95
+ const userRef = ensureRecordId(userId, TABLES.USER)
96
+ const organizationRef = ensureRecordId(organizationId, TABLES.ORGANIZATION)
97
+ const existing = await this.findAll({ in: userRef, out: organizationRef }, { limit: 1 })
98
+ return existing.length > 0
99
+ }
100
+
101
+ async removeMembership(params: { userId: RecordIdInput; organizationId: RecordIdInput }): Promise<void> {
102
+ const userRef = ensureRecordId(params.userId, TABLES.USER)
103
+ const organizationRef = ensureRecordId(params.organizationId, TABLES.ORGANIZATION)
104
+ const existing = await this.findAll({ in: userRef, out: organizationRef }, { limit: 1 })
105
+ const record = existing.at(0)
106
+ if (!record) {
107
+ return
108
+ }
109
+
110
+ await this.delete(record.id)
111
+ }
112
+ }
113
+
114
+ export const organizationMemberService = new OrganizationMemberService()
@@ -0,0 +1,117 @@
1
+ import { recordIdStringSchema } from '@lota-sdk/shared/schemas/common'
2
+ import { z } from 'zod'
3
+
4
+ import { BaseService } from '../db/base.service'
5
+ import { ensureRecordId, recordIdToString } from '../db/record-id'
6
+ import type { RecordIdInput, RecordIdRef } from '../db/record-id'
7
+ import { databaseService } from '../db/service'
8
+ import { TABLES } from '../db/tables'
9
+ import { toIsoDateTimeString, toOptionalIsoDateTimeString } from '../utils/date-time'
10
+
11
+ const organizationRecordSchema = z.object({
12
+ id: z.unknown(),
13
+ name: z.string(),
14
+ regularChatDigestLastWorkstreamCursorCreatedAt: z.union([z.date(), z.string(), z.number()]).optional(),
15
+ regularChatDigestLastWorkstreamCursorId: z.string().optional(),
16
+ skillExtractionLastCursorId: z.string().optional(),
17
+ skillExtractionLastCursorCreatedAt: z.union([z.date(), z.string(), z.number()]).optional(),
18
+ createdAt: z.coerce.date(),
19
+ updatedAt: z.coerce.date(),
20
+ })
21
+
22
+ const sdkOrganizationSchema = z.object({
23
+ id: recordIdStringSchema,
24
+ name: z.string(),
25
+ regularChatDigestLastWorkstreamCursorCreatedAt: z.iso.datetime().nullable().optional(),
26
+ regularChatDigestLastWorkstreamCursorId: z.string().nullable().optional(),
27
+ skillExtractionLastCursorId: z.string().nullable().optional(),
28
+ skillExtractionLastCursorCreatedAt: z.iso.datetime().nullable().optional(),
29
+ createdAt: z.iso.datetime(),
30
+ updatedAt: z.iso.datetime(),
31
+ })
32
+
33
+ export type SdkOrganizationRecord = z.infer<typeof organizationRecordSchema>
34
+ export type SdkOrganization = z.infer<typeof sdkOrganizationSchema>
35
+
36
+ interface BackgroundCursor {
37
+ createdAt: Date
38
+ id: string
39
+ }
40
+
41
+ function toOptionalCursorTimestamp(value: unknown): string | null {
42
+ return toOptionalIsoDateTimeString(value) ?? null
43
+ }
44
+
45
+ class OrganizationService extends BaseService<typeof organizationRecordSchema> {
46
+ constructor() {
47
+ super(TABLES.ORGANIZATION, organizationRecordSchema)
48
+ }
49
+
50
+ toPublic(record: SdkOrganizationRecord): SdkOrganization {
51
+ return sdkOrganizationSchema.parse({
52
+ id: recordIdToString(ensureRecordId(record.id as RecordIdInput, TABLES.ORGANIZATION), TABLES.ORGANIZATION),
53
+ name: record.name,
54
+ regularChatDigestLastWorkstreamCursorCreatedAt: toOptionalCursorTimestamp(
55
+ record.regularChatDigestLastWorkstreamCursorCreatedAt,
56
+ ),
57
+ regularChatDigestLastWorkstreamCursorId: record.regularChatDigestLastWorkstreamCursorId ?? null,
58
+ skillExtractionLastCursorId: record.skillExtractionLastCursorId ?? null,
59
+ skillExtractionLastCursorCreatedAt: toOptionalCursorTimestamp(record.skillExtractionLastCursorCreatedAt),
60
+ createdAt: toIsoDateTimeString(record.createdAt),
61
+ updatedAt: toIsoDateTimeString(record.updatedAt),
62
+ })
63
+ }
64
+
65
+ async createOrganization(params: { name: string }): Promise<SdkOrganization> {
66
+ return this.toPublic(await this.create({ name: params.name }))
67
+ }
68
+
69
+ async upsertOrganization(params: { id: RecordIdInput; name: string }): Promise<SdkOrganization> {
70
+ const organizationRef = ensureRecordId(params.id, TABLES.ORGANIZATION)
71
+ const record = await databaseService.upsert(
72
+ TABLES.ORGANIZATION,
73
+ organizationRef,
74
+ { name: params.name },
75
+ organizationRecordSchema,
76
+ )
77
+ return this.toPublic(record)
78
+ }
79
+
80
+ async listOrganizations(): Promise<SdkOrganization[]> {
81
+ return (await this.findAll({}, { orderBy: 'createdAt', orderDir: 'ASC' })).map((record) => this.toPublic(record))
82
+ }
83
+
84
+ async getOrganization(organizationId: RecordIdInput): Promise<SdkOrganization> {
85
+ return this.toPublic(await this.getById(ensureRecordId(organizationId, TABLES.ORGANIZATION)))
86
+ }
87
+
88
+ async getOrganizationRecord(organizationId: RecordIdRef): Promise<SdkOrganizationRecord> {
89
+ return await this.getById(ensureRecordId(organizationId, TABLES.ORGANIZATION))
90
+ }
91
+
92
+ async updateOrganization(organizationId: RecordIdInput, params: { name: string }): Promise<SdkOrganization> {
93
+ return this.toPublic(await this.update(ensureRecordId(organizationId, TABLES.ORGANIZATION), { name: params.name }))
94
+ }
95
+
96
+ async deleteOrganization(organizationId: RecordIdInput): Promise<void> {
97
+ const organizationRef = ensureRecordId(organizationId, TABLES.ORGANIZATION)
98
+ await databaseService.deleteWhere(TABLES.ORGANIZATION_MEMBER, { out: organizationRef })
99
+ await this.delete(organizationRef)
100
+ }
101
+
102
+ async updateRegularChatDigestCursor(organizationId: RecordIdRef, cursor: BackgroundCursor): Promise<void> {
103
+ await this.update(organizationId, {
104
+ regularChatDigestLastWorkstreamCursorCreatedAt: cursor.createdAt,
105
+ regularChatDigestLastWorkstreamCursorId: cursor.id,
106
+ })
107
+ }
108
+
109
+ async updateSkillExtractionCursor(organizationId: RecordIdRef, cursor: BackgroundCursor): Promise<void> {
110
+ await this.update(organizationId, {
111
+ skillExtractionLastCursorCreatedAt: cursor.createdAt,
112
+ skillExtractionLastCursorId: cursor.id,
113
+ })
114
+ }
115
+ }
116
+
117
+ export const organizationService = new OrganizationService()
@@ -0,0 +1,56 @@
1
+ import type { SdkUser, SdkUserRecord } from '@lota-sdk/shared/schemas/user'
2
+ import { sdkUserRecordSchema, sdkUserSchema } from '@lota-sdk/shared/schemas/user'
3
+
4
+ import { BaseService } from '../db/base.service'
5
+ import { ensureRecordId, recordIdToString } from '../db/record-id'
6
+ import type { RecordIdInput } from '../db/record-id'
7
+ import { databaseService } from '../db/service'
8
+ import { TABLES } from '../db/tables'
9
+ import { toIsoDateTimeString } from '../utils/date-time'
10
+
11
+ class UserService extends BaseService<typeof sdkUserRecordSchema> {
12
+ constructor() {
13
+ super(TABLES.USER, sdkUserRecordSchema)
14
+ }
15
+
16
+ toPublic(record: SdkUserRecord): SdkUser {
17
+ return sdkUserSchema.parse({
18
+ id: recordIdToString(ensureRecordId(record.id as RecordIdInput, TABLES.USER), TABLES.USER),
19
+ name: record.name,
20
+ email: record.email,
21
+ createdAt: toIsoDateTimeString(record.createdAt),
22
+ updatedAt: toIsoDateTimeString(record.updatedAt),
23
+ })
24
+ }
25
+
26
+ async upsertUser(params: { id: RecordIdInput; name: string; email: string }): Promise<SdkUser> {
27
+ const userRef = ensureRecordId(params.id, TABLES.USER)
28
+ const record = await databaseService.upsert(
29
+ TABLES.USER,
30
+ userRef,
31
+ { name: params.name, email: params.email },
32
+ sdkUserRecordSchema,
33
+ )
34
+ return this.toPublic(record)
35
+ }
36
+
37
+ async getUser(userId: RecordIdInput): Promise<SdkUser> {
38
+ return this.toPublic(await this.getById(ensureRecordId(userId, TABLES.USER)))
39
+ }
40
+
41
+ async listUsers(): Promise<SdkUser[]> {
42
+ return (await this.findAll({}, { orderBy: 'createdAt', orderDir: 'ASC' })).map((record) => this.toPublic(record))
43
+ }
44
+
45
+ async updateUser(userId: RecordIdInput, params: { name?: string; email?: string }): Promise<SdkUser> {
46
+ return this.toPublic(await this.update(ensureRecordId(userId, TABLES.USER), params))
47
+ }
48
+
49
+ async deleteUser(userId: RecordIdInput): Promise<void> {
50
+ const userRef = ensureRecordId(userId, TABLES.USER)
51
+ await databaseService.deleteWhere(TABLES.ORGANIZATION_MEMBER, { in: userRef })
52
+ await this.delete(userRef)
53
+ }
54
+ }
55
+
56
+ export const userService = new UserService()
@@ -2,56 +2,46 @@ import { WORKSTREAM } from '@lota-sdk/shared/constants/workstream'
2
2
 
3
3
  import { chatLogger } from '../config/logger'
4
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'
5
+ import { createHelperModelRuntime } from '../runtime/helper-model'
9
6
  import { deriveTitle, limitTitleWords } from '../runtime/title-helpers'
7
+ import {
8
+ createWorkstreamTitleGeneratorAgent,
9
+ WORKSTREAM_TITLE_GENERATOR_PROMPT,
10
+ } from '../system-agents/title-generator.agent'
11
+ import { compactWhitespace } from '../utils/string'
10
12
  import { workstreamService } from './workstream.service'
11
13
 
12
- const titlePromises = new Map<string, Promise<string>>()
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
+ }
13
20
 
14
21
  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
- }
22
+ helperRuntime = createHelperModelRuntime()
35
23
 
36
- private async generateAndPersistTitle(workstreamId: RecordIdRef, sourceText: string): Promise<string> {
24
+ async generateAndPersistTitle(workstreamId: RecordIdRef, sourceText: string): Promise<void> {
37
25
  let title = ''
38
26
  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
- })
27
+ title = normalizeTitle(
28
+ await this.helperRuntime.generateHelperText({
29
+ tag: 'workstream-title',
30
+ createAgent: createWorkstreamTitleGeneratorAgent,
31
+ defaultSystemPrompt: WORKSTREAM_TITLE_GENERATOR_PROMPT,
32
+ timeoutMs: 30_000,
33
+ messages: [{ role: 'user', content: sourceText }],
34
+ }),
35
+ )
44
36
  } catch (error) {
45
- chatLogger.warn`Failed to generate workstream title (non-fatal): ${error}`
46
- title = ''
37
+ chatLogger.warn`Failed to generate workstream title via LLM (non-fatal): ${error}`
47
38
  }
48
39
 
49
40
  if (!title) {
50
41
  title = limitTitleWords(deriveTitle(sourceText || WORKSTREAM.DEFAULT_TITLE))
51
42
  }
52
43
 
53
- await workstreamService.updateTitle(workstreamId, title)
54
- return title
44
+ await workstreamService.persistGeneratedTitle(workstreamId, title)
55
45
  }
56
46
  }
57
47
 
@@ -28,6 +28,7 @@ import { enqueuePostChatMemory } from '../queues/post-chat-memory.queue'
28
28
  import { enqueueRecentActivityTitleRefinement } from '../queues/recent-activity-title-refinement.queue'
29
29
  import { enqueueRegularChatMemoryDigest } from '../queues/regular-chat-memory-digest.queue'
30
30
  import { enqueueSkillExtraction } from '../queues/skill-extraction.queue'
31
+ import { enqueueWorkstreamTitleGeneration } from '../queues/workstream-title-generation.queue'
31
32
  import { buildAgentPromptContext } from '../runtime/agent-prompt-context'
32
33
  import {
33
34
  buildSpecialistTaskMessage,
@@ -445,6 +446,20 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
445
446
  ? [...liveHistory].reverse().find((m) => m.role === 'user')
446
447
  : (userMessage ?? [...liveHistory].reverse().find((m) => m.role === 'user'))
447
448
  const messageText = referenceUserMessage ? extractMessageText(referenceUserMessage).trim() : ''
449
+
450
+ if (
451
+ params.kind === 'userTurn' &&
452
+ workstream.mode === 'group' &&
453
+ !workstream.core &&
454
+ workstreamRecord.nameGenerated !== true &&
455
+ messageText.length > 0
456
+ ) {
457
+ void safeEnqueue(
458
+ () => enqueueWorkstreamTitleGeneration({ workstreamId: workstreamIdString, sourceText: messageText }),
459
+ { operationName: 'workstream-title-generation' },
460
+ )
461
+ }
462
+
448
463
  const onboardingActive = workspaceLifecycleState?.bootstrapActive ?? false
449
464
  if (workstream.core && !workstream.coreType) {
450
465
  throw new WorkstreamTurnError('Core workstreams require a core type.', 400)
@@ -356,7 +356,7 @@ class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
356
356
  .createWithId(
357
357
  TABLES.WORKSTREAM,
358
358
  directWorkstreamId,
359
- { userId, organizationId: orgId, mode, core: false, agentId, title, status: 'regular' },
359
+ { userId, organizationId: orgId, mode, core: false, agentId, title, status: 'regular', nameGenerated: true },
360
360
  WorkstreamSchema,
361
361
  )
362
362
  .catch((error) => {
@@ -402,6 +402,7 @@ class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
402
402
  agentId: coreProfile.config.agentId,
403
403
  title,
404
404
  status: 'regular',
405
+ nameGenerated: true,
405
406
  },
406
407
  WorkstreamSchema,
407
408
  )
@@ -781,6 +782,7 @@ class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
781
782
  mode,
782
783
  core,
783
784
  ...(coreType ? { coreType } : {}),
785
+ nameGenerated: workstream.nameGenerated === true,
784
786
  isRunning: activeRunId !== null,
785
787
  isCompacting,
786
788
  ...(isAgentName(workstream.agentId) ? { agentId: workstream.agentId } : {}),
@@ -807,6 +809,7 @@ class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
807
809
  const mode = typeof workstream.mode === 'string' ? workstream.mode : 'group'
808
810
  const core = workstream.core === true
809
811
  const coreType = core && typeof workstream.coreType === 'string' ? workstream.coreType : undefined
812
+ const nameGenerated = 'nameGenerated' in workstream ? workstream.nameGenerated === true : false
810
813
  return {
811
814
  id,
812
815
  mode,
@@ -815,6 +818,7 @@ class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
815
818
  ...(isAgentName(workstream.agentId) ? { agentId: workstream.agentId } : {}),
816
819
  title: workstream.title ?? this.getDefaultTitle(workstream),
817
820
  status: workstream.status ?? 'regular',
821
+ nameGenerated,
818
822
  isRunning,
819
823
  isCompacting,
820
824
  createdAt,
@@ -838,6 +842,10 @@ class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
838
842
  return { ...publicWorkstream, workstreamState }
839
843
  }
840
844
 
845
+ async persistGeneratedTitle(workstreamId: RecordIdRef, title: string): Promise<void> {
846
+ await this.update(workstreamId, { title, nameGenerated: true })
847
+ }
848
+
841
849
  private assertMutableWorkstream(
842
850
  workstream: WorkstreamRecord,
843
851
  action: 'rename' | 'archive' | 'unarchive' | 'delete',
@@ -22,6 +22,7 @@ export interface NormalizedWorkstream {
22
22
  mode: 'direct' | 'group'
23
23
  core: boolean
24
24
  coreType?: string
25
+ nameGenerated: boolean
25
26
  isRunning: boolean
26
27
  isCompacting: boolean
27
28
  agentId?: string | null
@@ -107,6 +108,7 @@ export const WorkstreamSchema = z.object({
107
108
  activeRunId: z.string().nullish(),
108
109
  chatSummary: z.string().nullish(),
109
110
  lastCompactedMessageId: z.string().nullish(),
111
+ nameGenerated: z.boolean().optional().default(false),
110
112
  isCompacting: z.boolean().optional(),
111
113
  state: z.unknown().optional(),
112
114
  createdAt: z.coerce.date(),
@@ -8,9 +8,9 @@ import {
8
8
  import type { CreateHelperToolLoopAgentOptions } from '../runtime/agent-types'
9
9
  import { resolveHelperAgentOptions } from './helper-agent-options'
10
10
 
11
- const TITLE_MAX_TOKENS = 500
11
+ const WORKSTREAM_TITLE_MAX_TOKENS = 64
12
12
 
13
- const WORKSTREAM_TITLE_GENERATOR_PROMPT = `<agent-instructions>
13
+ export const WORKSTREAM_TITLE_GENERATOR_PROMPT = `<agent-instructions>
14
14
  You are a **Title Generator** that creates concise chat titles.
15
15
 
16
16
  <task>
@@ -29,14 +29,14 @@ Return only the title text. No quotes, no labels, no explanation.
29
29
  </output-format>
30
30
  </agent-instructions>`
31
31
 
32
- export function createTitleGeneratorAgent(options: CreateHelperToolLoopAgentOptions) {
32
+ export function createWorkstreamTitleGeneratorAgent(options: CreateHelperToolLoopAgentOptions) {
33
33
  return new ToolLoopAgent({
34
- id: 'title-generator',
34
+ id: 'workstream-title-generator',
35
35
  model: bifrostModel(OPENROUTER_FAST_REASONING_MODEL_ID),
36
36
  providerOptions: OPENROUTER_MINIMAL_REASONING_PROVIDER_OPTIONS,
37
37
  ...resolveHelperAgentOptions(options, {
38
38
  instructions: WORKSTREAM_TITLE_GENERATOR_PROMPT,
39
- maxOutputTokens: TITLE_MAX_TOKENS,
39
+ maxOutputTokens: WORKSTREAM_TITLE_MAX_TOKENS,
40
40
  }),
41
41
  })
42
42
  }
@@ -1,8 +1,31 @@
1
1
  import { configureLogger, serverLogger } from '../config/logger'
2
- import { databaseService } from '../db/service'
2
+ import { LOTA_SDK_DATABASE_NAME } from '../db/sdk-database'
3
+ import { SurrealDBService, databaseService, setDatabaseService } from '../db/service'
3
4
  import { connectWithStartupRetry, waitForDatabaseBootstrap } from '../db/startup'
4
5
  import { getConfiguredPluginDatabaseConnector } from '../runtime/runtime-extensions'
5
6
 
7
+ /**
8
+ * Sandboxed BullMQ workers run in separate child processes where createLotaRuntime
9
+ * was never called, so the global databaseService proxy has no backing instance.
10
+ * Create one from env vars so the proxy resolves.
11
+ */
12
+ function getRequiredEnv(key: string): string {
13
+ const value = process.env[key]
14
+ if (!value) throw new Error(`Missing required env var: ${key}`)
15
+ return value
16
+ }
17
+
18
+ function ensureDatabaseServiceConfigured(): void {
19
+ const db = new SurrealDBService({
20
+ url: getRequiredEnv('SURREALDB_URL'),
21
+ namespace: getRequiredEnv('SURREALDB_NAMESPACE'),
22
+ database: LOTA_SDK_DATABASE_NAME,
23
+ username: process.env.SURREALDB_USER,
24
+ password: process.env.SURREALDB_PASSWORD,
25
+ })
26
+ setDatabaseService(db)
27
+ }
28
+
6
29
  let sandboxedWorkerRuntimePromise: Promise<void> | null = null
7
30
 
8
31
  export async function initializeSandboxedWorkerRuntime(): Promise<void> {
@@ -14,6 +37,8 @@ export async function initializeSandboxedWorkerRuntime(): Promise<void> {
14
37
  sandboxedWorkerRuntimePromise = (async () => {
15
38
  await configureLogger()
16
39
 
40
+ ensureDatabaseServiceConfigured()
41
+
17
42
  await connectWithStartupRetry({
18
43
  connect: () => databaseService.connect(),
19
44
  label: 'sandboxed worker AI database runtime',
@@ -276,13 +276,6 @@ async function cleanupOrphanedRelations(): Promise<number> {
276
276
  return result[0]?.count ?? 0
277
277
  }
278
278
 
279
- async function pruneOldOrgActions(): Promise<number> {
280
- const result = await databaseService.query<{ count: number }>(
281
- new BoundQuery(`DELETE ${TABLES.ORG_ACTION} WHERE createdAt < time::now() - 90d RETURN count() AS count`),
282
- )
283
- return result[0]?.count ?? 0
284
- }
285
-
286
279
  const handler = async (job: SandboxedJob<MemoryConsolidationJob>) => {
287
280
  const targetScope = job.data.scopeId
288
281
 
@@ -305,9 +298,8 @@ const handler = async (job: SandboxedJob<MemoryConsolidationJob>) => {
305
298
  const collapsed = await collapseSupersedeCh()
306
299
  const decayed = await decayImportance()
307
300
  const orphaned = await cleanupOrphanedRelations()
308
- const prunedActions = await pruneOldOrgActions()
309
301
 
310
- serverLogger.info`Memory consolidation complete (merged: ${totalMerged}, pruned: ${pruned}, collapsed: ${collapsed}, decayed: ${decayed}, orphaned relations: ${orphaned}, pruned actions: ${prunedActions})`
302
+ serverLogger.info`Memory consolidation complete (merged: ${totalMerged}, pruned: ${pruned}, collapsed: ${collapsed}, decayed: ${decayed}, orphaned relations: ${orphaned})`
311
303
  } catch (error) {
312
304
  const serialized = toSandboxedWorkerError(error, 'Memory consolidation job failed')
313
305
  serverLogger.error`${serialized.message}`