@demigodmode/pi-web-agent 1.7.2 → 1.9.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 (37) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/backends/config.d.ts +8 -0
  3. package/dist/backends/config.js +45 -0
  4. package/dist/backends/doctor.js +33 -0
  5. package/dist/backends/factory.js +45 -2
  6. package/dist/commands/web-agent-config.js +63 -6
  7. package/dist/extension.js +53 -12
  8. package/dist/orchestration/direct-url.js +2 -25
  9. package/dist/orchestration/evidence-quality.d.ts +1 -0
  10. package/dist/orchestration/evidence-quality.js +4 -2
  11. package/dist/orchestration/evidence-ranker.js +2 -0
  12. package/dist/orchestration/index.d.ts +2 -0
  13. package/dist/orchestration/research-orchestrator.d.ts +3 -1
  14. package/dist/orchestration/research-orchestrator.js +63 -6
  15. package/dist/orchestration/research-types.d.ts +7 -2
  16. package/dist/orchestration/research-worker.js +26 -3
  17. package/dist/orchestration/url.d.ts +6 -0
  18. package/dist/orchestration/url.js +34 -0
  19. package/dist/presentation/explore-presentation.js +18 -4
  20. package/dist/presentation/search-presentation.js +14 -2
  21. package/dist/readers/github-reader.d.ts +7 -0
  22. package/dist/readers/github-reader.js +130 -0
  23. package/dist/readers/limits.d.ts +3 -0
  24. package/dist/readers/limits.js +3 -0
  25. package/dist/readers/pdf-reader.d.ts +11 -0
  26. package/dist/readers/pdf-reader.js +76 -0
  27. package/dist/readers/resolver.d.ts +12 -0
  28. package/dist/readers/resolver.js +16 -0
  29. package/dist/readers/types.d.ts +10 -0
  30. package/dist/readers/types.js +1 -0
  31. package/dist/readers/youtube-reader.d.ts +21 -0
  32. package/dist/readers/youtube-reader.js +71 -0
  33. package/dist/search/fanout.d.ts +14 -0
  34. package/dist/search/fanout.js +118 -0
  35. package/dist/tools/web-explore.d.ts +3 -1
  36. package/dist/types.d.ts +13 -2
  37. package/package.json +4 -2
@@ -13,6 +13,9 @@ function classifyEvidenceUrl(url) {
13
13
  function summarizeText(text, maxLength = 180) {
14
14
  return text.replace(/\s+/g, ' ').trim().slice(0, maxLength);
15
15
  }
16
+ function isReaderMethod(method) {
17
+ return method === 'github' || method === 'pdf' || method === 'youtube';
18
+ }
16
19
  function isBotCheckContent({ title = '', text }) {
17
20
  return /performing security verification|security service|verify you are not a bot|just a moment|checking your browser/i.test(`${title}\n${text}`);
18
21
  }
@@ -21,6 +24,16 @@ function evidenceFromFetch(result) {
21
24
  return null;
22
25
  if (isBotCheckContent({ title: result.content.title, text: result.content.text }))
23
26
  return null;
27
+ if (isReaderMethod(result.metadata.method)) {
28
+ return {
29
+ title: result.content.title ?? result.url,
30
+ url: result.url,
31
+ sourceKind: 'primary-content',
32
+ method: result.metadata.method,
33
+ summary: result.content.text,
34
+ supports: [result.content.text]
35
+ };
36
+ }
24
37
  return {
25
38
  title: result.content.title ?? result.url,
26
39
  url: result.url,
@@ -66,13 +79,15 @@ function shouldRetryDirectWithHeadless(result, evidence) {
66
79
  return false;
67
80
  return classifySourceProfile(result.url).shouldPreferHeadlessWhenWeak;
68
81
  }
69
- function buildMetadata({ previousQueries, allEvidence, allGaps, allLowValueOutcomes, headlessAttempts, exhaustedBudget, caveatReasons = [] }) {
82
+ function buildMetadata({ previousQueries, allEvidence, allGaps, allLowValueOutcomes, headlessAttempts, exhaustedBudget, caveatReasons = [], fanoutProviders, fanoutSkipped }) {
70
83
  return {
71
84
  searchPasses: previousQueries.length,
72
85
  fetchedPages: allEvidence.length + allGaps.length + allLowValueOutcomes.length,
73
86
  headlessAttempts,
74
87
  exhaustedBudget,
75
- caveatReasons
88
+ caveatReasons,
89
+ fanoutProviders,
90
+ fanoutSkipped
76
91
  };
77
92
  }
78
93
  function decisionForAnswer({ action, query, ranked, exhaustedBudget }) {
@@ -99,6 +114,13 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
99
114
  const suggestedHeadlessUrls = [];
100
115
  let headlessAttempts = 0;
101
116
  let lastPass;
117
+ const fanoutProvidersSeen = new Set();
118
+ const fanoutSkippedSeen = new Set();
119
+ function fanoutSnapshot() {
120
+ const providers = fanoutProvidersSeen.size ? [...fanoutProvidersSeen] : undefined;
121
+ const skipped = [...fanoutSkippedSeen].filter((p) => !fanoutProvidersSeen.has(p));
122
+ return { fanoutProviders: providers, fanoutSkipped: skipped.length ? skipped : undefined };
123
+ }
102
124
  if (fetchDirect) {
103
125
  for (const url of extractDirectUrls(query).slice(0, 3)) {
104
126
  const directResult = await fetchDirect({ url });
@@ -134,6 +156,35 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
134
156
  }
135
157
  }
136
158
  }
159
+ if (allEvidence.some((item) => item.sourceKind === 'primary-content')) {
160
+ const ranked = rankEvidence(allEvidence.filter((item) => item.sourceKind !== 'package-page'));
161
+ const quality = analyzeEvidenceQuality({
162
+ evidence: ranked,
163
+ gaps: allGaps,
164
+ lowValueOutcomes: allLowValueOutcomes
165
+ });
166
+ return {
167
+ decision: decisionForAnswer({ action: 'answer', query, ranked, exhaustedBudget: false }),
168
+ evidence: ranked,
169
+ workerPass: combinedWorkerPass({
170
+ lastPass,
171
+ previousQueries,
172
+ allGaps,
173
+ allLowValueOutcomes,
174
+ exhaustedBudget: false
175
+ }),
176
+ metadata: buildMetadata({
177
+ previousQueries,
178
+ allEvidence,
179
+ allGaps,
180
+ allLowValueOutcomes,
181
+ headlessAttempts,
182
+ exhaustedBudget: false,
183
+ caveatReasons: quality.caveatReasons,
184
+ ...fanoutSnapshot()
185
+ })
186
+ };
187
+ }
137
188
  for (let passIndex = 0; passIndex < DEFAULT_MAX_PASSES; passIndex++) {
138
189
  const queries = planSearchQueries({
139
190
  originalQuery: query,
@@ -149,6 +200,8 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
149
200
  maxFetches: DEFAULT_MAX_FETCHES_PER_PASS
150
201
  });
151
202
  lastPass = pass;
203
+ pass.fanoutProviders?.forEach((p) => fanoutProvidersSeen.add(p));
204
+ pass.fanoutSkipped?.forEach((p) => fanoutSkippedSeen.add(p));
152
205
  allEvidence.push(...pass.evidence);
153
206
  allGaps.push(...pass.gaps);
154
207
  allLowValueOutcomes.push(...pass.lowValueOutcomes);
@@ -213,7 +266,8 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
213
266
  allLowValueOutcomes,
214
267
  headlessAttempts,
215
268
  exhaustedBudget,
216
- caveatReasons: updatedQuality.caveatReasons
269
+ caveatReasons: updatedQuality.caveatReasons,
270
+ ...fanoutSnapshot()
217
271
  })
218
272
  };
219
273
  }
@@ -239,7 +293,8 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
239
293
  allLowValueOutcomes,
240
294
  headlessAttempts,
241
295
  exhaustedBudget: false,
242
- caveatReasons: quality.caveatReasons
296
+ caveatReasons: quality.caveatReasons,
297
+ ...fanoutSnapshot()
243
298
  })
244
299
  };
245
300
  }
@@ -262,7 +317,8 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
262
317
  allLowValueOutcomes,
263
318
  headlessAttempts,
264
319
  exhaustedBudget,
265
- caveatReasons: quality.caveatReasons
320
+ caveatReasons: quality.caveatReasons,
321
+ ...fanoutSnapshot()
266
322
  })
267
323
  };
268
324
  }
@@ -291,7 +347,8 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
291
347
  allLowValueOutcomes,
292
348
  headlessAttempts,
293
349
  exhaustedBudget: true,
294
- caveatReasons: quality.caveatReasons
350
+ caveatReasons: quality.caveatReasons,
351
+ ...fanoutSnapshot()
295
352
  })
296
353
  };
297
354
  }
@@ -1,5 +1,6 @@
1
- export type ResearchSourceKind = 'official-docs' | 'official-api' | 'official-discussion' | 'community' | 'issue-thread' | 'package-page' | 'other';
2
- export type ResearchMethod = 'search' | 'http' | 'headless' | 'firecrawl';
1
+ import type { SearchProviderName } from '../types.js';
2
+ export type ResearchSourceKind = 'primary-content' | 'official-docs' | 'official-api' | 'official-discussion' | 'community' | 'issue-thread' | 'package-page' | 'other';
3
+ export type ResearchMethod = 'search' | 'http' | 'headless' | 'firecrawl' | 'github' | 'pdf' | 'youtube';
3
4
  export type ResearchEvidence = {
4
5
  title: string;
5
6
  url: string;
@@ -24,12 +25,16 @@ export type ResearchWorkerResult = {
24
25
  lowValueOutcomes: ResearchLowValueOutcome[];
25
26
  suggestedHeadlessUrl?: string;
26
27
  exhaustedBudget: boolean;
28
+ fanoutProviders?: SearchProviderName[];
29
+ fanoutSkipped?: SearchProviderName[];
27
30
  };
28
31
  export type ResearchRunMetadata = {
29
32
  searchPasses: number;
30
33
  fetchedPages: number;
31
34
  headlessAttempts: number;
32
35
  exhaustedBudget: boolean;
36
+ fanoutProviders?: SearchProviderName[];
37
+ fanoutSkipped?: SearchProviderName[];
33
38
  };
34
39
  export type ResearchOrchestratorDecision = {
35
40
  action: 'answer';
@@ -6,6 +6,9 @@ function classifySource(url) {
6
6
  function summarizeText(text, maxLength = 180) {
7
7
  return text.replace(/\s+/g, ' ').trim().slice(0, maxLength);
8
8
  }
9
+ function isReaderMethod(method) {
10
+ return method === 'github' || method === 'pdf' || method === 'youtube';
11
+ }
9
12
  function isBotCheckContent({ title = '', text }) {
10
13
  return /performing security verification|security service|verify you are not a bot|just a moment|checking your browser/i.test(`${title}\n${text}`);
11
14
  }
@@ -15,6 +18,18 @@ function evidenceFromFetch(fetched, fallbackTitle) {
15
18
  return null;
16
19
  if (isBotCheckContent({ title: content.title, text: content.text }))
17
20
  return null;
21
+ // A successful reader read with usable text is primary content, exempt from the
22
+ // package-page filter below.
23
+ if (isReaderMethod(fetched.metadata.method) && content.text.trim()) {
24
+ return {
25
+ title: content.title ?? fallbackTitle,
26
+ url: fetched.url,
27
+ sourceKind: 'primary-content',
28
+ method: fetched.metadata.method,
29
+ summary: content.text,
30
+ supports: [content.text]
31
+ };
32
+ }
18
33
  const sourceKind = classifySource(fetched.url);
19
34
  if (sourceKind === 'package-page') {
20
35
  return null;
@@ -65,6 +80,8 @@ export function createResearchWorker({ search, fetchPage }) {
65
80
  };
66
81
  }
67
82
  const searchResult = await search({ query });
83
+ const fanoutProviders = searchResult.metadata.fanout?.providers;
84
+ const fanoutSkipped = searchResult.metadata.fanout?.skipped;
68
85
  if (searchResult.status !== 'ok') {
69
86
  return {
70
87
  searchQueries,
@@ -77,7 +94,9 @@ export function createResearchWorker({ search, fetchPage }) {
77
94
  ],
78
95
  lowValueOutcomes,
79
96
  suggestedHeadlessUrl,
80
- exhaustedBudget: false
97
+ exhaustedBudget: false,
98
+ fanoutProviders,
99
+ fanoutSkipped
81
100
  };
82
101
  }
83
102
  if (searchResult.results.length === 0) {
@@ -92,7 +111,9 @@ export function createResearchWorker({ search, fetchPage }) {
92
111
  }
93
112
  ],
94
113
  suggestedHeadlessUrl,
95
- exhaustedBudget: false
114
+ exhaustedBudget: false,
115
+ fanoutProviders,
116
+ fanoutSkipped
96
117
  };
97
118
  }
98
119
  const candidates = selectCandidates({
@@ -133,7 +154,9 @@ export function createResearchWorker({ search, fetchPage }) {
133
154
  gaps,
134
155
  lowValueOutcomes,
135
156
  suggestedHeadlessUrl,
136
- exhaustedBudget: false
157
+ exhaustedBudget: false,
158
+ fanoutProviders,
159
+ fanoutSkipped
137
160
  };
138
161
  }
139
162
  };
@@ -0,0 +1,6 @@
1
+ declare const TRACKING_PARAMS: Set<string>;
2
+ /** Canonical form for dedupe/comparison. Returns undefined for non-http(s) or unparseable input. */
3
+ export declare function canonicalizeUrl(raw: string, options?: {
4
+ canonicalizeHost?: boolean;
5
+ }): string | undefined;
6
+ export { TRACKING_PARAMS };
@@ -0,0 +1,34 @@
1
+ const TRACKING_PARAMS = new Set([
2
+ 'utm_source',
3
+ 'utm_medium',
4
+ 'utm_campaign',
5
+ 'utm_term',
6
+ 'utm_content',
7
+ 'utm_name',
8
+ 'fbclid',
9
+ 'gclid'
10
+ ]);
11
+ /** Canonical form for dedupe/comparison. Returns undefined for non-http(s) or unparseable input. */
12
+ export function canonicalizeUrl(raw, options = {}) {
13
+ const { canonicalizeHost = true } = options;
14
+ let url;
15
+ try {
16
+ url = new URL(raw);
17
+ }
18
+ catch {
19
+ return undefined;
20
+ }
21
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
22
+ return undefined;
23
+ if (canonicalizeHost) {
24
+ url.hostname = url.hostname.replace(/^www\./, '');
25
+ }
26
+ for (const key of [...url.searchParams.keys()]) {
27
+ if (TRACKING_PARAMS.has(key.toLowerCase())) {
28
+ url.searchParams.delete(key);
29
+ }
30
+ }
31
+ url.hash = '';
32
+ return url.toString().replace(/\/$/, '');
33
+ }
34
+ export { TRACKING_PARAMS };
@@ -5,6 +5,12 @@ function internalReaderLabel(method) {
5
5
  return 'firecrawl';
6
6
  if (method === 'http')
7
7
  return 'web_fetch';
8
+ if (method === 'github')
9
+ return 'github';
10
+ if (method === 'pdf')
11
+ return 'pdf';
12
+ if (method === 'youtube')
13
+ return 'youtube';
8
14
  return 'web_explore';
9
15
  }
10
16
  export function buildExplorePresentation(result) {
@@ -16,8 +22,15 @@ export function buildExplorePresentation(result) {
16
22
  }
17
23
  };
18
24
  }
25
+ const fanoutProviders = result.metadata?.fanoutProviders;
26
+ const fanoutSkipped = result.metadata?.fanoutSkipped;
27
+ const fanoutNote = fanoutProviders?.length
28
+ ? ` (fanout: ${fanoutProviders.join(', ')}${fanoutSkipped?.length ? `; skipped: ${fanoutSkipped.join(', ')}` : ''})`
29
+ : fanoutSkipped?.length
30
+ ? ` (fanout; skipped: ${fanoutSkipped.join(', ')})`
31
+ : '';
19
32
  const internalSummary = result.metadata
20
- ? `Internal research: web_search ×${result.metadata.searchPasses}, web_fetch ×${result.metadata.fetchedPages}, web_fetch_headless ×${result.metadata.headlessAttempts}`
33
+ ? `Internal research: web_search ×${result.metadata.searchPasses}${fanoutNote}, web_fetch ×${result.metadata.fetchedPages}, web_fetch_headless ×${result.metadata.headlessAttempts}`
21
34
  : undefined;
22
35
  const hasEvidence = result.findings.length > 0 || result.sources.length > 0;
23
36
  const evidenceLines = hasEvidence
@@ -40,12 +53,13 @@ export function buildExplorePresentation(result) {
40
53
  ]
41
54
  .filter((line) => line !== undefined)
42
55
  .join('\n');
56
+ const compact = hasEvidence
57
+ ? `Reviewed ${result.sources.length} sources · synthesized answer with ${result.findings.length} findings`
58
+ : 'No usable evidence found';
43
59
  return {
44
60
  mode: 'compact',
45
61
  views: {
46
- compact: hasEvidence
47
- ? `Reviewed ${result.sources.length} sources · synthesized answer with ${result.findings.length} findings`
48
- : 'No usable evidence found',
62
+ compact,
49
63
  preview,
50
64
  verbose
51
65
  },
@@ -1,12 +1,24 @@
1
+ function fanoutNote(result) {
2
+ const f = result.metadata.fanout;
3
+ if (!f)
4
+ return '';
5
+ if (f.providers.length) {
6
+ return ` (fanout: ${f.providers.join(', ')}${f.skipped?.length ? `; skipped: ${f.skipped.join(', ')}` : ''})`;
7
+ }
8
+ if (f.skipped?.length) {
9
+ return ` (fanout; skipped: ${f.skipped.join(', ')})`;
10
+ }
11
+ return '';
12
+ }
1
13
  function formatCompact(result) {
2
14
  const fallbackPrefix = result.metadata.fallbackFrom
3
15
  ? `${result.metadata.fallbackFrom} failed; used ${result.metadata.backend} fallback. `
4
16
  : '';
5
17
  if (result.status === 'error') {
6
- return `${fallbackPrefix}Search failed: ${result.error?.message ?? 'Unknown search failure.'}`;
18
+ return `${fallbackPrefix}Search failed: ${result.error?.message ?? 'Unknown search failure.'}${fanoutNote(result)}`;
7
19
  }
8
20
  const suffix = result.results.length === 1 ? 'result' : 'results';
9
- return `${fallbackPrefix}Found ${result.results.length} ${suffix}`;
21
+ return `${fallbackPrefix}Found ${result.results.length} ${suffix}${fanoutNote(result)}`;
10
22
  }
11
23
  export function buildSearchPresentation(result) {
12
24
  const preview = result.results
@@ -0,0 +1,7 @@
1
+ import type { SpecialContentReader } from './types.js';
2
+ type GithubReaderDeps = {
3
+ fetchImpl?: typeof fetch;
4
+ token?: string;
5
+ };
6
+ export declare function createGithubReader({ fetchImpl, token }?: GithubReaderDeps): SpecialContentReader;
7
+ export {};
@@ -0,0 +1,130 @@
1
+ import { READER_TEXT_CAP } from './limits.js';
2
+ function parseGithub(url) {
3
+ try {
4
+ return new URL(url);
5
+ }
6
+ catch {
7
+ return undefined;
8
+ }
9
+ }
10
+ function classifyGithubShape(url) {
11
+ const parsed = parseGithub(url);
12
+ if (!parsed || parsed.hostname.toLowerCase() !== 'github.com') {
13
+ return undefined;
14
+ }
15
+ const segments = parsed.pathname.split('/').filter(Boolean);
16
+ const [owner, repo, type, ...rest] = segments;
17
+ // blob: [owner, repo, 'blob', ref, ...path] with rest.length >= 2
18
+ if (owner && repo && type === 'blob' && rest.length >= 2) {
19
+ const [ref, ...pathParts] = rest;
20
+ return { shape: 'blob', owner, repo, ref, path: pathParts.join('/') };
21
+ }
22
+ // issue: [owner, repo, 'issues', N]
23
+ if (owner && repo && type === 'issues' && rest[0]) {
24
+ return { shape: 'issue', owner, repo, num: rest[0] };
25
+ }
26
+ // pull: [owner, repo, 'pull', N]
27
+ if (owner && repo && type === 'pull' && rest[0]) {
28
+ return { shape: 'pull', owner, repo, num: rest[0] };
29
+ }
30
+ // repo-root: exactly [owner, repo]
31
+ if (owner && repo && !type) {
32
+ return { shape: 'repo-root', owner, repo };
33
+ }
34
+ return undefined;
35
+ }
36
+ function encodeSegment(segment) {
37
+ let decoded;
38
+ try {
39
+ decoded = decodeURIComponent(segment);
40
+ }
41
+ catch {
42
+ decoded = segment;
43
+ }
44
+ return encodeURIComponent(decoded);
45
+ }
46
+ function fail(url, message) {
47
+ return {
48
+ status: 'error',
49
+ url,
50
+ metadata: { method: 'github', cacheHit: false },
51
+ error: { code: 'GITHUB_FETCH_FAILED', message }
52
+ };
53
+ }
54
+ function okResponse(url, title, text) {
55
+ const capped = text.slice(0, READER_TEXT_CAP);
56
+ return {
57
+ status: 'ok',
58
+ url,
59
+ content: { title, text: capped },
60
+ metadata: { method: 'github', cacheHit: false, truncated: text.length >= READER_TEXT_CAP }
61
+ };
62
+ }
63
+ export function createGithubReader({ fetchImpl = fetch, token = process.env.GITHUB_TOKEN } = {}) {
64
+ function headers(json) {
65
+ const h = { 'User-Agent': 'pi-web-agent' };
66
+ if (json)
67
+ h.Accept = 'application/vnd.github+json';
68
+ if (token)
69
+ h.Authorization = `Bearer ${token}`;
70
+ return h;
71
+ }
72
+ async function getText(target) {
73
+ const res = await fetchImpl(target, { headers: headers(false) });
74
+ if (!res.ok)
75
+ throw new Error(`GitHub returned ${res.status} for ${target}`);
76
+ return res.text();
77
+ }
78
+ async function getJson(target) {
79
+ const res = await fetchImpl(target, { headers: headers(true) });
80
+ if (!res.ok)
81
+ throw new Error(`GitHub API returned ${res.status} for ${target}`);
82
+ return res.json();
83
+ }
84
+ async function readBlob(url, owner, repo, ref, path) {
85
+ const encodedPath = path.split('/').map(encodeSegment).join('/');
86
+ const raw = `https://raw.githubusercontent.com/${owner}/${repo}/${encodeSegment(ref)}/${encodedPath}`;
87
+ const text = await getText(raw);
88
+ return okResponse(url, `${owner}/${repo}/${path}`, text);
89
+ }
90
+ async function readThread(url, owner, repo, kind, num) {
91
+ const base = `https://api.github.com/repos/${owner}/${repo}/${kind}/${num}`;
92
+ const item = await getJson(base);
93
+ const comments = await getJson(`${base}/comments`);
94
+ const body = [item.body ?? '', ...comments.map((c) => c.body ?? '')].filter(Boolean).join('\n\n---\n\n');
95
+ return okResponse(url, item.title ?? `${owner}/${repo} ${kind} #${num}`, body);
96
+ }
97
+ async function readRepoRoot(url, owner, repo) {
98
+ const readmeMeta = await getJson(`https://api.github.com/repos/${owner}/${repo}/readme`);
99
+ const readme = readmeMeta.download_url ? await getText(readmeMeta.download_url) : '';
100
+ const tree = await getJson(`https://api.github.com/repos/${owner}/${repo}/contents`);
101
+ const listing = tree.map((entry) => `${entry.type === 'dir' ? '[dir] ' : ''}${entry.name}`).join('\n');
102
+ return okResponse(url, `${owner}/${repo}`, `${readme}\n\nTop-level contents:\n${listing}`);
103
+ }
104
+ return {
105
+ name: 'github',
106
+ canHandle(url) {
107
+ return classifyGithubShape(url) !== undefined;
108
+ },
109
+ async read(url) {
110
+ const shape = classifyGithubShape(url);
111
+ if (!shape)
112
+ return fail(url, 'Unsupported GitHub URL shape for the reader.');
113
+ try {
114
+ switch (shape.shape) {
115
+ case 'blob':
116
+ return await readBlob(url, shape.owner, shape.repo, shape.ref, shape.path);
117
+ case 'issue':
118
+ return await readThread(url, shape.owner, shape.repo, 'issues', shape.num);
119
+ case 'pull':
120
+ return await readThread(url, shape.owner, shape.repo, 'pulls', shape.num);
121
+ case 'repo-root':
122
+ return await readRepoRoot(url, shape.owner, shape.repo);
123
+ }
124
+ }
125
+ catch (err) {
126
+ return fail(url, err instanceof Error ? err.message : 'GitHub read failed.');
127
+ }
128
+ }
129
+ };
130
+ }
@@ -0,0 +1,3 @@
1
+ /** Max characters a special-content reader returns. Large enough for a typical transcript
2
+ * or a medium PDF; very long documents still truncate (section-aware chunking is future work). */
3
+ export declare const READER_TEXT_CAP = 24000;
@@ -0,0 +1,3 @@
1
+ /** Max characters a special-content reader returns. Large enough for a typical transcript
2
+ * or a medium PDF; very long documents still truncate (section-aware chunking is future work). */
3
+ export const READER_TEXT_CAP = 24000;
@@ -0,0 +1,11 @@
1
+ import type { SpecialContentReader } from './types.js';
2
+ type PdfReaderDeps = {
3
+ fetchImpl?: typeof fetch;
4
+ /** Injectable for tests; defaults to unpdf. */
5
+ extractPdfText?: (bytes: Uint8Array) => Promise<{
6
+ text: string;
7
+ title?: string;
8
+ }>;
9
+ };
10
+ export declare function createPdfReader({ fetchImpl, extractPdfText }?: PdfReaderDeps): SpecialContentReader;
11
+ export {};
@@ -0,0 +1,76 @@
1
+ import { extractText, getDocumentProxy, getMeta } from 'unpdf';
2
+ import { READER_TEXT_CAP } from './limits.js';
3
+ async function defaultExtract(bytes) {
4
+ const pdf = await getDocumentProxy(bytes);
5
+ const [{ text }, meta] = await Promise.all([
6
+ extractText(pdf, { mergePages: true }),
7
+ getMeta(pdf).catch(() => ({ info: undefined }))
8
+ ]);
9
+ const info = meta.info;
10
+ const rawTitle = info?.Title?.trim();
11
+ return { text, title: rawTitle ? rawTitle : undefined };
12
+ }
13
+ function isPdfUrl(url) {
14
+ try {
15
+ return new URL(url).pathname.toLowerCase().endsWith('.pdf');
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
21
+ function filenameFromUrl(url) {
22
+ try {
23
+ const last = new URL(url).pathname.split('/').filter(Boolean).pop() ?? 'PDF';
24
+ return decodeURIComponent(last);
25
+ }
26
+ catch {
27
+ return 'PDF';
28
+ }
29
+ }
30
+ export function createPdfReader({ fetchImpl = fetch, extractPdfText = defaultExtract } = {}) {
31
+ return {
32
+ name: 'pdf',
33
+ canHandle: isPdfUrl,
34
+ canHandleContentType(contentType) {
35
+ return contentType.toLowerCase().includes('application/pdf');
36
+ },
37
+ async read(url) {
38
+ try {
39
+ const response = await fetchImpl(url);
40
+ if (!('ok' in response) || !response.ok) {
41
+ return {
42
+ status: 'error',
43
+ url,
44
+ metadata: { method: 'pdf', cacheHit: false },
45
+ error: { code: 'PDF_READ_FAILED', message: `Fetching the PDF failed.` }
46
+ };
47
+ }
48
+ const bytes = new Uint8Array(await response.arrayBuffer());
49
+ const extracted = await extractPdfText(bytes);
50
+ const text = extracted.text.trim();
51
+ if (text.length === 0) {
52
+ return {
53
+ status: 'unsupported',
54
+ url,
55
+ metadata: { method: 'pdf', cacheHit: false, contentType: 'application/pdf' },
56
+ error: { code: 'PDF_NO_TEXT', message: 'This looks like a scanned PDF with no extractable text.' }
57
+ };
58
+ }
59
+ return {
60
+ status: 'ok',
61
+ url,
62
+ content: { title: extracted.title ?? filenameFromUrl(url), text: text.slice(0, READER_TEXT_CAP) },
63
+ metadata: { method: 'pdf', cacheHit: false, contentType: 'application/pdf', truncated: text.length >= READER_TEXT_CAP }
64
+ };
65
+ }
66
+ catch (err) {
67
+ return {
68
+ status: 'error',
69
+ url,
70
+ metadata: { method: 'pdf', cacheHit: false },
71
+ error: { code: 'PDF_READ_FAILED', message: err instanceof Error ? err.message : 'PDF read failed.' }
72
+ };
73
+ }
74
+ }
75
+ };
76
+ }
@@ -0,0 +1,12 @@
1
+ import type { WebFetchResponse } from '../types.js';
2
+ import type { SpecialContentReader } from './types.js';
3
+ type ResolverDeps = {
4
+ readers: SpecialContentReader[];
5
+ fallback: (input: {
6
+ url: string;
7
+ }) => Promise<WebFetchResponse>;
8
+ };
9
+ export declare function createSpecialContentResolver({ readers, fallback }: ResolverDeps): (input: {
10
+ url: string;
11
+ }) => Promise<WebFetchResponse>;
12
+ export {};
@@ -0,0 +1,16 @@
1
+ export function createSpecialContentResolver({ readers, fallback }) {
2
+ return async function resolve(input) {
3
+ const matched = readers.find((reader) => reader.canHandle(input.url));
4
+ if (matched) {
5
+ return matched.read(input.url);
6
+ }
7
+ const response = await fallback(input);
8
+ if (response.status === 'unsupported' && response.metadata.contentType) {
9
+ const byContentType = readers.find((reader) => reader.canHandleContentType?.(response.metadata.contentType));
10
+ if (byContentType) {
11
+ return byContentType.read(input.url);
12
+ }
13
+ }
14
+ return response;
15
+ };
16
+ }
@@ -0,0 +1,10 @@
1
+ import type { WebFetchResponse } from '../types.js';
2
+ export type SpecialContentReader = {
3
+ name: string;
4
+ /** Cheap URL-shape check. No network. */
5
+ canHandle(url: string): boolean;
6
+ /** Optional: claim a response by content-type after a normal fetch (e.g. application/pdf). */
7
+ canHandleContentType?(contentType: string): boolean;
8
+ /** Produce a normal WebFetchResponse. Must never throw. */
9
+ read(url: string): Promise<WebFetchResponse>;
10
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,21 @@
1
+ import type { SpecialContentReader } from './types.js';
2
+ type Subtitle = {
3
+ start: string;
4
+ dur: string;
5
+ text: string;
6
+ };
7
+ type YoutubeReaderDeps = {
8
+ fetchSubtitles?: (input: {
9
+ videoID: string;
10
+ lang: string;
11
+ }) => Promise<Subtitle[]>;
12
+ fetchDetails?: (input: {
13
+ videoID: string;
14
+ lang: string;
15
+ }) => Promise<{
16
+ title?: string;
17
+ description?: string;
18
+ }>;
19
+ };
20
+ export declare function createYoutubeReader({ fetchSubtitles, fetchDetails }?: YoutubeReaderDeps): SpecialContentReader;
21
+ export {};