@shareai-lab/kode 1.0.81 → 1.0.83

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,178 @@
1
+ import { Box, Text } from 'ink'
2
+ import React from 'react'
3
+ import { z } from 'zod'
4
+ import fetch from 'node-fetch'
5
+ import { Cost } from '../../components/Cost'
6
+ import { FallbackToolUseRejectedMessage } from '../../components/FallbackToolUseRejectedMessage'
7
+ import { Tool, ToolUseContext } from '../../Tool'
8
+ import { DESCRIPTION, TOOL_NAME_FOR_PROMPT } from './prompt'
9
+ import { convertHtmlToMarkdown } from './htmlToMarkdown'
10
+ import { urlCache } from './cache'
11
+ import { queryQuick } from '../../services/claude'
12
+
13
+ const inputSchema = z.strictObject({
14
+ url: z.string().url().describe('The URL to fetch content from'),
15
+ prompt: z.string().describe('The prompt to run on the fetched content'),
16
+ })
17
+
18
+ type Input = z.infer<typeof inputSchema>
19
+ type Output = {
20
+ url: string
21
+ fromCache: boolean
22
+ aiAnalysis: string
23
+ }
24
+
25
+ function normalizeUrl(url: string): string {
26
+ // Auto-upgrade HTTP to HTTPS
27
+ if (url.startsWith('http://')) {
28
+ return url.replace('http://', 'https://')
29
+ }
30
+ return url
31
+ }
32
+
33
+ export const URLFetcherTool = {
34
+ name: TOOL_NAME_FOR_PROMPT,
35
+ async description() {
36
+ return DESCRIPTION
37
+ },
38
+ userFacingName: () => 'URL Fetcher',
39
+ inputSchema,
40
+ isReadOnly: () => true,
41
+ isConcurrencySafe: () => true,
42
+ async isEnabled() {
43
+ return true
44
+ },
45
+ needsPermissions() {
46
+ return false
47
+ },
48
+ async prompt() {
49
+ return DESCRIPTION
50
+ },
51
+ renderToolUseMessage({ url, prompt }: Input) {
52
+ return `Fetching content from ${url} and analyzing with prompt: "${prompt}"`
53
+ },
54
+ renderToolUseRejectedMessage() {
55
+ return <FallbackToolUseRejectedMessage />
56
+ },
57
+ renderToolResultMessage(output: Output) {
58
+ const statusText = output.fromCache ? 'from cache' : 'fetched'
59
+
60
+ return (
61
+ <Box justifyContent="space-between" width="100%">
62
+ <Box flexDirection="row">
63
+ <Text>&nbsp;&nbsp;⎿ &nbsp;Content </Text>
64
+ <Text bold>{statusText} </Text>
65
+ <Text>and analyzed</Text>
66
+ </Box>
67
+ <Cost costUSD={0} durationMs={0} debug={false} />
68
+ </Box>
69
+ )
70
+ },
71
+ renderResultForAssistant(output: Output) {
72
+ if (!output.aiAnalysis.trim()) {
73
+ return `No content could be analyzed from URL: ${output.url}`
74
+ }
75
+
76
+ return output.aiAnalysis
77
+ },
78
+ async *call({ url, prompt }: Input, {}: ToolUseContext) {
79
+ const normalizedUrl = normalizeUrl(url)
80
+
81
+ try {
82
+ let content: string
83
+ let fromCache = false
84
+
85
+ // Check cache first
86
+ const cachedContent = urlCache.get(normalizedUrl)
87
+ if (cachedContent) {
88
+ content = cachedContent
89
+ fromCache = true
90
+ } else {
91
+ // Fetch from URL with AbortController for timeout
92
+ const abortController = new AbortController()
93
+ const timeout = setTimeout(() => abortController.abort(), 30000)
94
+
95
+ const response = await fetch(normalizedUrl, {
96
+ method: 'GET',
97
+ headers: {
98
+ 'User-Agent': 'Mozilla/5.0 (compatible; URLFetcher/1.0)',
99
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
100
+ 'Accept-Language': 'en-US,en;q=0.5',
101
+ 'Accept-Encoding': 'gzip, deflate',
102
+ 'Connection': 'keep-alive',
103
+ 'Upgrade-Insecure-Requests': '1',
104
+ },
105
+ signal: abortController.signal,
106
+ redirect: 'follow',
107
+ })
108
+
109
+ clearTimeout(timeout)
110
+
111
+ if (!response.ok) {
112
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
113
+ }
114
+
115
+ const contentType = response.headers.get('content-type') || ''
116
+ if (!contentType.includes('text/') && !contentType.includes('application/')) {
117
+ throw new Error(`Unsupported content type: ${contentType}`)
118
+ }
119
+
120
+ const html = await response.text()
121
+ content = convertHtmlToMarkdown(html)
122
+
123
+ // Cache the result
124
+ urlCache.set(normalizedUrl, content)
125
+ fromCache = false
126
+ }
127
+
128
+ // Truncate content if too large (keep within reasonable token limits)
129
+ const maxContentLength = 50000 // ~15k tokens approximately
130
+ const truncatedContent = content.length > maxContentLength
131
+ ? content.substring(0, maxContentLength) + '\n\n[Content truncated due to length]'
132
+ : content
133
+
134
+ // AI Analysis - always performed fresh, even with cached content
135
+ const systemPrompt = [
136
+ 'You are analyzing web content based on a user\'s specific request.',
137
+ 'The content has been extracted from a webpage and converted to markdown.',
138
+ 'Provide a focused response that directly addresses the user\'s prompt.',
139
+ ]
140
+
141
+ const userPrompt = `Here is the content from ${normalizedUrl}:
142
+
143
+ ${truncatedContent}
144
+
145
+ User request: ${prompt}`
146
+
147
+ const aiResponse = await queryQuick({
148
+ systemPrompt,
149
+ userPrompt,
150
+ enablePromptCaching: false,
151
+ })
152
+
153
+ const output: Output = {
154
+ url: normalizedUrl,
155
+ fromCache,
156
+ aiAnalysis: aiResponse.message.content[0]?.text || 'Unable to analyze content',
157
+ }
158
+
159
+ yield {
160
+ type: 'result' as const,
161
+ resultForAssistant: this.renderResultForAssistant(output),
162
+ data: output,
163
+ }
164
+ } catch (error: any) {
165
+ const output: Output = {
166
+ url: normalizedUrl,
167
+ fromCache: false,
168
+ aiAnalysis: '',
169
+ }
170
+
171
+ yield {
172
+ type: 'result' as const,
173
+ resultForAssistant: `Error processing URL ${normalizedUrl}: ${error.message}`,
174
+ data: output,
175
+ }
176
+ }
177
+ },
178
+ } satisfies Tool<typeof inputSchema, Output>
@@ -0,0 +1,55 @@
1
+ interface CacheEntry {
2
+ content: string
3
+ timestamp: number
4
+ }
5
+
6
+ class URLCache {
7
+ private cache = new Map<string, CacheEntry>()
8
+ private readonly CACHE_DURATION = 15 * 60 * 1000 // 15 minutes in milliseconds
9
+
10
+ set(url: string, content: string): void {
11
+ this.cache.set(url, {
12
+ content,
13
+ timestamp: Date.now()
14
+ })
15
+ }
16
+
17
+ get(url: string): string | null {
18
+ const entry = this.cache.get(url)
19
+ if (!entry) {
20
+ return null
21
+ }
22
+
23
+ // Check if entry has expired
24
+ if (Date.now() - entry.timestamp > this.CACHE_DURATION) {
25
+ this.cache.delete(url)
26
+ return null
27
+ }
28
+
29
+ return entry.content
30
+ }
31
+
32
+ clear(): void {
33
+ this.cache.clear()
34
+ }
35
+
36
+ // Clean expired entries
37
+ private cleanExpired(): void {
38
+ const now = Date.now()
39
+ for (const [url, entry] of this.cache.entries()) {
40
+ if (now - entry.timestamp > this.CACHE_DURATION) {
41
+ this.cache.delete(url)
42
+ }
43
+ }
44
+ }
45
+
46
+ // Auto-clean expired entries every 5 minutes
47
+ constructor() {
48
+ setInterval(() => {
49
+ this.cleanExpired()
50
+ }, 5 * 60 * 1000) // 5 minutes
51
+ }
52
+ }
53
+
54
+ // Export singleton instance
55
+ export const urlCache = new URLCache()
@@ -0,0 +1,55 @@
1
+ import TurndownService from 'turndown'
2
+
3
+ const turndownService = new TurndownService({
4
+ headingStyle: 'atx',
5
+ hr: '---',
6
+ bulletListMarker: '-',
7
+ codeBlockStyle: 'fenced',
8
+ fence: '```',
9
+ emDelimiter: '_',
10
+ strongDelimiter: '**'
11
+ })
12
+
13
+ // Configure rules to handle common HTML elements
14
+ turndownService.addRule('removeScripts', {
15
+ filter: ['script', 'style', 'noscript'],
16
+ replacement: () => ''
17
+ })
18
+
19
+ turndownService.addRule('removeComments', {
20
+ filter: (node) => node.nodeType === 8, // Comment nodes
21
+ replacement: () => ''
22
+ })
23
+
24
+ turndownService.addRule('cleanLinks', {
25
+ filter: 'a',
26
+ replacement: (content, node) => {
27
+ const href = node.getAttribute('href')
28
+ if (!href || href.startsWith('javascript:') || href.startsWith('#')) {
29
+ return content
30
+ }
31
+ return `[${content}](${href})`
32
+ }
33
+ })
34
+
35
+ export function convertHtmlToMarkdown(html: string): string {
36
+ try {
37
+ // Clean up the HTML before conversion
38
+ const cleanHtml = html
39
+ .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '') // Remove script tags
40
+ .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '') // Remove style tags
41
+ .replace(/<!--[\s\S]*?-->/g, '') // Remove HTML comments
42
+ .replace(/\s+/g, ' ') // Normalize whitespace
43
+ .trim()
44
+
45
+ const markdown = turndownService.turndown(cleanHtml)
46
+
47
+ // Clean up the resulting markdown
48
+ return markdown
49
+ .replace(/\n{3,}/g, '\n\n') // Remove excessive line breaks
50
+ .replace(/^\s+|\s+$/gm, '') // Remove leading/trailing spaces on each line
51
+ .trim()
52
+ } catch (error) {
53
+ throw new Error(`Failed to convert HTML to markdown: ${error instanceof Error ? error.message : String(error)}`)
54
+ }
55
+ }
@@ -0,0 +1,17 @@
1
+ export const TOOL_NAME_FOR_PROMPT = 'URLFetcher'
2
+ export const DESCRIPTION = `- Fetches content from a specified URL and processes it using an AI model
3
+ - Takes a URL and a prompt as input
4
+ - Fetches the URL content, converts HTML to markdown
5
+ - Processes the content with the prompt using a small, fast model
6
+ - Returns the model's response about the content
7
+ - Use this tool when you need to retrieve and analyze web content
8
+
9
+ Usage notes:
10
+ - IMPORTANT: If an MCP-provided web fetch tool is available, prefer using that tool instead of this one, as it may have fewer restrictions. All MCP-provided tools start with "mcp__".
11
+ - The URL must be a fully-formed valid URL (e.g., https://example.com)
12
+ - HTTP URLs will be automatically upgraded to HTTPS
13
+ - The prompt should describe what information you want to extract from the page
14
+ - This tool is read-only and does not modify any files
15
+ - Results may be summarized if the content is very large
16
+ - Includes a self-cleaning 15-minute cache for faster responses when repeatedly accessing the same URL
17
+ - When a URL redirects, the tool will inform you and provide the redirect URL in a special format. You should then make a new URLFetcher request with the redirect URL to fetch the content.`
@@ -0,0 +1,103 @@
1
+ import { Box, Text } from 'ink'
2
+ import React from 'react'
3
+ import { z } from 'zod'
4
+ import { Cost } from '../../components/Cost'
5
+ import { FallbackToolUseRejectedMessage } from '../../components/FallbackToolUseRejectedMessage'
6
+ import { Tool, ToolUseContext } from '../../Tool'
7
+ import { DESCRIPTION, TOOL_NAME_FOR_PROMPT } from './prompt'
8
+ import { SearchResult, searchProviders } from './searchProviders'
9
+
10
+ const inputSchema = z.strictObject({
11
+ query: z.string().describe('The search query'),
12
+ })
13
+
14
+ type Input = z.infer<typeof inputSchema>
15
+ type Output = {
16
+ durationMs: number
17
+ results: SearchResult[]
18
+ }
19
+
20
+
21
+ export const WebSearchTool = {
22
+ name: TOOL_NAME_FOR_PROMPT,
23
+ async description() {
24
+ return DESCRIPTION
25
+ },
26
+ userFacingName: () => 'Web Search',
27
+ inputSchema,
28
+ isReadOnly: () => true,
29
+ isConcurrencySafe: () => true,
30
+ async isEnabled() {
31
+ return true
32
+ },
33
+ needsPermissions() {
34
+ return false
35
+ },
36
+ async prompt() {
37
+ return DESCRIPTION
38
+ },
39
+ renderToolUseMessage({ query }: Input) {
40
+ return `Searching for: "${query}" using DuckDuckGo`
41
+ },
42
+ renderToolUseRejectedMessage() {
43
+ return <FallbackToolUseRejectedMessage />
44
+ },
45
+ renderToolResultMessage(output: Output) {
46
+ return (
47
+ <Box justifyContent="space-between" width="100%">
48
+ <Box flexDirection="row">
49
+ <Text>&nbsp;&nbsp;⎿ &nbsp;Found </Text>
50
+ <Text bold>{output.results.length} </Text>
51
+ <Text>
52
+ {output.results.length === 1 ? 'result' : 'results'} using DuckDuckGo
53
+ </Text>
54
+ </Box>
55
+ <Cost costUSD={0} durationMs={output.durationMs} debug={false} />
56
+ </Box>
57
+ )
58
+ },
59
+ renderResultForAssistant(output: Output) {
60
+ if (output.results.length === 0) {
61
+ return `No results found using DuckDuckGo.`
62
+ }
63
+
64
+ let result = `Found ${output.results.length} search results using DuckDuckGo:\n\n`
65
+
66
+ output.results.forEach((item, index) => {
67
+ result += `${index + 1}. **${item.title}**\n`
68
+ result += ` ${item.snippet}\n`
69
+ result += ` Link: ${item.link}\n\n`
70
+ })
71
+
72
+ result += `You can reference these results to provide current, accurate information to the user.`
73
+ return result
74
+ },
75
+ async *call({ query }: Input, {}: ToolUseContext) {
76
+ const start = Date.now()
77
+
78
+ try {
79
+ const searchResults = await searchProviders.duckduckgo.search(query)
80
+
81
+ const output: Output = {
82
+ results: searchResults,
83
+ durationMs: Date.now() - start,
84
+ }
85
+
86
+ yield {
87
+ type: 'result' as const,
88
+ resultForAssistant: this.renderResultForAssistant(output),
89
+ data: output,
90
+ }
91
+ } catch (error: any) {
92
+ const output: Output = {
93
+ results: [],
94
+ durationMs: Date.now() - start,
95
+ }
96
+ yield {
97
+ type: 'result' as const,
98
+ resultForAssistant: `An error occurred during web search with DuckDuckGo: ${error.message}`,
99
+ data: output,
100
+ }
101
+ }
102
+ },
103
+ } satisfies Tool<typeof inputSchema, Output>
@@ -0,0 +1,13 @@
1
+
2
+ export const TOOL_NAME_FOR_PROMPT = 'WebSearch'
3
+ export const DESCRIPTION = `- Allows Kode to search the web and use the results to inform responses
4
+ - Provides up-to-date information for current events and recent data
5
+ - Returns search result information formatted as search result blocks
6
+ - Use this tool for accessing information beyond the Kode's knowledge cutoff
7
+ - Searches are performed automatically within a single API call using DuckDuckGo
8
+
9
+ Usage notes:
10
+ - Use when you need current information not in training data
11
+ - Effective for recent news, current events, product updates, or real-time data
12
+ - Search queries should be specific and well-targeted for best results
13
+ - Results include both title and snippet content for context`
@@ -0,0 +1,66 @@
1
+ import fetch from 'node-fetch'
2
+ import { parse } from 'node-html-parser'
3
+
4
+ export interface SearchResult {
5
+ title: string
6
+ snippet: string
7
+ link: string
8
+ }
9
+
10
+ export interface SearchProvider {
11
+ search: (query: string, apiKey?: string) => Promise<SearchResult[]>
12
+ isEnabled: (apiKey?: string) => boolean
13
+ }
14
+
15
+
16
+ const duckDuckGoSearchProvider: SearchProvider = {
17
+ isEnabled: () => true,
18
+ search: async (query: string): Promise<SearchResult[]> => {
19
+ const response = await fetch(`https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`, {
20
+ headers: {
21
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
22
+ }
23
+ });
24
+
25
+ if (!response.ok) {
26
+ throw new Error(`DuckDuckGo search failed with status: ${response.status}`);
27
+ }
28
+
29
+ const html = await response.text();
30
+ const root = parse(html);
31
+ const results: SearchResult[] = [];
32
+
33
+ const resultNodes = root.querySelectorAll('.result.web-result');
34
+
35
+ for (const node of resultNodes) {
36
+ const titleNode = node.querySelector('.result__a');
37
+ const snippetNode = node.querySelector('.result__snippet');
38
+
39
+ if (titleNode && snippetNode) {
40
+ const title = titleNode.text;
41
+ const link = titleNode.getAttribute('href');
42
+ const snippet = snippetNode.text;
43
+
44
+ if (title && link && snippet) {
45
+ // Clean the link - DuckDuckGo doesn't use uddg parameter anymore
46
+ let cleanLink = link;
47
+ if (link.startsWith('https://duckduckgo.com/l/?uddg=')) {
48
+ try {
49
+ const url = new URL(link);
50
+ cleanLink = url.searchParams.get('uddg') || link;
51
+ } catch {
52
+ cleanLink = link;
53
+ }
54
+ }
55
+ results.push({ title: title.trim(), snippet: snippet.trim(), link: cleanLink });
56
+ }
57
+ }
58
+ }
59
+
60
+ return results;
61
+ },
62
+ }
63
+
64
+ export const searchProviders = {
65
+ duckduckgo: duckDuckGoSearchProvider,
66
+ }
package/src/tools.ts CHANGED
@@ -16,6 +16,8 @@ import { NotebookEditTool } from './tools/NotebookEditTool/NotebookEditTool'
16
16
  import { NotebookReadTool } from './tools/NotebookReadTool/NotebookReadTool'
17
17
  import { ThinkTool } from './tools/ThinkTool/ThinkTool'
18
18
  import { TodoWriteTool } from './tools/TodoWriteTool/TodoWriteTool'
19
+ import { WebSearchTool } from './tools/WebSearchTool/WebSearchTool'
20
+ import { URLFetcherTool } from './tools/URLFetcherTool/URLFetcherTool'
19
21
  import { getMCPTools } from './services/mcpClient'
20
22
  import { memoize } from 'lodash-es'
21
23
 
@@ -38,6 +40,8 @@ export const getAllTools = (): Tool[] => {
38
40
  NotebookEditTool as unknown as Tool,
39
41
  ThinkTool as unknown as Tool,
40
42
  TodoWriteTool as unknown as Tool,
43
+ WebSearchTool as unknown as Tool,
44
+ URLFetcherTool as unknown as Tool,
41
45
  ...ANT_ONLY_TOOLS,
42
46
  ]
43
47
  }