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.
Files changed (2) hide show
  1. package/index.js +111 -13
  2. 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.0'
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
- function parseDiffByFile(diffOutput) {
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 (!diffOutput) return files
360
- const sections = diffOutput.split(/(?=^diff --git )/m)
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
- const match = section.match(/^diff --git a\/.+ b\/(.+)$/m)
364
- if (match) files[match[1]] = section
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(git('git diff HEAD', repoPath))
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
- if (!sessionId || !prompt) {
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
- log(`▶ ai_chat ${sessionId} @ ${repoPath} (resume=${claudeSessionId ? 'yes' : 'no'}) via ${claudePath}`)
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(prompt); child.stdin.end() }
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitdone-agent",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Local git agent for gitdone — watches a local repo and sends snapshots to gitdone.eu",
5
5
  "type": "module",
6
6
  "bin": {