zames_pro 1.2.1 → 2.1.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/dist/agent-loop.js +671 -0
- package/dist/browser.js +656 -0
- package/dist/config.js +73 -0
- package/dist/confirm.js +81 -0
- package/dist/diff.js +37 -0
- package/dist/gitTools.js +218 -0
- package/dist/index.js +1378 -0
- package/dist/input.js +523 -0
- package/dist/markdown.js +47 -0
- package/dist/net-capture.js +100 -0
- package/dist/self-review.js +280 -0
- package/dist/sessions.js +122 -0
- package/dist/spinner.js +137 -0
- package/{src → dist}/system-prompt.js +23 -14
- package/{src → dist}/theme.js +16 -19
- package/dist/tools.js +211 -0
- package/dist/transcript.js +50 -0
- package/dist/types.js +3 -0
- package/dist/undo.js +99 -0
- package/dist/web.js +219 -0
- package/dist/xml-toolcall.js +67 -0
- package/package.json +17 -5
- package/src/agent-loop.js +0 -634
- package/src/browser.js +0 -627
- package/src/config.js +0 -81
- package/src/confirm.js +0 -85
- package/src/diff.js +0 -32
- package/src/gitTools.js +0 -256
- package/src/index.js +0 -1515
- package/src/input.js +0 -447
- package/src/markdown.js +0 -49
- package/src/self-review.js +0 -319
- package/src/sessions.js +0 -110
- package/src/spinner.js +0 -144
- package/src/tools.js +0 -206
- package/src/transcript.js +0 -46
- package/src/undo.js +0 -101
- package/src/web.js +0 -266
- package/src/xml-toolcall.js +0 -69
package/src/tools.js
DELETED
|
@@ -1,206 +0,0 @@
|
|
|
1
|
-
import fs from 'fs/promises'
|
|
2
|
-
import path from 'path'
|
|
3
|
-
import { exec } from 'child_process'
|
|
4
|
-
import { createGitTools } from './gitTools.js'
|
|
5
|
-
import { createWebTools } from './web.js'
|
|
6
|
-
|
|
7
|
-
export function createTools(workdir, { undo } = {}) {
|
|
8
|
-
const root = path.resolve(workdir)
|
|
9
|
-
const safe = (p) => {
|
|
10
|
-
const resolved = path.resolve(root, p)
|
|
11
|
-
// startsWith(root) пропускал бы соседние пути с общим префиксом
|
|
12
|
-
// (C:\work\proj vs C:\work\proj-old). Считаем через relative().
|
|
13
|
-
const rel = path.relative(root, resolved)
|
|
14
|
-
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
15
|
-
throw new Error(`Доступ за пределы рабочей директории: ${p}`)
|
|
16
|
-
}
|
|
17
|
-
return resolved
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
// Sandbox (вариант A): не даём команде выйти выше root.
|
|
21
|
-
// Это защитный барьер, а не полноценная изоляция ОС.
|
|
22
|
-
const assertCommandInsideRoot = (command) => {
|
|
23
|
-
const cmd = String(command || '')
|
|
24
|
-
const cdRe = /(?:^|[;&|]|\s)(?:cd|pushd)\s+([^;&|]+)/gi
|
|
25
|
-
let m
|
|
26
|
-
while ((m = cdRe.exec(cmd))) {
|
|
27
|
-
const raw = m[1].trim()
|
|
28
|
-
if (!raw || raw === '-') continue
|
|
29
|
-
const target = path.resolve(root, raw)
|
|
30
|
-
const rel = path.relative(root, target)
|
|
31
|
-
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
32
|
-
throw new Error('Sandbox: выход за пределы ' + root + ' запрещён (cd ' + raw + ')')
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
const runShell = (command, timeout = 30_000) =>
|
|
37
|
-
new Promise((resolve) => {
|
|
38
|
-
const options = {
|
|
39
|
-
cwd: workdir,
|
|
40
|
-
timeout,
|
|
41
|
-
maxBuffer: 1024 * 1024 * 8,
|
|
42
|
-
windowsHide: true,
|
|
43
|
-
env: { ...process.env },
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
if (process.platform === 'win32') {
|
|
47
|
-
options.shell = process.env.ComSpec || 'C:\\Windows\\System32\\cmd.exe'
|
|
48
|
-
} else {
|
|
49
|
-
options.shell = '/bin/sh'
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
exec(command, options, (err, stdout, stderr) => {
|
|
53
|
-
const out = (stdout || '').toString()
|
|
54
|
-
const errStr = (stderr || '').toString()
|
|
55
|
-
|
|
56
|
-
if (!err) {
|
|
57
|
-
const combined = (out + errStr).trim()
|
|
58
|
-
resolve(combined || '(команда выполнена без вывода)')
|
|
59
|
-
return
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const parts = []
|
|
63
|
-
|
|
64
|
-
if (err.killed) {
|
|
65
|
-
parts.push(`⏱ Таймаут после ${timeout}ms — процесс убит.`)
|
|
66
|
-
} else if (err.code !== undefined && err.code !== null) {
|
|
67
|
-
parts.push(`Exit code: ${err.code}`)
|
|
68
|
-
} else if (err.signal) {
|
|
69
|
-
parts.push(`Убит сигналом: ${err.signal}`)
|
|
70
|
-
} else {
|
|
71
|
-
parts.push(`Ошибка: ${err.message}`)
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
if (out.trim()) parts.push(`stdout:\n${out.trim()}`)
|
|
75
|
-
if (errStr.trim()) parts.push(`stderr:\n${errStr.trim()}`)
|
|
76
|
-
|
|
77
|
-
resolve(parts.join('\n'))
|
|
78
|
-
})
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
const baseTools = [
|
|
82
|
-
{
|
|
83
|
-
name: 'Read',
|
|
84
|
-
description:
|
|
85
|
-
'Прочитать содержимое файла. Опционально: offset и limit (строки).',
|
|
86
|
-
parameters: { path: 'string', offset: 'number?', limit: 'number?' },
|
|
87
|
-
fn: async ({ path: p, offset, limit }) => {
|
|
88
|
-
const file = safe(p)
|
|
89
|
-
const content = await fs.readFile(file, 'utf-8')
|
|
90
|
-
const lines = content.split('\n')
|
|
91
|
-
const start = offset ?? 0
|
|
92
|
-
const end = limit ? start + limit : lines.length
|
|
93
|
-
return lines.slice(start, end).join('\n')
|
|
94
|
-
},
|
|
95
|
-
},
|
|
96
|
-
|
|
97
|
-
{
|
|
98
|
-
name: 'Write',
|
|
99
|
-
description: 'Создать или перезаписать файл.',
|
|
100
|
-
parameters: { path: 'string', content: 'string' },
|
|
101
|
-
fn: async ({ path: p, content }) => {
|
|
102
|
-
const file = safe(p)
|
|
103
|
-
if (undo) await undo.backup(file)
|
|
104
|
-
await fs.mkdir(path.dirname(file), { recursive: true })
|
|
105
|
-
await fs.writeFile(file, content, 'utf-8')
|
|
106
|
-
return `Файл записан: ${p}`
|
|
107
|
-
},
|
|
108
|
-
},
|
|
109
|
-
|
|
110
|
-
{
|
|
111
|
-
name: 'Edit',
|
|
112
|
-
description:
|
|
113
|
-
'Точечная замена строки в файле. old_string должен встречаться один раз.',
|
|
114
|
-
parameters: {
|
|
115
|
-
path: 'string',
|
|
116
|
-
old_string: 'string',
|
|
117
|
-
new_string: 'string',
|
|
118
|
-
},
|
|
119
|
-
fn: async ({ path: p, old_string, new_string }) => {
|
|
120
|
-
const file = safe(p)
|
|
121
|
-
if (typeof old_string !== 'string' || old_string === '') {
|
|
122
|
-
throw new Error('old_string пустой — нечего заменять.')
|
|
123
|
-
}
|
|
124
|
-
let content = await fs.readFile(file, 'utf-8')
|
|
125
|
-
const occurrences = content.split(old_string).length - 1
|
|
126
|
-
if (occurrences === 0) {
|
|
127
|
-
throw new Error(
|
|
128
|
-
`Строка не найдена в ${p}: "${old_string.slice(0, 60)}..."`,
|
|
129
|
-
)
|
|
130
|
-
}
|
|
131
|
-
if (occurrences > 1) {
|
|
132
|
-
throw new Error(
|
|
133
|
-
`Строка встречается ${occurrences} раз в ${p}. Уточните old_string.`,
|
|
134
|
-
)
|
|
135
|
-
}
|
|
136
|
-
if (undo) await undo.backup(file)
|
|
137
|
-
content = content.replace(old_string, new_string)
|
|
138
|
-
await fs.writeFile(file, content, 'utf-8')
|
|
139
|
-
return `Отредактирован: ${p}`
|
|
140
|
-
},
|
|
141
|
-
},
|
|
142
|
-
|
|
143
|
-
{
|
|
144
|
-
name: 'Bash',
|
|
145
|
-
description:
|
|
146
|
-
'Выполнить shell-команду в рабочей директории (cmd.exe на Windows, sh на Linux/macOS). ' +
|
|
147
|
-
'Не использовать для long-running процессов (серверы) — уйдут в таймаут. ' +
|
|
148
|
-
'Не использовать для команд, требующих интерактивного ввода. ' +
|
|
149
|
-
'Для git — инструменты Git*. Для интернета — WebFetch / WebSearch.',
|
|
150
|
-
parameters: { command: 'string', timeout: 'number?' },
|
|
151
|
-
fn: async ({ command, timeout }) => {
|
|
152
|
-
assertCommandInsideRoot(command)
|
|
153
|
-
return runShell(command, timeout)
|
|
154
|
-
},
|
|
155
|
-
},
|
|
156
|
-
|
|
157
|
-
{
|
|
158
|
-
name: 'Glob',
|
|
159
|
-
description: 'Найти файлы по glob-паттерну (например, "**/*.js").',
|
|
160
|
-
parameters: { pattern: 'string' },
|
|
161
|
-
fn: async ({ pattern }) => {
|
|
162
|
-
const { glob } = await import('fs/promises')
|
|
163
|
-
const results = []
|
|
164
|
-
for await (const f of glob(pattern, { cwd: workdir })) {
|
|
165
|
-
// Sandbox: игнорируем всё, что выходит за пределы root.
|
|
166
|
-
const abs = path.resolve(workdir, f)
|
|
167
|
-
const rel = path.relative(root, abs)
|
|
168
|
-
if (rel.startsWith('..') || path.isAbsolute(rel)) continue
|
|
169
|
-
results.push(f)
|
|
170
|
-
}
|
|
171
|
-
return results.length ? results.join('\n') : 'Ничего не найдено.'
|
|
172
|
-
},
|
|
173
|
-
},
|
|
174
|
-
|
|
175
|
-
{
|
|
176
|
-
name: 'Grep',
|
|
177
|
-
description: 'Поиск по содержимому файлов (регулярное выражение).',
|
|
178
|
-
parameters: { pattern: 'string', path: 'string?' },
|
|
179
|
-
fn: async ({ pattern, path: searchPath }) => {
|
|
180
|
-
const target = searchPath ? safe(searchPath) : workdir
|
|
181
|
-
if (process.platform === 'win32') {
|
|
182
|
-
const escaped = pattern.replace(/"/g, '\\"')
|
|
183
|
-
const scope = searchPath
|
|
184
|
-
? '"' + target + '\\*'
|
|
185
|
-
: '*'
|
|
186
|
-
return runShell(`findstr /s /n /r /c:"${escaped}" ` + scope)
|
|
187
|
-
}
|
|
188
|
-
return runShell(
|
|
189
|
-
`grep -rn -E ${JSON.stringify(pattern)} ${JSON.stringify(target)} || true`,
|
|
190
|
-
)
|
|
191
|
-
},
|
|
192
|
-
},
|
|
193
|
-
]
|
|
194
|
-
|
|
195
|
-
const gitTools = createGitTools(workdir)
|
|
196
|
-
const webTools = createWebTools()
|
|
197
|
-
|
|
198
|
-
const respondTool = {
|
|
199
|
-
name: 'respond',
|
|
200
|
-
description: 'Дать финальный ответ пользователю и завершить задачу.',
|
|
201
|
-
parameters: { message: 'string' },
|
|
202
|
-
fn: async ({ message }) => message,
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
return [...baseTools, ...gitTools, ...webTools, respondTool]
|
|
206
|
-
}
|
package/src/transcript.js
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import fs from 'fs'
|
|
2
|
-
import path from 'path'
|
|
3
|
-
|
|
4
|
-
export class Transcript {
|
|
5
|
-
constructor({ dir, enabled = true, sessionName = 'session' } = {}) {
|
|
6
|
-
this.enabled = enabled
|
|
7
|
-
this.file = null
|
|
8
|
-
this.stream = null
|
|
9
|
-
this.startedAt = Date.now()
|
|
10
|
-
|
|
11
|
-
if (!enabled) return
|
|
12
|
-
|
|
13
|
-
try {
|
|
14
|
-
fs.mkdirSync(dir, { recursive: true })
|
|
15
|
-
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
16
|
-
this.file = path.join(dir, `${sessionName}-${stamp}.jsonl`)
|
|
17
|
-
this.stream = fs.createWriteStream(this.file, { flags: 'a' })
|
|
18
|
-
// Ошибки записи (диск переполнен, файл удалён и т.п.) приходят
|
|
19
|
-
// событием 'error'; без слушателя это uncaught exception.
|
|
20
|
-
this.stream.on('error', (e) => {
|
|
21
|
-
console.error(`transcript: ошибка записи: ${e.message}`)
|
|
22
|
-
this.enabled = false
|
|
23
|
-
})
|
|
24
|
-
} catch (e) {
|
|
25
|
-
console.error(`transcript: не удалось создать файл: ${e.message}`)
|
|
26
|
-
this.enabled = false
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
log(type, data = {}) {
|
|
31
|
-
if (!this.enabled || !this.stream) return
|
|
32
|
-
const entry = {
|
|
33
|
-
ts: new Date().toISOString(),
|
|
34
|
-
elapsed: Date.now() - this.startedAt,
|
|
35
|
-
type,
|
|
36
|
-
...data,
|
|
37
|
-
}
|
|
38
|
-
try {
|
|
39
|
-
this.stream.write(JSON.stringify(entry) + '\n')
|
|
40
|
-
} catch {}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
close() {
|
|
44
|
-
if (this.stream) this.stream.end()
|
|
45
|
-
}
|
|
46
|
-
}
|
package/src/undo.js
DELETED
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
import fs from 'fs/promises'
|
|
2
|
-
import path from 'path'
|
|
3
|
-
import os from 'os'
|
|
4
|
-
|
|
5
|
-
const UNDO_DIR = path.join(os.homedir(), '.zames', 'undo')
|
|
6
|
-
const INDEX = path.join(UNDO_DIR, 'index.json')
|
|
7
|
-
|
|
8
|
-
export class UndoStore {
|
|
9
|
-
constructor({ enabled = true, maxBackups = 200 } = {}) {
|
|
10
|
-
this.enabled = enabled
|
|
11
|
-
this.maxBackups = maxBackups
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
async _ensure() {
|
|
15
|
-
await fs.mkdir(UNDO_DIR, { recursive: true })
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
async _readIndex() {
|
|
19
|
-
try {
|
|
20
|
-
return JSON.parse(await fs.readFile(INDEX, 'utf-8'))
|
|
21
|
-
} catch {
|
|
22
|
-
return []
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
async _writeIndex(history) {
|
|
27
|
-
await fs.writeFile(INDEX, JSON.stringify(history, null, 2))
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
async backup(filePath) {
|
|
31
|
-
if (!this.enabled) return null
|
|
32
|
-
await this._ensure()
|
|
33
|
-
|
|
34
|
-
let content = null
|
|
35
|
-
let existed = false
|
|
36
|
-
try {
|
|
37
|
-
content = await fs.readFile(filePath)
|
|
38
|
-
existed = true
|
|
39
|
-
} catch (e) {
|
|
40
|
-
if (e.code !== 'ENOENT') throw e
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const stamp = Date.now()
|
|
44
|
-
const hash = Buffer.from(filePath).toString('hex').slice(0, 16)
|
|
45
|
-
|
|
46
|
-
let backupFile = null
|
|
47
|
-
if (existed) {
|
|
48
|
-
backupFile = path.join(UNDO_DIR, `${stamp}-${hash}.bak`)
|
|
49
|
-
await fs.writeFile(backupFile, content)
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const history = await this._readIndex()
|
|
53
|
-
history.push({ originalPath: filePath, existed, stamp, backupFile })
|
|
54
|
-
|
|
55
|
-
while (history.length > this.maxBackups) {
|
|
56
|
-
const old = history.shift()
|
|
57
|
-
if (old.backupFile) {
|
|
58
|
-
try {
|
|
59
|
-
await fs.unlink(old.backupFile)
|
|
60
|
-
} catch {}
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
await this._writeIndex(history)
|
|
65
|
-
return { originalPath: filePath, existed, stamp }
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
async undoLast() {
|
|
69
|
-
if (!this.enabled) return { ok: false, reason: 'undo отключён в конфиге' }
|
|
70
|
-
await this._ensure()
|
|
71
|
-
|
|
72
|
-
const history = await this._readIndex()
|
|
73
|
-
if (!history.length) return { ok: false, reason: 'история пуста' }
|
|
74
|
-
|
|
75
|
-
const record = history.pop()
|
|
76
|
-
const { originalPath, existed, backupFile } = record
|
|
77
|
-
|
|
78
|
-
try {
|
|
79
|
-
if (existed && backupFile) {
|
|
80
|
-
const data = await fs.readFile(backupFile)
|
|
81
|
-
await fs.writeFile(originalPath, data)
|
|
82
|
-
try {
|
|
83
|
-
await fs.unlink(backupFile)
|
|
84
|
-
} catch {}
|
|
85
|
-
} else {
|
|
86
|
-
try {
|
|
87
|
-
await fs.unlink(originalPath)
|
|
88
|
-
} catch {}
|
|
89
|
-
}
|
|
90
|
-
await this._writeIndex(history)
|
|
91
|
-
return { ok: true, record }
|
|
92
|
-
} catch (e) {
|
|
93
|
-
return { ok: false, reason: e.message }
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
async list(count = 10) {
|
|
98
|
-
const history = await this._readIndex()
|
|
99
|
-
return history.slice(-count).reverse()
|
|
100
|
-
}
|
|
101
|
-
}
|
package/src/web.js
DELETED
|
@@ -1,266 +0,0 @@
|
|
|
1
|
-
import { chromium } from 'playwright'
|
|
2
|
-
|
|
3
|
-
const DEFAULT_TIMEOUT = 20_000
|
|
4
|
-
const MAX_TEXT = 12_000
|
|
5
|
-
|
|
6
|
-
// Убирает скрипты, стили, и превращает HTML в читабельный текст.
|
|
7
|
-
function htmlToText(html) {
|
|
8
|
-
// Удаляем блоки, которые не нужны
|
|
9
|
-
let s = html
|
|
10
|
-
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
11
|
-
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
12
|
-
.replace(/<noscript[\s\S]*?<\/noscript>/gi, '')
|
|
13
|
-
.replace(/<svg[\s\S]*?<\/svg>/gi, '')
|
|
14
|
-
.replace(/<!--[\s\S]*?-->/g, '')
|
|
15
|
-
|
|
16
|
-
// Ссылки: <a href="URL">text</a> → text (URL)
|
|
17
|
-
s = s.replace(
|
|
18
|
-
/<a\b[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi,
|
|
19
|
-
(_, href, text) => {
|
|
20
|
-
const t = text.replace(/<[^>]+>/g, '').trim()
|
|
21
|
-
return t ? `${t} (${href})` : href
|
|
22
|
-
},
|
|
23
|
-
)
|
|
24
|
-
|
|
25
|
-
// Заголовки, параграфы, BR → переводы строк
|
|
26
|
-
s = s
|
|
27
|
-
.replace(/<\/(h[1-6]|p|div|li|tr|section|article)>/gi, '\n')
|
|
28
|
-
.replace(/<br\s*\/?>/gi, '\n')
|
|
29
|
-
.replace(/<li[^>]*>/gi, '- ')
|
|
30
|
-
.replace(/<[^>]+>/g, '')
|
|
31
|
-
|
|
32
|
-
// HTML entities — базовые
|
|
33
|
-
s = s
|
|
34
|
-
.replace(/ /g, ' ')
|
|
35
|
-
.replace(/&/g, '&')
|
|
36
|
-
.replace(/</g, '<')
|
|
37
|
-
.replace(/>/g, '>')
|
|
38
|
-
.replace(/"/g, '"')
|
|
39
|
-
.replace(/'/g, "'")
|
|
40
|
-
.replace(/—/g, '—')
|
|
41
|
-
.replace(/–/g, '–')
|
|
42
|
-
.replace(/…/g, '…')
|
|
43
|
-
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
|
|
44
|
-
.replace(/&#x([0-9a-f]+);/gi, (_, n) =>
|
|
45
|
-
String.fromCodePoint(parseInt(n, 16)),
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
// Сжимаем пустые строки
|
|
49
|
-
s = s
|
|
50
|
-
.split('\n')
|
|
51
|
-
.map((l) => l.replace(/[ \t]+/g, ' ').trim())
|
|
52
|
-
.filter(Boolean)
|
|
53
|
-
.join('\n')
|
|
54
|
-
|
|
55
|
-
return s
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// fetch с редиректами и таймаутом
|
|
59
|
-
async function httpFetch(
|
|
60
|
-
url,
|
|
61
|
-
{ timeout = DEFAULT_TIMEOUT, headers = {} } = {},
|
|
62
|
-
) {
|
|
63
|
-
const ctrl = new AbortController()
|
|
64
|
-
const t = setTimeout(() => ctrl.abort(), timeout)
|
|
65
|
-
try {
|
|
66
|
-
const res = await fetch(url, {
|
|
67
|
-
signal: ctrl.signal,
|
|
68
|
-
redirect: 'follow',
|
|
69
|
-
headers: {
|
|
70
|
-
'User-Agent':
|
|
71
|
-
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) ds-agent/1.0',
|
|
72
|
-
'Accept-Language': 'en-US,en;q=0.9,ru;q=0.8',
|
|
73
|
-
...headers,
|
|
74
|
-
},
|
|
75
|
-
})
|
|
76
|
-
const ct = res.headers.get('content-type') || ''
|
|
77
|
-
const body = await res.text()
|
|
78
|
-
return { status: res.status, url: res.url, contentType: ct, body }
|
|
79
|
-
} finally {
|
|
80
|
-
clearTimeout(t)
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Отдельный headless-браузер для JS-страниц.
|
|
85
|
-
// Ленивая инициализация, чтобы не тратить ресурсы впустую.
|
|
86
|
-
let _headless = null
|
|
87
|
-
async function getHeadless() {
|
|
88
|
-
if (_headless) return _headless
|
|
89
|
-
_headless = await chromium.launch({
|
|
90
|
-
headless: true,
|
|
91
|
-
args: ['--disable-blink-features=AutomationControlled'],
|
|
92
|
-
})
|
|
93
|
-
return _headless
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export async function closeWeb() {
|
|
97
|
-
if (_headless) {
|
|
98
|
-
try {
|
|
99
|
-
await _headless.close()
|
|
100
|
-
} catch {}
|
|
101
|
-
_headless = null
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
async function renderWithHeadless(url, { timeout = DEFAULT_TIMEOUT } = {}) {
|
|
106
|
-
const browser = await getHeadless()
|
|
107
|
-
const ctx = await browser.newContext({
|
|
108
|
-
userAgent:
|
|
109
|
-
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
110
|
-
})
|
|
111
|
-
const page = await ctx.newPage()
|
|
112
|
-
try {
|
|
113
|
-
const resp = await page.goto(url, {
|
|
114
|
-
waitUntil: 'domcontentloaded',
|
|
115
|
-
timeout,
|
|
116
|
-
})
|
|
117
|
-
// Дать JS немного времени дорисовать
|
|
118
|
-
await page.waitForTimeout(1500)
|
|
119
|
-
const html = await page.content()
|
|
120
|
-
const status = resp ? resp.status() : 0
|
|
121
|
-
const finalUrl = page.url()
|
|
122
|
-
return { status, url: finalUrl, html }
|
|
123
|
-
} finally {
|
|
124
|
-
await ctx.close()
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// ---------- инструменты ----------
|
|
129
|
-
|
|
130
|
-
export function createWebTools() {
|
|
131
|
-
return [
|
|
132
|
-
{
|
|
133
|
-
name: 'WebFetch',
|
|
134
|
-
description:
|
|
135
|
-
'Загрузить URL и вернуть очищенный текст страницы (без HTML-тегов). ' +
|
|
136
|
-
'Используй для чтения документации, статей, README на GitHub. ' +
|
|
137
|
-
'Если страница рендерится JavaScript-ом и текст пустой — попробуй ещё раз с render=true.',
|
|
138
|
-
parameters: {
|
|
139
|
-
url: 'string',
|
|
140
|
-
render: 'boolean?',
|
|
141
|
-
maxChars: 'number?',
|
|
142
|
-
},
|
|
143
|
-
fn: async ({ url, render, maxChars }) => {
|
|
144
|
-
if (!/^https?:\/\//i.test(url)) {
|
|
145
|
-
return `Ошибка: URL должен начинаться с http:// или https://`
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const limit = Math.min(Number(maxChars) || MAX_TEXT, 60_000)
|
|
149
|
-
|
|
150
|
-
try {
|
|
151
|
-
if (render) {
|
|
152
|
-
const r = await renderWithHeadless(url)
|
|
153
|
-
const text = htmlToText(r.html)
|
|
154
|
-
return formatResult(r.status, r.url, text, limit)
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const r = await httpFetch(url)
|
|
158
|
-
|
|
159
|
-
// Если это JSON/plain text — вернуть как есть
|
|
160
|
-
if (
|
|
161
|
-
/application\/json|text\/plain|text\/markdown/i.test(r.contentType)
|
|
162
|
-
) {
|
|
163
|
-
const trimmed = r.body.slice(0, limit)
|
|
164
|
-
return `HTTP ${r.status} ${r.url}\nContent-Type: ${r.contentType}\n\n${trimmed}`
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// HTML — почистить
|
|
168
|
-
const text = htmlToText(r.body)
|
|
169
|
-
|
|
170
|
-
// Если текста почти нет — вероятно JS-страница, дать намёк
|
|
171
|
-
if (text.length < 200) {
|
|
172
|
-
return (
|
|
173
|
-
`HTTP ${r.status} ${r.url}\n` +
|
|
174
|
-
`Content-Type: ${r.contentType}\n\n` +
|
|
175
|
-
`Страница почти пустая в сыром HTML (${text.length} символов). ` +
|
|
176
|
-
`Похоже, контент рендерится JavaScript-ом. ` +
|
|
177
|
-
`Повтори вызов с render=true.\n\n---\n${text}`
|
|
178
|
-
)
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
return formatResult(r.status, r.url, text, limit)
|
|
182
|
-
} catch (e) {
|
|
183
|
-
return `Ошибка загрузки ${url}: ${e.message}`
|
|
184
|
-
}
|
|
185
|
-
},
|
|
186
|
-
},
|
|
187
|
-
|
|
188
|
-
{
|
|
189
|
-
name: 'WebSearch',
|
|
190
|
-
description:
|
|
191
|
-
'Поиск в интернете через DuckDuckGo HTML (без API-ключа). ' +
|
|
192
|
-
'Возвращает список результатов: заголовок, URL, краткое описание.',
|
|
193
|
-
parameters: {
|
|
194
|
-
query: 'string',
|
|
195
|
-
maxResults: 'number?',
|
|
196
|
-
},
|
|
197
|
-
fn: async ({ query, maxResults }) => {
|
|
198
|
-
const q = encodeURIComponent(query || '')
|
|
199
|
-
if (!q) return 'Ошибка: query пустой.'
|
|
200
|
-
|
|
201
|
-
const limit = Math.min(Math.max(Number(maxResults) || 8, 1), 20)
|
|
202
|
-
|
|
203
|
-
try {
|
|
204
|
-
const r = await httpFetch(
|
|
205
|
-
`https://html.duckduckgo.com/html/?q=${q}`,
|
|
206
|
-
{
|
|
207
|
-
timeout: 25_000,
|
|
208
|
-
headers: {
|
|
209
|
-
Accept: 'text/html,application/xhtml+xml',
|
|
210
|
-
},
|
|
211
|
-
},
|
|
212
|
-
)
|
|
213
|
-
|
|
214
|
-
if (r.status !== 200) {
|
|
215
|
-
return `DuckDuckGo вернул HTTP ${r.status}`
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// Парсим простыми регексами. Формат html.duckduckgo.com стабильный.
|
|
219
|
-
const results = []
|
|
220
|
-
const re =
|
|
221
|
-
/<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?(?:<a[^>]*class="[^"]*result__snippet[^"]*"[^>]*>([\s\S]*?)<\/a>)?/gi
|
|
222
|
-
|
|
223
|
-
let m
|
|
224
|
-
while ((m = re.exec(r.body)) && results.length < limit) {
|
|
225
|
-
let href = m[1]
|
|
226
|
-
// DuckDuckGo заворачивает ссылки в редирект вида /l/?uddg=...
|
|
227
|
-
const uddgMatch = href.match(/[?&]uddg=([^&]+)/)
|
|
228
|
-
if (uddgMatch) href = decodeURIComponent(uddgMatch[1])
|
|
229
|
-
|
|
230
|
-
const title = htmlToText(m[2] || '').trim()
|
|
231
|
-
const snippet = htmlToText(m[3] || '').trim()
|
|
232
|
-
|
|
233
|
-
if (!title || !href) continue
|
|
234
|
-
results.push({ title, url: href, snippet })
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
if (!results.length) {
|
|
238
|
-
return `Результатов не найдено. Возможно, изменился формат выдачи DuckDuckGo.`
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
const lines = results.map(
|
|
242
|
-
(r, i) =>
|
|
243
|
-
`${i + 1}. ${r.title}\n ${r.url}` +
|
|
244
|
-
(r.snippet ? `\n ${r.snippet}` : ''),
|
|
245
|
-
)
|
|
246
|
-
return lines.join('\n\n')
|
|
247
|
-
} catch (e) {
|
|
248
|
-
return `Ошибка поиска: ${e.message}`
|
|
249
|
-
}
|
|
250
|
-
},
|
|
251
|
-
},
|
|
252
|
-
]
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
function formatResult(status, url, text, limit) {
|
|
256
|
-
let out = text
|
|
257
|
-
let truncated = false
|
|
258
|
-
if (out.length > limit) {
|
|
259
|
-
out = out.slice(0, limit)
|
|
260
|
-
truncated = true
|
|
261
|
-
}
|
|
262
|
-
return (
|
|
263
|
-
`HTTP ${status} ${url}\n\n${out}` +
|
|
264
|
-
(truncated ? `\n\n[...обрезано на ${limit} символах]` : '')
|
|
265
|
-
)
|
|
266
|
-
}
|
package/src/xml-toolcall.js
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
function unescapeXml(s) {
|
|
2
|
-
const A = String.fromCharCode(38) // ampersand
|
|
3
|
-
return String(s)
|
|
4
|
-
.split(A + "lt;").join(String.fromCharCode(60))
|
|
5
|
-
.split(A + "gt;").join(String.fromCharCode(62))
|
|
6
|
-
.split(A + "quot;").join(String.fromCharCode(34))
|
|
7
|
-
.split(A + "apos;").join(String.fromCharCode(39))
|
|
8
|
-
.split(A + "amp;").join(A)
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function readAttr(attrs, name) {
|
|
12
|
-
const Q = String.fromCharCode(34)
|
|
13
|
-
const re = new RegExp(name + "[ ]*=[ ]*([" + Q + "]([^" + Q + "]*)[" + Q + "])")
|
|
14
|
-
const m = attrs.match(re)
|
|
15
|
-
return m ? m[2] : null
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function parseXmlToolCalls(text) {
|
|
19
|
-
if (!text || typeof text !== "string") return null
|
|
20
|
-
if (!/<[^>]*invoke/i.test(text)) return null
|
|
21
|
-
|
|
22
|
-
const invokeRe = /<[^>]*?invoke([^>]*)>/gi
|
|
23
|
-
const calls = []
|
|
24
|
-
let m
|
|
25
|
-
while ((m = invokeRe.exec(text)) !== null) {
|
|
26
|
-
const attrs = m[1] || ""
|
|
27
|
-
const name = readAttr(attrs, "name")
|
|
28
|
-
if (!name) continue
|
|
29
|
-
|
|
30
|
-
const rest = text.slice(invokeRe.lastIndex)
|
|
31
|
-
const closeRe = /<\/[^>]*?invoke[^>]*>/i
|
|
32
|
-
const closeMatch = closeRe.exec(rest)
|
|
33
|
-
const body = closeMatch ? rest.slice(0, closeMatch.index) : rest
|
|
34
|
-
|
|
35
|
-
const args = {}
|
|
36
|
-
const paramRe = /<[^>]*?parameter([^>]*)>([\s\S]*?)<\/[^>]*?parameter[^>]*>/gi
|
|
37
|
-
let p
|
|
38
|
-
while ((p = paramRe.exec(body)) !== null) {
|
|
39
|
-
const pname = readAttr(p[1] || "", "name")
|
|
40
|
-
if (!pname) continue
|
|
41
|
-
const isString = /string[ ]*=[ ]*"?true"?/i.test(p[1] || "")
|
|
42
|
-
let value = unescapeXml(p[2])
|
|
43
|
-
if (!isString) {
|
|
44
|
-
try { value = JSON.parse(value) } catch (e) {}
|
|
45
|
-
}
|
|
46
|
-
args[pname] = value
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Некоторые модели кладут весь JSON-объект аргументов в один
|
|
50
|
-
// parameter с именем args. Разворачиваем, чтобы не было
|
|
51
|
-
// {args: {args: {...}}}.
|
|
52
|
-
let finalArgs = args
|
|
53
|
-
const keys = Object.keys(args)
|
|
54
|
-
if (
|
|
55
|
-
keys.length === 1 &&
|
|
56
|
-
keys[0] === 'args' &&
|
|
57
|
-
args.args &&
|
|
58
|
-
typeof args.args === 'object' &&
|
|
59
|
-
!Array.isArray(args.args)
|
|
60
|
-
) {
|
|
61
|
-
finalArgs = args.args
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
calls.push({ tool: name, args: finalArgs })
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
if (!calls.length) return null
|
|
68
|
-
return calls.length === 1 ? calls[0] : calls
|
|
69
|
-
}
|