@shareai-lab/kode 1.0.80 → 1.0.82
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 +201 -661
- package/README.md +23 -2
- package/README.zh-CN.md +18 -2
- package/package.json +6 -3
- package/src/components/ModelSelector.tsx +2 -1
- package/src/query.ts +11 -3
- package/src/services/claude.ts +166 -52
- package/src/tools/URLFetcherTool/URLFetcherTool.tsx +178 -0
- package/src/tools/URLFetcherTool/cache.ts +55 -0
- package/src/tools/URLFetcherTool/htmlToMarkdown.ts +55 -0
- package/src/tools/URLFetcherTool/prompt.ts +17 -0
- package/src/tools/WebSearchTool/WebSearchTool.tsx +103 -0
- package/src/tools/WebSearchTool/prompt.ts +13 -0
- package/src/tools/WebSearchTool/searchProviders.ts +66 -0
- package/src/tools.ts +4 -0
- package/src/utils/PersistentShell.ts +196 -17
- package/src/utils/debugLogger.ts +12 -6
|
@@ -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> ⎿ 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
|
}
|
|
@@ -37,6 +37,165 @@ const SHELL_CONFIGS: Record<string, string> = {
|
|
|
37
37
|
'/bin/zsh': '.zshrc',
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
type DetectedShell = {
|
|
41
|
+
bin: string
|
|
42
|
+
args: string[]
|
|
43
|
+
type: 'posix' | 'msys' | 'wsl'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function quoteForBash(str: string): string {
|
|
47
|
+
return `'${str.replace(/'/g, "'\\''")}'`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function toBashPath(pathStr: string, type: 'posix' | 'msys' | 'wsl'): string {
|
|
51
|
+
// Already POSIX absolute path
|
|
52
|
+
if (pathStr.startsWith('/')) return pathStr
|
|
53
|
+
if (type === 'posix') return pathStr
|
|
54
|
+
|
|
55
|
+
// Normalize backslashes
|
|
56
|
+
const normalized = pathStr.replace(/\\/g, '/').replace(/\\\\/g, '/')
|
|
57
|
+
const driveMatch = /^[A-Za-z]:/.exec(normalized)
|
|
58
|
+
if (driveMatch) {
|
|
59
|
+
const drive = normalized[0].toLowerCase()
|
|
60
|
+
const rest = normalized.slice(2)
|
|
61
|
+
if (type === 'msys') {
|
|
62
|
+
return `/` + drive + (rest.startsWith('/') ? rest : `/${rest}`)
|
|
63
|
+
}
|
|
64
|
+
// wsl
|
|
65
|
+
return `/mnt/` + drive + (rest.startsWith('/') ? rest : `/${rest}`)
|
|
66
|
+
}
|
|
67
|
+
// Relative path: just convert slashes
|
|
68
|
+
return normalized
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function fileExists(p: string | undefined): p is string {
|
|
72
|
+
return !!p && existsSync(p)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Robust PATH splitter for Windows and POSIX
|
|
76
|
+
function splitPathEntries(pathEnv: string, platform: NodeJS.Platform): string[] {
|
|
77
|
+
if (!pathEnv) return []
|
|
78
|
+
|
|
79
|
+
// POSIX: ':' is the separator
|
|
80
|
+
if (platform !== 'win32') {
|
|
81
|
+
return pathEnv
|
|
82
|
+
.split(':')
|
|
83
|
+
.map(s => s.trim().replace(/^"|"$/g, ''))
|
|
84
|
+
.filter(Boolean)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Windows: primarily ';', but some environments may use ':'
|
|
88
|
+
// We must not split drive letters like 'C:\\' or 'D:foo\\bar'
|
|
89
|
+
const entries: string[] = []
|
|
90
|
+
let current = ''
|
|
91
|
+
const pushCurrent = () => {
|
|
92
|
+
const cleaned = current.trim().replace(/^"|"$/g, '')
|
|
93
|
+
if (cleaned) entries.push(cleaned)
|
|
94
|
+
current = ''
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (let i = 0; i < pathEnv.length; i++) {
|
|
98
|
+
const ch = pathEnv[i]
|
|
99
|
+
|
|
100
|
+
if (ch === ';') {
|
|
101
|
+
pushCurrent()
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (ch === ':') {
|
|
106
|
+
const segmentLength = current.length
|
|
107
|
+
const firstChar = current[0]
|
|
108
|
+
const isDriveLetterPrefix = segmentLength === 1 && /[A-Za-z]/.test(firstChar || '')
|
|
109
|
+
// Treat ':' as separator only if it's NOT the drive letter colon
|
|
110
|
+
if (!isDriveLetterPrefix) {
|
|
111
|
+
pushCurrent()
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
current += ch
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Flush the final segment
|
|
120
|
+
pushCurrent()
|
|
121
|
+
|
|
122
|
+
return entries
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function detectShell(): DetectedShell {
|
|
126
|
+
const isWin = process.platform === 'win32'
|
|
127
|
+
if (!isWin) {
|
|
128
|
+
const bin = process.env.SHELL || '/bin/bash'
|
|
129
|
+
return { bin, args: ['-l'], type: 'posix' }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 1) Respect SHELL if it points to a bash.exe that exists
|
|
133
|
+
if (process.env.SHELL && /bash\.exe$/i.test(process.env.SHELL) && existsSync(process.env.SHELL)) {
|
|
134
|
+
return { bin: process.env.SHELL, args: ['-l'], type: 'msys' }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 1.1) Explicit override
|
|
138
|
+
if (process.env.KODE_BASH && existsSync(process.env.KODE_BASH)) {
|
|
139
|
+
return { bin: process.env.KODE_BASH, args: ['-l'], type: 'msys' }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 2) Common Git Bash/MSYS2 locations
|
|
143
|
+
const programFiles = [
|
|
144
|
+
process.env['ProgramFiles'],
|
|
145
|
+
process.env['ProgramFiles(x86)'],
|
|
146
|
+
process.env['ProgramW6432'],
|
|
147
|
+
].filter(Boolean) as string[]
|
|
148
|
+
|
|
149
|
+
const localAppData = process.env['LocalAppData']
|
|
150
|
+
|
|
151
|
+
const candidates: string[] = []
|
|
152
|
+
for (const base of programFiles) {
|
|
153
|
+
candidates.push(
|
|
154
|
+
join(base, 'Git', 'bin', 'bash.exe'),
|
|
155
|
+
join(base, 'Git', 'usr', 'bin', 'bash.exe'),
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
if (localAppData) {
|
|
159
|
+
candidates.push(
|
|
160
|
+
join(localAppData, 'Programs', 'Git', 'bin', 'bash.exe'),
|
|
161
|
+
join(localAppData, 'Programs', 'Git', 'usr', 'bin', 'bash.exe'),
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
// MSYS2 default
|
|
165
|
+
candidates.push('C:/msys64/usr/bin/bash.exe')
|
|
166
|
+
|
|
167
|
+
for (const c of candidates) {
|
|
168
|
+
if (existsSync(c)) {
|
|
169
|
+
return { bin: c, args: ['-l'], type: 'msys' }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 2.1) Search in PATH for bash.exe
|
|
174
|
+
const pathEnv = process.env.PATH || process.env.Path || process.env.path || ''
|
|
175
|
+
const pathEntries = splitPathEntries(pathEnv, process.platform)
|
|
176
|
+
for (const p of pathEntries) {
|
|
177
|
+
const candidate = join(p, 'bash.exe')
|
|
178
|
+
if (existsSync(candidate)) {
|
|
179
|
+
return { bin: candidate, args: ['-l'], type: 'msys' }
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 3) WSL
|
|
184
|
+
try {
|
|
185
|
+
// Quick probe to ensure WSL+bash exists
|
|
186
|
+
execSync('wsl.exe -e bash -lc "echo KODE_OK"', { stdio: 'ignore', timeout: 1500 })
|
|
187
|
+
return { bin: 'wsl.exe', args: ['-e', 'bash', '-l'], type: 'wsl' }
|
|
188
|
+
} catch {}
|
|
189
|
+
|
|
190
|
+
// 4) Last resort: meaningful error
|
|
191
|
+
const hint = [
|
|
192
|
+
'无法找到可用的 bash。请安装 Git for Windows 或启用 WSL。',
|
|
193
|
+
'推荐安装 Git: https://git-scm.com/download/win',
|
|
194
|
+
'或启用 WSL 并安装 Ubuntu: https://learn.microsoft.com/windows/wsl/install',
|
|
195
|
+
].join('\n')
|
|
196
|
+
throw new Error(hint)
|
|
197
|
+
}
|
|
198
|
+
|
|
40
199
|
export class PersistentShell {
|
|
41
200
|
private commandQueue: QueuedCommand[] = []
|
|
42
201
|
private isExecuting: boolean = false
|
|
@@ -49,10 +208,20 @@ export class PersistentShell {
|
|
|
49
208
|
private cwdFile: string
|
|
50
209
|
private cwd: string
|
|
51
210
|
private binShell: string
|
|
211
|
+
private shellArgs: string[]
|
|
212
|
+
private shellType: 'posix' | 'msys' | 'wsl'
|
|
213
|
+
private statusFileBashPath: string
|
|
214
|
+
private stdoutFileBashPath: string
|
|
215
|
+
private stderrFileBashPath: string
|
|
216
|
+
private cwdFileBashPath: string
|
|
52
217
|
|
|
53
218
|
constructor(cwd: string) {
|
|
54
|
-
|
|
55
|
-
this.
|
|
219
|
+
const { bin, args, type } = detectShell()
|
|
220
|
+
this.binShell = bin
|
|
221
|
+
this.shellArgs = args
|
|
222
|
+
this.shellType = type
|
|
223
|
+
|
|
224
|
+
this.shell = spawn(this.binShell, this.shellArgs, {
|
|
56
225
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
57
226
|
cwd,
|
|
58
227
|
env: {
|
|
@@ -98,13 +267,15 @@ export class PersistentShell {
|
|
|
98
267
|
}
|
|
99
268
|
// Initialize CWD file with initial directory
|
|
100
269
|
fs.writeFileSync(this.cwdFile, cwd)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
270
|
+
|
|
271
|
+
// Compute bash-visible paths for redirections
|
|
272
|
+
this.statusFileBashPath = toBashPath(this.statusFile, this.shellType)
|
|
273
|
+
this.stdoutFileBashPath = toBashPath(this.stdoutFile, this.shellType)
|
|
274
|
+
this.stderrFileBashPath = toBashPath(this.stderrFile, this.shellType)
|
|
275
|
+
this.cwdFileBashPath = toBashPath(this.cwdFile, this.shellType)
|
|
276
|
+
|
|
277
|
+
// Source ~/.bashrc when available (works for bash on POSIX/MSYS/WSL)
|
|
278
|
+
this.sendToShell('[ -f ~/.bashrc ] && source ~/.bashrc || true')
|
|
108
279
|
}
|
|
109
280
|
|
|
110
281
|
private static instance: PersistentShell | null = null
|
|
@@ -232,10 +403,17 @@ export class PersistentShell {
|
|
|
232
403
|
|
|
233
404
|
// Check the syntax of the command
|
|
234
405
|
try {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
406
|
+
if (this.shellType === 'wsl') {
|
|
407
|
+
execSync(`wsl.exe -e bash -n -c ${quotedCommand}`, {
|
|
408
|
+
stdio: 'ignore',
|
|
409
|
+
timeout: 1000,
|
|
410
|
+
})
|
|
411
|
+
} else {
|
|
412
|
+
execSync(`${this.binShell} -n -c ${quotedCommand}`, {
|
|
413
|
+
stdio: 'ignore',
|
|
414
|
+
timeout: 1000,
|
|
415
|
+
})
|
|
416
|
+
}
|
|
239
417
|
} catch (stderr) {
|
|
240
418
|
// If there's a syntax error, return an error and log it
|
|
241
419
|
const errorStr =
|
|
@@ -264,17 +442,17 @@ export class PersistentShell {
|
|
|
264
442
|
|
|
265
443
|
// 1. Execute the main command with redirections
|
|
266
444
|
commandParts.push(
|
|
267
|
-
`eval ${quotedCommand} < /dev/null > ${this.
|
|
445
|
+
`eval ${quotedCommand} < /dev/null > ${quoteForBash(this.stdoutFileBashPath)} 2> ${quoteForBash(this.stderrFileBashPath)}`,
|
|
268
446
|
)
|
|
269
447
|
|
|
270
448
|
// 2. Capture exit code immediately after command execution to avoid losing it
|
|
271
449
|
commandParts.push(`EXEC_EXIT_CODE=$?`)
|
|
272
450
|
|
|
273
451
|
// 3. Update CWD file
|
|
274
|
-
commandParts.push(`pwd > ${this.
|
|
452
|
+
commandParts.push(`pwd > ${quoteForBash(this.cwdFileBashPath)}`)
|
|
275
453
|
|
|
276
454
|
// 4. Write the preserved exit code to status file to avoid race with pwd
|
|
277
|
-
commandParts.push(`echo $EXEC_EXIT_CODE > ${this.
|
|
455
|
+
commandParts.push(`echo $EXEC_EXIT_CODE > ${quoteForBash(this.statusFileBashPath)}`)
|
|
278
456
|
|
|
279
457
|
// Send the combined commands as a single operation to maintain atomicity
|
|
280
458
|
this.sendToShell(commandParts.join('\n'))
|
|
@@ -363,7 +541,8 @@ export class PersistentShell {
|
|
|
363
541
|
if (!existsSync(resolved)) {
|
|
364
542
|
throw new Error(`Path "${resolved}" does not exist`)
|
|
365
543
|
}
|
|
366
|
-
|
|
544
|
+
const bashPath = toBashPath(resolved, this.shellType)
|
|
545
|
+
await this.exec(`cd ${quoteForBash(bashPath)}`)
|
|
367
546
|
}
|
|
368
547
|
|
|
369
548
|
close(): void {
|
package/src/utils/debugLogger.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, appendFileSync } from 'fs'
|
|
2
2
|
import { join } from 'path'
|
|
3
|
+
import { homedir } from 'os'
|
|
3
4
|
import { randomUUID } from 'crypto'
|
|
4
5
|
import chalk from 'chalk'
|
|
5
6
|
import envPaths from 'env-paths'
|
|
@@ -60,14 +61,14 @@ const USER_FRIENDLY_LEVELS = new Set([
|
|
|
60
61
|
const STARTUP_TIMESTAMP = new Date().toISOString().replace(/[:.]/g, '-')
|
|
61
62
|
const REQUEST_START_TIME = Date.now()
|
|
62
63
|
|
|
63
|
-
// 路径配置
|
|
64
|
-
const
|
|
64
|
+
// 路径配置 - 统一使用 ~/.kode 目录
|
|
65
|
+
const KODE_DIR = join(homedir(), '.kode')
|
|
65
66
|
function getProjectDir(cwd: string): string {
|
|
66
67
|
return cwd.replace(/[^a-zA-Z0-9]/g, '-')
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
const DEBUG_PATHS = {
|
|
70
|
-
base: () => join(
|
|
71
|
+
base: () => join(KODE_DIR, getProjectDir(process.cwd()), 'debug'),
|
|
71
72
|
detailed: () => join(DEBUG_PATHS.base(), `${STARTUP_TIMESTAMP}-detailed.log`),
|
|
72
73
|
flow: () => join(DEBUG_PATHS.base(), `${STARTUP_TIMESTAMP}-flow.log`),
|
|
73
74
|
api: () => join(DEBUG_PATHS.base(), `${STARTUP_TIMESTAMP}-api.log`),
|
|
@@ -467,11 +468,16 @@ export function logAPIError(context: {
|
|
|
467
468
|
response?: any
|
|
468
469
|
provider?: string
|
|
469
470
|
}) {
|
|
470
|
-
const errorDir = join(
|
|
471
|
+
const errorDir = join(KODE_DIR, 'logs', 'error', 'api')
|
|
471
472
|
|
|
472
473
|
// 确保目录存在
|
|
473
474
|
if (!existsSync(errorDir)) {
|
|
474
|
-
|
|
475
|
+
try {
|
|
476
|
+
mkdirSync(errorDir, { recursive: true })
|
|
477
|
+
} catch (err) {
|
|
478
|
+
console.error('Failed to create error log directory:', err)
|
|
479
|
+
return // Exit early if we can't create the directory
|
|
480
|
+
}
|
|
475
481
|
}
|
|
476
482
|
|
|
477
483
|
// 生成文件名
|
|
@@ -557,7 +563,7 @@ export function logAPIError(context: {
|
|
|
557
563
|
}
|
|
558
564
|
|
|
559
565
|
console.log()
|
|
560
|
-
console.log(chalk.dim(` 📁 Full log:
|
|
566
|
+
console.log(chalk.dim(` 📁 Full log: ${filepath}`))
|
|
561
567
|
console.log(chalk.red('━'.repeat(60)))
|
|
562
568
|
console.log()
|
|
563
569
|
}
|