zames_pro 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/src/undo.js ADDED
@@ -0,0 +1,101 @@
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 ADDED
@@ -0,0 +1,266 @@
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(/&nbsp;/g, ' ')
35
+ .replace(/&amp;/g, '&')
36
+ .replace(/&lt;/g, '<')
37
+ .replace(/&gt;/g, '>')
38
+ .replace(/&quot;/g, '"')
39
+ .replace(/&#39;/g, "'")
40
+ .replace(/&mdash;/g, '—')
41
+ .replace(/&ndash;/g, '–')
42
+ .replace(/&hellip;/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
+ }