@larkup/workspace-server 0.0.0-stage → 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.
Files changed (180) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +2 -2
  3. package/dist-types/app/api/chat/feedback/route.d.ts +4 -0
  4. package/dist-types/app/api/chat/feedback/route.d.ts.map +1 -0
  5. package/dist-types/app/api/chat/route.d.ts +3 -0
  6. package/dist-types/app/api/chat/route.d.ts.map +1 -0
  7. package/dist-types/app/api/chat/tools.d.ts +67 -0
  8. package/dist-types/app/api/chat/tools.d.ts.map +1 -0
  9. package/dist-types/app/api/config/test-embedding/route.d.ts +3 -0
  10. package/dist-types/app/api/config/test-embedding/route.d.ts.map +1 -0
  11. package/dist-types/app/api/config/verify/route.d.ts +3 -0
  12. package/dist-types/app/api/config/verify/route.d.ts.map +1 -0
  13. package/dist-types/app/api/describe-image/route.d.ts +4 -0
  14. package/dist-types/app/api/describe-image/route.d.ts.map +1 -0
  15. package/dist-types/app/api/doc-editor/edit/route.d.ts +10 -0
  16. package/dist-types/app/api/doc-editor/edit/route.d.ts.map +1 -0
  17. package/dist-types/app/api/doc-editor/export/route.d.ts +9 -0
  18. package/dist-types/app/api/doc-editor/export/route.d.ts.map +1 -0
  19. package/dist-types/app/api/doc-editor/parse/route.d.ts +10 -0
  20. package/dist-types/app/api/doc-editor/parse/route.d.ts.map +1 -0
  21. package/dist-types/app/api/doc-editor/restore/route.d.ts +2 -0
  22. package/dist-types/app/api/doc-editor/restore/route.d.ts.map +1 -0
  23. package/dist-types/app/api/document/sign/route.d.ts +2 -0
  24. package/dist-types/app/api/document/sign/route.d.ts.map +1 -0
  25. package/dist-types/app/api/files/remote/route.d.ts +5 -0
  26. package/dist-types/app/api/files/remote/route.d.ts.map +1 -0
  27. package/dist-types/app/api/mcp/[connectionId]/route.d.ts +9 -0
  28. package/dist-types/app/api/mcp/[connectionId]/route.d.ts.map +1 -0
  29. package/dist-types/app/api/mcp/[connectionId]/test/route.d.ts +8 -0
  30. package/dist-types/app/api/mcp/[connectionId]/test/route.d.ts.map +1 -0
  31. package/dist-types/app/api/mcp/route.d.ts +4 -0
  32. package/dist-types/app/api/mcp/route.d.ts.map +1 -0
  33. package/dist-types/app/api/parse-file/route.d.ts +6 -0
  34. package/dist-types/app/api/parse-file/route.d.ts.map +1 -0
  35. package/dist-types/app/api/pdf-page/route.d.ts +4 -0
  36. package/dist-types/app/api/pdf-page/route.d.ts.map +1 -0
  37. package/dist-types/app/api/proxy/verify/route.d.ts +4 -0
  38. package/dist-types/app/api/proxy/verify/route.d.ts.map +1 -0
  39. package/dist-types/app/api/search/bing/route.d.ts +5 -0
  40. package/dist-types/app/api/search/bing/route.d.ts.map +1 -0
  41. package/dist-types/app/api/search/brave/route.d.ts +5 -0
  42. package/dist-types/app/api/search/brave/route.d.ts.map +1 -0
  43. package/dist-types/app/api/search/exa/route.d.ts +5 -0
  44. package/dist-types/app/api/search/exa/route.d.ts.map +1 -0
  45. package/dist-types/app/api/search/google/route.d.ts +30 -0
  46. package/dist-types/app/api/search/google/route.d.ts.map +1 -0
  47. package/dist-types/app/api/search/route.d.ts +7 -0
  48. package/dist-types/app/api/search/route.d.ts.map +1 -0
  49. package/dist-types/app/api/search/tavily/route.d.ts +5 -0
  50. package/dist-types/app/api/search/tavily/route.d.ts.map +1 -0
  51. package/dist-types/app/api/search/verify/route.d.ts +4 -0
  52. package/dist-types/app/api/search/verify/route.d.ts.map +1 -0
  53. package/dist-types/app/api/tabular/route.d.ts +9 -0
  54. package/dist-types/app/api/tabular/route.d.ts.map +1 -0
  55. package/dist-types/lib/chat/assistant-source-scope.d.ts +10 -0
  56. package/dist-types/lib/chat/assistant-source-scope.d.ts.map +1 -0
  57. package/dist-types/lib/chat/conversation-memory.d.ts +80 -0
  58. package/dist-types/lib/chat/conversation-memory.d.ts.map +1 -0
  59. package/dist-types/lib/chat/gateway-fallbacks.d.ts +7 -0
  60. package/dist-types/lib/chat/gateway-fallbacks.d.ts.map +1 -0
  61. package/dist-types/lib/chat/image-description.d.ts +10 -0
  62. package/dist-types/lib/chat/image-description.d.ts.map +1 -0
  63. package/dist-types/lib/chat/media-retrieval-routing.d.ts +14 -0
  64. package/dist-types/lib/chat/media-retrieval-routing.d.ts.map +1 -0
  65. package/dist-types/lib/chat/media-source-routing.d.ts +53 -0
  66. package/dist-types/lib/chat/media-source-routing.d.ts.map +1 -0
  67. package/dist-types/lib/chat/message-input.d.ts +8 -0
  68. package/dist-types/lib/chat/message-input.d.ts.map +1 -0
  69. package/dist-types/lib/chat/model-provider.d.ts +23 -0
  70. package/dist-types/lib/chat/model-provider.d.ts.map +1 -0
  71. package/dist-types/lib/chat/response-style.d.ts +2 -0
  72. package/dist-types/lib/chat/response-style.d.ts.map +1 -0
  73. package/dist-types/lib/chat/retrieval-ranking.d.ts +32 -0
  74. package/dist-types/lib/chat/retrieval-ranking.d.ts.map +1 -0
  75. package/dist-types/lib/chat/retrieval-routing.d.ts +39 -0
  76. package/dist-types/lib/chat/retrieval-routing.d.ts.map +1 -0
  77. package/dist-types/lib/chat/tabular-dataset-routing.d.ts +15 -0
  78. package/dist-types/lib/chat/tabular-dataset-routing.d.ts.map +1 -0
  79. package/dist-types/lib/chat/tabular-query-plan.d.ts +12 -0
  80. package/dist-types/lib/chat/tabular-query-plan.d.ts.map +1 -0
  81. package/dist-types/lib/chat/tabular-routing.d.ts +41 -0
  82. package/dist-types/lib/chat/tabular-routing.d.ts.map +1 -0
  83. package/dist-types/lib/chat/tabular-sandbox-files.d.ts +33 -0
  84. package/dist-types/lib/chat/tabular-sandbox-files.d.ts.map +1 -0
  85. package/dist-types/lib/chat/tabular-visualization.d.ts +31 -0
  86. package/dist-types/lib/chat/tabular-visualization.d.ts.map +1 -0
  87. package/dist-types/lib/chat/tool-call-repair.d.ts +11 -0
  88. package/dist-types/lib/chat/tool-call-repair.d.ts.map +1 -0
  89. package/dist-types/lib/chat/tool-context.d.ts +72 -0
  90. package/dist-types/lib/chat/tool-context.d.ts.map +1 -0
  91. package/dist-types/lib/chat/tool-registry.d.ts +3 -0
  92. package/dist-types/lib/chat/tool-registry.d.ts.map +1 -0
  93. package/dist-types/lib/chat/video-rag-routing.d.ts +33 -0
  94. package/dist-types/lib/chat/video-rag-routing.d.ts.map +1 -0
  95. package/dist-types/lib/chat/visual-routing.d.ts +53 -0
  96. package/dist-types/lib/chat/visual-routing.d.ts.map +1 -0
  97. package/dist-types/lib/enterprise-client.d.ts +31 -0
  98. package/dist-types/lib/enterprise-client.d.ts.map +1 -0
  99. package/dist-types/lib/marketplace/tool-runtime-config.d.ts +7 -0
  100. package/dist-types/lib/marketplace/tool-runtime-config.d.ts.map +1 -0
  101. package/dist-types/lib/media/knowledge.d.ts +47 -0
  102. package/dist-types/lib/media/knowledge.d.ts.map +1 -0
  103. package/dist-types/lib/media/pdf-inspection.d.ts +30 -0
  104. package/dist-types/lib/media/pdf-inspection.d.ts.map +1 -0
  105. package/dist-types/lib/media/source-identity.d.ts +12 -0
  106. package/dist-types/lib/media/source-identity.d.ts.map +1 -0
  107. package/dist-types/lib/media/source-utils.d.ts +157 -0
  108. package/dist-types/lib/media/source-utils.d.ts.map +1 -0
  109. package/dist-types/lib/media/video/quick-look.d.ts +60 -0
  110. package/dist-types/lib/media/video/quick-look.d.ts.map +1 -0
  111. package/dist-types/lib/media/video-intelligence-adapter.d.ts +521 -0
  112. package/dist-types/lib/media/video-intelligence-adapter.d.ts.map +1 -0
  113. package/dist-types/lib/media/video-intelligence-connection.d.ts +32 -0
  114. package/dist-types/lib/media/video-intelligence-connection.d.ts.map +1 -0
  115. package/dist-types/lib/remote-files.d.ts +12 -0
  116. package/dist-types/lib/remote-files.d.ts.map +1 -0
  117. package/dist-types/lib/search/provider-verification.d.ts +5 -0
  118. package/dist-types/lib/search/provider-verification.d.ts.map +1 -0
  119. package/dist-types/runtime-host.d.ts +20 -0
  120. package/dist-types/runtime-host.d.ts.map +1 -0
  121. package/package.json +338 -3
  122. package/src/app/api/chat/feedback/route.ts +77 -0
  123. package/src/app/api/chat/route.ts +2277 -0
  124. package/src/app/api/chat/tools.ts +3563 -0
  125. package/src/app/api/config/test-embedding/route.ts +39 -0
  126. package/src/app/api/config/verify/route.ts +309 -0
  127. package/src/app/api/describe-image/route.ts +108 -0
  128. package/src/app/api/doc-editor/edit/route.ts +87 -0
  129. package/src/app/api/doc-editor/export/route.ts +32 -0
  130. package/src/app/api/doc-editor/parse/route.ts +74 -0
  131. package/src/app/api/doc-editor/restore/route.ts +70 -0
  132. package/src/app/api/document/sign/route.ts +55 -0
  133. package/src/app/api/files/remote/route.ts +32 -0
  134. package/src/app/api/mcp/[connectionId]/route.ts +29 -0
  135. package/src/app/api/mcp/[connectionId]/test/route.ts +13 -0
  136. package/src/app/api/mcp/route.ts +22 -0
  137. package/src/app/api/parse-file/route.ts +86 -0
  138. package/src/app/api/pdf-page/route.ts +40 -0
  139. package/src/app/api/proxy/verify/route.ts +78 -0
  140. package/src/app/api/search/bing/route.ts +86 -0
  141. package/src/app/api/search/brave/route.ts +88 -0
  142. package/src/app/api/search/exa/route.ts +100 -0
  143. package/src/app/api/search/google/route.ts +147 -0
  144. package/src/app/api/search/route.ts +28 -0
  145. package/src/app/api/search/tavily/route.ts +93 -0
  146. package/src/app/api/search/verify/route.ts +27 -0
  147. package/src/app/api/tabular/route.ts +68 -0
  148. package/src/lib/chat/assistant-source-scope.ts +32 -0
  149. package/src/lib/chat/conversation-memory.ts +621 -0
  150. package/src/lib/chat/gateway-fallbacks.ts +12 -0
  151. package/src/lib/chat/image-description.ts +21 -0
  152. package/src/lib/chat/media-retrieval-routing.ts +37 -0
  153. package/src/lib/chat/media-source-routing.ts +100 -0
  154. package/src/lib/chat/message-input.ts +48 -0
  155. package/src/lib/chat/model-provider.ts +157 -0
  156. package/src/lib/chat/response-style.ts +3 -0
  157. package/src/lib/chat/retrieval-ranking.ts +126 -0
  158. package/src/lib/chat/retrieval-routing.ts +221 -0
  159. package/src/lib/chat/tabular-dataset-routing.ts +93 -0
  160. package/src/lib/chat/tabular-query-plan.ts +195 -0
  161. package/src/lib/chat/tabular-routing.ts +122 -0
  162. package/src/lib/chat/tabular-sandbox-files.ts +78 -0
  163. package/src/lib/chat/tabular-visualization.ts +165 -0
  164. package/src/lib/chat/tool-call-repair.ts +84 -0
  165. package/src/lib/chat/tool-context.ts +1222 -0
  166. package/src/lib/chat/tool-registry.ts +6 -0
  167. package/src/lib/chat/video-rag-routing.ts +149 -0
  168. package/src/lib/chat/visual-routing.ts +225 -0
  169. package/src/lib/enterprise-client.ts +96 -0
  170. package/src/lib/marketplace/tool-runtime-config.ts +155 -0
  171. package/src/lib/media/knowledge.ts +489 -0
  172. package/src/lib/media/pdf-inspection.ts +280 -0
  173. package/src/lib/media/source-identity.ts +50 -0
  174. package/src/lib/media/source-utils.ts +1156 -0
  175. package/src/lib/media/video/quick-look.ts +368 -0
  176. package/src/lib/media/video-intelligence-adapter.ts +1946 -0
  177. package/src/lib/media/video-intelligence-connection.ts +151 -0
  178. package/src/lib/remote-files.ts +182 -0
  179. package/src/lib/search/provider-verification.ts +95 -0
  180. package/src/runtime-host.ts +31 -0
@@ -0,0 +1,280 @@
1
+ import { workspaceReadBinary } from '../../runtime-host';
2
+ import { promises as fs } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { getData } from 'pdf-parse/worker';
5
+ import { getLarkupDataDir } from '@larkup/core/project-store';
6
+
7
+ const MAX_SOURCE_BYTES = 100 * 1024 * 1024;
8
+ const MAX_PAGES = 3;
9
+ const MAX_PAGE_TEXT = 4_000;
10
+
11
+ export type PdfSource = { id: string; title: string; url?: string };
12
+
13
+ export type PdfPageInspection = {
14
+ pageNumber: number;
15
+ text: string;
16
+ tables: unknown[];
17
+ previewUrl?: string;
18
+ };
19
+
20
+ let parserPromise: Promise<(typeof import('pdf-parse'))['PDFParse']> | undefined;
21
+
22
+ async function getPdfParser() {
23
+ if (!parserPromise) {
24
+ parserPromise = (async () => {
25
+ // PDF.js loads its compatible @napi-rs/canvas version in Node. Setting
26
+ // globals from the app's separate canvas dependency creates incompatible
27
+ // Path2D instances, which breaks page rendering for text-heavy PDFs.
28
+ const { PDFParse } = await import('pdf-parse');
29
+ PDFParse.setWorker(getData());
30
+ return PDFParse;
31
+ })();
32
+ }
33
+ return parserPromise;
34
+ }
35
+
36
+ function uploadFileName(url: string | undefined): string {
37
+ const match = url?.match(/^\/api\/uploads\/([^/?#]+)$/);
38
+ if (!match) throw new Error('The original PDF file is not available for local inspection.');
39
+ const fileName = decodeURIComponent(match[1]);
40
+ if (!fileName || fileName !== path.basename(fileName) || fileName.includes('..')) {
41
+ throw new Error('The PDF source path is invalid.');
42
+ }
43
+ return fileName;
44
+ }
45
+
46
+ export async function readStoredPdfBytes(source: PdfSource): Promise<Buffer> {
47
+ const hosted = workspaceReadBinary(source);
48
+ if (hosted) return Buffer.from(await hosted);
49
+ const fileName = uploadFileName(source.url);
50
+ const filePath = path.join(getLarkupDataDir(), 'uploads', fileName);
51
+ const stat = await fs.stat(filePath);
52
+ if (!stat.isFile() || stat.size > MAX_SOURCE_BYTES) {
53
+ throw new Error('The PDF is unavailable or too large for bounded local inspection.');
54
+ }
55
+ return fs.readFile(filePath);
56
+ }
57
+
58
+ const LOW_SIGNAL_QUERY_TERMS = new Set([
59
+ 'a',
60
+ 'an',
61
+ 'and',
62
+ 'are',
63
+ 'as',
64
+ 'at',
65
+ 'be',
66
+ 'by',
67
+ 'do',
68
+ 'for',
69
+ 'from',
70
+ 'give',
71
+ 'how',
72
+ 'i',
73
+ 'in',
74
+ 'is',
75
+ 'it',
76
+ 'me',
77
+ 'of',
78
+ 'on',
79
+ 'or',
80
+ 'please',
81
+ 'show',
82
+ 'state',
83
+ 'the',
84
+ 'to',
85
+ 'what',
86
+ 'when',
87
+ 'where',
88
+ 'which',
89
+ 'why',
90
+ 'with',
91
+ 'write',
92
+ ]);
93
+
94
+ function queryTerms(question: string): string[] {
95
+ return [
96
+ ...new Set(
97
+ (question.toLocaleLowerCase().match(/[\p{L}\p{N}_]{2,}/gu) ?? []).filter(
98
+ (term) => !LOW_SIGNAL_QUERY_TERMS.has(term),
99
+ ),
100
+ ),
101
+ ];
102
+ }
103
+
104
+ /** Numbered document references are stable, high-signal page anchors. */
105
+ function referencedDocumentLabels(question: string): string[] {
106
+ const matches = [
107
+ ...question
108
+ .toLocaleLowerCase()
109
+ .matchAll(
110
+ /\b(fig(?:ure)?|diagram|chart|graph|illustration|table|eq(?:uation)?|section|chapter|appendix)\.?\s*(\d+(?:\.\d+)+)\b/gu,
111
+ ),
112
+ ];
113
+ const labels = matches.flatMap((match) => {
114
+ const kind = match[1];
115
+ const number = match[2];
116
+ if (!kind || !number) return [];
117
+ // Authors commonly call the same visual object a figure, diagram, chart,
118
+ // or illustration in questions while captions standardize on one of them.
119
+ // The number remains the precise anchor; these are document-generic
120
+ // caption aliases rather than a rule for any particular source.
121
+ if (/^(?:fig(?:ure)?|diagram|chart|graph|illustration)$/u.test(kind)) {
122
+ return [
123
+ `figure ${number}`,
124
+ `fig. ${number}`,
125
+ `diagram ${number}`,
126
+ `chart ${number}`,
127
+ `graph ${number}`,
128
+ `illustration ${number}`,
129
+ ];
130
+ }
131
+ return [`${kind} ${number}`];
132
+ });
133
+ return [...new Set(labels)];
134
+ }
135
+
136
+ /**
137
+ * A page can mention an object while the following page contains its caption
138
+ * and visual. A caption/declaration is therefore a stronger match than prose
139
+ * that only references the same numbered object.
140
+ */
141
+ function declaredDocumentLabelScore(text: string, label: string): number {
142
+ const declaration = new RegExp(`(?:^|\\n)\\s*${escapeRegExp(label)}\\s*[:.\\-]`, 'iu');
143
+ return declaration.test(text) ? 100 : 0;
144
+ }
145
+
146
+ /** Rank pages only from their own extracted text; no document-specific assumptions. */
147
+ export function selectRelevantPdfPages(
148
+ pages: Array<{ num: number; text: string }>,
149
+ question: string,
150
+ limit = MAX_PAGES,
151
+ ): number[] {
152
+ const terms = queryTerms(question);
153
+ const labels = referencedDocumentLabels(question);
154
+ if (pages.length === 0) return [];
155
+ if (terms.length === 0) return pages.slice(0, limit).map((page) => page.num);
156
+ const pageText = pages.map((page) => page.text.toLocaleLowerCase());
157
+ const documentFrequency = new Map(
158
+ terms.map((term) => [term, pageText.filter((text) => text.includes(term)).length]),
159
+ );
160
+ return pages
161
+ .map((page, index) => {
162
+ const text = pageText[index];
163
+ const score = terms.reduce((total, term) => {
164
+ const count = text.match(new RegExp(`\\b${escapeRegExp(term)}\\b`, 'gu'))?.length ?? 0;
165
+ const frequency = documentFrequency.get(term) ?? pages.length;
166
+ // A term found on only one or two pages is a stronger page anchor
167
+ // than common prose found throughout the document or its contents.
168
+ const specificity = Math.log((pages.length + 1) / (frequency + 1)) + 1;
169
+ return total + count * specificity;
170
+ }, 0);
171
+ // A caption such as "Figure 5.1" is much more precise than its
172
+ // individual number tokens, which can occur throughout a long paper.
173
+ // Prefer the declaration/caption page over a preceding sentence that
174
+ // merely says "see Figure 5.1".
175
+ const labelScore = labels.reduce(
176
+ (total, label) =>
177
+ total + (text.includes(label) ? 20 : 0) + declaredDocumentLabelScore(text, label),
178
+ 0,
179
+ );
180
+ return { pageNumber: page.num, score: score + labelScore };
181
+ })
182
+ .sort((left, right) => right.score - left.score || left.pageNumber - right.pageNumber)
183
+ .slice(0, limit)
184
+ .map((page) => page.pageNumber);
185
+ }
186
+
187
+ function escapeRegExp(value: string): string {
188
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
189
+ }
190
+
191
+ function requestedPages(pages: number[] | undefined, total: number): number[] {
192
+ return [...new Set(pages ?? [])]
193
+ .filter((page) => Number.isInteger(page) && page >= 1 && page <= total)
194
+ .slice(0, MAX_PAGES);
195
+ }
196
+
197
+ /** Preserve the ranked page order when a full extraction supplied the source text. */
198
+ export function selectInspectedPdfPages<T extends { num: number }>(
199
+ pages: T[],
200
+ selected: number[],
201
+ ): T[] {
202
+ const byNumber = new Map(pages.map((page) => [page.num, page]));
203
+ return selected.flatMap((pageNumber) => {
204
+ const page = byNumber.get(pageNumber);
205
+ return page ? [page] : [];
206
+ });
207
+ }
208
+
209
+ export async function inspectStoredPdf(
210
+ source: PdfSource,
211
+ question: string,
212
+ pageNumbers?: number[],
213
+ previewUrlForPage?: (pageNumber: number) => string,
214
+ ): Promise<{ totalPages: number; pages: PdfPageInspection[] }> {
215
+ const PDFParse = await getPdfParser();
216
+ const parser = new PDFParse({ data: new Uint8Array(await readStoredPdfBytes(source)) });
217
+ try {
218
+ const info = await parser.getInfo();
219
+ const explicitPages = requestedPages(pageNumbers, info.total);
220
+ const allText = explicitPages.length ? undefined : await parser.getText();
221
+ const selectedPages =
222
+ explicitPages.length > 0
223
+ ? explicitPages
224
+ : selectRelevantPdfPages(allText?.pages ?? [], question, MAX_PAGES);
225
+ const textResult = allText ?? (await parser.getText({ partial: selectedPages }));
226
+ // When we read the full PDF to rank its pages, return the selected page
227
+ // objects rather than accidentally taking the first three pages from that
228
+ // full extraction. This keeps the ranking decision and the model evidence
229
+ // on the same source pages.
230
+ const textPages = allText
231
+ ? selectInspectedPdfPages(allText.pages, selectedPages)
232
+ : textResult.pages;
233
+ let tablePages: Array<{ pageNumber?: number; tables?: unknown[] }> = [];
234
+ try {
235
+ const tables = await parser.getTable({ partial: selectedPages });
236
+ tablePages = (tables.pages ?? []) as Array<{ pageNumber?: number; tables?: unknown[] }>;
237
+ } catch {
238
+ // Table detection is optional; text and visual rendering remain useful.
239
+ }
240
+ return {
241
+ totalPages: info.total,
242
+ pages: textPages.slice(0, MAX_PAGES).map((page) => ({
243
+ pageNumber: page.num,
244
+ text: page.text.slice(0, MAX_PAGE_TEXT),
245
+ tables:
246
+ tablePages.find((candidate) => candidate.pageNumber === page.num)?.tables?.slice(0, 4) ??
247
+ [],
248
+ previewUrl: previewUrlForPage?.(page.num),
249
+ })),
250
+ };
251
+ } finally {
252
+ await parser.destroy();
253
+ }
254
+ }
255
+
256
+ export async function renderStoredPdfPage(
257
+ source: PdfSource,
258
+ pageNumber: number,
259
+ width = 1_600,
260
+ ): Promise<{ data: Buffer; contentType: string }> {
261
+ const PDFParse = await getPdfParser();
262
+ const parser = new PDFParse({ data: new Uint8Array(await readStoredPdfBytes(source)) });
263
+ try {
264
+ const info = await parser.getInfo();
265
+ if (!Number.isInteger(pageNumber) || pageNumber < 1 || pageNumber > info.total) {
266
+ throw new Error(`Page ${pageNumber} is outside this PDF.`);
267
+ }
268
+ const screenshots = await parser.getScreenshot({
269
+ partial: [pageNumber],
270
+ desiredWidth: Math.min(Math.max(width, 480), 2_000),
271
+ imageBuffer: true,
272
+ imageDataUrl: false,
273
+ });
274
+ const screenshot = screenshots.pages[0];
275
+ if (!screenshot?.data) throw new Error('Could not render the requested PDF page.');
276
+ return { data: Buffer.from(screenshot.data), contentType: 'image/png' };
277
+ } finally {
278
+ await parser.destroy();
279
+ }
280
+ }
@@ -0,0 +1,50 @@
1
+ const POSITION_PARAMETERS = new Set(['t', 'start', 'time', 'time_continue']);
2
+ const PRESENTATION_PARAMETERS = new Set(['si', 'feature']);
3
+
4
+ /** Identity for one remote media source, independent of seek/tracking parameters. */
5
+ export function canonicalMediaSourceUrl(value: string): string {
6
+ const trimmed = value.trim();
7
+ if (!trimmed) return '';
8
+ try {
9
+ const url = new URL(trimmed);
10
+ url.hash = '';
11
+ for (const key of [...url.searchParams.keys()]) {
12
+ if (
13
+ POSITION_PARAMETERS.has(key.toLocaleLowerCase()) ||
14
+ PRESENTATION_PARAMETERS.has(key.toLocaleLowerCase()) ||
15
+ key.toLocaleLowerCase().startsWith('utm_')
16
+ ) {
17
+ url.searchParams.delete(key);
18
+ }
19
+ }
20
+ url.searchParams.sort();
21
+ return url.toString();
22
+ } catch {
23
+ return trimmed;
24
+ }
25
+ }
26
+
27
+ type EquivalentMediaAsset = {
28
+ id: string;
29
+ originalUrl?: string;
30
+ updatedAt?: string;
31
+ createdAt?: string;
32
+ };
33
+
34
+ /** Resolve duplicate imports of one remote source to the newest indexed asset. */
35
+ export function newestEquivalentMediaAsset<T extends EquivalentMediaAsset>(
36
+ asset: T,
37
+ assets: readonly T[],
38
+ ): T {
39
+ const source = canonicalMediaSourceUrl(asset.originalUrl ?? '');
40
+ if (!source) return asset;
41
+ const timestamp = (candidate: T) => {
42
+ const parsed = Date.parse(candidate.updatedAt ?? candidate.createdAt ?? '');
43
+ return Number.isFinite(parsed) ? parsed : 0;
44
+ };
45
+ return (
46
+ assets
47
+ .filter((candidate) => canonicalMediaSourceUrl(candidate.originalUrl ?? '') === source)
48
+ .sort((left, right) => timestamp(right) - timestamp(left))[0] ?? asset
49
+ );
50
+ }