@echofiles/echo-pdf 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,224 @@
1
+ import type { Env, FileStore, ReturnMode } from "./types"
2
+ import type { AgentTraceEvent, EchoPdfConfig, PdfOperationRequest } from "./pdf-types"
3
+ import { resolveModelForProvider, resolveProviderAlias } from "./agent-defaults"
4
+ import { fromBase64, normalizeReturnMode, toDataUrl } from "./file-utils"
5
+ import { extractPdfPageText, getPdfPageCount, renderPdfPageToPng, toBytes } from "./pdfium-engine"
6
+ import { visionRecognize } from "./provider-client"
7
+
8
+ interface RuntimeOptions {
9
+ readonly trace?: (event: AgentTraceEvent) => void
10
+ readonly fileStore: FileStore
11
+ }
12
+
13
+ const traceStep = (
14
+ opts: RuntimeOptions,
15
+ phase: AgentTraceEvent["phase"],
16
+ name: string,
17
+ payload?: unknown,
18
+ level?: AgentTraceEvent["level"]
19
+ ): void => {
20
+ if (!opts.trace) return
21
+ opts.trace({ kind: "step", phase, name, payload, level })
22
+ }
23
+
24
+ const ensurePages = (pages: ReadonlyArray<number>, pageCount: number, maxPages: number): number[] => {
25
+ if (pages.length === 0) throw new Error("At least one page is required")
26
+ if (pages.length > maxPages) throw new Error(`Page count exceeds maxPagesPerRequest (${maxPages})`)
27
+ for (const page of pages) {
28
+ if (!Number.isInteger(page) || page < 1 || page > pageCount) {
29
+ throw new Error(`Page ${page} out of range 1..${pageCount}`)
30
+ }
31
+ }
32
+ return [...new Set(pages)].sort((a, b) => a - b)
33
+ }
34
+
35
+ export const ingestPdfFromPayload = async (
36
+ config: EchoPdfConfig,
37
+ input: {
38
+ readonly fileId?: string
39
+ readonly url?: string
40
+ readonly base64?: string
41
+ readonly filename?: string
42
+ },
43
+ opts: RuntimeOptions
44
+ ): Promise<{ id: string; filename: string; bytes: Uint8Array }> => {
45
+ if (input.fileId) {
46
+ const existing = await opts.fileStore.get(input.fileId)
47
+ if (!existing) {
48
+ throw new Error(`File not found: ${input.fileId}`)
49
+ }
50
+ return {
51
+ id: existing.id,
52
+ filename: existing.filename,
53
+ bytes: existing.bytes,
54
+ }
55
+ }
56
+
57
+ let bytes: Uint8Array | null = null
58
+ let filename = input.filename ?? "document.pdf"
59
+
60
+ if (input.url) {
61
+ traceStep(opts, "start", "file.fetch.url", { url: input.url })
62
+ bytes = await toBytes(input.url)
63
+ try {
64
+ const u = new URL(input.url)
65
+ filename = decodeURIComponent(u.pathname.split("/").pop() || filename)
66
+ } catch {
67
+ // ignore URL parse failure
68
+ }
69
+ traceStep(opts, "end", "file.fetch.url", { sizeBytes: bytes.byteLength })
70
+ } else if (input.base64) {
71
+ traceStep(opts, "start", "file.decode.base64")
72
+ bytes = fromBase64(input.base64)
73
+ traceStep(opts, "end", "file.decode.base64", { sizeBytes: bytes.byteLength })
74
+ }
75
+
76
+ if (!bytes) {
77
+ throw new Error("Missing file input. Provide fileId, url or base64")
78
+ }
79
+ if (bytes.byteLength > config.service.maxPdfBytes) {
80
+ throw new Error(`PDF exceeds max size (${config.service.maxPdfBytes} bytes)`)
81
+ }
82
+
83
+ const meta = await opts.fileStore.put({
84
+ filename,
85
+ mimeType: "application/pdf",
86
+ bytes,
87
+ })
88
+ traceStep(opts, "end", "file.stored", { fileId: meta.id, sizeBytes: meta.sizeBytes })
89
+ return {
90
+ id: meta.id,
91
+ filename: meta.filename,
92
+ bytes,
93
+ }
94
+ }
95
+
96
+ const resolveReturnMode = (value: ReturnMode | undefined): ReturnMode => normalizeReturnMode(value)
97
+
98
+ const stripCodeFences = (value: string): string => {
99
+ const text = value.trim()
100
+ const fenced = text.match(/^```[a-zA-Z0-9_-]*\n([\s\S]*?)\n```$/)
101
+ return typeof fenced?.[1] === "string" ? fenced[1].trim() : text
102
+ }
103
+
104
+ const extractTabularLatex = (value: string): string => {
105
+ const text = stripCodeFences(value)
106
+ const blocks = text.match(/\\begin\{tabular\}[\s\S]*?\\end\{tabular\}/g)
107
+ if (!blocks || blocks.length === 0) return ""
108
+ return blocks.map((b) => b.trim()).join("\n\n")
109
+ }
110
+
111
+ export const runPdfAgent = async (
112
+ config: EchoPdfConfig,
113
+ env: Env,
114
+ request: PdfOperationRequest,
115
+ opts: RuntimeOptions
116
+ ): Promise<unknown> => {
117
+ traceStep(opts, "start", "pdf.operation", { operation: request.operation })
118
+ const file = await ingestPdfFromPayload(config, request, opts)
119
+ const pageCount = await getPdfPageCount(config, file.bytes)
120
+ traceStep(opts, "log", "pdf.meta", { fileId: file.id, pageCount })
121
+
122
+ const pages = ensurePages(request.pages, pageCount, config.service.maxPagesPerRequest)
123
+ const scale = request.renderScale ?? config.service.defaultRenderScale
124
+ const returnMode = resolveReturnMode(request.returnMode)
125
+ if (returnMode === "url") {
126
+ throw new Error("returnMode=url is not implemented; use inline or file_id")
127
+ }
128
+
129
+ if (request.operation === "extract_pages") {
130
+ const images: Array<{ page: number; mimeType: string; data?: string; fileId?: string; url?: string | null }> = []
131
+ for (const page of pages) {
132
+ traceStep(opts, "start", "render.page", { page })
133
+ const rendered = await renderPdfPageToPng(config, file.bytes, page - 1, scale)
134
+ if (returnMode === "file_id") {
135
+ const stored = await opts.fileStore.put({
136
+ filename: `${file.filename}-p${page}.png`,
137
+ mimeType: "image/png",
138
+ bytes: rendered.png,
139
+ })
140
+ images.push({ page, mimeType: "image/png", fileId: stored.id })
141
+ } else {
142
+ images.push({
143
+ page,
144
+ mimeType: "image/png",
145
+ data: toDataUrl(rendered.png, "image/png"),
146
+ })
147
+ }
148
+ traceStep(opts, "end", "render.page", { page, width: rendered.width, height: rendered.height })
149
+ }
150
+ const result = { fileId: file.id, pageCount, returnMode, images }
151
+ traceStep(opts, "end", "pdf.operation", { operation: request.operation })
152
+ return result
153
+ }
154
+
155
+ const providerAlias = resolveProviderAlias(config, request.provider)
156
+ const model = resolveModelForProvider(config, providerAlias, request.model)
157
+ if (!model) {
158
+ throw new Error("model is required for OCR or table extraction; set agent.defaultModel")
159
+ }
160
+
161
+ if (request.operation === "ocr_pages") {
162
+ const results: Array<{ page: number; text: string }> = []
163
+ for (const page of pages) {
164
+ traceStep(opts, "start", "ocr.page", { page })
165
+ const rendered = await renderPdfPageToPng(config, file.bytes, page - 1, scale)
166
+ const imageDataUrl = toDataUrl(rendered.png, "image/png")
167
+ const fallbackText = await extractPdfPageText(config, file.bytes, page - 1)
168
+ const prompt = request.prompt?.trim() || config.agent.ocrPrompt
169
+ const llmText = await visionRecognize({
170
+ config,
171
+ env,
172
+ providerAlias,
173
+ model,
174
+ prompt,
175
+ imageDataUrl,
176
+ runtimeApiKeys: request.providerApiKeys,
177
+ })
178
+ const text = stripCodeFences(llmText || fallbackText || "")
179
+ results.push({ page, text })
180
+ traceStep(opts, "end", "ocr.page", { page, chars: text.length })
181
+ }
182
+ const result = {
183
+ fileId: file.id,
184
+ pageCount,
185
+ provider: providerAlias,
186
+ model,
187
+ pages: results,
188
+ }
189
+ traceStep(opts, "end", "pdf.operation", { operation: request.operation })
190
+ return result
191
+ }
192
+
193
+ const tables: Array<{ page: number; latex: string }> = []
194
+ for (const page of pages) {
195
+ traceStep(opts, "start", "table.page", { page })
196
+ const rendered = await renderPdfPageToPng(config, file.bytes, page - 1, scale)
197
+ const imageDataUrl = toDataUrl(rendered.png, "image/png")
198
+ const prompt = request.prompt?.trim() || config.agent.tablePrompt
199
+ const rawLatex = await visionRecognize({
200
+ config,
201
+ env,
202
+ providerAlias,
203
+ model,
204
+ prompt,
205
+ imageDataUrl,
206
+ runtimeApiKeys: request.providerApiKeys,
207
+ })
208
+ const latex = extractTabularLatex(rawLatex)
209
+ if (!latex) {
210
+ throw new Error(`table extraction did not return valid LaTeX tabular for page ${page}`)
211
+ }
212
+ tables.push({ page, latex })
213
+ traceStep(opts, "end", "table.page", { page, chars: latex.length })
214
+ }
215
+ const result = {
216
+ fileId: file.id,
217
+ pageCount,
218
+ provider: providerAlias,
219
+ model,
220
+ pages: tables,
221
+ }
222
+ traceStep(opts, "end", "pdf.operation", { operation: request.operation })
223
+ return result
224
+ }
@@ -0,0 +1,105 @@
1
+ import rawConfig from "../echo-pdf.config.json"
2
+ import type { Env, JsonObject, JsonValue } from "./types"
3
+ import type { EchoPdfConfig } from "./pdf-types"
4
+
5
+ const ENV_PATTERN = /\$\{([A-Z0-9_]+)\}/g
6
+
7
+ const isObject = (value: unknown): value is Record<string, unknown> =>
8
+ typeof value === "object" && value !== null && !Array.isArray(value)
9
+
10
+ const interpolateEnv = (input: string, env: Env): string =>
11
+ input.replace(ENV_PATTERN, (_, name: string) => {
12
+ const value = env[name]
13
+ return typeof value === "string" ? value : `\${${name}}`
14
+ })
15
+
16
+ const resolveEnvRefs = (value: JsonValue, env: Env): JsonValue => {
17
+ if (typeof value === "string") return interpolateEnv(value, env)
18
+ if (Array.isArray(value)) return value.map((item) => resolveEnvRefs(item, env))
19
+ if (isObject(value)) {
20
+ const out: JsonObject = {}
21
+ for (const [key, nested] of Object.entries(value)) {
22
+ out[key] = resolveEnvRefs(nested as JsonValue, env)
23
+ }
24
+ return out
25
+ }
26
+ return value
27
+ }
28
+
29
+ const validateConfig = (config: EchoPdfConfig): EchoPdfConfig => {
30
+ if (!config.service?.name) throw new Error("service.name is required")
31
+ if (!config.pdfium?.wasmUrl) throw new Error("pdfium.wasmUrl is required")
32
+ if (!config.service?.storage) throw new Error("service.storage is required")
33
+ if (!Number.isFinite(config.service.storage.maxFileBytes) || config.service.storage.maxFileBytes <= 0) {
34
+ throw new Error("service.storage.maxFileBytes must be positive")
35
+ }
36
+ if (config.service.storage.maxFileBytes < config.service.maxPdfBytes) {
37
+ throw new Error("service.storage.maxFileBytes must be >= service.maxPdfBytes")
38
+ }
39
+ if (!Number.isFinite(config.service.storage.maxTotalBytes) || config.service.storage.maxTotalBytes <= 0) {
40
+ throw new Error("service.storage.maxTotalBytes must be positive")
41
+ }
42
+ if (config.service.storage.maxTotalBytes < config.service.storage.maxFileBytes) {
43
+ throw new Error("service.storage.maxTotalBytes must be >= maxFileBytes")
44
+ }
45
+ if (!Number.isFinite(config.service.storage.ttlHours) || config.service.storage.ttlHours <= 0) {
46
+ throw new Error("service.storage.ttlHours must be positive")
47
+ }
48
+ if (!Number.isFinite(config.service.storage.cleanupBatchSize) || config.service.storage.cleanupBatchSize <= 0) {
49
+ throw new Error("service.storage.cleanupBatchSize must be positive")
50
+ }
51
+ if (!config.agent?.defaultProvider) throw new Error("agent.defaultProvider is required")
52
+ if (!config.providers?.[config.agent.defaultProvider]) {
53
+ throw new Error(`default provider "${config.agent.defaultProvider}" missing`)
54
+ }
55
+ if (typeof config.agent.defaultModel !== "string") {
56
+ throw new Error("agent.defaultModel must be a string")
57
+ }
58
+ return config
59
+ }
60
+
61
+ export const loadEchoPdfConfig = (env: Env): EchoPdfConfig => {
62
+ const fromEnv = env.ECHO_PDF_CONFIG_JSON?.trim()
63
+ const configJson = fromEnv ? JSON.parse(fromEnv) : rawConfig
64
+ const resolved = resolveEnvRefs(configJson as unknown as JsonValue, env) as unknown as EchoPdfConfig
65
+
66
+ const providerOverride = env.ECHO_PDF_DEFAULT_PROVIDER
67
+ const modelOverride = env.ECHO_PDF_DEFAULT_MODEL
68
+ const withOverrides: EchoPdfConfig = {
69
+ ...resolved,
70
+ agent: {
71
+ ...resolved.agent,
72
+ defaultProvider:
73
+ typeof providerOverride === "string" && providerOverride.trim().length > 0
74
+ ? providerOverride.trim()
75
+ : resolved.agent.defaultProvider,
76
+ defaultModel:
77
+ typeof modelOverride === "string" && modelOverride.trim().length > 0
78
+ ? modelOverride.trim()
79
+ : resolved.agent.defaultModel,
80
+ },
81
+ }
82
+
83
+ return validateConfig(withOverrides)
84
+ }
85
+
86
+ export const readRequiredEnv = (env: Env, key: string): string => {
87
+ const read = (name: string): string | null => {
88
+ const value = env[name]
89
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null
90
+ }
91
+ const direct = read(key)
92
+ if (direct) return direct
93
+
94
+ // Backward compatibility: allow *_KEY and *_API_KEY aliases.
95
+ if (key.endsWith("_API_KEY")) {
96
+ const alt = read(key.replace(/_API_KEY$/, "_KEY"))
97
+ if (alt) return alt
98
+ }
99
+ if (key.endsWith("_KEY")) {
100
+ const alt = read(key.replace(/_KEY$/, "_API_KEY"))
101
+ if (alt) return alt
102
+ }
103
+
104
+ throw new Error(`Missing required env var "${key}"`)
105
+ }
@@ -0,0 +1,94 @@
1
+ import { DurableObjectFileStore } from "./file-store-do"
2
+ import type { EchoPdfConfig } from "./pdf-types"
3
+ import type { Env, FileStore, StoredFileMeta, StoredFileRecord } from "./types"
4
+
5
+ class InMemoryFileStore implements FileStore {
6
+ private readonly store = new Map<string, StoredFileRecord>()
7
+
8
+ async put(input: {
9
+ readonly filename: string
10
+ readonly mimeType: string
11
+ readonly bytes: Uint8Array
12
+ }): Promise<StoredFileMeta> {
13
+ const id = crypto.randomUUID()
14
+ const record: StoredFileRecord = {
15
+ id,
16
+ filename: input.filename,
17
+ mimeType: input.mimeType,
18
+ sizeBytes: input.bytes.byteLength,
19
+ createdAt: new Date().toISOString(),
20
+ bytes: input.bytes,
21
+ }
22
+ this.store.set(id, record)
23
+ return this.toMeta(record)
24
+ }
25
+
26
+ async get(fileId: string): Promise<StoredFileRecord | null> {
27
+ return this.store.get(fileId) ?? null
28
+ }
29
+
30
+ async list(): Promise<ReadonlyArray<StoredFileMeta>> {
31
+ return [...this.store.values()].map((record) => this.toMeta(record))
32
+ }
33
+
34
+ async delete(fileId: string): Promise<boolean> {
35
+ return this.store.delete(fileId)
36
+ }
37
+
38
+ private toMeta(record: StoredFileRecord): StoredFileMeta {
39
+ return {
40
+ id: record.id,
41
+ filename: record.filename,
42
+ mimeType: record.mimeType,
43
+ sizeBytes: record.sizeBytes,
44
+ createdAt: record.createdAt,
45
+ }
46
+ }
47
+ }
48
+
49
+ const fallbackStore = new InMemoryFileStore()
50
+
51
+ export interface RuntimeFileStoreBundle {
52
+ readonly store: FileStore
53
+ stats: () => Promise<unknown>
54
+ cleanup: () => Promise<unknown>
55
+ }
56
+
57
+ export const getRuntimeFileStore = (env: Env, config: EchoPdfConfig): RuntimeFileStoreBundle => {
58
+ if (env.FILE_STORE_DO) {
59
+ const store = new DurableObjectFileStore(env.FILE_STORE_DO, config.service.storage)
60
+ return {
61
+ store,
62
+ stats: async () => store.stats(),
63
+ cleanup: async () => store.cleanup(),
64
+ }
65
+ }
66
+
67
+ return {
68
+ store: fallbackStore,
69
+ stats: async () => {
70
+ const files = await fallbackStore.list()
71
+ const totalBytes = files.reduce((sum, file) => sum + file.sizeBytes, 0)
72
+ return {
73
+ backend: "memory",
74
+ policy: config.service.storage,
75
+ stats: {
76
+ fileCount: files.length,
77
+ totalBytes,
78
+ },
79
+ }
80
+ },
81
+ cleanup: async () => ({
82
+ backend: "memory",
83
+ deletedExpired: 0,
84
+ deletedEvicted: 0,
85
+ stats: await (async () => {
86
+ const files = await fallbackStore.list()
87
+ return {
88
+ fileCount: files.length,
89
+ totalBytes: files.reduce((sum, file) => sum + file.sizeBytes, 0),
90
+ }
91
+ })(),
92
+ }),
93
+ }
94
+ }
@@ -0,0 +1,79 @@
1
+ import type { ProviderType, ReturnMode } from "./types"
2
+
3
+ export interface EchoPdfProviderConfig {
4
+ readonly type: ProviderType
5
+ readonly apiKeyEnv: string
6
+ readonly baseUrl?: string
7
+ readonly headers?: Record<string, string>
8
+ readonly timeoutMs?: number
9
+ readonly endpoints?: {
10
+ readonly chatCompletionsPath?: string
11
+ readonly modelsPath?: string
12
+ }
13
+ }
14
+
15
+ export interface StoragePolicy {
16
+ readonly maxFileBytes: number
17
+ readonly maxTotalBytes: number
18
+ readonly ttlHours: number
19
+ readonly cleanupBatchSize: number
20
+ }
21
+
22
+ export interface EchoPdfConfig {
23
+ readonly service: {
24
+ readonly name: string
25
+ readonly maxPdfBytes: number
26
+ readonly maxPagesPerRequest: number
27
+ readonly defaultRenderScale: number
28
+ readonly storage: StoragePolicy
29
+ }
30
+ readonly pdfium: {
31
+ readonly wasmUrl: string
32
+ }
33
+ readonly agent: {
34
+ readonly defaultProvider: string
35
+ readonly defaultModel: string
36
+ readonly ocrPrompt: string
37
+ readonly tablePrompt: string
38
+ }
39
+ readonly providers: Record<string, EchoPdfProviderConfig>
40
+ readonly mcp: {
41
+ readonly serverName: string
42
+ readonly version: string
43
+ readonly authHeader?: string
44
+ readonly authEnv?: string
45
+ }
46
+ }
47
+
48
+ export interface AgentTraceEvent {
49
+ readonly kind: "step"
50
+ readonly phase: "start" | "end" | "log"
51
+ readonly name: string
52
+ readonly level?: "info" | "error"
53
+ readonly payload?: unknown
54
+ }
55
+
56
+ export interface PdfOperationRequest {
57
+ readonly operation: "extract_pages" | "ocr_pages" | "tables_to_latex"
58
+ readonly fileId?: string
59
+ readonly url?: string
60
+ readonly base64?: string
61
+ readonly filename?: string
62
+ readonly pages: ReadonlyArray<number>
63
+ readonly renderScale?: number
64
+ readonly provider?: string
65
+ readonly model: string
66
+ readonly providerApiKeys?: Record<string, string>
67
+ readonly returnMode?: ReturnMode
68
+ readonly prompt?: string
69
+ }
70
+
71
+ export interface ToolSchema {
72
+ readonly name: string
73
+ readonly description: string
74
+ readonly inputSchema: Record<string, unknown>
75
+ readonly source: {
76
+ readonly kind: "local"
77
+ readonly toolName: string
78
+ }
79
+ }