@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.
- package/infrastructure/schema/00_workstream.surql +55 -0
- package/infrastructure/schema/01_memory.surql +47 -0
- package/infrastructure/schema/02_execution_plan.surql +62 -0
- package/infrastructure/schema/03_learned_skill.surql +32 -0
- package/infrastructure/schema/04_runtime_bootstrap.surql +8 -0
- package/package.json +128 -0
- package/src/ai/definitions.ts +308 -0
- package/src/bifrost/bifrost.ts +256 -0
- package/src/config/agent-defaults.ts +99 -0
- package/src/config/constants.ts +33 -0
- package/src/config/env-shapes.ts +122 -0
- package/src/config/logger.ts +29 -0
- package/src/config/model-constants.ts +31 -0
- package/src/config/search.ts +17 -0
- package/src/config/workstream-defaults.ts +68 -0
- package/src/db/base.service.ts +55 -0
- package/src/db/cursor-pagination.ts +73 -0
- package/src/db/memory-query-builder.ts +207 -0
- package/src/db/memory-store.helpers.ts +118 -0
- package/src/db/memory-store.rows.ts +29 -0
- package/src/db/memory-store.ts +974 -0
- package/src/db/memory-types.ts +193 -0
- package/src/db/memory.ts +505 -0
- package/src/db/record-id.ts +78 -0
- package/src/db/service.ts +932 -0
- package/src/db/startup.ts +152 -0
- package/src/db/tables.ts +20 -0
- package/src/document/org-document-chunking.ts +224 -0
- package/src/document/parsing.ts +40 -0
- package/src/embeddings/provider.ts +76 -0
- package/src/index.ts +302 -0
- package/src/queues/context-compaction.queue.ts +82 -0
- package/src/queues/document-processor.queue.ts +118 -0
- package/src/queues/memory-consolidation.queue.ts +65 -0
- package/src/queues/post-chat-memory.queue.ts +128 -0
- package/src/queues/recent-activity-title-refinement.queue.ts +69 -0
- package/src/queues/regular-chat-memory-digest.config.ts +12 -0
- package/src/queues/regular-chat-memory-digest.queue.ts +73 -0
- package/src/queues/skill-extraction.config.ts +9 -0
- package/src/queues/skill-extraction.queue.ts +62 -0
- package/src/redis/connection.ts +176 -0
- package/src/redis/index.ts +30 -0
- package/src/redis/org-memory-lock.ts +43 -0
- package/src/redis/redis-lease-lock.ts +158 -0
- package/src/runtime/agent-contract.ts +1 -0
- package/src/runtime/agent-prompt-context.ts +119 -0
- package/src/runtime/agent-runtime-policy.ts +192 -0
- package/src/runtime/agent-stream-helpers.ts +117 -0
- package/src/runtime/agent-types.ts +22 -0
- package/src/runtime/approval-continuation.ts +16 -0
- package/src/runtime/chat-attachments.ts +46 -0
- package/src/runtime/chat-message.ts +10 -0
- package/src/runtime/chat-request-routing.ts +21 -0
- package/src/runtime/chat-run-orchestration.ts +25 -0
- package/src/runtime/chat-run-registry.ts +20 -0
- package/src/runtime/chat-types.ts +18 -0
- package/src/runtime/context-compaction-constants.ts +11 -0
- package/src/runtime/context-compaction-runtime.ts +86 -0
- package/src/runtime/context-compaction.ts +909 -0
- package/src/runtime/execution-plan.ts +59 -0
- package/src/runtime/helper-model.ts +405 -0
- package/src/runtime/indexed-repositories-policy.ts +28 -0
- package/src/runtime/instruction-sections.ts +8 -0
- package/src/runtime/llm-content.ts +71 -0
- package/src/runtime/memory-block.ts +264 -0
- package/src/runtime/memory-digest-policy.ts +14 -0
- package/src/runtime/memory-format.ts +8 -0
- package/src/runtime/memory-pipeline.ts +570 -0
- package/src/runtime/memory-prompts-fact.ts +47 -0
- package/src/runtime/memory-prompts-parse.ts +3 -0
- package/src/runtime/memory-prompts-update.ts +37 -0
- package/src/runtime/memory-scope.ts +43 -0
- package/src/runtime/plugin-types.ts +10 -0
- package/src/runtime/retrieval-adapters.ts +25 -0
- package/src/runtime/retrieval-pipeline.ts +3 -0
- package/src/runtime/runtime-extensions.ts +154 -0
- package/src/runtime/skill-extraction-policy.ts +3 -0
- package/src/runtime/team-consultation-orchestrator.ts +245 -0
- package/src/runtime/team-consultation-prompts.ts +32 -0
- package/src/runtime/title-helpers.ts +12 -0
- package/src/runtime/turn-lifecycle.ts +28 -0
- package/src/runtime/workstream-chat-helpers.ts +187 -0
- package/src/runtime/workstream-routing-policy.ts +301 -0
- package/src/runtime/workstream-state.ts +261 -0
- package/src/services/attachment.service.ts +159 -0
- package/src/services/chat-attachments.service.ts +17 -0
- package/src/services/chat-run-registry.service.ts +3 -0
- package/src/services/context-compaction-runtime.ts +13 -0
- package/src/services/context-compaction.service.ts +115 -0
- package/src/services/document-chunk.service.ts +141 -0
- package/src/services/execution-plan.service.ts +890 -0
- package/src/services/learned-skill.service.ts +328 -0
- package/src/services/memory-assessment.service.ts +43 -0
- package/src/services/memory.service.ts +807 -0
- package/src/services/memory.utils.ts +84 -0
- package/src/services/mutating-approval.service.ts +110 -0
- package/src/services/recent-activity-title.service.ts +74 -0
- package/src/services/recent-activity.service.ts +397 -0
- package/src/services/workstream-change-tracker.service.ts +313 -0
- package/src/services/workstream-message.service.ts +283 -0
- package/src/services/workstream-title.service.ts +58 -0
- package/src/services/workstream-turn-preparation.ts +1340 -0
- package/src/services/workstream-turn.ts +37 -0
- package/src/services/workstream.service.ts +854 -0
- package/src/services/workstream.types.ts +118 -0
- package/src/storage/attachment-parser.ts +101 -0
- package/src/storage/attachment-storage.service.ts +391 -0
- package/src/storage/attachments.types.ts +11 -0
- package/src/storage/attachments.utils.ts +58 -0
- package/src/storage/generated-document-storage.service.ts +55 -0
- package/src/system-agents/agent-result.ts +27 -0
- package/src/system-agents/context-compacter.agent.ts +46 -0
- package/src/system-agents/delegated-agent-factory.ts +177 -0
- package/src/system-agents/helper-agent-options.ts +20 -0
- package/src/system-agents/memory-reranker.agent.ts +38 -0
- package/src/system-agents/memory.agent.ts +58 -0
- package/src/system-agents/recent-activity-title-refiner.agent.ts +53 -0
- package/src/system-agents/regular-chat-memory-digest.agent.ts +75 -0
- package/src/system-agents/researcher.agent.ts +34 -0
- package/src/system-agents/skill-extractor.agent.ts +88 -0
- package/src/system-agents/skill-manager.agent.ts +80 -0
- package/src/system-agents/title-generator.agent.ts +42 -0
- package/src/system-agents/workstream-tracker.agent.ts +58 -0
- package/src/tools/execution-plan.tool.ts +163 -0
- package/src/tools/fetch-webpage.tool.ts +132 -0
- package/src/tools/firecrawl-client.ts +12 -0
- package/src/tools/memory-block.tool.ts +55 -0
- package/src/tools/read-file-parts.tool.ts +80 -0
- package/src/tools/remember-memory.tool.ts +85 -0
- package/src/tools/research-topic.tool.ts +15 -0
- package/src/tools/search-tools.ts +55 -0
- package/src/tools/search-web.tool.ts +175 -0
- package/src/tools/team-think.tool.ts +125 -0
- package/src/tools/tool-contract.ts +21 -0
- package/src/tools/user-questions.tool.ts +18 -0
- package/src/utils/async.ts +50 -0
- package/src/utils/date-time.ts +34 -0
- package/src/utils/error.ts +10 -0
- package/src/utils/errors.ts +28 -0
- package/src/utils/hono-error-handler.ts +71 -0
- package/src/utils/string.ts +51 -0
- package/src/workers/bootstrap.ts +44 -0
- package/src/workers/memory-consolidation.worker.ts +318 -0
- package/src/workers/regular-chat-memory-digest.helpers.ts +100 -0
- package/src/workers/regular-chat-memory-digest.runner.ts +363 -0
- package/src/workers/regular-chat-memory-digest.worker.ts +22 -0
- package/src/workers/skill-extraction.runner.ts +331 -0
- package/src/workers/skill-extraction.worker.ts +22 -0
- package/src/workers/utils/repo-indexer-chunker.ts +331 -0
- package/src/workers/utils/repo-structure-extractor.ts +645 -0
- package/src/workers/utils/repomix-process-concurrency.ts +65 -0
- package/src/workers/utils/sandbox-error.ts +5 -0
- 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
|
+
}
|