@lota-sdk/core 0.1.5 → 0.1.7

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 (36) hide show
  1. package/infrastructure/schema/00_identity.surql +26 -0
  2. package/infrastructure/schema/00_workstream.surql +8 -0
  3. package/infrastructure/schema/05_recent_activity.surql +48 -0
  4. package/package.json +4 -3
  5. package/src/ai/embedding-cache.ts +48 -0
  6. package/src/config/background-processing.ts +33 -0
  7. package/src/config/env-shapes.ts +0 -1
  8. package/src/config/model-constants.ts +4 -0
  9. package/src/db/memory-store.ts +110 -19
  10. package/src/db/memory-types.ts +11 -0
  11. package/src/db/memory.ts +11 -1
  12. package/src/db/schema-fingerprint.ts +21 -0
  13. package/src/db/sdk-database.ts +1 -0
  14. package/src/db/service.ts +0 -4
  15. package/src/db/tables.ts +1 -1
  16. package/src/index.ts +207 -10
  17. package/src/queues/memory-consolidation.queue.ts +6 -0
  18. package/src/queues/workstream-title-generation.queue.ts +69 -0
  19. package/src/runtime/agent-types.ts +5 -22
  20. package/src/runtime/helper-model.ts +9 -2
  21. package/src/runtime/memory-digest-policy.ts +30 -2
  22. package/src/runtime/skill-extraction-policy.ts +9 -2
  23. package/src/services/memory.service.ts +35 -0
  24. package/src/services/organization-member.service.ts +114 -0
  25. package/src/services/organization.service.ts +117 -0
  26. package/src/services/user.service.ts +56 -0
  27. package/src/services/workstream-title.service.ts +25 -35
  28. package/src/services/workstream-turn-preparation.ts +37 -10
  29. package/src/services/workstream-turn.ts +2 -0
  30. package/src/services/workstream.service.ts +61 -1
  31. package/src/services/workstream.types.ts +3 -0
  32. package/src/system-agents/title-generator.agent.ts +5 -5
  33. package/src/tools/research-topic.tool.ts +5 -1
  34. package/src/utils/sse-keepalive.ts +40 -0
  35. package/src/workers/bootstrap.ts +26 -1
  36. package/src/workers/memory-consolidation.worker.ts +1 -9
@@ -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
 
@@ -24,10 +24,12 @@ import type { RecordIdRef } from '../db/record-id'
24
24
  import { recordIdToString } from '../db/record-id'
25
25
  import { TABLES } from '../db/tables'
26
26
  import { enqueueContextCompaction } from '../queues/context-compaction.queue'
27
+ import { enqueueMemoryConsolidation } from '../queues/memory-consolidation.queue'
27
28
  import { enqueuePostChatMemory } from '../queues/post-chat-memory.queue'
28
29
  import { enqueueRecentActivityTitleRefinement } from '../queues/recent-activity-title-refinement.queue'
29
30
  import { enqueueRegularChatMemoryDigest } from '../queues/regular-chat-memory-digest.queue'
30
31
  import { enqueueSkillExtraction } from '../queues/skill-extraction.queue'
32
+ import { enqueueWorkstreamTitleGeneration } from '../queues/workstream-title-generation.queue'
31
33
  import { buildAgentPromptContext } from '../runtime/agent-prompt-context'
32
34
  import {
33
35
  buildSpecialistTaskMessage,
@@ -43,6 +45,8 @@ import { CONTEXT_SIZE } from '../runtime/context-compaction-constants'
43
45
  import { createExecutionPlanInstructionSectionCache } from '../runtime/execution-plan'
44
46
  import { mergeInstructionSections } from '../runtime/instruction-sections'
45
47
  import {
48
+ shouldEnqueueMemoryConsolidation,
49
+ shouldEnqueueMemoryExtraction,
46
50
  shouldEnqueueOnboardingPostChatMemory,
47
51
  shouldEnqueueRegularDigestForWorkstream,
48
52
  } from '../runtime/memory-digest-policy'
@@ -445,6 +449,20 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
445
449
  ? [...liveHistory].reverse().find((m) => m.role === 'user')
446
450
  : (userMessage ?? [...liveHistory].reverse().find((m) => m.role === 'user'))
447
451
  const messageText = referenceUserMessage ? extractMessageText(referenceUserMessage).trim() : ''
452
+
453
+ if (
454
+ params.kind === 'userTurn' &&
455
+ workstream.mode === 'group' &&
456
+ !workstream.core &&
457
+ workstreamRecord.nameGenerated !== true &&
458
+ messageText.length > 0
459
+ ) {
460
+ void safeEnqueue(
461
+ () => enqueueWorkstreamTitleGeneration({ workstreamId: workstreamIdString, sourceText: messageText }),
462
+ { operationName: 'workstream-title-generation' },
463
+ )
464
+ }
465
+
448
466
  const onboardingActive = workspaceLifecycleState?.bootstrapActive ?? false
449
467
  if (workstream.core && !workstream.coreType) {
450
468
  throw new WorkstreamTurnError('Core workstreams require a core type.', 400)
@@ -1196,6 +1214,7 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
1196
1214
  }
1197
1215
 
1198
1216
  if (allAssistantMessages.length > 0 && shouldProcessPostRunSideEffects) {
1217
+ const turnCount = await workstreamService.incrementTurnCount(workstreamRef)
1199
1218
  const agentMessages = buildAgentHistoryMessages(allAssistantMessages)
1200
1219
  const historyMessagesForMemory = appendCompactionContextToHistoryMessages(
1201
1220
  toHistoryMessages(recentHistory),
@@ -1206,14 +1225,16 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
1206
1225
  const readableUploads = listReadableUploads()
1207
1226
  const attachmentMetadataContext = buildReadableUploadMetadataContext(readableUploads)
1208
1227
  const hasAttachmentContext = Boolean(attachmentMetadataContext)
1209
- if (
1210
- shouldEnqueueOnboardingPostChatMemory({
1211
- onboardingActive,
1212
- userMessageText,
1213
- hasAttachmentContext,
1214
- agentMessageCount: agentMessages.length,
1215
- })
1216
- ) {
1228
+ const shouldExtractMemory = onboardingActive
1229
+ ? shouldEnqueueOnboardingPostChatMemory({
1230
+ onboardingActive,
1231
+ userMessageText,
1232
+ hasAttachmentContext,
1233
+ agentMessageCount: agentMessages.length,
1234
+ })
1235
+ : shouldEnqueueMemoryExtraction({ onboardingActive, turnCount }) && userMessageText.length > 0
1236
+
1237
+ if (shouldExtractMemory) {
1217
1238
  const memoryUserMessage = userMessageText || 'User uploaded attachment(s).'
1218
1239
  await safeEnqueue(
1219
1240
  () =>
@@ -1300,17 +1321,23 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
1300
1321
  }
1301
1322
  }
1302
1323
 
1303
- if (shouldEnqueueRegularDigestForWorkstream({ onboardingActive })) {
1324
+ if (shouldEnqueueRegularDigestForWorkstream({ onboardingActive, turnCount })) {
1304
1325
  await safeEnqueue(() => enqueueRegularChatMemoryDigest({ orgId: orgIdString }), {
1305
1326
  operationName: 'regular chat memory digest enqueue',
1306
1327
  })
1307
1328
  }
1308
1329
 
1309
- if (shouldEnqueueSkillExtraction({ onboardingActive })) {
1330
+ if (shouldEnqueueSkillExtraction({ onboardingActive, turnCount })) {
1310
1331
  await safeEnqueue(() => enqueueSkillExtraction({ orgId: orgIdString }), {
1311
1332
  operationName: 'skill extraction enqueue',
1312
1333
  })
1313
1334
  }
1335
+
1336
+ if (shouldEnqueueMemoryConsolidation({ onboardingActive, turnCount })) {
1337
+ await safeEnqueue(() => enqueueMemoryConsolidation({ scopeId: orgIdString }), {
1338
+ operationName: 'memory consolidation enqueue',
1339
+ })
1340
+ }
1314
1341
  }
1315
1342
 
1316
1343
  if (allAssistantMessages.length > 0) {
@@ -2,10 +2,12 @@ import type { ChatMessage } from '@lota-sdk/shared/schemas/chat-message'
2
2
  import { createUIMessageStream } from 'ai'
3
3
 
4
4
  import { hasApprovalRespondedParts, isApprovalContinuationRequest } from '../runtime/approval-continuation'
5
+ import { wrapResponseWithKeepalive } from '../utils/sse-keepalive'
5
6
  import { prepareWorkstreamRunCore } from './workstream-turn-preparation'
6
7
  import type { WorkstreamTurnParams, WorkstreamApprovalContinuationParams } from './workstream-turn-preparation'
7
8
 
8
9
  export { hasApprovalRespondedParts, isApprovalContinuationRequest }
10
+ export { wrapResponseWithKeepalive }
9
11
 
10
12
  export async function createWorkstreamApprovalContinuationStream(params: WorkstreamApprovalContinuationParams) {
11
13
  const prepared = await prepareWorkstreamRunCore({ ...params, kind: 'approvalContinuation' })
@@ -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
  )
@@ -567,6 +568,48 @@ class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
567
568
  return { workstreams: sliced.map((workstream) => this.normalizeWorkstream(workstream)), hasMore }
568
569
  }
569
570
 
571
+ async listOrganizationWorkstreams(params: {
572
+ orgId: RecordIdRef
573
+ mode?: 'direct' | 'group'
574
+ agentId?: string
575
+ core?: boolean
576
+ includeArchived?: boolean
577
+ }): Promise<NormalizedWorkstream[]> {
578
+ const whereClauses = ['organizationId = $orgId']
579
+ const variables: Record<string, unknown> = { orgId: params.orgId }
580
+
581
+ if (params.mode) {
582
+ whereClauses.push('mode = $mode')
583
+ variables.mode = params.mode
584
+ }
585
+
586
+ if (typeof params.core === 'boolean') {
587
+ whereClauses.push('core = $core')
588
+ variables.core = params.core
589
+ }
590
+
591
+ if (params.agentId) {
592
+ whereClauses.push('agentId = $agentId')
593
+ variables.agentId = params.agentId
594
+ }
595
+
596
+ if (params.includeArchived !== true) {
597
+ whereClauses.push('status = "regular"')
598
+ }
599
+
600
+ const workstreams = await databaseService.queryMany<typeof WorkstreamSchema>(
601
+ new BoundQuery(
602
+ `SELECT * FROM ${TABLES.WORKSTREAM}
603
+ WHERE ${whereClauses.join('\n AND ')}
604
+ ORDER BY createdAt ASC, id ASC`,
605
+ variables,
606
+ ),
607
+ WorkstreamSchema,
608
+ )
609
+
610
+ return workstreams.map((workstream) => this.normalizeWorkstream(workstream))
611
+ }
612
+
570
613
  async getWorkstream(workstreamId: RecordIdRef): Promise<NormalizedWorkstream> {
571
614
  const workstream = await this.getById(workstreamId)
572
615
  return this.normalizeWorkstream(workstream)
@@ -781,6 +824,7 @@ class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
781
824
  mode,
782
825
  core,
783
826
  ...(coreType ? { coreType } : {}),
827
+ nameGenerated: workstream.nameGenerated === true,
784
828
  isRunning: activeRunId !== null,
785
829
  isCompacting,
786
830
  ...(isAgentName(workstream.agentId) ? { agentId: workstream.agentId } : {}),
@@ -807,6 +851,7 @@ class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
807
851
  const mode = typeof workstream.mode === 'string' ? workstream.mode : 'group'
808
852
  const core = workstream.core === true
809
853
  const coreType = core && typeof workstream.coreType === 'string' ? workstream.coreType : undefined
854
+ const nameGenerated = 'nameGenerated' in workstream ? workstream.nameGenerated === true : false
810
855
  return {
811
856
  id,
812
857
  mode,
@@ -815,6 +860,7 @@ class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
815
860
  ...(isAgentName(workstream.agentId) ? { agentId: workstream.agentId } : {}),
816
861
  title: workstream.title ?? this.getDefaultTitle(workstream),
817
862
  status: workstream.status ?? 'regular',
863
+ nameGenerated,
818
864
  isRunning,
819
865
  isCompacting,
820
866
  createdAt,
@@ -838,6 +884,20 @@ class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
838
884
  return { ...publicWorkstream, workstreamState }
839
885
  }
840
886
 
887
+ async incrementTurnCount(workstreamId: RecordIdRef): Promise<number> {
888
+ const workstreamRef = ensureRecordId(workstreamId, TABLES.WORKSTREAM)
889
+ const result = await databaseService.query<{ turnCount: number }>(surql`
890
+ UPDATE ONLY ${workstreamRef}
891
+ SET turnCount += 1
892
+ RETURN turnCount
893
+ `)
894
+ return result[0]?.turnCount ?? 0
895
+ }
896
+
897
+ async persistGeneratedTitle(workstreamId: RecordIdRef, title: string): Promise<void> {
898
+ await this.update(workstreamId, { title, nameGenerated: true })
899
+ }
900
+
841
901
  private assertMutableWorkstream(
842
902
  workstream: WorkstreamRecord,
843
903
  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,8 +108,10 @@ 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(),
114
+ turnCount: z.number().int().optional().default(0),
112
115
  createdAt: z.coerce.date(),
113
116
  updatedAt: z.coerce.date(),
114
117
  userId: z.any(), // RecordId
@@ -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,5 +1,8 @@
1
1
  import { bifrostChatModel } from '../bifrost/bifrost'
2
- import { OPENROUTER_WEB_RESEARCH_MODEL_ID } from '../config/model-constants'
2
+ import {
3
+ OPENROUTER_MEDIUM_REASONING_PROVIDER_OPTIONS,
4
+ OPENROUTER_WEB_RESEARCH_MODEL_ID,
5
+ } from '../config/model-constants'
3
6
  import { createDelegatedAgentTool } from '../system-agents/delegated-agent-factory'
4
7
  import { RESEARCHER_PROMPT } from '../system-agents/researcher.agent'
5
8
  import { fetchWebpageTool } from './fetch-webpage.tool'
@@ -10,6 +13,7 @@ export const researchTopicTool = createDelegatedAgentTool({
10
13
  description:
11
14
  'Delegate a research task to a dedicated research agent that searches the web, fetches pages, and returns a synthesized markdown report. Call multiple instances in parallel for broad research across different topics.',
12
15
  model: bifrostChatModel(OPENROUTER_WEB_RESEARCH_MODEL_ID),
16
+ providerOptions: OPENROUTER_MEDIUM_REASONING_PROVIDER_OPTIONS,
13
17
  instructions: RESEARCHER_PROMPT,
14
18
  tools: { searchWeb: searchWebTool.create(), fetchWebpage: fetchWebpageTool.create() },
15
19
  })