@lota-sdk/core 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/infrastructure/schema/00_workstream.surql +55 -0
  2. package/infrastructure/schema/01_memory.surql +47 -0
  3. package/infrastructure/schema/02_execution_plan.surql +62 -0
  4. package/infrastructure/schema/03_learned_skill.surql +32 -0
  5. package/infrastructure/schema/04_runtime_bootstrap.surql +8 -0
  6. package/package.json +128 -0
  7. package/src/ai/definitions.ts +308 -0
  8. package/src/bifrost/bifrost.ts +256 -0
  9. package/src/config/agent-defaults.ts +99 -0
  10. package/src/config/constants.ts +33 -0
  11. package/src/config/env-shapes.ts +122 -0
  12. package/src/config/logger.ts +29 -0
  13. package/src/config/model-constants.ts +31 -0
  14. package/src/config/search.ts +17 -0
  15. package/src/config/workstream-defaults.ts +68 -0
  16. package/src/db/base.service.ts +55 -0
  17. package/src/db/cursor-pagination.ts +73 -0
  18. package/src/db/memory-query-builder.ts +207 -0
  19. package/src/db/memory-store.helpers.ts +118 -0
  20. package/src/db/memory-store.rows.ts +29 -0
  21. package/src/db/memory-store.ts +974 -0
  22. package/src/db/memory-types.ts +193 -0
  23. package/src/db/memory.ts +505 -0
  24. package/src/db/record-id.ts +78 -0
  25. package/src/db/service.ts +932 -0
  26. package/src/db/startup.ts +152 -0
  27. package/src/db/tables.ts +20 -0
  28. package/src/document/org-document-chunking.ts +224 -0
  29. package/src/document/parsing.ts +40 -0
  30. package/src/embeddings/provider.ts +76 -0
  31. package/src/index.ts +302 -0
  32. package/src/queues/context-compaction.queue.ts +82 -0
  33. package/src/queues/document-processor.queue.ts +118 -0
  34. package/src/queues/memory-consolidation.queue.ts +65 -0
  35. package/src/queues/post-chat-memory.queue.ts +128 -0
  36. package/src/queues/recent-activity-title-refinement.queue.ts +69 -0
  37. package/src/queues/regular-chat-memory-digest.config.ts +12 -0
  38. package/src/queues/regular-chat-memory-digest.queue.ts +73 -0
  39. package/src/queues/skill-extraction.config.ts +9 -0
  40. package/src/queues/skill-extraction.queue.ts +62 -0
  41. package/src/redis/connection.ts +176 -0
  42. package/src/redis/index.ts +30 -0
  43. package/src/redis/org-memory-lock.ts +43 -0
  44. package/src/redis/redis-lease-lock.ts +158 -0
  45. package/src/runtime/agent-contract.ts +1 -0
  46. package/src/runtime/agent-prompt-context.ts +119 -0
  47. package/src/runtime/agent-runtime-policy.ts +192 -0
  48. package/src/runtime/agent-stream-helpers.ts +117 -0
  49. package/src/runtime/agent-types.ts +22 -0
  50. package/src/runtime/approval-continuation.ts +16 -0
  51. package/src/runtime/chat-attachments.ts +46 -0
  52. package/src/runtime/chat-message.ts +10 -0
  53. package/src/runtime/chat-request-routing.ts +21 -0
  54. package/src/runtime/chat-run-orchestration.ts +25 -0
  55. package/src/runtime/chat-run-registry.ts +20 -0
  56. package/src/runtime/chat-types.ts +18 -0
  57. package/src/runtime/context-compaction-constants.ts +11 -0
  58. package/src/runtime/context-compaction-runtime.ts +86 -0
  59. package/src/runtime/context-compaction.ts +909 -0
  60. package/src/runtime/execution-plan.ts +59 -0
  61. package/src/runtime/helper-model.ts +405 -0
  62. package/src/runtime/indexed-repositories-policy.ts +28 -0
  63. package/src/runtime/instruction-sections.ts +8 -0
  64. package/src/runtime/llm-content.ts +71 -0
  65. package/src/runtime/memory-block.ts +264 -0
  66. package/src/runtime/memory-digest-policy.ts +14 -0
  67. package/src/runtime/memory-format.ts +8 -0
  68. package/src/runtime/memory-pipeline.ts +570 -0
  69. package/src/runtime/memory-prompts-fact.ts +47 -0
  70. package/src/runtime/memory-prompts-parse.ts +3 -0
  71. package/src/runtime/memory-prompts-update.ts +37 -0
  72. package/src/runtime/memory-scope.ts +43 -0
  73. package/src/runtime/plugin-types.ts +10 -0
  74. package/src/runtime/retrieval-adapters.ts +25 -0
  75. package/src/runtime/retrieval-pipeline.ts +3 -0
  76. package/src/runtime/runtime-extensions.ts +154 -0
  77. package/src/runtime/skill-extraction-policy.ts +3 -0
  78. package/src/runtime/team-consultation-orchestrator.ts +245 -0
  79. package/src/runtime/team-consultation-prompts.ts +32 -0
  80. package/src/runtime/title-helpers.ts +12 -0
  81. package/src/runtime/turn-lifecycle.ts +28 -0
  82. package/src/runtime/workstream-chat-helpers.ts +187 -0
  83. package/src/runtime/workstream-routing-policy.ts +301 -0
  84. package/src/runtime/workstream-state.ts +261 -0
  85. package/src/services/attachment.service.ts +159 -0
  86. package/src/services/chat-attachments.service.ts +17 -0
  87. package/src/services/chat-run-registry.service.ts +3 -0
  88. package/src/services/context-compaction-runtime.ts +13 -0
  89. package/src/services/context-compaction.service.ts +115 -0
  90. package/src/services/document-chunk.service.ts +141 -0
  91. package/src/services/execution-plan.service.ts +890 -0
  92. package/src/services/learned-skill.service.ts +328 -0
  93. package/src/services/memory-assessment.service.ts +43 -0
  94. package/src/services/memory.service.ts +807 -0
  95. package/src/services/memory.utils.ts +84 -0
  96. package/src/services/mutating-approval.service.ts +110 -0
  97. package/src/services/recent-activity-title.service.ts +74 -0
  98. package/src/services/recent-activity.service.ts +397 -0
  99. package/src/services/workstream-change-tracker.service.ts +313 -0
  100. package/src/services/workstream-message.service.ts +283 -0
  101. package/src/services/workstream-title.service.ts +58 -0
  102. package/src/services/workstream-turn-preparation.ts +1340 -0
  103. package/src/services/workstream-turn.ts +37 -0
  104. package/src/services/workstream.service.ts +854 -0
  105. package/src/services/workstream.types.ts +118 -0
  106. package/src/storage/attachment-parser.ts +101 -0
  107. package/src/storage/attachment-storage.service.ts +391 -0
  108. package/src/storage/attachments.types.ts +11 -0
  109. package/src/storage/attachments.utils.ts +58 -0
  110. package/src/storage/generated-document-storage.service.ts +55 -0
  111. package/src/system-agents/agent-result.ts +27 -0
  112. package/src/system-agents/context-compacter.agent.ts +46 -0
  113. package/src/system-agents/delegated-agent-factory.ts +177 -0
  114. package/src/system-agents/helper-agent-options.ts +20 -0
  115. package/src/system-agents/memory-reranker.agent.ts +38 -0
  116. package/src/system-agents/memory.agent.ts +58 -0
  117. package/src/system-agents/recent-activity-title-refiner.agent.ts +53 -0
  118. package/src/system-agents/regular-chat-memory-digest.agent.ts +75 -0
  119. package/src/system-agents/researcher.agent.ts +34 -0
  120. package/src/system-agents/skill-extractor.agent.ts +88 -0
  121. package/src/system-agents/skill-manager.agent.ts +80 -0
  122. package/src/system-agents/title-generator.agent.ts +42 -0
  123. package/src/system-agents/workstream-tracker.agent.ts +58 -0
  124. package/src/tools/execution-plan.tool.ts +163 -0
  125. package/src/tools/fetch-webpage.tool.ts +132 -0
  126. package/src/tools/firecrawl-client.ts +12 -0
  127. package/src/tools/memory-block.tool.ts +55 -0
  128. package/src/tools/read-file-parts.tool.ts +80 -0
  129. package/src/tools/remember-memory.tool.ts +85 -0
  130. package/src/tools/research-topic.tool.ts +15 -0
  131. package/src/tools/search-tools.ts +55 -0
  132. package/src/tools/search-web.tool.ts +175 -0
  133. package/src/tools/team-think.tool.ts +125 -0
  134. package/src/tools/tool-contract.ts +21 -0
  135. package/src/tools/user-questions.tool.ts +18 -0
  136. package/src/utils/async.ts +50 -0
  137. package/src/utils/date-time.ts +34 -0
  138. package/src/utils/error.ts +10 -0
  139. package/src/utils/errors.ts +28 -0
  140. package/src/utils/hono-error-handler.ts +71 -0
  141. package/src/utils/string.ts +51 -0
  142. package/src/workers/bootstrap.ts +44 -0
  143. package/src/workers/memory-consolidation.worker.ts +318 -0
  144. package/src/workers/regular-chat-memory-digest.helpers.ts +100 -0
  145. package/src/workers/regular-chat-memory-digest.runner.ts +363 -0
  146. package/src/workers/regular-chat-memory-digest.worker.ts +22 -0
  147. package/src/workers/skill-extraction.runner.ts +331 -0
  148. package/src/workers/skill-extraction.worker.ts +22 -0
  149. package/src/workers/utils/repo-indexer-chunker.ts +331 -0
  150. package/src/workers/utils/repo-structure-extractor.ts +645 -0
  151. package/src/workers/utils/repomix-process-concurrency.ts +65 -0
  152. package/src/workers/utils/sandbox-error.ts +5 -0
  153. package/src/workers/worker-utils.ts +182 -0
@@ -0,0 +1,118 @@
1
+ import { z } from 'zod'
2
+
3
+ import type { WorkstreamState } from '../runtime/workstream-state'
4
+
5
+ export interface Citation {
6
+ title?: string
7
+ url?: string
8
+ snippet?: string
9
+ source?: string
10
+ sourceId?: string
11
+ retrievedAt?: string
12
+ }
13
+
14
+ const WorkstreamModeSchema = z.enum(['direct', 'group'])
15
+ export const WorkstreamStatusSchema = z.enum(['regular', 'archived'])
16
+ const CoreWorkstreamTypeSchema = z.string()
17
+
18
+ export interface NormalizedWorkstream {
19
+ id: string
20
+ title: string
21
+ status: 'regular' | 'archived'
22
+ mode: 'direct' | 'group'
23
+ core: boolean
24
+ coreType?: string
25
+ isRunning: boolean
26
+ isCompacting: boolean
27
+ agentId?: string | null
28
+ memoryBlock: string
29
+ createdAt: string
30
+ updatedAt: string
31
+ userId: string
32
+ organizationId: string
33
+ }
34
+
35
+ export interface PublicWorkstreamStateFocus {
36
+ kind: 'plan' | 'task' | 'question' | 'decision' | 'agent-note' | 'chat-summary'
37
+ text: string
38
+ }
39
+
40
+ export interface PublicWorkstreamTaskProgress {
41
+ total: number
42
+ open: number
43
+ inProgress: number
44
+ done: number
45
+ blocked: number
46
+ }
47
+
48
+ export interface PublicWorkstreamConstraintProgress {
49
+ total: number
50
+ approved: number
51
+ candidate: number
52
+ }
53
+
54
+ export interface PublicWorkstreamStateProgress {
55
+ hasState: boolean
56
+ lastUpdated: string | null
57
+ completionRatio: number | null
58
+ tasks: PublicWorkstreamTaskProgress
59
+ constraints: PublicWorkstreamConstraintProgress
60
+ keyDecisions: number
61
+ openQuestions: number
62
+ risks: number
63
+ artifacts: number
64
+ agentContributions: number
65
+ }
66
+
67
+ export interface PublicWorkstreamApprovalState {
68
+ approvedBy: string | null
69
+ approvedAt: string | null
70
+ approvalMessageId: string | null
71
+ approvalNote: string | null
72
+ }
73
+
74
+ export interface PublicWorkstreamStatePayload {
75
+ focus: PublicWorkstreamStateFocus | null
76
+ chatSummary: string | null
77
+ approval: PublicWorkstreamApprovalState | null
78
+ progress: PublicWorkstreamStateProgress
79
+ snapshot: WorkstreamState | null
80
+ }
81
+
82
+ export interface PublicWorkstreamDetail {
83
+ id: string
84
+ title: string
85
+ status: 'regular' | 'archived'
86
+ mode: 'direct' | 'group'
87
+ core: boolean
88
+ coreType?: string
89
+ isRunning: boolean
90
+ isCompacting: boolean
91
+ agentId?: string | null
92
+ createdAt: string
93
+ updatedAt: string
94
+ workstreamState: PublicWorkstreamStatePayload
95
+ }
96
+
97
+ export const WorkstreamSchema = z.object({
98
+ id: z.any(), // RecordId
99
+ mode: WorkstreamModeSchema.optional().default('group'),
100
+ core: z.boolean().optional().default(false),
101
+ coreType: CoreWorkstreamTypeSchema.nullish(),
102
+ agentId: z.string().nullish(),
103
+ title: z.string().nullish(),
104
+ status: WorkstreamStatusSchema.nullish(),
105
+ memoryBlock: z.string().nullish(),
106
+ memoryBlockSummary: z.string().nullish(),
107
+ activeRunId: z.string().nullish(),
108
+ chatSummary: z.string().nullish(),
109
+ lastCompactedMessageId: z.string().nullish(),
110
+ isCompacting: z.boolean().optional(),
111
+ state: z.unknown().optional(),
112
+ createdAt: z.coerce.date(),
113
+ updatedAt: z.coerce.date(),
114
+ userId: z.any(), // RecordId
115
+ organizationId: z.any(), // RecordId
116
+ })
117
+
118
+ export type WorkstreamRecord = z.infer<typeof WorkstreamSchema>
@@ -0,0 +1,101 @@
1
+ import { SUPPORTED_ATTACHMENT_MIME_TYPES, getAttachmentExtension } from '@lota-sdk/shared/constants/attachments'
2
+ import mammoth from 'mammoth'
3
+ import { PDFParse } from 'pdf-parse'
4
+
5
+ const READ_FILE_PARTS_CHARS_PER_PAGE = 3500
6
+
7
+ export function normalizeExtractedText(value: string): string {
8
+ return value.replaceAll('\u0000', '').replace(/\r\n/g, '\n').trim()
9
+ }
10
+
11
+ export function isPdfAttachmentFile(file: File): boolean {
12
+ const lowerName = file.name.toLowerCase()
13
+ const lowerType = file.type.toLowerCase()
14
+ return lowerType === 'application/pdf' || lowerName.endsWith('.pdf')
15
+ }
16
+
17
+ export function isDocxAttachmentFile(file: File): boolean {
18
+ const lowerName = file.name.toLowerCase()
19
+ const lowerType = file.type.toLowerCase()
20
+ return (
21
+ lowerType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
22
+ lowerName.endsWith('.docx')
23
+ )
24
+ }
25
+
26
+ export function isTextAttachmentFile(file: File): boolean {
27
+ if (SUPPORTED_ATTACHMENT_MIME_TYPES.has(file.type.toLowerCase())) return true
28
+ const ext = getAttachmentExtension(file.name)
29
+ return ext === 'txt' || ext === 'md' || ext === 'markdown'
30
+ }
31
+
32
+ export async function extractPdfPages(file: File): Promise<string[]> {
33
+ const parser = new PDFParse({ data: new Uint8Array(await file.arrayBuffer()) })
34
+ try {
35
+ const info = await parser.getInfo()
36
+ const textResult = await parser.getText()
37
+
38
+ const pageTextByNumber = new Map<number, string>()
39
+ for (const page of textResult.pages) {
40
+ pageTextByNumber.set(page.num, normalizeExtractedText(page.text))
41
+ }
42
+
43
+ const pages: string[] = []
44
+ for (let pageNumber = 1; pageNumber <= info.total; pageNumber += 1) {
45
+ pages.push(pageTextByNumber.get(pageNumber) ?? '')
46
+ }
47
+ return pages
48
+ } finally {
49
+ await parser.destroy()
50
+ }
51
+ }
52
+
53
+ export async function extractDocxText(file: File): Promise<string> {
54
+ const buffer = Buffer.from(await file.arrayBuffer())
55
+ const result = await mammoth.extractRawText({ buffer })
56
+ return normalizeExtractedText(result.value)
57
+ }
58
+
59
+ export async function extractAttachmentText(file: File): Promise<string> {
60
+ if (isTextAttachmentFile(file)) {
61
+ return normalizeExtractedText(await file.text())
62
+ }
63
+ if (isPdfAttachmentFile(file)) {
64
+ return normalizeExtractedText((await extractPdfPages(file)).join('\n\n'))
65
+ }
66
+ if (isDocxAttachmentFile(file)) {
67
+ return await extractDocxText(file)
68
+ }
69
+ return ''
70
+ }
71
+
72
+ export function splitExtractedTextIntoPages(extractedText: string): string[] {
73
+ const normalized = extractedText.replace(/\r\n/g, '\n').trim()
74
+ if (!normalized) return []
75
+
76
+ const explicitPages = normalized
77
+ .split(/\f+/)
78
+ .map((value) => value.trim())
79
+ .filter((value) => value.length > 0)
80
+ if (explicitPages.length > 1) return explicitPages
81
+
82
+ const pages: string[] = []
83
+ let cursor = 0
84
+ while (cursor < normalized.length) {
85
+ let end = Math.min(cursor + READ_FILE_PARTS_CHARS_PER_PAGE, normalized.length)
86
+ if (end < normalized.length) {
87
+ const breakAt = normalized.lastIndexOf('\n', end)
88
+ if (breakAt > cursor + Math.floor(READ_FILE_PARTS_CHARS_PER_PAGE * 0.6)) {
89
+ end = breakAt
90
+ }
91
+ }
92
+
93
+ const pageText = normalized.slice(cursor, end).trim()
94
+ if (pageText) pages.push(pageText)
95
+
96
+ cursor = end
97
+ while (cursor < normalized.length && normalized[cursor] === '\n') cursor += 1
98
+ }
99
+
100
+ return pages
101
+ }
@@ -0,0 +1,391 @@
1
+ import { inferContentType } from '@lota-sdk/shared/constants/attachments'
2
+ import { S3Client } from 'bun'
3
+
4
+ import { env } from '../config/env-shapes'
5
+ import { serverLogger } from '../config/logger'
6
+ import { readString } from '../utils/string'
7
+ import {
8
+ extractAttachmentText,
9
+ extractPdfPages,
10
+ isPdfAttachmentFile,
11
+ splitExtractedTextIntoPages,
12
+ } from './attachment-parser'
13
+ import type {
14
+ MessagePartLike,
15
+ ReadableUploadMetadata,
16
+ ReadableUploadPageMode,
17
+ ReadableUploadPagePart,
18
+ } from './attachments.types'
19
+ import {
20
+ buildOrganizationDocumentStorageKey,
21
+ buildUploadStorageKey,
22
+ buildUploadStoragePrefix,
23
+ readNonNegativeInteger,
24
+ readRecord,
25
+ } from './attachments.utils'
26
+
27
+ const READ_FILE_PARTS_PAGES_PER_PART = 25
28
+
29
+ export type UploadedWorkstreamAttachment = {
30
+ filename: string
31
+ mediaType: string
32
+ sizeBytes: number
33
+ storageKey: string
34
+ url: string
35
+ expiresInSeconds: number
36
+ }
37
+
38
+ type AttachmentContextCandidate = { storageKey: string; name: string; contentType: string; sizeBytes: number | null }
39
+
40
+ export class AttachmentStorageService {
41
+ private readonly client: S3Client
42
+
43
+ constructor() {
44
+ this.client = new S3Client({
45
+ accessKeyId: env.S3_ACCESS_KEY_ID,
46
+ secretAccessKey: env.S3_SECRET_ACCESS_KEY,
47
+ bucket: env.S3_BUCKET,
48
+ endpoint: env.S3_ENDPOINT,
49
+ region: env.S3_REGION,
50
+ })
51
+ }
52
+
53
+ getAttachmentUrl(storageKey: string): string {
54
+ return this.client.file(storageKey).presign({ expiresIn: env.ATTACHMENT_URL_EXPIRES_IN })
55
+ }
56
+
57
+ async writeOrganizationDocument({
58
+ orgId,
59
+ namespace,
60
+ relativePath,
61
+ content,
62
+ contentType,
63
+ }: {
64
+ orgId: string
65
+ namespace: string
66
+ relativePath: string
67
+ content: string
68
+ contentType: string
69
+ }): Promise<{ storageKey: string; sizeBytes: number }> {
70
+ const storageKey = buildOrganizationDocumentStorageKey({ orgId, namespace, relativePath })
71
+ const sizeBytes = Buffer.byteLength(content, 'utf8')
72
+
73
+ await this.client.file(storageKey).write(new Blob([content]), { type: contentType })
74
+
75
+ return { storageKey, sizeBytes }
76
+ }
77
+
78
+ async uploadOrganizationDocument({
79
+ file,
80
+ orgId,
81
+ namespace,
82
+ relativePath,
83
+ }: {
84
+ file: File
85
+ orgId: string
86
+ namespace: string
87
+ relativePath: string
88
+ }): Promise<UploadedWorkstreamAttachment> {
89
+ const filename = file.name || 'document'
90
+ const mediaType = file.type || inferContentType(filename)
91
+ const storageKey = buildOrganizationDocumentStorageKey({
92
+ orgId,
93
+ namespace,
94
+ relativePath: relativePath.trim().length > 0 ? relativePath : filename,
95
+ })
96
+
97
+ await this.client.file(storageKey).write(file, { type: mediaType })
98
+
99
+ return {
100
+ filename,
101
+ mediaType,
102
+ sizeBytes: file.size,
103
+ storageKey,
104
+ url: this.getAttachmentUrl(storageKey),
105
+ expiresInSeconds: env.ATTACHMENT_URL_EXPIRES_IN,
106
+ }
107
+ }
108
+
109
+ async uploadWorkstreamAttachment({
110
+ file,
111
+ orgId,
112
+ userId,
113
+ }: {
114
+ file: File
115
+ orgId: string
116
+ userId: string
117
+ }): Promise<UploadedWorkstreamAttachment> {
118
+ const filename = file.name || 'attachment'
119
+ const mediaType = file.type || inferContentType(filename)
120
+ const sizeBytes = file.size
121
+ const storageKey = buildUploadStorageKey({ orgId, userId, filename, uploadId: Bun.randomUUIDv7() })
122
+
123
+ await this.client.file(storageKey).write(file, { type: mediaType })
124
+
125
+ return {
126
+ filename,
127
+ mediaType,
128
+ sizeBytes,
129
+ storageKey,
130
+ url: this.getAttachmentUrl(storageKey),
131
+ expiresInSeconds: env.ATTACHMENT_URL_EXPIRES_IN,
132
+ }
133
+ }
134
+
135
+ hydrateSignedFileUrlsInMessageParts({
136
+ parts,
137
+ orgId,
138
+ userId,
139
+ }: {
140
+ parts: readonly MessagePartLike[]
141
+ orgId: string
142
+ userId: string
143
+ }): MessagePartLike[] {
144
+ const storagePrefix = buildUploadStoragePrefix({ orgId, userId })
145
+
146
+ return parts.map((part) => {
147
+ if (part.type !== 'file') return part
148
+
149
+ const providerMetadata = readRecord(part.providerMetadata)
150
+ const lotaMetadata = readRecord(providerMetadata?.lota)
151
+ const storageKey = lotaMetadata?.attachmentStorageKey
152
+ if (typeof storageKey !== 'string' || !storageKey.startsWith(storagePrefix)) return part
153
+
154
+ return { ...part, url: this.getAttachmentUrl(storageKey) }
155
+ })
156
+ }
157
+
158
+ listReadableUploadsFromMessages({
159
+ messages,
160
+ orgId,
161
+ userId,
162
+ }: {
163
+ messages: ReadonlyArray<{ parts: readonly MessagePartLike[] }>
164
+ orgId: string
165
+ userId: string
166
+ }): ReadableUploadMetadata[] {
167
+ const uploadsByStorageKey = new Map<
168
+ string,
169
+ { storageKey: string; filename: string; mediaType: string; sizeBytes: number | null }
170
+ >()
171
+
172
+ for (const message of messages) {
173
+ const candidates = this.collectAttachmentContextCandidates({ parts: message.parts, orgId, userId })
174
+ for (const candidate of candidates) {
175
+ if (uploadsByStorageKey.has(candidate.storageKey)) continue
176
+ uploadsByStorageKey.set(candidate.storageKey, {
177
+ storageKey: candidate.storageKey,
178
+ filename: candidate.name,
179
+ mediaType: candidate.contentType,
180
+ sizeBytes: candidate.sizeBytes,
181
+ })
182
+ }
183
+ }
184
+
185
+ return [...uploadsByStorageKey.values()].map((upload) => ({
186
+ storageKey: upload.storageKey,
187
+ filename: upload.filename,
188
+ mediaType: upload.mediaType,
189
+ sizeBytes: upload.sizeBytes,
190
+ }))
191
+ }
192
+
193
+ async extractStoredAttachmentText({
194
+ storageKey,
195
+ name,
196
+ contentType,
197
+ }: {
198
+ storageKey: string
199
+ name: string
200
+ contentType: string
201
+ }): Promise<string> {
202
+ try {
203
+ const s3File = this.client.file(storageKey)
204
+ const fileLike = new File([await s3File.arrayBuffer()], name, { type: contentType })
205
+ return await extractAttachmentText(fileLike)
206
+ } catch (error) {
207
+ serverLogger.warn`Failed to extract stored attachment text: ${error}`
208
+ return ''
209
+ }
210
+ }
211
+
212
+ async extractStoredAttachmentPages({
213
+ storageKey,
214
+ name,
215
+ contentType,
216
+ }: {
217
+ storageKey: string
218
+ name: string
219
+ contentType: string
220
+ }): Promise<{ pageMode: ReadableUploadPageMode; pages: string[] }> {
221
+ try {
222
+ const s3File = this.client.file(storageKey)
223
+ const fileLike = new File([await s3File.arrayBuffer()], name, { type: contentType })
224
+
225
+ if (isPdfAttachmentFile(fileLike)) {
226
+ const pages = await extractPdfPages(fileLike)
227
+ return { pageMode: 'pdf', pages }
228
+ }
229
+
230
+ const extractedText = await extractAttachmentText(fileLike)
231
+ return { pageMode: 'logical', pages: splitExtractedTextIntoPages(extractedText) }
232
+ } catch (error) {
233
+ serverLogger.warn`Failed to extract attachment pages from storage: ${error}`
234
+ return { pageMode: 'logical', pages: [] }
235
+ }
236
+ }
237
+
238
+ async readFilePartsFromUpload({
239
+ upload,
240
+ orgId,
241
+ userId,
242
+ part = 1,
243
+ pagesPerPart = READ_FILE_PARTS_PAGES_PER_PART,
244
+ }: {
245
+ upload: ReadableUploadMetadata
246
+ orgId: string
247
+ userId: string
248
+ part?: number
249
+ pagesPerPart?: number
250
+ }): Promise<{
251
+ pageMode: ReadableUploadPageMode
252
+ totalPages: number
253
+ fullPageLength: number
254
+ currentPart: number
255
+ totalParts: number
256
+ hasNextPart: boolean
257
+ hasPreviousPart: boolean
258
+ pagesPerPart: number
259
+ pageStart: number
260
+ pageEnd: number
261
+ data: ReadableUploadPagePart[]
262
+ }> {
263
+ this.assertUploadOwnership({ storageKey: upload.storageKey, orgId, userId })
264
+
265
+ const parsed = await this.extractStoredAttachmentPages({
266
+ storageKey: upload.storageKey,
267
+ name: upload.filename,
268
+ contentType: upload.mediaType,
269
+ })
270
+ const totalPages = parsed.pages.length
271
+ if (totalPages === 0) {
272
+ return {
273
+ pageMode: parsed.pageMode,
274
+ totalPages: 0,
275
+ fullPageLength: 0,
276
+ currentPart: 1,
277
+ totalParts: 0,
278
+ hasNextPart: false,
279
+ hasPreviousPart: false,
280
+ pagesPerPart,
281
+ pageStart: 1,
282
+ pageEnd: 0,
283
+ data: [],
284
+ }
285
+ }
286
+
287
+ if (!Number.isInteger(pagesPerPart) || pagesPerPart <= 0) {
288
+ throw new Error('pagesPerPart must be a positive integer.')
289
+ }
290
+ if (!Number.isInteger(part) || part <= 0) {
291
+ throw new Error('part must be an integer greater than or equal to 1.')
292
+ }
293
+
294
+ const totalParts = Math.ceil(totalPages / pagesPerPart)
295
+ if (part > totalParts) {
296
+ throw new Error(`part ${part} exceeds total parts ${totalParts}.`)
297
+ }
298
+
299
+ const pageStart = (part - 1) * pagesPerPart + 1
300
+ const pageEnd = Math.min(pageStart + pagesPerPart - 1, totalPages)
301
+
302
+ const data = parsed.pages
303
+ .slice(pageStart - 1, pageEnd)
304
+ .map((text, index) => ({ pageNumber: pageStart + index, text, charCount: text.length }))
305
+
306
+ return {
307
+ pageMode: parsed.pageMode,
308
+ totalPages,
309
+ fullPageLength: totalPages,
310
+ currentPart: part,
311
+ totalParts,
312
+ hasNextPart: part < totalParts,
313
+ hasPreviousPart: part > 1,
314
+ pagesPerPart,
315
+ pageStart,
316
+ pageEnd,
317
+ data,
318
+ }
319
+ }
320
+
321
+ private assertUploadOwnership({
322
+ storageKey,
323
+ orgId,
324
+ userId,
325
+ }: {
326
+ storageKey: string
327
+ orgId: string
328
+ userId: string
329
+ }): void {
330
+ const storagePrefix = buildUploadStoragePrefix({ orgId, userId })
331
+ if (!storageKey.startsWith(storagePrefix)) {
332
+ throw new Error('Upload does not belong to the current organization/user scope.')
333
+ }
334
+ }
335
+
336
+ private collectAttachmentContextCandidates({
337
+ parts,
338
+ orgId,
339
+ userId,
340
+ }: {
341
+ parts: readonly MessagePartLike[]
342
+ orgId: string
343
+ userId: string
344
+ }): AttachmentContextCandidate[] {
345
+ const storagePrefix = buildUploadStoragePrefix({ orgId, userId })
346
+ const candidates: AttachmentContextCandidate[] = []
347
+
348
+ for (const part of parts) {
349
+ if (part.type !== 'file') continue
350
+
351
+ const providerMetadata = readRecord(part.providerMetadata)
352
+ const lotaMetadata = readRecord(providerMetadata?.lota)
353
+ const storageKey = readString(lotaMetadata?.attachmentStorageKey)
354
+ if (!storageKey || !storageKey.startsWith(storagePrefix)) continue
355
+
356
+ const name = readString(part.filename) ?? this.readFilenameFromStorageKey(storageKey) ?? 'Attachment'
357
+ const contentType = readString(part.mediaType) ?? inferContentType(name)
358
+ const sizeBytes = readNonNegativeInteger(lotaMetadata?.attachmentSizeBytes)
359
+ candidates.push({ storageKey, name, contentType, sizeBytes })
360
+ }
361
+
362
+ return candidates
363
+ }
364
+
365
+ private readFilenameFromStorageKey(storageKey: string): string | null {
366
+ const slashIndex = storageKey.lastIndexOf('/')
367
+ if (slashIndex < 0) return null
368
+ const rawName = storageKey.slice(slashIndex + 1).trim()
369
+ if (!rawName) return null
370
+
371
+ const dashIndex = rawName.indexOf('-')
372
+ if (dashIndex <= 0 || dashIndex === rawName.length - 1) return rawName
373
+ return rawName.slice(dashIndex + 1)
374
+ }
375
+ }
376
+
377
+ let _attachmentStorageService: AttachmentStorageService | undefined
378
+
379
+ /** @lintignore */
380
+ export function getAttachmentStorageService(): AttachmentStorageService {
381
+ if (!_attachmentStorageService) {
382
+ _attachmentStorageService = new AttachmentStorageService()
383
+ }
384
+ return _attachmentStorageService
385
+ }
386
+
387
+ export const attachmentStorageService = new Proxy({} as AttachmentStorageService, {
388
+ get(_target, prop: string) {
389
+ return (getAttachmentStorageService() as unknown as Record<string, unknown>)[prop]
390
+ },
391
+ })
@@ -0,0 +1,11 @@
1
+ export type ReadableUploadMetadata = {
2
+ storageKey: string
3
+ filename: string
4
+ mediaType: string
5
+ sizeBytes: number | null
6
+ }
7
+
8
+ export type ReadableUploadPagePart = { pageNumber: number; text: string; charCount: number }
9
+ export type ReadableUploadPageMode = 'pdf' | 'logical'
10
+
11
+ export type { MessagePartLike } from '../runtime/chat-types'
@@ -0,0 +1,58 @@
1
+ function sanitizeFilename(name: string): string {
2
+ const ascii = name.replace(/[^\x20-\x7E]/g, '')
3
+ return ascii.replace(/[\\/:"*?<>|]+/g, '-').trim()
4
+ }
5
+
6
+ function toSafeSegment(value: string): string {
7
+ const cleaned = value.replace(/[^a-zA-Z0-9._-]/g, '-')
8
+ return cleaned.length > 0 ? cleaned : 'unknown'
9
+ }
10
+
11
+ function sanitizeRelativePath(path: string): string {
12
+ return path
13
+ .split('/')
14
+ .map((segment) => sanitizeFilename(segment))
15
+ .filter((segment) => segment.length > 0)
16
+ .join('/')
17
+ }
18
+
19
+ export function buildUploadStoragePrefix(params: { orgId: string; userId: string }): string {
20
+ const safeOrgId = toSafeSegment(params.orgId)
21
+ const safeUserId = toSafeSegment(params.userId)
22
+ return `${safeOrgId}/${safeUserId}/uploads/`
23
+ }
24
+
25
+ export function buildUploadStorageKey(params: {
26
+ orgId: string
27
+ userId: string
28
+ filename: string
29
+ uploadId: string
30
+ }): string {
31
+ const safeUploadId = toSafeSegment(params.uploadId)
32
+ const safeName = sanitizeFilename(params.filename) || 'attachment'
33
+ return `${buildUploadStoragePrefix({ orgId: params.orgId, userId: params.userId })}${safeUploadId}-${safeName}`
34
+ }
35
+
36
+ function buildOrganizationDocumentStoragePrefix(params: { orgId: string; namespace: string }): string {
37
+ const safeOrgId = toSafeSegment(params.orgId)
38
+ const safeNamespace = toSafeSegment(params.namespace)
39
+ return `${safeOrgId}/organization-documents/${safeNamespace}/`
40
+ }
41
+
42
+ export function buildOrganizationDocumentStorageKey(params: {
43
+ orgId: string
44
+ namespace: string
45
+ relativePath: string
46
+ }): string {
47
+ const relativePath = sanitizeRelativePath(params.relativePath)
48
+ return `${buildOrganizationDocumentStoragePrefix({ orgId: params.orgId, namespace: params.namespace })}${relativePath || 'document.txt'}`
49
+ }
50
+
51
+ export function readRecord(value: unknown): Record<string, unknown> | null {
52
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null
53
+ return value as Record<string, unknown>
54
+ }
55
+
56
+ export function readNonNegativeInteger(value: unknown): number | null {
57
+ return Number.isInteger(value) && typeof value === 'number' && value >= 0 ? value : null
58
+ }