gitdone-agent 0.6.0 → 0.6.2
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/index.js +111 -13
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -12,10 +12,10 @@
|
|
|
12
12
|
import { execSync, spawn } from 'node:child_process'
|
|
13
13
|
import {
|
|
14
14
|
existsSync, writeFileSync, readFileSync, unlinkSync,
|
|
15
|
-
mkdirSync, copyFileSync, appendFileSync, readdirSync,
|
|
15
|
+
mkdirSync, copyFileSync, appendFileSync, readdirSync, rmSync,
|
|
16
16
|
} from 'node:fs'
|
|
17
17
|
import { resolve, join } from 'node:path'
|
|
18
|
-
import { homedir, hostname } from 'node:os'
|
|
18
|
+
import { homedir, hostname, tmpdir } from 'node:os'
|
|
19
19
|
import { randomUUID } from 'node:crypto'
|
|
20
20
|
|
|
21
21
|
// ─── Stable agent dir, config + logging ────────────────────────────────────────
|
|
@@ -27,7 +27,7 @@ import { randomUUID } from 'node:crypto'
|
|
|
27
27
|
// Reported to the server on every sync so the web UI can flag outdated agents.
|
|
28
28
|
// Keep in lockstep with packages/agent/package.json "version" AND
|
|
29
29
|
// src/lib/agentVersion.ts LATEST_AGENT_VERSION.
|
|
30
|
-
const AGENT_VERSION = '0.6.
|
|
30
|
+
const AGENT_VERSION = '0.6.2'
|
|
31
31
|
|
|
32
32
|
const AGENT_DIR = join(homedir(), '.gitdone-agent')
|
|
33
33
|
const CONFIG_PATH = join(AGENT_DIR, 'config.json')
|
|
@@ -239,6 +239,18 @@ function git(cmd, cwd) {
|
|
|
239
239
|
}
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
// Like git(), but returns the RAW bytes instead of a UTF-8 string. Needed for
|
|
243
|
+
// `git diff`, whose payload can be Windows-1251 (CP1251) Cyrillic — decoding
|
|
244
|
+
// those bytes as UTF-8 up front would corrupt them to „�"/„?" irreversibly
|
|
245
|
+
// (gd-276). Callers decode per-file via decodeDiffText().
|
|
246
|
+
function gitRaw(cmd, cwd) {
|
|
247
|
+
try {
|
|
248
|
+
return execSync(cmd, { cwd, encoding: 'buffer', stdio: ['pipe', 'pipe', 'pipe'] })
|
|
249
|
+
} catch {
|
|
250
|
+
return Buffer.alloc(0)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
242
254
|
// Like git(), but surfaces failures (with stderr) instead of swallowing them —
|
|
243
255
|
// used for push/pull where we need to detect auth rejection.
|
|
244
256
|
function gitTry(cmd, cwd) {
|
|
@@ -354,14 +366,57 @@ function discardFile(repoPath, file) {
|
|
|
354
366
|
return 'discarded (new file removed)'
|
|
355
367
|
}
|
|
356
368
|
|
|
357
|
-
|
|
369
|
+
// ─── Windows-1251 (CP1251) aware diff decoding (gd-276) ───────────────────────
|
|
370
|
+
// Legacy Cyrillic sources are often saved in CP1251. Their high bytes (0x80–0xFF,
|
|
371
|
+
// single-byte) are invalid UTF-8, so reading the diff as UTF-8 shows „?"/„�"
|
|
372
|
+
// instead of кирилица. We keep the raw bytes and decode each file's diff section
|
|
373
|
+
// on its own: valid UTF-8 stays UTF-8; the rest goes through the CP1251 table
|
|
374
|
+
// below — so a UTF-8 repo is untouched while CP1251 files finally read correctly.
|
|
375
|
+
|
|
376
|
+
// CP1251 high range 0x80–0xFF → Unicode code points (0x00–0x7F is plain ASCII).
|
|
377
|
+
// 0x98 is unassigned in CP1251 → U+FFFD.
|
|
378
|
+
const CP1251_HIGH = [
|
|
379
|
+
0x0402, 0x0403, 0x201A, 0x0453, 0x201E, 0x2026, 0x2020, 0x2021, 0x20AC, 0x2030, 0x0409, 0x2039, 0x040A, 0x040C, 0x040B, 0x040F,
|
|
380
|
+
0x0452, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0x0459, 0x203A, 0x045A, 0x045C, 0x045B, 0x045F,
|
|
381
|
+
0x00A0, 0x040E, 0x045E, 0x0408, 0x00A4, 0x0490, 0x00A6, 0x00A7, 0x0401, 0x00A9, 0x0404, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x0407,
|
|
382
|
+
0x00B0, 0x00B1, 0x0406, 0x0456, 0x0491, 0x00B5, 0x00B6, 0x00B7, 0x0451, 0x2116, 0x0454, 0x00BB, 0x0458, 0x0405, 0x0455, 0x0457,
|
|
383
|
+
0x0410, 0x0411, 0x0412, 0x0413, 0x0414, 0x0415, 0x0416, 0x0417, 0x0418, 0x0419, 0x041A, 0x041B, 0x041C, 0x041D, 0x041E, 0x041F,
|
|
384
|
+
0x0420, 0x0421, 0x0422, 0x0423, 0x0424, 0x0425, 0x0426, 0x0427, 0x0428, 0x0429, 0x042A, 0x042B, 0x042C, 0x042D, 0x042E, 0x042F,
|
|
385
|
+
0x0430, 0x0431, 0x0432, 0x0433, 0x0434, 0x0435, 0x0436, 0x0437, 0x0438, 0x0439, 0x043A, 0x043B, 0x043C, 0x043D, 0x043E, 0x043F,
|
|
386
|
+
0x0440, 0x0441, 0x0442, 0x0443, 0x0444, 0x0445, 0x0446, 0x0447, 0x0448, 0x0449, 0x044A, 0x044B, 0x044C, 0x044D, 0x044E, 0x044F,
|
|
387
|
+
]
|
|
388
|
+
|
|
389
|
+
function decodeCp1251(buf) {
|
|
390
|
+
let out = ''
|
|
391
|
+
for (const b of buf) out += String.fromCharCode(b < 0x80 ? b : CP1251_HIGH[b - 0x80])
|
|
392
|
+
return out
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// A strict UTF-8 decoder (always available even on small-ICU builds) — throws on
|
|
396
|
+
// any invalid sequence, which is exactly how we tell UTF-8 apart from CP1251.
|
|
397
|
+
const UTF8_STRICT = new TextDecoder('utf-8', { fatal: true })
|
|
398
|
+
function decodeDiffText(buf) {
|
|
399
|
+
try {
|
|
400
|
+
return UTF8_STRICT.decode(buf)
|
|
401
|
+
} catch {
|
|
402
|
+
return decodeCp1251(buf)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function parseDiffByFile(diffBuf) {
|
|
358
407
|
const files = {}
|
|
359
|
-
if (!
|
|
360
|
-
|
|
408
|
+
if (!diffBuf || diffBuf.length === 0) return files
|
|
409
|
+
// Latin1 is a lossless 1-byte⇢1-char mapping, so the ASCII "diff --git" split
|
|
410
|
+
// markers match while every original byte survives for the per-section decode.
|
|
411
|
+
const raw = Buffer.isBuffer(diffBuf) ? diffBuf.toString('latin1') : String(diffBuf)
|
|
412
|
+
const sections = raw.split(/(?=^diff --git )/m)
|
|
361
413
|
for (const section of sections) {
|
|
362
414
|
if (!section.trim()) continue
|
|
363
|
-
|
|
364
|
-
|
|
415
|
+
// Decode THIS file's bytes on their own (UTF-8 or CP1251), then read the
|
|
416
|
+
// filename from the decoded header.
|
|
417
|
+
const text = decodeDiffText(Buffer.from(section, 'latin1'))
|
|
418
|
+
const match = text.match(/^diff --git a\/.+ b\/(.+)$/m)
|
|
419
|
+
if (match) files[match[1]] = text
|
|
365
420
|
}
|
|
366
421
|
return files
|
|
367
422
|
}
|
|
@@ -391,7 +446,7 @@ function getSnapshot(repoPath) {
|
|
|
391
446
|
lastCommit = { sha, message, author, date }
|
|
392
447
|
}
|
|
393
448
|
|
|
394
|
-
const diffs = parseDiffByFile(
|
|
449
|
+
const diffs = parseDiffByFile(gitRaw('git diff HEAD', repoPath))
|
|
395
450
|
|
|
396
451
|
// Origin remote → lets the server auto-detect the GitHub repo for the History tab.
|
|
397
452
|
const remoteUrl = git('git config --get remote.origin.url', repoPath) || null
|
|
@@ -711,15 +766,42 @@ function runAiCommand(cfg, cmd, repoPath) {
|
|
|
711
766
|
})
|
|
712
767
|
}
|
|
713
768
|
|
|
769
|
+
// Download the images attached to a chat turn to a per-session temp dir and
|
|
770
|
+
// return their local paths. `claude -p` can't take image URLs on stdin, so we
|
|
771
|
+
// fetch them locally and point Claude at the files (its Read tool renders
|
|
772
|
+
// images). Best-effort: a failed download is skipped, not fatal.
|
|
773
|
+
async function downloadSessionImages(sessionId, urls) {
|
|
774
|
+
const dir = join(tmpdir(), `gitdone-ai-${sessionId}`)
|
|
775
|
+
mkdirSync(dir, { recursive: true })
|
|
776
|
+
const paths = []
|
|
777
|
+
for (let i = 0; i < urls.length; i++) {
|
|
778
|
+
try {
|
|
779
|
+
const res = await fetch(urls[i])
|
|
780
|
+
if (!res.ok) { log(`✗ image download HTTP ${res.status}: ${urls[i]}`); continue }
|
|
781
|
+
const buf = Buffer.from(await res.arrayBuffer())
|
|
782
|
+
const m = /\.(png|jpe?g|gif|webp|bmp)(?:\?|$)/i.exec(urls[i])
|
|
783
|
+
const ext = m ? m[1].toLowerCase() : 'png'
|
|
784
|
+
const file = join(dir, `image-${i + 1}.${ext}`)
|
|
785
|
+
writeFileSync(file, buf)
|
|
786
|
+
paths.push(file)
|
|
787
|
+
} catch (e) {
|
|
788
|
+
log(`✗ image download failed (${urls[i]}): ${e.message}`)
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
return { dir, paths }
|
|
792
|
+
}
|
|
793
|
+
|
|
714
794
|
// Run one turn of an interactive chat session: `claude -p` (resuming the prior
|
|
715
795
|
// Claude session when known) with the user's message on stdin, streaming the
|
|
716
796
|
// reply back to the session console. Long-running + fire-and-forget like ai_run.
|
|
717
|
-
function runAiChat(cfg, cmd, repoPath) {
|
|
797
|
+
async function runAiChat(cfg, cmd, repoPath) {
|
|
718
798
|
const sessionId = cmd.payload?.sessionId
|
|
719
799
|
const prompt = cmd.payload?.prompt ?? ''
|
|
800
|
+
const images = Array.isArray(cmd.payload?.images) ? cmd.payload.images.filter((u) => typeof u === 'string' && u) : []
|
|
720
801
|
const claudeSessionId = cmd.payload?.claudeSessionId || null
|
|
721
802
|
const allowCommit = cmd.payload?.allowCommit === true
|
|
722
|
-
|
|
803
|
+
// A turn may be text-only, image-only, or both — but needs at least one.
|
|
804
|
+
if (!sessionId || (!prompt && images.length === 0)) {
|
|
723
805
|
reportCommandResult(cfg, cmd.id, 'error', 'invalid ai_chat payload')
|
|
724
806
|
return
|
|
725
807
|
}
|
|
@@ -766,13 +848,28 @@ function runAiChat(cfg, cmd, repoPath) {
|
|
|
766
848
|
const onInit = (sid) => { if (sid && !claudeSessionId) capturedSession = sid }
|
|
767
849
|
const timer = setInterval(flush, 800)
|
|
768
850
|
|
|
769
|
-
|
|
851
|
+
// Fetch any attached images locally and fold their paths into the prompt so
|
|
852
|
+
// Claude reads them with its Read tool (URLs on stdin don't render).
|
|
853
|
+
let imgDir = null
|
|
854
|
+
let fullPrompt = prompt
|
|
855
|
+
if (images.length > 0) {
|
|
856
|
+
const dl = await downloadSessionImages(sessionId, images)
|
|
857
|
+
imgDir = dl.dir
|
|
858
|
+
if (dl.paths.length > 0) {
|
|
859
|
+
const list = dl.paths.map((p) => `- ${p}`).join('\n')
|
|
860
|
+
const note = `Потребителят прикачи ${dl.paths.length} изображени${dl.paths.length === 1 ? 'е' : 'я'}. Прегледай ги с Read tool от следните локални пътища:\n${list}`
|
|
861
|
+
fullPrompt = prompt ? `${prompt}\n\n[${note}]` : `[${note}]`
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
log(`▶ ai_chat ${sessionId} @ ${repoPath} (resume=${claudeSessionId ? 'yes' : 'no'}, images=${images.length}) via ${claudePath}`)
|
|
770
866
|
|
|
771
867
|
let child
|
|
772
868
|
try {
|
|
773
869
|
child = spawn(claudePath, args, { cwd: repoPath, shell, windowsHide: true, env: childEnv })
|
|
774
870
|
} catch (err) {
|
|
775
871
|
clearInterval(timer)
|
|
872
|
+
if (imgDir) { try { rmSync(imgDir, { recursive: true, force: true }) } catch { /* best-effort */ } }
|
|
776
873
|
postSessionEvents(cfg, sessionId, [{ role: 'SYSTEM', text: `Грешка при стартиране: ${err.message}` }], 'error')
|
|
777
874
|
reportCommandResult(cfg, cmd.id, 'error', err.message)
|
|
778
875
|
return
|
|
@@ -780,7 +877,7 @@ function runAiChat(cfg, cmd, repoPath) {
|
|
|
780
877
|
|
|
781
878
|
if (child.stdin) {
|
|
782
879
|
child.stdin.on('error', () => {})
|
|
783
|
-
try { child.stdin.write(
|
|
880
|
+
try { child.stdin.write(fullPrompt); child.stdin.end() }
|
|
784
881
|
catch (e) { push('SYSTEM', `stdin write failed: ${e.message}`) }
|
|
785
882
|
}
|
|
786
883
|
|
|
@@ -798,6 +895,7 @@ function runAiChat(cfg, cmd, repoPath) {
|
|
|
798
895
|
child.on('error', (err) => push('SYSTEM', `Процесна грешка: ${err.message}`))
|
|
799
896
|
child.on('close', async (code) => {
|
|
800
897
|
clearInterval(timer)
|
|
898
|
+
if (imgDir) { try { rmSync(imgDir, { recursive: true, force: true }) } catch { /* best-effort */ } }
|
|
801
899
|
if (buf.trim()) parseStreamLine(buf.trim(), push, onInit)
|
|
802
900
|
await flush()
|
|
803
901
|
const ok = code === 0
|