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/LICENSE +21 -0
- package/README.md +48 -0
- package/package.json +36 -0
- package/src/agent-loop.js +419 -0
- package/src/browser.js +468 -0
- package/src/config.js +80 -0
- package/src/confirm.js +85 -0
- package/src/diff.js +124 -0
- package/src/gitTools.js +256 -0
- package/src/index.js +792 -0
- package/src/self-review.js +304 -0
- package/src/spinner.js +55 -0
- package/src/system-prompt.js +84 -0
- package/src/tools.js +206 -0
- package/src/transcript.js +46 -0
- package/src/undo.js +101 -0
- package/src/web.js +266 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import path from 'path'
|
|
2
|
+
import fs from 'fs/promises'
|
|
3
|
+
import chalk from 'chalk'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
|
|
6
|
+
import { createTools } from './tools.js'
|
|
7
|
+
import { runAgentLoop } from './agent-loop.js'
|
|
8
|
+
import { ZAMES_HOME } from './config.js'
|
|
9
|
+
|
|
10
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
11
|
+
const SRC_DIR = __dirname
|
|
12
|
+
const SNAP_ROOT = path.join(ZAMES_HOME, 'snapshots')
|
|
13
|
+
|
|
14
|
+
async function ensureDir(p) {
|
|
15
|
+
await fs.mkdir(p, { recursive: true })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function copyDirJsFiles(from, to) {
|
|
19
|
+
await ensureDir(to)
|
|
20
|
+
const entries = await fs.readdir(from)
|
|
21
|
+
const copied = []
|
|
22
|
+
for (const e of entries) {
|
|
23
|
+
if (!e.endsWith('.js')) continue
|
|
24
|
+
const data = await fs.readFile(path.join(from, e), 'utf-8')
|
|
25
|
+
await fs.writeFile(path.join(to, e), data, 'utf-8')
|
|
26
|
+
copied.push(e)
|
|
27
|
+
}
|
|
28
|
+
return copied
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
// ---------- /self-review ----------
|
|
33
|
+
|
|
34
|
+
export async function selfReview({ browser, config, focus, transcript }) {
|
|
35
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
36
|
+
const snapRoot = path.join(SNAP_ROOT)
|
|
37
|
+
const snapDir = path.join(snapRoot, `run-${stamp}`)
|
|
38
|
+
await ensureDir(snapRoot)
|
|
39
|
+
await ensureDir(snapDir)
|
|
40
|
+
|
|
41
|
+
const copied = await copyDirJsFiles(SRC_DIR, snapDir)
|
|
42
|
+
|
|
43
|
+
if (copied.length === 0) {
|
|
44
|
+
console.error(
|
|
45
|
+
chalk.red(
|
|
46
|
+
`\n✖ Самообзор отменён: в ${SRC_DIR} нет .js файлов.\n` +
|
|
47
|
+
`Проверь, что src/ не пуст и ты запускаешь агента из корня проекта.\n`,
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
throw new Error('SRC_DIR пуст — нечего ревьюить')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Копируем package.json для контекста — чтобы агент видел зависимости
|
|
54
|
+
try {
|
|
55
|
+
const pkg = await fs.readFile(
|
|
56
|
+
path.resolve(SRC_DIR, '..', 'package.json'),
|
|
57
|
+
'utf-8',
|
|
58
|
+
)
|
|
59
|
+
await fs.writeFile(path.join(snapDir, 'package.json'), pkg, 'utf-8')
|
|
60
|
+
} catch {}
|
|
61
|
+
|
|
62
|
+
console.log(chalk.gray(`\n📸 Снапшот: ${snapDir}`))
|
|
63
|
+
console.log(chalk.gray(`Файлов: ${copied.length} — ${copied.join(', ')}`))
|
|
64
|
+
console.log(chalk.gray('Начинаю самообзор...\n'))
|
|
65
|
+
|
|
66
|
+
const taskPrompt = buildReviewPrompt({ focus, snapDir })
|
|
67
|
+
|
|
68
|
+
const tools = createTools(snapDir, { undo: null })
|
|
69
|
+
let finalMessage = ''
|
|
70
|
+
|
|
71
|
+
await runAgentLoop({
|
|
72
|
+
browser,
|
|
73
|
+
tools,
|
|
74
|
+
task: taskPrompt,
|
|
75
|
+
workdir: snapDir,
|
|
76
|
+
maxIterations: 80,
|
|
77
|
+
freshChat: true,
|
|
78
|
+
sendSystemPrompt: true,
|
|
79
|
+
transcript,
|
|
80
|
+
onThinking: () => {},
|
|
81
|
+
onToolCall: (name, args) => {
|
|
82
|
+
const preview = JSON.stringify(args).slice(0, 120)
|
|
83
|
+
console.log(chalk.yellow(`🔧 ${name}`), chalk.gray(preview))
|
|
84
|
+
},
|
|
85
|
+
onToolResult: (r) => {
|
|
86
|
+
const t = typeof r === 'string' ? r : JSON.stringify(r)
|
|
87
|
+
console.log(chalk.gray(` → ${t.slice(0, 200).replace(/\n/g, ' ↵ ')}\n`))
|
|
88
|
+
},
|
|
89
|
+
onAssistantMessage: (msg) => {
|
|
90
|
+
finalMessage = msg
|
|
91
|
+
console.log(chalk.green('\n📋 Отчёт:\n'))
|
|
92
|
+
console.log(msg)
|
|
93
|
+
console.log()
|
|
94
|
+
},
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// Сохраняем отчёт
|
|
98
|
+
const reportPath = path.join(snapDir, '_report.md')
|
|
99
|
+
await fs.writeFile(reportPath, finalMessage || '(пусто)', 'utf-8')
|
|
100
|
+
|
|
101
|
+
// Считаем, что изменилось
|
|
102
|
+
const changed = await diffFiles(SRC_DIR, snapDir)
|
|
103
|
+
|
|
104
|
+
console.log(chalk.gray('─'.repeat(60)))
|
|
105
|
+
console.log(chalk.gray(`Отчёт: ${reportPath}`))
|
|
106
|
+
console.log(chalk.gray(`Снапшот: ${snapDir}`))
|
|
107
|
+
if (changed.length) {
|
|
108
|
+
console.log(
|
|
109
|
+
chalk.green(
|
|
110
|
+
`Изменено: ${changed.length} файл(ов): ${changed.join(', ')}`,
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
} else {
|
|
114
|
+
console.log(chalk.gray('Изменено: (ничего — только отчёт)'))
|
|
115
|
+
}
|
|
116
|
+
console.log(chalk.cyan(`\nДальше:`))
|
|
117
|
+
console.log(
|
|
118
|
+
chalk.cyan(
|
|
119
|
+
` /self-diff ${path.basename(snapDir)} — посмотреть различия`,
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
console.log(
|
|
123
|
+
chalk.cyan(
|
|
124
|
+
` /self-apply ${path.basename(snapDir)} — применить к живому src/`,
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
console.log()
|
|
128
|
+
|
|
129
|
+
return { snapDir, reportPath, changed }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------- /self-diff ----------
|
|
133
|
+
|
|
134
|
+
export async function selfDiff({ config, name }) {
|
|
135
|
+
const snapDir = path.join(SNAP_ROOT, name)
|
|
136
|
+
const snapStat = await fs.stat(snapDir).catch(() => null)
|
|
137
|
+
if (!snapStat) throw new Error(`Снапшот не найден: ${snapDir}`)
|
|
138
|
+
|
|
139
|
+
const { unifiedDiff, colorDiff } = await import('./diff.js')
|
|
140
|
+
const files = await fs.readdir(snapDir)
|
|
141
|
+
let anyDiff = false
|
|
142
|
+
|
|
143
|
+
for (const f of files) {
|
|
144
|
+
if (!f.endsWith('.js')) continue
|
|
145
|
+
const orig = await fs
|
|
146
|
+
.readFile(path.join(SRC_DIR, f), 'utf-8')
|
|
147
|
+
.catch(() => '')
|
|
148
|
+
const next = await fs
|
|
149
|
+
.readFile(path.join(snapDir, f), 'utf-8')
|
|
150
|
+
.catch(() => '')
|
|
151
|
+
if (orig === next) continue
|
|
152
|
+
|
|
153
|
+
anyDiff = true
|
|
154
|
+
console.log(chalk.bold(`\n=== src/${f} ===`))
|
|
155
|
+
const diff = unifiedDiff(orig, next, { label: f })
|
|
156
|
+
console.log(colorDiff(diff, chalk))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!anyDiff) {
|
|
160
|
+
console.log(chalk.gray('Различий нет.'))
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------- /self-apply ----------
|
|
165
|
+
|
|
166
|
+
export async function selfApply({ config, name }) {
|
|
167
|
+
const snapDir = path.join(SNAP_ROOT, name)
|
|
168
|
+
const snapStat = await fs.stat(snapDir).catch(() => null)
|
|
169
|
+
if (!snapStat) throw new Error(`Снапшот не найден: ${snapDir}`)
|
|
170
|
+
|
|
171
|
+
// Бэкап текущего src перед перезаписью
|
|
172
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
173
|
+
const backupDir = path.join(
|
|
174
|
+
SNAP_ROOT,
|
|
175
|
+
`backup-before-apply-${stamp}`,
|
|
176
|
+
)
|
|
177
|
+
await copyDirJsFiles(SRC_DIR, backupDir)
|
|
178
|
+
|
|
179
|
+
const files = await fs.readdir(snapDir)
|
|
180
|
+
const jsFiles = files.filter((f) => f.endsWith('.js'))
|
|
181
|
+
|
|
182
|
+
if (jsFiles.length === 0) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`В снапшоте ${snapDir} нет .js файлов. Apply отменён, чтобы не стирать src/.`,
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const applied = []
|
|
189
|
+
for (const f of jsFiles) {
|
|
190
|
+
const data = await fs.readFile(path.join(snapDir, f), 'utf-8')
|
|
191
|
+
await fs.writeFile(path.join(SRC_DIR, f), data, 'utf-8')
|
|
192
|
+
applied.push(f)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
console.log(chalk.green(`\n✅ Применено: ${applied.length} файл(ов)`))
|
|
196
|
+
console.log(chalk.gray(applied.join(', ')))
|
|
197
|
+
console.log(chalk.gray(`Бэкап: ${backupDir}`))
|
|
198
|
+
console.log(
|
|
199
|
+
chalk.yellow(`\nПерезапусти агента, чтобы изменения вступили в силу.\n`),
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------- /self-list ----------
|
|
204
|
+
|
|
205
|
+
export async function selfList({ config }) {
|
|
206
|
+
const snapRoot = path.join(SNAP_ROOT)
|
|
207
|
+
let entries = []
|
|
208
|
+
try {
|
|
209
|
+
entries = await fs.readdir(snapRoot)
|
|
210
|
+
} catch {
|
|
211
|
+
console.log(chalk.gray('Снапшотов нет.'))
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!entries.length) {
|
|
216
|
+
console.log(chalk.gray('Снапшотов нет.'))
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
entries.sort()
|
|
221
|
+
console.log(chalk.gray('Снапшоты самообзора:'))
|
|
222
|
+
for (const e of entries) {
|
|
223
|
+
const full = path.join(snapRoot, e)
|
|
224
|
+
const stat = await fs.stat(full).catch(() => null)
|
|
225
|
+
if (!stat || !stat.isDirectory()) continue
|
|
226
|
+
const report = path.join(full, '_report.md')
|
|
227
|
+
const hasReport = await fs
|
|
228
|
+
.stat(report)
|
|
229
|
+
.then(() => true)
|
|
230
|
+
.catch(() => false)
|
|
231
|
+
const changedCount = (await diffFiles(SRC_DIR, full)).length
|
|
232
|
+
const tag = changedCount
|
|
233
|
+
? chalk.green(`[${changedCount} изменено]`)
|
|
234
|
+
: chalk.gray('[без правок]')
|
|
235
|
+
const rep = hasReport ? chalk.cyan('📋') : ' '
|
|
236
|
+
console.log(` ${rep} ${e} ${tag}`)
|
|
237
|
+
}
|
|
238
|
+
console.log()
|
|
239
|
+
console.log(chalk.gray('Команды: /self-diff <name>, /self-apply <name>\n'))
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---------- helpers ----------
|
|
243
|
+
|
|
244
|
+
async function diffFiles(origDir, newDir) {
|
|
245
|
+
const files = await fs.readdir(newDir).catch(() => [])
|
|
246
|
+
const changed = []
|
|
247
|
+
for (const f of files) {
|
|
248
|
+
if (!f.endsWith('.js')) continue
|
|
249
|
+
const a = await fs.readFile(path.join(origDir, f), 'utf-8').catch(() => '')
|
|
250
|
+
const b = await fs.readFile(path.join(newDir, f), 'utf-8').catch(() => '')
|
|
251
|
+
if (a !== b) changed.push(f)
|
|
252
|
+
}
|
|
253
|
+
return changed
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function buildReviewPrompt({ focus, snapDir }) {
|
|
257
|
+
const focusLine = focus
|
|
258
|
+
? `\nThe user asked you to focus especially on: ${focus}\n`
|
|
259
|
+
: ''
|
|
260
|
+
|
|
261
|
+
return `You are a senior JavaScript/Node.js engineer. You are reviewing your own source code — a Playwright-based coding agent that talks to the DeepSeek web chat as an LLM backend.
|
|
262
|
+
|
|
263
|
+
All source files are in your working directory: ${snapDir}
|
|
264
|
+
${focusLine}
|
|
265
|
+
## Your job
|
|
266
|
+
|
|
267
|
+
1. Read EVERY .js file in your working directory. Use Glob("**/*.js") to list them, then Read each one. Do not skip files.
|
|
268
|
+
2. Identify real, concrete problems. Focus on:
|
|
269
|
+
- Race conditions and async/promise bugs (missing await, unhandled rejections, timing issues)
|
|
270
|
+
- Error handling gaps: places that can throw and crash the agent
|
|
271
|
+
- JSON parsing edge cases in agent-loop.js (LLM output is unreliable)
|
|
272
|
+
- Fragile DOM selectors in browser.js (DeepSeek changes layout)
|
|
273
|
+
- Cross-platform issues (Windows paths, cmd.exe vs bash, encoding)
|
|
274
|
+
- Undo/transcript consistency: could undo leave files in bad state?
|
|
275
|
+
- Self-review mode safety: could /self-apply corrupt src/?
|
|
276
|
+
- Anything that breaks state between interactive tasks (chat context, workdir, spinner)
|
|
277
|
+
3. Fix what you can fix confidently. Use Edit (small targeted change) or Write (rewrite a file only if really needed). Keep the existing architecture and naming.
|
|
278
|
+
4. Do NOT invent problems. If you're not sure, mention it in the report, don't change it.
|
|
279
|
+
5. Do NOT touch package.json, node_modules, or files outside the working directory.
|
|
280
|
+
|
|
281
|
+
## Constraints
|
|
282
|
+
|
|
283
|
+
- You CANNOT run the agent itself (node_modules is not here). But you CAN use \`node --check <file>\` via Bash to verify syntax after edits.
|
|
284
|
+
- Always read a file before editing it.
|
|
285
|
+
- After all edits, run \`node --check\` on every file you changed. If any fails, fix the syntax error.
|
|
286
|
+
|
|
287
|
+
## Report format
|
|
288
|
+
|
|
289
|
+
When done, respond with a markdown report as the message:
|
|
290
|
+
|
|
291
|
+
## Найдено
|
|
292
|
+
- (bullet list of issues you found, with file:line if possible)
|
|
293
|
+
|
|
294
|
+
## Исправлено
|
|
295
|
+
- (bullet list: file — что именно изменил)
|
|
296
|
+
|
|
297
|
+
## Не исправлено (осознанно)
|
|
298
|
+
- (bullet list: what you left alone and why)
|
|
299
|
+
|
|
300
|
+
## Проверка
|
|
301
|
+
- (bullet list: what you verified, e.g. \`node --check\` results)
|
|
302
|
+
|
|
303
|
+
Be honest and specific. If the code is fine in some area, say so.`
|
|
304
|
+
}
|
package/src/spinner.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import ora from 'ora'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
|
|
4
|
+
export function createSpinner() {
|
|
5
|
+
let spinner = null
|
|
6
|
+
|
|
7
|
+
const start = (text) => {
|
|
8
|
+
if (!spinner) spinner = ora(text).start()
|
|
9
|
+
else spinner.start(text)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const stop = () => {
|
|
13
|
+
if (spinner) spinner.stop()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const succeed = (text) => {
|
|
17
|
+
if (spinner) spinner.succeed(text)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const fail = (text) => {
|
|
21
|
+
if (spinner) spinner.fail(text)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
// Единственный метод, который ЗАПУСКАЕТ спиннер
|
|
26
|
+
thinking: () => start(chalk.gray('Думаю...')),
|
|
27
|
+
|
|
28
|
+
// Оба метода только останавливают и печатают — не перезапускают
|
|
29
|
+
toolCall: (name, args) => {
|
|
30
|
+
stop()
|
|
31
|
+
const preview = JSON.stringify(args).slice(0, 120)
|
|
32
|
+
console.log(chalk.yellow(`🔧 ${name}`), chalk.gray(preview))
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
toolResult: (result) => {
|
|
36
|
+
stop()
|
|
37
|
+
const text = typeof result === 'string' ? result : JSON.stringify(result)
|
|
38
|
+
const preview = text.slice(0, 200).replace(/\n/g, ' ↵ ')
|
|
39
|
+
console.log(chalk.gray(` → ${preview}\n`))
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
assistant: (msg) => {
|
|
43
|
+
stop()
|
|
44
|
+
console.log(chalk.green('\n✅ ' + msg) + '\n')
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
taskHeader: (task) => {
|
|
48
|
+
console.log(chalk.bold.cyan(`\n🎯 Задача: ${task}\n`))
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
stop,
|
|
52
|
+
succeed,
|
|
53
|
+
fail,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export function buildSystemPrompt({ workdir, tools, gitContext = null }) {
|
|
2
|
+
const toolDescriptions = tools
|
|
3
|
+
.map(
|
|
4
|
+
(t) =>
|
|
5
|
+
`### ${t.name}\n${t.description}\nПараметры: ${JSON.stringify(t.parameters)}`,
|
|
6
|
+
)
|
|
7
|
+
.join('\n\n')
|
|
8
|
+
|
|
9
|
+
const gitSection = gitContext
|
|
10
|
+
? `\n## Git context\n\n${gitContext}\n`
|
|
11
|
+
: '\n## Git context\n\nNot a git repository (or git is not installed).\n'
|
|
12
|
+
|
|
13
|
+
return `You are a coding agent running in a terminal. You help the user with software engineering tasks by reading files, writing code, running commands, and iterating until the task is done.
|
|
14
|
+
|
|
15
|
+
You work in the directory: ${workdir}
|
|
16
|
+
|
|
17
|
+
You have access to the following tools:
|
|
18
|
+
|
|
19
|
+
${toolDescriptions}
|
|
20
|
+
${gitSection}
|
|
21
|
+
## How to use tools
|
|
22
|
+
|
|
23
|
+
To call ONE tool, respond with ONLY a JSON object (no markdown fences, no extra text):
|
|
24
|
+
|
|
25
|
+
{"tool": "tool_name", "args": {"param1": "value1"}}
|
|
26
|
+
|
|
27
|
+
To call MULTIPLE tools at once (only if they are independent — e.g. reading several files), respond with a JSON array:
|
|
28
|
+
|
|
29
|
+
[{"tool": "Read", "args": {"path": "a.js"}}, {"tool": "Read", "args": {"path": "b.js"}}]
|
|
30
|
+
|
|
31
|
+
After the tool result(s) come back, decide the next action.
|
|
32
|
+
|
|
33
|
+
To give a final answer to the user, respond with:
|
|
34
|
+
|
|
35
|
+
{"tool": "respond", "args": {"message": "your final answer here"}}
|
|
36
|
+
|
|
37
|
+
## Git
|
|
38
|
+
|
|
39
|
+
- You CAN commit and push using GitAdd / GitCommit / GitPush. Prefer these over raw \`git\` through Bash.
|
|
40
|
+
- Do NOT run \`git push --force\`, \`git reset --hard\`, \`git clean -fd\`, or rewrite history (rebase, amend published commits) unless the user explicitly asks.
|
|
41
|
+
- Do NOT commit or push unless the user asked for it, or the task clearly implies it.
|
|
42
|
+
- Before committing, check GitStatus and GitDiff so you know what goes in.
|
|
43
|
+
- Commit messages: short imperative subject line, then optional body. Use \`git commit\` style, not emoji.
|
|
44
|
+
- If \`git push\` fails due to credentials or non-fast-forward, report it to the user and stop — do NOT try to work around it.
|
|
45
|
+
|
|
46
|
+
## CRITICAL: JSON escaping
|
|
47
|
+
|
|
48
|
+
Your response MUST be valid JSON. This means:
|
|
49
|
+
- Backslashes MUST be escaped: "C:\\\\Users\\\\name\\\\project"
|
|
50
|
+
- OR use forward slashes for Windows paths: "C:/Users/name/project"
|
|
51
|
+
- Quotes inside strings MUST be escaped: \\"
|
|
52
|
+
- Newlines inside strings must be \\n
|
|
53
|
+
|
|
54
|
+
When in doubt — use forward slashes for all file paths.
|
|
55
|
+
|
|
56
|
+
## CRITICAL: quoting inside shell commands
|
|
57
|
+
|
|
58
|
+
When you call Bash, put the whole shell command in a single JSON string.
|
|
59
|
+
Inside that string:
|
|
60
|
+
- Escape any double quote as \\"
|
|
61
|
+
- Escape any backslash as \\\\
|
|
62
|
+
- OR avoid quotes entirely. For Windows paths use forward slashes.
|
|
63
|
+
- Pipes, redirects and chained commands work: "cd dir && dir /b"
|
|
64
|
+
|
|
65
|
+
## Rules
|
|
66
|
+
|
|
67
|
+
- Always read a file before editing it.
|
|
68
|
+
- Prefer targeted edits over rewriting entire files.
|
|
69
|
+
- Run tests or commands to verify your changes when possible.
|
|
70
|
+
- If a tool call fails, read the error and try a different approach.
|
|
71
|
+
- Keep responses concise. The user sees the terminal output.
|
|
72
|
+
- Never output anything except JSON when you want to call a tool.
|
|
73
|
+
- Your ENTIRE response must be exactly one JSON object (or array of objects).
|
|
74
|
+
|
|
75
|
+
REMINDER: respond with exactly one JSON object or array. No text, no markdown, no explanations.
|
|
76
|
+
## Web access
|
|
77
|
+
|
|
78
|
+
- You have WebSearch and WebFetch. Use them when the answer requires current information (versions, changelogs, recent bugs, docs).
|
|
79
|
+
- WebSearch uses DuckDuckGo. If results look stale or missing, try a different phrasing.
|
|
80
|
+
- WebFetch strips HTML. If a page is JS-rendered and text comes back empty, call WebFetch again with render=true.
|
|
81
|
+
- Do NOT fetch the same URL repeatedly. If content is missing, report that instead.
|
|
82
|
+
- Do NOT use WebFetch on localhost, private IPs, or 'file://' — it will fail.
|
|
83
|
+
`
|
|
84
|
+
}
|
package/src/tools.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
}
|