@thegitai/cli 1.0.0-beta.13 → 1.0.0-beta.14
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/src/api/chat.js
CHANGED
|
@@ -3,6 +3,7 @@ import { applySessionSnapshot, snapshotFromSession, } from '../session-store.js'
|
|
|
3
3
|
import { executeLocalToolCall } from '../tool-executor.js';
|
|
4
4
|
import { createTraceContext, normalizeServerUrl, readErrorResponse, } from './http.js';
|
|
5
5
|
import { collectClientEnvironment } from '../client-environment.js';
|
|
6
|
+
import { autoAttachImages } from '../core/image-path-extractor.js';
|
|
6
7
|
export class TurnCancelledError extends Error {
|
|
7
8
|
name = 'TurnCancelledError';
|
|
8
9
|
constructor(message = 'Turn cancelled.') {
|
|
@@ -63,6 +64,9 @@ function snapshotForServer(session) {
|
|
|
63
64
|
snapshot.clientState.safety = sanitizeSessionSafetyForServer(snapshot.clientState.safety);
|
|
64
65
|
return snapshot;
|
|
65
66
|
}
|
|
67
|
+
function imageAttachmentsForServer(attachments) {
|
|
68
|
+
return (attachments ?? []).map(({ filePath: _filePath, ...attachment }) => attachment);
|
|
69
|
+
}
|
|
66
70
|
function userHistoryText(entry) {
|
|
67
71
|
return (entry.parts ?? [])
|
|
68
72
|
.map((part) => (typeof part?.text === 'string' ? part.text : ''))
|
|
@@ -285,19 +289,33 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
|
|
|
285
289
|
return finalResult.current;
|
|
286
290
|
}
|
|
287
291
|
export async function sendServerUserMessage({ config, projectIndex, session, input, imageAttachments = [], fetchImpl = globalThis.fetch, signal, }) {
|
|
292
|
+
const autoAttach = autoAttachImages(input, session.rootDir, imageAttachments);
|
|
293
|
+
const requestImageAttachments = autoAttach.attachments.length > 0
|
|
294
|
+
? [...imageAttachments, ...autoAttach.attachments]
|
|
295
|
+
: imageAttachments;
|
|
296
|
+
// requestInput is the server-bound, path-sanitized text ([Image #N] markers in
|
|
297
|
+
// place of local image paths). Every history-preservation path below (abort,
|
|
298
|
+
// cancel, failure, mid-stream tool cancel) must persist THIS, never the raw
|
|
299
|
+
// input — otherwise a cancelled or failed image turn leaks the local
|
|
300
|
+
// filesystem path into session history and the next snapshot ships it to the
|
|
301
|
+
// server, defeating the point of sanitizing before the request crosses over.
|
|
302
|
+
const requestInput = autoAttach.attachments.length > 0 ? autoAttach.sanitizedInput : input;
|
|
303
|
+
for (const err of autoAttach.errors) {
|
|
304
|
+
session.onStatus(`Image: ${err}`);
|
|
305
|
+
}
|
|
288
306
|
const request = {
|
|
289
307
|
modelId: session.modelId,
|
|
290
308
|
session: snapshotForServer(session),
|
|
291
|
-
input,
|
|
309
|
+
input: requestInput,
|
|
292
310
|
clientEnvironment: collectClientEnvironment({ env: session.env }),
|
|
293
|
-
imageAttachments,
|
|
311
|
+
imageAttachments: imageAttachmentsForServer(requestImageAttachments),
|
|
294
312
|
maxToolSteps: session.maxToolSteps,
|
|
295
313
|
autoYes: session.autoYes,
|
|
296
314
|
agentMode: session.agentMode,
|
|
297
315
|
};
|
|
298
316
|
const trace = createTraceContext();
|
|
299
317
|
const preTurnHistoryLength = session.history.length;
|
|
300
|
-
const preserveOnAbort = () => preserveCancelledTurnInput(session,
|
|
318
|
+
const preserveOnAbort = () => preserveCancelledTurnInput(session, requestInput);
|
|
301
319
|
if (signal?.aborted) {
|
|
302
320
|
preserveOnAbort();
|
|
303
321
|
}
|
|
@@ -324,7 +342,7 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
|
|
|
324
342
|
config,
|
|
325
343
|
projectIndex,
|
|
326
344
|
session,
|
|
327
|
-
input,
|
|
345
|
+
input: requestInput,
|
|
328
346
|
fetchImpl,
|
|
329
347
|
signal,
|
|
330
348
|
traceId: trace.traceId,
|
|
@@ -339,7 +357,7 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
|
|
|
339
357
|
}
|
|
340
358
|
catch (error) {
|
|
341
359
|
if (isTurnCancelledError(error)) {
|
|
342
|
-
preserveCancelledTurnInput(session,
|
|
360
|
+
preserveCancelledTurnInput(session, requestInput);
|
|
343
361
|
throw error instanceof TurnCancelledError
|
|
344
362
|
? error
|
|
345
363
|
: new TurnCancelledError();
|
|
@@ -348,7 +366,7 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
|
|
|
348
366
|
// speculative cancelled-turn entries in history — otherwise the next
|
|
349
367
|
// request replays a malformed transcript to the server.
|
|
350
368
|
session.history.length = preTurnHistoryLength;
|
|
351
|
-
preserveFailedTurnInput(session,
|
|
369
|
+
preserveFailedTurnInput(session, requestInput, error instanceof ChatTurnFailedError ? error.category : 'unknown_error');
|
|
352
370
|
throw error;
|
|
353
371
|
}
|
|
354
372
|
finally {
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
2
4
|
const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024;
|
|
3
5
|
const MIME_BY_EXT = {
|
|
4
6
|
'.png': 'image/png',
|
|
@@ -262,3 +264,20 @@ export function writeClipboardText(text, platform = process.platform) {
|
|
|
262
264
|
}
|
|
263
265
|
throw new ClipboardError(`Clipboard text copy is not supported on ${platform}.`, 'NO_TOOL');
|
|
264
266
|
}
|
|
267
|
+
export function loadImageFromFile(filePath) {
|
|
268
|
+
const resolved = path.resolve(filePath);
|
|
269
|
+
if (!existsSync(resolved)) {
|
|
270
|
+
throw new ClipboardError(`Image file not found: ${resolved}`, 'READ_FAILED');
|
|
271
|
+
}
|
|
272
|
+
const stat = statSync(resolved);
|
|
273
|
+
if (stat.size > MAX_IMAGE_SIZE_BYTES) {
|
|
274
|
+
throw new ClipboardError(`Image file exceeds 10MB limit (${(stat.size / 1024 / 1024).toFixed(1)}MB): ${resolved}`, 'READ_FAILED');
|
|
275
|
+
}
|
|
276
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
277
|
+
const mimeType = MIME_BY_EXT[ext];
|
|
278
|
+
if (!mimeType) {
|
|
279
|
+
throw new ClipboardError(`Unsupported image format "${ext}". Supported: PNG, JPEG, GIF, WebP.`, 'READ_FAILED');
|
|
280
|
+
}
|
|
281
|
+
const buf = readFileSync(resolved);
|
|
282
|
+
return { base64Data: buf.toString('base64'), mimeType };
|
|
283
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { loadImageFromFile } from './clipboard.js';
|
|
5
|
+
const EXT = '(?:png|jpe?g|gif|webp)';
|
|
6
|
+
// Bare-path characters: exclude whitespace, quotes, prose terminators, ':' (so we
|
|
7
|
+
// don't swallow URL schemes or trailing "see foo.png:" prose) and bracket/brace/paren
|
|
8
|
+
// delimiters on both sides (so markdown "" / "(x.png)" don't leak in).
|
|
9
|
+
const BARE_CHAR = "[^\\s\"'<>,:;!?()\\[\\]{}]";
|
|
10
|
+
// A bare token may carry backslash-escaped spaces (terminal drag-and-drop) and an
|
|
11
|
+
// optional Windows drive prefix ("C:\\" or "C:/").
|
|
12
|
+
const BARE_PATH = `(?:[A-Za-z]:[\\\\/])?(?:\\\\ |${BARE_CHAR})+\\.${EXT}`;
|
|
13
|
+
const IMAGE_PATH_PATTERN = new RegExp(`"([^"]*\\.${EXT})"` +
|
|
14
|
+
`|'([^']*\\.${EXT})'` +
|
|
15
|
+
`|file://(\\S*\\.${EXT})` +
|
|
16
|
+
`|(${BARE_PATH})`, 'gi');
|
|
17
|
+
function detectImagePaths(input, cwd) {
|
|
18
|
+
const regex = new RegExp(IMAGE_PATH_PATTERN.source, IMAGE_PATH_PATTERN.flags);
|
|
19
|
+
const rawsByPath = new Map();
|
|
20
|
+
let match;
|
|
21
|
+
while ((match = regex.exec(input)) !== null) {
|
|
22
|
+
const raw = match[0];
|
|
23
|
+
// A bare match containing "://" is a URL scheme remnant (e.g. the "p://" tail
|
|
24
|
+
// of "http://…" caught by the drive-letter prefix), never a local file path.
|
|
25
|
+
// Scope this to the bare branch only — a quoted value may legitimately be a
|
|
26
|
+
// file:// URL, which the quoted branch below decodes.
|
|
27
|
+
if (match[4] != null && raw.includes('://'))
|
|
28
|
+
continue;
|
|
29
|
+
let inner;
|
|
30
|
+
if (match[1] != null || match[2] != null) {
|
|
31
|
+
const quoted = (match[1] ?? match[2]);
|
|
32
|
+
// A quoted value can itself be a file:// URL ("file:///a/b%20c.png"); it
|
|
33
|
+
// still needs percent-decoding, so route it through the same decoder.
|
|
34
|
+
if (/^file:\/\//i.test(quoted)) {
|
|
35
|
+
try {
|
|
36
|
+
inner = fileURLToPath(quoted);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
inner = quoted;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else if (match[3] != null) {
|
|
47
|
+
// file:// URL: decode percent-escapes (e.g. "%20") to the real filesystem
|
|
48
|
+
// path. A malformed URL (bad %-escape) must be skipped, never thrown:
|
|
49
|
+
// ordinary prose containing a broken file:// URL should not abort the turn.
|
|
50
|
+
try {
|
|
51
|
+
inner = fileURLToPath(raw);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
try {
|
|
55
|
+
inner = decodeURIComponent(match[3]);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
// Bare path: unescape "\\ " sequences produced by terminal drag-and-drop.
|
|
64
|
+
inner = match[4].replace(/\\ /g, ' ');
|
|
65
|
+
}
|
|
66
|
+
const resolvedPath = path.isAbsolute(inner) ? inner : path.resolve(cwd, inner);
|
|
67
|
+
const existing = rawsByPath.get(resolvedPath);
|
|
68
|
+
if (existing) {
|
|
69
|
+
existing.push(raw);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
rawsByPath.set(resolvedPath, [raw]);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return rawsByPath;
|
|
76
|
+
}
|
|
77
|
+
export function autoAttachImages(input, cwd, existing = []) {
|
|
78
|
+
const max = 2;
|
|
79
|
+
const rawsByPath = detectImagePaths(input, cwd);
|
|
80
|
+
let sanitizedInput = input;
|
|
81
|
+
const attachments = [];
|
|
82
|
+
const errors = [];
|
|
83
|
+
// Seed new markers after the highest existing index, not the existing count.
|
|
84
|
+
// The TUI can submit a sparse attachment set (e.g. [Image #2] kept after
|
|
85
|
+
// [Image #1] was deleted from the prompt), so counting would reuse an index
|
|
86
|
+
// still present in the prompt and make the model inspect the wrong image.
|
|
87
|
+
const maxExistingIndex = existing.reduce((highest, a) => Math.max(highest, a.index ?? 0), 0);
|
|
88
|
+
for (const [resolvedPath, rawForms] of rawsByPath) {
|
|
89
|
+
if (existing.length + attachments.length >= max)
|
|
90
|
+
break;
|
|
91
|
+
if (!existsSync(resolvedPath))
|
|
92
|
+
continue;
|
|
93
|
+
try {
|
|
94
|
+
const loaded = loadImageFromFile(resolvedPath);
|
|
95
|
+
const idx = maxExistingIndex + attachments.length + 1;
|
|
96
|
+
attachments.push({
|
|
97
|
+
index: idx,
|
|
98
|
+
mimeType: loaded.mimeType,
|
|
99
|
+
base64Data: loaded.base64Data,
|
|
100
|
+
source: 'file',
|
|
101
|
+
filePath: resolvedPath,
|
|
102
|
+
});
|
|
103
|
+
for (const raw of rawForms) {
|
|
104
|
+
sanitizedInput = sanitizedInput.replace(raw, `[Image #${idx}]`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
errors.push(err.message);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { sanitizedInput, attachments, errors };
|
|
112
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thegitai/cli",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.14",
|
|
4
4
|
"description": "TheGitAI CLI client (source-visible, proprietary)",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"homepage": "https://thegit.ai",
|
|
@@ -25,10 +25,10 @@
|
|
|
25
25
|
"@lydell/node-pty-linux-x64": "1.1.0",
|
|
26
26
|
"@lydell/node-pty-win32-arm64": "1.1.0",
|
|
27
27
|
"@lydell/node-pty-win32-x64": "1.1.0",
|
|
28
|
-
"@thegitai/tui-darwin-arm64": "1.0.0-beta.
|
|
29
|
-
"@thegitai/tui-darwin-x64": "1.0.0-beta.
|
|
30
|
-
"@thegitai/tui-linux-x64": "1.0.0-beta.
|
|
31
|
-
"@thegitai/tui-win32-x64": "1.0.0-beta.
|
|
28
|
+
"@thegitai/tui-darwin-arm64": "1.0.0-beta.14",
|
|
29
|
+
"@thegitai/tui-darwin-x64": "1.0.0-beta.14",
|
|
30
|
+
"@thegitai/tui-linux-x64": "1.0.0-beta.14",
|
|
31
|
+
"@thegitai/tui-win32-x64": "1.0.0-beta.14",
|
|
32
32
|
"@vscode/ripgrep": "1.18.0"
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|