opencode-dux 1.0.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.
- package/LICENSE +21 -0
- package/README.md +452 -0
- package/dist/agents/descriptions.d.ts +6 -0
- package/dist/agents/designer.d.ts +2 -0
- package/dist/agents/explorer.d.ts +2 -0
- package/dist/agents/fixer.d.ts +2 -0
- package/dist/agents/index.d.ts +22 -0
- package/dist/agents/interpreter.d.ts +2 -0
- package/dist/agents/librarian.d.ts +2 -0
- package/dist/agents/oracle.d.ts +2 -0
- package/dist/agents/orchestrator.d.ts +27 -0
- package/dist/agents/overrides.d.ts +18 -0
- package/dist/agents/prompt-blocks.d.ts +97 -0
- package/dist/agents/steward.d.ts +3 -0
- package/dist/cli/config-io.d.ts +24 -0
- package/dist/cli/config-manager.d.ts +4 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +1006 -0
- package/dist/cli/install.d.ts +2 -0
- package/dist/cli/mcps.d.ts +13 -0
- package/dist/cli/model-key-normalization.d.ts +1 -0
- package/dist/cli/paths.d.ts +35 -0
- package/dist/cli/providers.d.ts +137 -0
- package/dist/cli/skills.d.ts +22 -0
- package/dist/cli/system.d.ts +5 -0
- package/dist/cli/types.d.ts +38 -0
- package/dist/config/constants.d.ts +12 -0
- package/dist/config/index.d.ts +4 -0
- package/dist/config/loader.d.ts +40 -0
- package/dist/config/runtime-preset.d.ts +12 -0
- package/dist/config/schema.d.ts +281 -0
- package/dist/config/utils.d.ts +10 -0
- package/dist/discovery/local/types.d.ts +79 -0
- package/dist/discovery/local.d.ts +73 -0
- package/dist/discovery/mcp-servers.d.ts +88 -0
- package/dist/discovery/skills.d.ts +94 -0
- package/dist/hooks/apply-patch/codec.d.ts +7 -0
- package/dist/hooks/apply-patch/errors.d.ts +25 -0
- package/dist/hooks/apply-patch/execution-context.d.ts +27 -0
- package/dist/hooks/apply-patch/index.d.ts +15 -0
- package/dist/hooks/apply-patch/matching.d.ts +26 -0
- package/dist/hooks/apply-patch/operations.d.ts +3 -0
- package/dist/hooks/apply-patch/patch.d.ts +2 -0
- package/dist/hooks/apply-patch/prepared-changes.d.ts +17 -0
- package/dist/hooks/apply-patch/resolution.d.ts +19 -0
- package/dist/hooks/apply-patch/rewrite.d.ts +7 -0
- package/dist/hooks/apply-patch/test-helpers.d.ts +6 -0
- package/dist/hooks/apply-patch/types.d.ts +80 -0
- package/dist/hooks/auto-update-checker/cache.d.ts +11 -0
- package/dist/hooks/auto-update-checker/checker.d.ts +32 -0
- package/dist/hooks/auto-update-checker/constants.d.ts +11 -0
- package/dist/hooks/auto-update-checker/index.d.ts +18 -0
- package/dist/hooks/auto-update-checker/types.d.ts +22 -0
- package/dist/hooks/chat-headers.d.ts +16 -0
- package/dist/hooks/context-pressure-reminder/index.d.ts +33 -0
- package/dist/hooks/delegate-task-retry/guidance.d.ts +2 -0
- package/dist/hooks/delegate-task-retry/hook.d.ts +8 -0
- package/dist/hooks/delegate-task-retry/index.d.ts +4 -0
- package/dist/hooks/delegate-task-retry/patterns.d.ts +11 -0
- package/dist/hooks/filter-available-skills/index.d.ts +32 -0
- package/dist/hooks/foreground-fallback/index.d.ts +72 -0
- package/dist/hooks/image-hook.d.ts +5 -0
- package/dist/hooks/index.d.ts +14 -0
- package/dist/hooks/json-error-recovery/hook.d.ts +18 -0
- package/dist/hooks/json-error-recovery/index.d.ts +1 -0
- package/dist/hooks/phase-reminder/index.d.ts +26 -0
- package/dist/hooks/post-file-tool-nudge/index.d.ts +19 -0
- package/dist/hooks/task-session-manager/index.d.ts +52 -0
- package/dist/hooks/todo-continuation/index.d.ts +53 -0
- package/dist/hooks/todo-continuation/todo-hygiene.d.ts +35 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +31782 -0
- package/dist/mcp/context7.d.ts +6 -0
- package/dist/mcp/grep-app.d.ts +6 -0
- package/dist/mcp/index.d.ts +13 -0
- package/dist/mcp/types.d.ts +12 -0
- package/dist/mcp/websearch.d.ts +9 -0
- package/dist/skills/registry.d.ts +29 -0
- package/dist/subscriptions/accounts-store.d.ts +57 -0
- package/dist/subscriptions/index.d.ts +13 -0
- package/dist/subscriptions/neuralwatt-scraper.d.ts +14 -0
- package/dist/subscriptions/opencode-go-scraper.d.ts +27 -0
- package/dist/subscriptions/types.d.ts +115 -0
- package/dist/subscriptions/usage-service.d.ts +74 -0
- package/dist/tools/ast-grep/cli.d.ts +15 -0
- package/dist/tools/ast-grep/constants.d.ts +25 -0
- package/dist/tools/ast-grep/downloader.d.ts +5 -0
- package/dist/tools/ast-grep/index.d.ts +10 -0
- package/dist/tools/ast-grep/tools.d.ts +3 -0
- package/dist/tools/ast-grep/types.d.ts +30 -0
- package/dist/tools/ast-grep/utils.d.ts +4 -0
- package/dist/tools/delegate.d.ts +14 -0
- package/dist/tools/index.d.ts +5 -0
- package/dist/tools/preset-manager.d.ts +27 -0
- package/dist/tools/smartfetch/binary.d.ts +3 -0
- package/dist/tools/smartfetch/cache.d.ts +6 -0
- package/dist/tools/smartfetch/constants.d.ts +12 -0
- package/dist/tools/smartfetch/index.d.ts +3 -0
- package/dist/tools/smartfetch/network.d.ts +38 -0
- package/dist/tools/smartfetch/secondary-model.d.ts +28 -0
- package/dist/tools/smartfetch/tool.d.ts +3 -0
- package/dist/tools/smartfetch/types.d.ts +122 -0
- package/dist/tools/smartfetch/utils.d.ts +18 -0
- package/dist/tui-state.d.ts +168 -0
- package/dist/tui.d.ts +37 -0
- package/dist/tui.js +1896 -0
- package/dist/utils/agent-variant.d.ts +63 -0
- package/dist/utils/compat.d.ts +30 -0
- package/dist/utils/env.d.ts +1 -0
- package/dist/utils/index.d.ts +9 -0
- package/dist/utils/internal-initiator.d.ts +6 -0
- package/dist/utils/logger.d.ts +8 -0
- package/dist/utils/polling.d.ts +21 -0
- package/dist/utils/session-manager.d.ts +55 -0
- package/dist/utils/session.d.ts +90 -0
- package/dist/utils/subagent-depth.d.ts +35 -0
- package/dist/utils/system-collapse.d.ts +6 -0
- package/dist/utils/task.d.ts +4 -0
- package/dist/utils/zip-extractor.d.ts +1 -0
- package/index.ts +1 -0
- package/opencode-dux.schema.json +634 -0
- package/package.json +103 -0
- package/src/agents/descriptions.ts +55 -0
- package/src/agents/designer.test.ts +86 -0
- package/src/agents/designer.ts +154 -0
- package/src/agents/display-name.test.ts +186 -0
- package/src/agents/explorer.test.ts +79 -0
- package/src/agents/explorer.ts +144 -0
- package/src/agents/fixer.test.ts +79 -0
- package/src/agents/fixer.ts +145 -0
- package/src/agents/index.test.ts +472 -0
- package/src/agents/index.ts +248 -0
- package/src/agents/interpreter.ts +136 -0
- package/src/agents/librarian.test.ts +80 -0
- package/src/agents/librarian.ts +145 -0
- package/src/agents/oracle.test.ts +89 -0
- package/src/agents/oracle.ts +184 -0
- package/src/agents/orchestrator.test.ts +116 -0
- package/src/agents/orchestrator.ts +574 -0
- package/src/agents/overrides.ts +95 -0
- package/src/agents/prompt-blocks.test.ts +114 -0
- package/src/agents/prompt-blocks.ts +640 -0
- package/src/agents/steward.ts +146 -0
- package/src/cli/config-io.test.ts +536 -0
- package/src/cli/config-io.ts +473 -0
- package/src/cli/config-manager.test.ts +141 -0
- package/src/cli/config-manager.ts +4 -0
- package/src/cli/index.ts +88 -0
- package/src/cli/install.ts +282 -0
- package/src/cli/mcps.test.ts +62 -0
- package/src/cli/mcps.ts +39 -0
- package/src/cli/model-key-normalization.test.ts +21 -0
- package/src/cli/model-key-normalization.ts +60 -0
- package/src/cli/paths.test.ts +167 -0
- package/src/cli/paths.ts +144 -0
- package/src/cli/providers.test.ts +118 -0
- package/src/cli/providers.ts +141 -0
- package/src/cli/skills.test.ts +111 -0
- package/src/cli/skills.ts +103 -0
- package/src/cli/system.test.ts +91 -0
- package/src/cli/system.ts +180 -0
- package/src/cli/types.ts +43 -0
- package/src/config/constants.ts +58 -0
- package/src/config/index.ts +4 -0
- package/src/config/loader.test.ts +1194 -0
- package/src/config/loader.ts +269 -0
- package/src/config/model-resolution.test.ts +176 -0
- package/src/config/runtime-preset.test.ts +61 -0
- package/src/config/runtime-preset.ts +37 -0
- package/src/config/schema.ts +248 -0
- package/src/config/utils.test.ts +41 -0
- package/src/config/utils.ts +23 -0
- package/src/discovery/local/types.ts +85 -0
- package/src/discovery/local.ts +322 -0
- package/src/discovery/mcp-servers.ts +804 -0
- package/src/discovery/skills.ts +959 -0
- package/src/hooks/apply-patch/codec.test.ts +184 -0
- package/src/hooks/apply-patch/codec.ts +352 -0
- package/src/hooks/apply-patch/errors.ts +117 -0
- package/src/hooks/apply-patch/execution-context.ts +432 -0
- package/src/hooks/apply-patch/hook.test.ts +768 -0
- package/src/hooks/apply-patch/index.ts +126 -0
- package/src/hooks/apply-patch/matching.test.ts +215 -0
- package/src/hooks/apply-patch/matching.ts +586 -0
- package/src/hooks/apply-patch/operations.test.ts +1535 -0
- package/src/hooks/apply-patch/operations.ts +3 -0
- package/src/hooks/apply-patch/patch.ts +9 -0
- package/src/hooks/apply-patch/prepared-changes.ts +400 -0
- package/src/hooks/apply-patch/resolution.test.ts +420 -0
- package/src/hooks/apply-patch/resolution.ts +437 -0
- package/src/hooks/apply-patch/rewrite.ts +496 -0
- package/src/hooks/apply-patch/test-helpers.ts +52 -0
- package/src/hooks/apply-patch/types.ts +111 -0
- package/src/hooks/auto-update-checker/cache.test.ts +179 -0
- package/src/hooks/auto-update-checker/cache.ts +188 -0
- package/src/hooks/auto-update-checker/checker.test.ts +159 -0
- package/src/hooks/auto-update-checker/checker.ts +308 -0
- package/src/hooks/auto-update-checker/constants.ts +33 -0
- package/src/hooks/auto-update-checker/index.test.ts +282 -0
- package/src/hooks/auto-update-checker/index.ts +225 -0
- package/src/hooks/auto-update-checker/types.ts +26 -0
- package/src/hooks/chat-headers.test.ts +236 -0
- package/src/hooks/chat-headers.ts +97 -0
- package/src/hooks/context-pressure-reminder/index.test.ts +179 -0
- package/src/hooks/context-pressure-reminder/index.ts +137 -0
- package/src/hooks/delegate-task-retry/guidance.ts +41 -0
- package/src/hooks/delegate-task-retry/hook.ts +23 -0
- package/src/hooks/delegate-task-retry/index.test.ts +38 -0
- package/src/hooks/delegate-task-retry/index.ts +7 -0
- package/src/hooks/delegate-task-retry/patterns.ts +79 -0
- package/src/hooks/filter-available-skills/index.test.ts +297 -0
- package/src/hooks/filter-available-skills/index.ts +160 -0
- package/src/hooks/foreground-fallback/index.test.ts +624 -0
- package/src/hooks/foreground-fallback/index.ts +374 -0
- package/src/hooks/image-hook.ts +6 -0
- package/src/hooks/index.ts +17 -0
- package/src/hooks/json-error-recovery/hook.ts +73 -0
- package/src/hooks/json-error-recovery/index.test.ts +111 -0
- package/src/hooks/json-error-recovery/index.ts +6 -0
- package/src/hooks/phase-reminder/index.test.ts +74 -0
- package/src/hooks/phase-reminder/index.ts +85 -0
- package/src/hooks/post-file-tool-nudge/index.test.ts +94 -0
- package/src/hooks/post-file-tool-nudge/index.ts +63 -0
- package/src/hooks/task-session-manager/index.test.ts +833 -0
- package/src/hooks/task-session-manager/index.ts +434 -0
- package/src/hooks/todo-continuation/index.test.ts +3026 -0
- package/src/hooks/todo-continuation/index.ts +878 -0
- package/src/hooks/todo-continuation/todo-hygiene.test.ts +204 -0
- package/src/hooks/todo-continuation/todo-hygiene.ts +207 -0
- package/src/index.ts +1672 -0
- package/src/mcp/context7.ts +14 -0
- package/src/mcp/grep-app.ts +11 -0
- package/src/mcp/index.test.ts +96 -0
- package/src/mcp/index.ts +66 -0
- package/src/mcp/types.ts +16 -0
- package/src/mcp/websearch.ts +47 -0
- package/src/skills/codemap/README.md +60 -0
- package/src/skills/codemap/SKILL.md +174 -0
- package/src/skills/codemap/scripts/codemap.mjs +483 -0
- package/src/skills/codemap/scripts/codemap.test.ts +129 -0
- package/src/skills/registry.ts +218 -0
- package/src/skills/simplify/README.md +19 -0
- package/src/skills/simplify/SKILL.md +138 -0
- package/src/subscriptions/accounts-store.test.ts +236 -0
- package/src/subscriptions/accounts-store.ts +184 -0
- package/src/subscriptions/index.ts +30 -0
- package/src/subscriptions/neuralwatt-scraper.ts +108 -0
- package/src/subscriptions/opencode-go-scraper.ts +301 -0
- package/src/subscriptions/types.ts +145 -0
- package/src/subscriptions/usage-service.test.ts +202 -0
- package/src/subscriptions/usage-service.ts +651 -0
- package/src/tools/ast-grep/cli.ts +257 -0
- package/src/tools/ast-grep/constants.ts +214 -0
- package/src/tools/ast-grep/downloader.ts +131 -0
- package/src/tools/ast-grep/index.ts +24 -0
- package/src/tools/ast-grep/tools.ts +117 -0
- package/src/tools/ast-grep/types.ts +51 -0
- package/src/tools/ast-grep/utils.ts +126 -0
- package/src/tools/delegate-handoff.test.ts +18 -0
- package/src/tools/delegate.ts +508 -0
- package/src/tools/index.ts +8 -0
- package/src/tools/preset-manager.test.ts +795 -0
- package/src/tools/preset-manager.ts +332 -0
- package/src/tools/smartfetch/binary.ts +58 -0
- package/src/tools/smartfetch/cache.test.ts +34 -0
- package/src/tools/smartfetch/cache.ts +112 -0
- package/src/tools/smartfetch/constants.ts +29 -0
- package/src/tools/smartfetch/index.ts +8 -0
- package/src/tools/smartfetch/network.test.ts +178 -0
- package/src/tools/smartfetch/network.ts +614 -0
- package/src/tools/smartfetch/secondary-model.test.ts +85 -0
- package/src/tools/smartfetch/secondary-model.ts +276 -0
- package/src/tools/smartfetch/tool.test.ts +60 -0
- package/src/tools/smartfetch/tool.ts +832 -0
- package/src/tools/smartfetch/types.ts +135 -0
- package/src/tools/smartfetch/utils.test.ts +24 -0
- package/src/tools/smartfetch/utils.ts +456 -0
- package/src/tui-state.test.ts +867 -0
- package/src/tui-state.ts +1255 -0
- package/src/tui.test.ts +336 -0
- package/src/tui.ts +1539 -0
- package/src/utils/agent-variant.test.ts +244 -0
- package/src/utils/agent-variant.ts +187 -0
- package/src/utils/compat.ts +91 -0
- package/src/utils/env.ts +12 -0
- package/src/utils/index.ts +9 -0
- package/src/utils/internal-initiator.ts +28 -0
- package/src/utils/logger.test.ts +220 -0
- package/src/utils/logger.ts +136 -0
- package/src/utils/polling.test.ts +191 -0
- package/src/utils/polling.ts +67 -0
- package/src/utils/session-manager.test.ts +173 -0
- package/src/utils/session-manager.ts +356 -0
- package/src/utils/session.test.ts +110 -0
- package/src/utils/session.ts +389 -0
- package/src/utils/subagent-depth.test.ts +170 -0
- package/src/utils/subagent-depth.ts +75 -0
- package/src/utils/system-collapse.test.ts +86 -0
- package/src/utils/system-collapse.ts +24 -0
- package/src/utils/task.test.ts +24 -0
- package/src/utils/task.ts +20 -0
- package/src/utils/zip-extractor.ts +102 -0
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import {
|
|
3
|
+
BINARY_PREFIXES,
|
|
4
|
+
DEFAULT_ACCEPT_LANGUAGE,
|
|
5
|
+
DOCS_HOST_PREFIXES,
|
|
6
|
+
DOCS_HOST_SUFFIXES,
|
|
7
|
+
MAX_REDIRECTS,
|
|
8
|
+
MAX_RESPONSE_BYTES,
|
|
9
|
+
} from './constants';
|
|
10
|
+
import type {
|
|
11
|
+
BinaryFetch,
|
|
12
|
+
DecodedBody,
|
|
13
|
+
FetchResult,
|
|
14
|
+
FetchWithRedirectsResult,
|
|
15
|
+
LlmsProbeResult,
|
|
16
|
+
} from './types';
|
|
17
|
+
import { trimBlankRuns } from './utils';
|
|
18
|
+
|
|
19
|
+
export function normalizeUrl(input: string) {
|
|
20
|
+
const parsed = new URL(input);
|
|
21
|
+
const originalUrl = parsed.toString();
|
|
22
|
+
let upgradedToHttps = false;
|
|
23
|
+
let fallbackUrl: string | undefined;
|
|
24
|
+
if (parsed.protocol === 'http:') {
|
|
25
|
+
fallbackUrl = originalUrl;
|
|
26
|
+
parsed.protocol = 'https:';
|
|
27
|
+
upgradedToHttps = true;
|
|
28
|
+
}
|
|
29
|
+
return { url: parsed.toString(), upgradedToHttps, fallbackUrl, originalUrl };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isDocsLikeUrl(url: URL) {
|
|
33
|
+
const host = url.hostname.toLowerCase();
|
|
34
|
+
return (
|
|
35
|
+
DOCS_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix)) ||
|
|
36
|
+
DOCS_HOST_PREFIXES.some((prefix) => host.startsWith(prefix))
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function buildPermissionPatterns(
|
|
41
|
+
normalized: ReturnType<typeof normalizeUrl>,
|
|
42
|
+
shouldProbeLlmsTxt: boolean,
|
|
43
|
+
) {
|
|
44
|
+
const patterns = new Set<string>([normalized.url]);
|
|
45
|
+
const origins = [new URL(normalized.url).origin];
|
|
46
|
+
if (normalized.fallbackUrl) {
|
|
47
|
+
patterns.add(normalized.fallbackUrl);
|
|
48
|
+
origins.push(new URL(normalized.fallbackUrl).origin);
|
|
49
|
+
}
|
|
50
|
+
if (shouldProbeLlmsTxt) {
|
|
51
|
+
for (const origin of origins) {
|
|
52
|
+
patterns.add(`${origin}/llms-full.txt`);
|
|
53
|
+
patterns.add(`${origin}/llms.txt`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return [...patterns];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function buildAllowedOrigins(patterns: string[]) {
|
|
60
|
+
const origins = new Set<string>();
|
|
61
|
+
for (const pattern of patterns) {
|
|
62
|
+
try {
|
|
63
|
+
origins.add(new URL(pattern).origin);
|
|
64
|
+
} catch {
|
|
65
|
+
// ignore invalid patterns
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return origins;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function canUseCanonicalCacheAlias(baseUrl: string, aliasUrl: string) {
|
|
72
|
+
try {
|
|
73
|
+
const base = new URL(baseUrl);
|
|
74
|
+
const alias = new URL(aliasUrl);
|
|
75
|
+
if (alias.username || alias.password) return false;
|
|
76
|
+
return (
|
|
77
|
+
base.protocol === alias.protocol &&
|
|
78
|
+
base.hostname === alias.hostname &&
|
|
79
|
+
base.port === alias.port &&
|
|
80
|
+
base.pathname === alias.pathname &&
|
|
81
|
+
base.search === alias.search
|
|
82
|
+
);
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isPermittedRedirect(
|
|
89
|
+
from: string,
|
|
90
|
+
to: string,
|
|
91
|
+
allowedOrigins?: Set<string>,
|
|
92
|
+
) {
|
|
93
|
+
try {
|
|
94
|
+
const a = new URL(from);
|
|
95
|
+
const b = new URL(to);
|
|
96
|
+
if (a.protocol !== b.protocol) return false;
|
|
97
|
+
if (a.port !== b.port) return false;
|
|
98
|
+
if (b.username || b.password) return false;
|
|
99
|
+
if (allowedOrigins) return allowedOrigins.has(b.origin);
|
|
100
|
+
return a.origin === b.origin;
|
|
101
|
+
} catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function isBinaryContentType(contentType: string) {
|
|
107
|
+
const mime = contentType.split(';')[0]?.trim().toLowerCase() || '';
|
|
108
|
+
return BINARY_PREFIXES.some((prefix) => mime.startsWith(prefix));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function getBinaryKind(contentType: string): BinaryFetch['binaryKind'] {
|
|
112
|
+
const mime = contentType.split(';')[0]?.trim().toLowerCase() || '';
|
|
113
|
+
if (mime.startsWith('image/')) return 'image';
|
|
114
|
+
if (mime.startsWith('audio/')) return 'audio';
|
|
115
|
+
if (mime.startsWith('video/')) return 'video';
|
|
116
|
+
if (mime === 'application/pdf') return 'pdf';
|
|
117
|
+
return 'binary';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function acceptHeader(_format: 'text' | 'markdown' | 'html') {
|
|
121
|
+
return 'text/html;q=1.0, application/xhtml+xml;q=0.9, text/markdown;q=0.8, text/plain;q=0.8, */*;q=0.1';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function inferCharsetFromHtml(text: string) {
|
|
125
|
+
const metaCharset = text.match(
|
|
126
|
+
/<meta[^>]+charset\s*=\s*["']?([^\s"'>/;]+)/i,
|
|
127
|
+
)?.[1];
|
|
128
|
+
if (metaCharset) return metaCharset.trim();
|
|
129
|
+
const httpEquiv = text.match(
|
|
130
|
+
/<meta[^>]+http-equiv\s*=\s*["']content-type["'][^>]+content\s*=\s*["'][^"']*charset=([^\s"'>;]+)/i,
|
|
131
|
+
)?.[1];
|
|
132
|
+
if (httpEquiv) return httpEquiv.trim();
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function looksLikeHtmlText(text: string) {
|
|
137
|
+
return /^\s*(<!doctype html|<html\b|<head\b|<body\b)/i.test(text);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isLikelyDecodedText(text: string) {
|
|
141
|
+
if (!text) return false;
|
|
142
|
+
let suspicious = 0;
|
|
143
|
+
let printable = 0;
|
|
144
|
+
for (const char of text.slice(0, 2048)) {
|
|
145
|
+
const code = char.charCodeAt(0);
|
|
146
|
+
const isWhitespace =
|
|
147
|
+
code === 9 || code === 10 || code === 13 || code === 32;
|
|
148
|
+
const isControl = code < 32 && !isWhitespace;
|
|
149
|
+
if (isControl) suspicious++;
|
|
150
|
+
else printable++;
|
|
151
|
+
}
|
|
152
|
+
const total = Math.max(printable + suspicious, 1);
|
|
153
|
+
return suspicious / total < 0.02 && printable / total > 0.85;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function tryDecodeWithCharset(data: Uint8Array, charset: string) {
|
|
157
|
+
try {
|
|
158
|
+
return new TextDecoder(
|
|
159
|
+
charset,
|
|
160
|
+
charset.toLowerCase() === 'utf-8' ? { fatal: true } : undefined,
|
|
161
|
+
).decode(data);
|
|
162
|
+
} catch {
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function detectBestEffortCharset(data: Uint8Array) {
|
|
168
|
+
for (const charset of ['utf-8', 'windows-1252', 'iso-8859-1']) {
|
|
169
|
+
const decoded = tryDecodeWithCharset(data, charset);
|
|
170
|
+
if (decoded && isLikelyDecodedText(decoded)) {
|
|
171
|
+
return { charset, text: decoded };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function runWithScopedTimeout<T>(
|
|
178
|
+
parentSignal: AbortSignal,
|
|
179
|
+
timeoutMs: number,
|
|
180
|
+
fn: (signal: AbortSignal) => Promise<T>,
|
|
181
|
+
) {
|
|
182
|
+
const controller = new AbortController();
|
|
183
|
+
const abortHandler = () => controller.abort(parentSignal.reason);
|
|
184
|
+
if (parentSignal.aborted) controller.abort(parentSignal.reason);
|
|
185
|
+
else parentSignal.addEventListener('abort', abortHandler, { once: true });
|
|
186
|
+
const timeout = setTimeout(
|
|
187
|
+
() => controller.abort(new Error(`timeout after ${timeoutMs}ms`)),
|
|
188
|
+
timeoutMs,
|
|
189
|
+
);
|
|
190
|
+
try {
|
|
191
|
+
return await fn(controller.signal);
|
|
192
|
+
} finally {
|
|
193
|
+
clearTimeout(timeout);
|
|
194
|
+
parentSignal.removeEventListener('abort', abortHandler);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function readBodyLimited(
|
|
199
|
+
response: Response,
|
|
200
|
+
maxBytes = MAX_RESPONSE_BYTES,
|
|
201
|
+
) {
|
|
202
|
+
if (!response.body) return { data: new Uint8Array(), truncated: false };
|
|
203
|
+
const reader = response.body.getReader();
|
|
204
|
+
const chunks: Uint8Array[] = [];
|
|
205
|
+
let total = 0;
|
|
206
|
+
let truncated = false;
|
|
207
|
+
|
|
208
|
+
while (true) {
|
|
209
|
+
const { value, done } = await reader.read();
|
|
210
|
+
if (done) break;
|
|
211
|
+
if (!value) continue;
|
|
212
|
+
if (total + value.byteLength > maxBytes) {
|
|
213
|
+
const allowed = maxBytes - total;
|
|
214
|
+
if (allowed > 0) chunks.push(value.slice(0, allowed));
|
|
215
|
+
truncated = true;
|
|
216
|
+
try {
|
|
217
|
+
await reader.cancel();
|
|
218
|
+
} catch {
|
|
219
|
+
// ignore cancel failures
|
|
220
|
+
}
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
chunks.push(value);
|
|
224
|
+
total += value.byteLength;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const merged = new Uint8Array(
|
|
228
|
+
chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0),
|
|
229
|
+
);
|
|
230
|
+
let offset = 0;
|
|
231
|
+
for (const chunk of chunks) {
|
|
232
|
+
merged.set(chunk, offset);
|
|
233
|
+
offset += chunk.byteLength;
|
|
234
|
+
}
|
|
235
|
+
return { data: merged, truncated };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export async function fetchWithRedirects(
|
|
239
|
+
url: string,
|
|
240
|
+
_timeoutMs: number,
|
|
241
|
+
format: 'text' | 'markdown' | 'html',
|
|
242
|
+
signal: AbortSignal,
|
|
243
|
+
extraHeaders?: Record<string, string>,
|
|
244
|
+
method: 'GET' | 'HEAD' = 'GET',
|
|
245
|
+
allowedOrigins?: Set<string>,
|
|
246
|
+
): Promise<FetchWithRedirectsResult> {
|
|
247
|
+
const redirects = [];
|
|
248
|
+
let current = url;
|
|
249
|
+
|
|
250
|
+
for (let i = 0; i < MAX_REDIRECTS; i++) {
|
|
251
|
+
const response = await fetch(current, {
|
|
252
|
+
redirect: 'manual',
|
|
253
|
+
signal,
|
|
254
|
+
method,
|
|
255
|
+
headers: {
|
|
256
|
+
'User-Agent': 'opencode-smartfetch/1.0',
|
|
257
|
+
Accept: acceptHeader(format),
|
|
258
|
+
'Accept-Language': DEFAULT_ACCEPT_LANGUAGE,
|
|
259
|
+
...extraHeaders,
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
if (response.status >= 300 && response.status < 400) {
|
|
264
|
+
const location = response.headers.get('location');
|
|
265
|
+
if (!location) {
|
|
266
|
+
throw new Error(
|
|
267
|
+
`Redirect response missing location header: ${response.status}`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
const next = new URL(location, current).toString();
|
|
271
|
+
redirects.push({ from: current, to: next, status: response.status });
|
|
272
|
+
if (!isPermittedRedirect(current, next, allowedOrigins)) {
|
|
273
|
+
try {
|
|
274
|
+
await response.body?.cancel();
|
|
275
|
+
} catch {
|
|
276
|
+
// ignore cancel failures
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
blockedRedirect: true,
|
|
280
|
+
redirectUrl: next,
|
|
281
|
+
statusCode: response.status,
|
|
282
|
+
redirectChain: redirects,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
await response.body?.cancel();
|
|
287
|
+
} catch {
|
|
288
|
+
// ignore cancel failures
|
|
289
|
+
}
|
|
290
|
+
current = next;
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return { response, finalUrl: current, redirectChain: redirects };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
throw new Error(`Too many redirects (exceeded ${MAX_REDIRECTS})`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export async function fetchWithUpgradeFallback(
|
|
301
|
+
normalized: ReturnType<typeof normalizeUrl>,
|
|
302
|
+
timeoutMs: number,
|
|
303
|
+
format: 'text' | 'markdown' | 'html',
|
|
304
|
+
signal: AbortSignal,
|
|
305
|
+
extraHeaders?: Record<string, string>,
|
|
306
|
+
method: 'GET' | 'HEAD' = 'GET',
|
|
307
|
+
allowedOrigins?: Set<string>,
|
|
308
|
+
) {
|
|
309
|
+
try {
|
|
310
|
+
const result = await fetchWithRedirects(
|
|
311
|
+
normalized.url,
|
|
312
|
+
timeoutMs,
|
|
313
|
+
format,
|
|
314
|
+
signal,
|
|
315
|
+
extraHeaders,
|
|
316
|
+
method,
|
|
317
|
+
allowedOrigins,
|
|
318
|
+
);
|
|
319
|
+
if (normalized.fallbackUrl && 'blockedRedirect' in result) {
|
|
320
|
+
const fallbackResult = await fetchWithRedirects(
|
|
321
|
+
normalized.fallbackUrl,
|
|
322
|
+
timeoutMs,
|
|
323
|
+
format,
|
|
324
|
+
signal,
|
|
325
|
+
extraHeaders,
|
|
326
|
+
method,
|
|
327
|
+
allowedOrigins,
|
|
328
|
+
);
|
|
329
|
+
return { result: fallbackResult, upgradedToHttps: false };
|
|
330
|
+
}
|
|
331
|
+
if (
|
|
332
|
+
normalized.fallbackUrl &&
|
|
333
|
+
!('blockedRedirect' in result) &&
|
|
334
|
+
result.response.status !== 304 &&
|
|
335
|
+
!result.response.ok
|
|
336
|
+
) {
|
|
337
|
+
const fallbackResult = await fetchWithRedirects(
|
|
338
|
+
normalized.fallbackUrl,
|
|
339
|
+
timeoutMs,
|
|
340
|
+
format,
|
|
341
|
+
signal,
|
|
342
|
+
extraHeaders,
|
|
343
|
+
method,
|
|
344
|
+
allowedOrigins,
|
|
345
|
+
);
|
|
346
|
+
return { result: fallbackResult, upgradedToHttps: false };
|
|
347
|
+
}
|
|
348
|
+
return { result, upgradedToHttps: normalized.upgradedToHttps };
|
|
349
|
+
} catch (error) {
|
|
350
|
+
if (!normalized.fallbackUrl) throw error;
|
|
351
|
+
const result = await fetchWithRedirects(
|
|
352
|
+
normalized.fallbackUrl,
|
|
353
|
+
timeoutMs,
|
|
354
|
+
format,
|
|
355
|
+
signal,
|
|
356
|
+
extraHeaders,
|
|
357
|
+
method,
|
|
358
|
+
allowedOrigins,
|
|
359
|
+
);
|
|
360
|
+
return { result, upgradedToHttps: false };
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function parseContentLength(headers: Headers) {
|
|
365
|
+
const raw = headers.get('content-length');
|
|
366
|
+
if (!raw) return undefined;
|
|
367
|
+
const parsed = Number.parseInt(raw, 10);
|
|
368
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function parseCharset(contentType: string) {
|
|
372
|
+
const match = contentType.match(/charset\s*=\s*([^;]+)/i);
|
|
373
|
+
return match?.[1]?.trim().replace(/^['"]|['"]$/g, '') || undefined;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function isHtmlLikeContentType(contentType: string) {
|
|
377
|
+
const mime = contentType.split(';')[0]?.trim().toLowerCase() || '';
|
|
378
|
+
return mime === 'text/html' || mime === 'application/xhtml+xml';
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function decodeBody(
|
|
382
|
+
data: Uint8Array,
|
|
383
|
+
charset: string | undefined,
|
|
384
|
+
contentType?: string,
|
|
385
|
+
): DecodedBody {
|
|
386
|
+
let declaredCharset = charset?.trim() || undefined;
|
|
387
|
+
const utf8Text = new TextDecoder().decode(data);
|
|
388
|
+
|
|
389
|
+
if (!declaredCharset && contentType && isHtmlLikeContentType(contentType)) {
|
|
390
|
+
declaredCharset = inferCharsetFromHtml(utf8Text);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (!declaredCharset) {
|
|
394
|
+
const detected = detectBestEffortCharset(data);
|
|
395
|
+
if (detected && detected.charset !== 'utf-8') {
|
|
396
|
+
return {
|
|
397
|
+
text: detected.text,
|
|
398
|
+
decodedCharset: detected.charset,
|
|
399
|
+
decodeFallback: true,
|
|
400
|
+
decodeWarning: `Guessed charset without declaration: ${detected.charset}`,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
return {
|
|
404
|
+
text: utf8Text,
|
|
405
|
+
decodedCharset: 'utf-8',
|
|
406
|
+
decodeFallback: false,
|
|
407
|
+
decodeWarning: undefined,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
try {
|
|
412
|
+
return {
|
|
413
|
+
text: new TextDecoder(declaredCharset).decode(data),
|
|
414
|
+
decodedCharset: declaredCharset,
|
|
415
|
+
decodeFallback: false,
|
|
416
|
+
decodeWarning: undefined,
|
|
417
|
+
};
|
|
418
|
+
} catch {
|
|
419
|
+
return {
|
|
420
|
+
text: utf8Text,
|
|
421
|
+
decodedCharset: 'utf-8',
|
|
422
|
+
decodeFallback: true,
|
|
423
|
+
decodeWarning: `Unsupported charset decoder: ${declaredCharset}`,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export function looksLikeTextBody(data: Uint8Array) {
|
|
429
|
+
if (!data.byteLength) return true;
|
|
430
|
+
const sample = data.slice(0, Math.min(data.byteLength, 2048));
|
|
431
|
+
if (detectBestEffortCharset(sample)) return true;
|
|
432
|
+
|
|
433
|
+
let suspicious = 0;
|
|
434
|
+
let printableAscii = 0;
|
|
435
|
+
for (const byte of sample) {
|
|
436
|
+
if (byte === 0) return false;
|
|
437
|
+
const isWhitespace = byte === 9 || byte === 10 || byte === 13;
|
|
438
|
+
const isPrintableAscii = byte >= 32 && byte <= 126;
|
|
439
|
+
if (isWhitespace || isPrintableAscii) printableAscii++;
|
|
440
|
+
if (!isWhitespace && !isPrintableAscii) suspicious++;
|
|
441
|
+
}
|
|
442
|
+
return (
|
|
443
|
+
suspicious / sample.byteLength < 0.02 &&
|
|
444
|
+
printableAscii / sample.byteLength > 0.85
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export function isGenericBinaryMime(contentType: string) {
|
|
449
|
+
const mime = contentType.split(';')[0]?.trim().toLowerCase() || '';
|
|
450
|
+
return mime === 'application/octet-stream';
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function parseFilenameFromContentDisposition(value: string | null) {
|
|
454
|
+
if (!value) return undefined;
|
|
455
|
+
const utf8 = value.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
|
|
456
|
+
if (utf8?.[1]) {
|
|
457
|
+
try {
|
|
458
|
+
return decodeURIComponent(utf8[1].trim().replace(/^"|"$/g, ''));
|
|
459
|
+
} catch {
|
|
460
|
+
// ignore invalid encoding
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
const basic = value.match(/filename\s*=\s*("?)([^";]+)\1/i);
|
|
464
|
+
if (basic?.[2]) return basic[2].trim();
|
|
465
|
+
return undefined;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function inferFilenameFromUrl(url: string) {
|
|
469
|
+
try {
|
|
470
|
+
const parsed = new URL(url);
|
|
471
|
+
const last = parsed.pathname.split('/').filter(Boolean).pop();
|
|
472
|
+
if (!last?.includes('.')) return undefined;
|
|
473
|
+
return decodeURIComponent(last);
|
|
474
|
+
} catch {
|
|
475
|
+
return undefined;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function truncateFilename(name: string, maxLength = 180) {
|
|
480
|
+
if (name.length <= maxLength) return name;
|
|
481
|
+
const parsed = path.parse(name);
|
|
482
|
+
const ext = parsed.ext || '';
|
|
483
|
+
const baseLimit = Math.max(1, maxLength - ext.length);
|
|
484
|
+
return `${parsed.name.slice(0, baseLimit)}${ext}`;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function sanitizeFilename(name: string) {
|
|
488
|
+
let sanitized = Array.from(name, (char) => {
|
|
489
|
+
const code = char.charCodeAt(0);
|
|
490
|
+
if (code < 32 || '<>:"/\\|?*'.includes(char)) return '_';
|
|
491
|
+
return char;
|
|
492
|
+
}).join('');
|
|
493
|
+
sanitized = sanitized.replace(/\s+/g, ' ').trim();
|
|
494
|
+
sanitized = sanitized.replace(/^[.\s]+|[.\s]+$/g, '');
|
|
495
|
+
if (!sanitized) sanitized = 'download';
|
|
496
|
+
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i.test(sanitized)) {
|
|
497
|
+
sanitized = `_${sanitized}`;
|
|
498
|
+
}
|
|
499
|
+
return truncateFilename(sanitized);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export function extractHeaderMetadata(headers: Headers, finalUrl: string) {
|
|
503
|
+
const filename =
|
|
504
|
+
parseFilenameFromContentDisposition(headers.get('content-disposition')) ||
|
|
505
|
+
inferFilenameFromUrl(finalUrl);
|
|
506
|
+
const contentType = headers.get('content-type') || '';
|
|
507
|
+
return {
|
|
508
|
+
contentType: contentType || undefined,
|
|
509
|
+
charset: parseCharset(contentType),
|
|
510
|
+
etag: headers.get('etag') || undefined,
|
|
511
|
+
lastModified: headers.get('last-modified') || undefined,
|
|
512
|
+
contentLength: parseContentLength(headers),
|
|
513
|
+
filename: filename ? sanitizeFilename(filename) : undefined,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export function buildConditionalHeaders(cached: FetchResult | undefined) {
|
|
518
|
+
if (!cached || (!cached.etag && !cached.lastModified)) {
|
|
519
|
+
return undefined;
|
|
520
|
+
}
|
|
521
|
+
const headers: Record<string, string> = {};
|
|
522
|
+
if (cached.etag) headers['If-None-Match'] = cached.etag;
|
|
523
|
+
if (cached.lastModified) headers['If-Modified-Since'] = cached.lastModified;
|
|
524
|
+
return Object.keys(headers).length ? headers : undefined;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export async function probeLlmsText(
|
|
528
|
+
url: URL,
|
|
529
|
+
timeoutMs: number,
|
|
530
|
+
signal: AbortSignal,
|
|
531
|
+
fallbackOrigin?: string,
|
|
532
|
+
): Promise<LlmsProbeResult> {
|
|
533
|
+
const origins = [`${url.protocol}//${url.host}`];
|
|
534
|
+
if (fallbackOrigin && !origins.includes(fallbackOrigin)) {
|
|
535
|
+
origins.push(fallbackOrigin);
|
|
536
|
+
}
|
|
537
|
+
const allowedOrigins = new Set(origins);
|
|
538
|
+
let lastError: string | undefined;
|
|
539
|
+
for (const candidate of origins.flatMap((origin) => [
|
|
540
|
+
`${origin}/llms-full.txt`,
|
|
541
|
+
`${origin}/llms.txt`,
|
|
542
|
+
])) {
|
|
543
|
+
try {
|
|
544
|
+
const result = await fetchWithRedirects(
|
|
545
|
+
candidate,
|
|
546
|
+
timeoutMs,
|
|
547
|
+
'markdown',
|
|
548
|
+
signal,
|
|
549
|
+
{
|
|
550
|
+
Accept: 'text/plain, text/markdown;q=0.9, */*;q=0.1',
|
|
551
|
+
},
|
|
552
|
+
'GET',
|
|
553
|
+
allowedOrigins,
|
|
554
|
+
);
|
|
555
|
+
if ('blockedRedirect' in result) {
|
|
556
|
+
lastError = `llms.txt probe blocked by cross-host redirect: ${result.redirectUrl}`;
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
const { response, finalUrl, redirectChain } = result;
|
|
560
|
+
if (!response.ok) {
|
|
561
|
+
try {
|
|
562
|
+
await response.body?.cancel();
|
|
563
|
+
} catch {
|
|
564
|
+
// ignore cancel failures
|
|
565
|
+
}
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
const headers = extractHeaderMetadata(response.headers, finalUrl);
|
|
569
|
+
const body = await readBodyLimited(response, MAX_RESPONSE_BYTES);
|
|
570
|
+
const decoded = decodeBody(
|
|
571
|
+
body.data,
|
|
572
|
+
headers.charset,
|
|
573
|
+
headers.contentType,
|
|
574
|
+
);
|
|
575
|
+
const text = decoded.text;
|
|
576
|
+
const finalPath = new URL(finalUrl).pathname.toLowerCase();
|
|
577
|
+
const contentType = (headers.contentType || '').toLowerCase();
|
|
578
|
+
const looksLikeLlmsPath =
|
|
579
|
+
finalPath.endsWith('/llms.txt') || finalPath.endsWith('/llms-full.txt');
|
|
580
|
+
const looksHtml =
|
|
581
|
+
contentType.includes('text/html') ||
|
|
582
|
+
contentType.includes('application/xhtml+xml');
|
|
583
|
+
const looksLikeHtmlBody = /^\s*(<!doctype html|<html\b)/i.test(text);
|
|
584
|
+
const looksLikeLoginWall =
|
|
585
|
+
/<title>\s*(log in|sign in|login)\b/i.test(text) ||
|
|
586
|
+
/\blog[ -]?in\b/i.test(finalUrl);
|
|
587
|
+
if (!looksLikeLlmsPath) {
|
|
588
|
+
lastError = `llms.txt probe resolved to non-llms path: ${finalUrl}`;
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
if (looksHtml || looksLikeHtmlBody || looksLikeLoginWall) {
|
|
592
|
+
lastError = `llms.txt probe returned HTML/login content: ${finalUrl}`;
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
if (text.trim()) {
|
|
596
|
+
return {
|
|
597
|
+
url: finalUrl,
|
|
598
|
+
statusCode: response.status,
|
|
599
|
+
redirectChain,
|
|
600
|
+
text: trimBlankRuns(text),
|
|
601
|
+
headers,
|
|
602
|
+
truncated: body.truncated,
|
|
603
|
+
decodedCharset: decoded.decodedCharset,
|
|
604
|
+
decodeFallback: decoded.decodeFallback,
|
|
605
|
+
decodeWarning: decoded.decodeWarning,
|
|
606
|
+
upgradedToHttps: candidate.startsWith('https://') && !!fallbackOrigin,
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
} catch (error: unknown) {
|
|
610
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return { error: lastError };
|
|
614
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { afterEach, describe, expect, mock, test } from 'bun:test';
|
|
2
|
+
import { runSecondaryModelWithFallback } from './secondary-model';
|
|
3
|
+
import type { SecondaryModel } from './types';
|
|
4
|
+
|
|
5
|
+
type PromptStep = {
|
|
6
|
+
text?: string;
|
|
7
|
+
error?: Error;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function createMockClient(steps: PromptStep[]) {
|
|
11
|
+
let createCount = 0;
|
|
12
|
+
let promptCount = 0;
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
session: {
|
|
16
|
+
create: mock(async () => ({ id: `session-${createCount++}` })),
|
|
17
|
+
prompt: mock(async () => {
|
|
18
|
+
const step = steps[promptCount++] ?? {};
|
|
19
|
+
if (step.error) {
|
|
20
|
+
throw step.error;
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
data: {
|
|
24
|
+
parts: [{ type: 'text', text: step.text ?? '' }],
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}),
|
|
28
|
+
delete: mock(async () => ({})),
|
|
29
|
+
},
|
|
30
|
+
tool: {
|
|
31
|
+
ids: mock(async () => ({ data: ['read', 'bash'] })),
|
|
32
|
+
},
|
|
33
|
+
} as any;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('smartfetch/secondary-model', () => {
|
|
37
|
+
const models: SecondaryModel[] = [
|
|
38
|
+
{ providerID: 'provider-a', modelID: 'small' },
|
|
39
|
+
{ providerID: 'provider-b', modelID: 'fallback' },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
mock.restore();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('falls back when the first model returns empty text', async () => {
|
|
47
|
+
const client = createMockClient([
|
|
48
|
+
{ text: ' ' },
|
|
49
|
+
{ text: 'Useful answer' },
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
const result = await runSecondaryModelWithFallback(
|
|
53
|
+
client,
|
|
54
|
+
'/tmp/project',
|
|
55
|
+
models,
|
|
56
|
+
'Summarize the page',
|
|
57
|
+
'This is enough fetched content to clear the short-content guard.',
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
expect(result.text).toBe('Useful answer');
|
|
61
|
+
expect(result.model).toEqual(models[1]);
|
|
62
|
+
expect(client.session.prompt).toHaveBeenCalledTimes(2);
|
|
63
|
+
expect(client.session.delete).toHaveBeenCalledTimes(2);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('falls back when the first model throws', async () => {
|
|
67
|
+
const client = createMockClient([
|
|
68
|
+
{ error: new Error('primary failed') },
|
|
69
|
+
{ text: 'Recovered answer' },
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
const result = await runSecondaryModelWithFallback(
|
|
73
|
+
client,
|
|
74
|
+
'/tmp/project',
|
|
75
|
+
models,
|
|
76
|
+
'Extract the answer',
|
|
77
|
+
'This is enough fetched content to clear the short-content guard.',
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
expect(result.text).toBe('Recovered answer');
|
|
81
|
+
expect(result.model).toEqual(models[1]);
|
|
82
|
+
expect(client.session.prompt).toHaveBeenCalledTimes(2);
|
|
83
|
+
expect(client.session.delete).toHaveBeenCalledTimes(2);
|
|
84
|
+
});
|
|
85
|
+
});
|