@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.
- package/infrastructure/schema/00_identity.surql +26 -0
- package/infrastructure/schema/00_workstream.surql +8 -0
- package/infrastructure/schema/05_recent_activity.surql +48 -0
- package/package.json +4 -3
- package/src/ai/embedding-cache.ts +48 -0
- package/src/config/background-processing.ts +33 -0
- package/src/config/env-shapes.ts +0 -1
- package/src/config/model-constants.ts +4 -0
- package/src/db/memory-store.ts +110 -19
- package/src/db/memory-types.ts +11 -0
- package/src/db/memory.ts +11 -1
- package/src/db/schema-fingerprint.ts +21 -0
- package/src/db/sdk-database.ts +1 -0
- package/src/db/service.ts +0 -4
- package/src/db/tables.ts +1 -1
- package/src/index.ts +207 -10
- package/src/queues/memory-consolidation.queue.ts +6 -0
- package/src/queues/workstream-title-generation.queue.ts +69 -0
- package/src/runtime/agent-types.ts +5 -22
- package/src/runtime/helper-model.ts +9 -2
- package/src/runtime/memory-digest-policy.ts +30 -2
- package/src/runtime/skill-extraction-policy.ts +9 -2
- package/src/services/memory.service.ts +35 -0
- package/src/services/organization-member.service.ts +114 -0
- package/src/services/organization.service.ts +117 -0
- package/src/services/user.service.ts +56 -0
- package/src/services/workstream-title.service.ts +25 -35
- package/src/services/workstream-turn-preparation.ts +37 -10
- package/src/services/workstream-turn.ts +2 -0
- package/src/services/workstream.service.ts +61 -1
- package/src/services/workstream.types.ts +3 -0
- package/src/system-agents/title-generator.agent.ts +5 -5
- package/src/tools/research-topic.tool.ts +5 -1
- package/src/utils/sse-keepalive.ts +40 -0
- package/src/workers/bootstrap.ts +26 -1
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
+
async generateAndPersistTitle(workstreamId: RecordIdRef, sourceText: string): Promise<void> {
|
|
37
25
|
let title = ''
|
|
38
26
|
try {
|
|
39
|
-
title =
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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.
|
|
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
|
-
|
|
1210
|
-
shouldEnqueueOnboardingPostChatMemory({
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
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
|
|
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
|
|
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:
|
|
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 {
|
|
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
|
})
|