@thegitai/cli 1.0.0-preview.20 → 1.0.0-preview.22

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/README.md CHANGED
@@ -34,7 +34,7 @@ always be viewed or resumed from that computer. Continuing it through the
34
34
  service requires the TheGitAI account used for that session. Local sessions can
35
35
  also be listed while signed out or offline.
36
36
 
37
- CLI login tokens use a rolling 24-hour inactivity timeout. If one expires during
37
+ CLI login tokens use a rolling 48-hour inactivity timeout. If one expires during
38
38
  any server request, the CLI saves the local session, removes the expired
39
39
  credential, and asks you to run `ai login` before resuming.
40
40
 
@@ -3,7 +3,7 @@ import { createPromptCheckpoint, sanitizeSessionSafetyForServer, } from '../sess
3
3
  import { applySessionSnapshot, saveSessionState, snapshotFromSession, } from '../session-store.js';
4
4
  import { executeLocalToolCall } from '../tool-executor.js';
5
5
  import { isUserInputQuestionArray } from './contracts.js';
6
- import { createTraceContext, normalizeServerUrl, readErrorResponse, } from './http.js';
6
+ import { createTraceContext, gatewayFailureCategory, normalizeServerUrl, readErrorResponse, } from './http.js';
7
7
  import { collectClientEnvironment } from '../client-environment.js';
8
8
  import { collectProjectOrientation } from '../project-orientation.js';
9
9
  import { autoAttachImages } from '../core/image-path-extractor.js';
@@ -248,8 +248,12 @@ async function postUserInputResult({ config, turnId, requestId, result, fetchImp
248
248
  throw await readErrorResponse(response, trace.traceId);
249
249
  }
250
250
  }
251
- export async function postInterjection({ config, turnId, text, messageId, fetchImpl = globalThis.fetch, traceId, }) {
252
- const payload = { text, messageId };
251
+ export async function postInterjection({ config, turnId, text, messageId, imageAttachments = [], fetchImpl = globalThis.fetch, traceId, }) {
252
+ const payload = {
253
+ text,
254
+ messageId,
255
+ ...(imageAttachments.length > 0 ? { imageAttachments } : {}),
256
+ };
253
257
  const trace = createTraceContext(traceId);
254
258
  const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/chat/turn/${encodeURIComponent(turnId)}/interject`, {
255
259
  method: 'POST',
@@ -583,7 +587,10 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
583
587
  ? error
584
588
  : new TurnCancelledError();
585
589
  }
586
- const category = error instanceof ChatTurnFailedError ? error.category : 'unknown_error';
590
+ const category = error instanceof ChatTurnFailedError
591
+ ? error.category
592
+ :
593
+ gatewayFailureCategory(error) ?? 'unknown_error';
587
594
  const partial = error instanceof ChatTurnFailedError ? error.partialSnapshot : undefined;
588
595
  if (partial) {
589
596
  applySessionSnapshot(session, partial, { preserveAgentMode: true });
@@ -84,6 +84,41 @@ export function normalizeServerUrl(serverUrl) {
84
84
  }
85
85
  return normalized;
86
86
  }
87
+ const MAX_NON_JSON_BODY_CHARS = 200;
88
+ const GATEWAY_STATUS_MESSAGES = {
89
+ 502: 'Could not reach the TheGitAI server (bad gateway).',
90
+ 503: 'The TheGitAI server is temporarily unavailable.',
91
+ 504: 'The TheGitAI server did not respond in time (gateway timeout).',
92
+ 520: 'The connection to the TheGitAI server failed (edge error 520).',
93
+ 521: 'The TheGitAI server is not accepting connections (edge error 521).',
94
+ 522: 'The connection to the TheGitAI server timed out (edge error 522).',
95
+ 523: 'The TheGitAI server is unreachable (edge error 523).',
96
+ 524: 'The TheGitAI server did not respond in time (edge error 524).',
97
+ };
98
+ function truncateByCodePoint(text, maxChars) {
99
+ if (text.length <= maxChars)
100
+ return text;
101
+ const points = Array.from(text);
102
+ if (points.length <= maxChars)
103
+ return text;
104
+ return `${points.slice(0, maxChars).join('').trimEnd()}…`;
105
+ }
106
+ export function nonJsonErrorMessage(body, status) {
107
+ const rayId = /Cloudflare Ray ID:\s*(?:<[^>]*>\s*)*([0-9a-f]{8,})/i.exec(body)?.[1];
108
+ const gateway = GATEWAY_STATUS_MESSAGES[status];
109
+ const summary = truncateByCodePoint(body
110
+ .replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, ' ')
111
+ .replace(/<[^>]*>/g, ' ')
112
+ .replace(/\s+/g, ' ')
113
+ .trim(), MAX_NON_JSON_BODY_CHARS);
114
+ const base = gateway ?? (summary || `Request failed with ${status}`);
115
+ return rayId ? `${base} (Cloudflare Ray ID: ${rayId})` : base;
116
+ }
117
+ export function gatewayFailureCategory(error) {
118
+ if (!(error instanceof ServerApiError))
119
+ return null;
120
+ return error.status in GATEWAY_STATUS_MESSAGES ? 'gateway_error' : null;
121
+ }
87
122
  export async function readJsonResponse(response) {
88
123
  const text = await response.text();
89
124
  if (!text.trim())
@@ -92,7 +127,9 @@ export async function readJsonResponse(response) {
92
127
  return JSON.parse(text);
93
128
  }
94
129
  catch {
95
- return { error: { message: text } };
130
+ return {
131
+ error: { message: nonJsonErrorMessage(text, response.status) },
132
+ };
96
133
  }
97
134
  }
98
135
  export function failureMessage(data, status) {
@@ -106,7 +143,7 @@ export function isAuthenticationError(error) {
106
143
  }
107
144
  export function authenticationErrorMessage(error) {
108
145
  return error.code === 'AUTH_TOKEN_EXPIRED'
109
- ? 'Your login expired after 24 hours of inactivity. Run `ai login` and resume this saved session.'
146
+ ? 'Your login expired after 48 hours of inactivity. Run `ai login` and resume this saved session.'
110
147
  : 'Your login is no longer valid. Run `ai login` and resume this saved session.';
111
148
  }
112
149
  export async function readErrorResponse(response, traceId = response.headers.get(TRACE_ID_HEADER) ?? '') {
@@ -1,15 +1,7 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { existsSync, readFileSync, statSync } from 'node:fs';
3
3
  import path from 'node:path';
4
- const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024;
5
- const MIME_BY_EXT = {
6
- '.png': 'image/png',
7
- '.jpg': 'image/jpeg',
8
- '.jpeg': 'image/jpeg',
9
- '.gif': 'image/gif',
10
- '.webp': 'image/webp',
11
- };
12
- const SUPPORTED_MIME_TYPES = new Set(Object.values(MIME_BY_EXT));
4
+ import { MAX_IMAGE_SIZE_BYTES, SUPPORTED_IMAGE_MIME_TYPES as SUPPORTED_MIME_TYPES, sniffImageMimeType, } from './image-limits.js';
13
5
  export class ClipboardError extends Error {
14
6
  code;
15
7
  constructor(message, code) {
@@ -273,11 +265,13 @@ export function loadImageFromFile(filePath) {
273
265
  if (stat.size > MAX_IMAGE_SIZE_BYTES) {
274
266
  throw new ClipboardError(`Image file exceeds 10MB limit (${(stat.size / 1024 / 1024).toFixed(1)}MB): ${resolved}`, 'READ_FAILED');
275
267
  }
276
- const ext = path.extname(resolved).toLowerCase();
277
- const mimeType = MIME_BY_EXT[ext];
268
+ const buf = readFileSync(resolved);
269
+ const mimeType = sniffImageMimeType(buf);
278
270
  if (!mimeType) {
279
- throw new ClipboardError(`Unsupported image format "${ext}". Supported: PNG, JPEG, GIF, WebP.`, 'READ_FAILED');
271
+ const ext = path.extname(resolved).toLowerCase();
272
+ throw new ClipboardError(ext
273
+ ? `"${path.basename(resolved)}" is named ${ext} but its contents are not a supported image. Supported: PNG, JPEG, GIF, WebP.`
274
+ : `Not a supported image file: ${resolved}. Supported: PNG, JPEG, GIF, WebP.`, 'READ_FAILED');
280
275
  }
281
- const buf = readFileSync(resolved);
282
276
  return { base64Data: buf.toString('base64'), mimeType };
283
277
  }
@@ -0,0 +1,56 @@
1
+ export const MAX_IMAGES_PER_MESSAGE = 5;
2
+ export const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024;
3
+ export const MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE = 20 * 1024 * 1024;
4
+ export function approximateBase64DecodedBytes(base64) {
5
+ const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
6
+ return Math.max(0, Math.floor((base64.length * 3) / 4) - padding);
7
+ }
8
+ export function totalAttachmentBytes(attachments) {
9
+ return attachments.reduce((sum, attachment) => sum + approximateBase64DecodedBytes(attachment.base64Data), 0);
10
+ }
11
+ export const SUPPORTED_IMAGE_MIME_TYPES = new Set([
12
+ 'image/png',
13
+ 'image/jpeg',
14
+ 'image/gif',
15
+ 'image/webp',
16
+ ]);
17
+ export const IMAGE_MIME_BY_EXT = {
18
+ '.png': 'image/png',
19
+ '.jpg': 'image/jpeg',
20
+ '.jpeg': 'image/jpeg',
21
+ '.gif': 'image/gif',
22
+ '.webp': 'image/webp',
23
+ };
24
+ export const IMAGE_EXT_BY_MIME = {
25
+ 'image/png': '.png',
26
+ 'image/jpeg': '.jpg',
27
+ 'image/gif': '.gif',
28
+ 'image/webp': '.webp',
29
+ };
30
+ export function isSupportedImageMimeType(mime) {
31
+ return SUPPORTED_IMAGE_MIME_TYPES.has(mime);
32
+ }
33
+ export function sniffImageMimeType(bytes) {
34
+ if (bytes.length < 12)
35
+ return null;
36
+ if (bytes[0] === 0x89 &&
37
+ bytes[1] === 0x50 &&
38
+ bytes[2] === 0x4e &&
39
+ bytes[3] === 0x47) {
40
+ return 'image/png';
41
+ }
42
+ if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
43
+ return 'image/jpeg';
44
+ }
45
+ if (bytes.subarray(0, 3).toString('latin1') === 'GIF') {
46
+ return 'image/gif';
47
+ }
48
+ if (bytes.subarray(0, 4).toString('latin1') === 'RIFF' &&
49
+ bytes.subarray(8, 12).toString('latin1') === 'WEBP') {
50
+ return 'image/webp';
51
+ }
52
+ return null;
53
+ }
54
+ export function imageExtensionForMime(mime) {
55
+ return IMAGE_EXT_BY_MIME[mime] ?? '.png';
56
+ }
@@ -1,7 +1,9 @@
1
- import { existsSync, statSync } from 'node:fs';
1
+ import { closeSync, existsSync, openSync, readSync, statSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { loadImageFromFile } from './clipboard.js';
5
+ import { MAX_IMAGES_PER_MESSAGE, MAX_IMAGE_SIZE_BYTES, MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE, approximateBase64DecodedBytes, sniffImageMimeType, totalAttachmentBytes, } from './image-limits.js';
6
+ import { tryCacheAttachmentBytes } from './session-image-store.js';
5
7
  const EXT = '(?:png|jpe?g|gif|webp)';
6
8
  const BARE_CHAR = "[^\\s\"'<>,:;!?()\\[\\]{}]";
7
9
  const BARE_PATH = `(?:[A-Za-z]:[\\\\/])?(?:\\\\ |${BARE_CHAR})+\\.${EXT}`;
@@ -110,13 +112,67 @@ function detectImagePaths(input, cwd) {
110
112
  }
111
113
  return rawsByPath;
112
114
  }
115
+ const EXTENSIONLESS_CANDIDATE = new RegExp(`"([^"]*[\\\\/][^"]*)"` +
116
+ `|'([^']*[\\\\/][^']*)'` +
117
+ `|((?:[A-Za-z]:[\\\\/])?(?:\\\\ |${BARE_CHAR})*[\\\\/](?:\\\\ |${BARE_CHAR})+)`, 'g');
118
+ function sniffFileHeader(resolvedPath) {
119
+ let fd = null;
120
+ try {
121
+ const stat = statSync(resolvedPath, { throwIfNoEntry: false });
122
+ if (!stat?.isFile() || stat.size > MAX_IMAGE_SIZE_BYTES)
123
+ return null;
124
+ fd = openSync(resolvedPath, 'r');
125
+ const header = Buffer.alloc(12);
126
+ const read = readSync(fd, header, 0, 12, 0);
127
+ return read === 12 ? header : null;
128
+ }
129
+ catch {
130
+ return null;
131
+ }
132
+ finally {
133
+ if (fd !== null) {
134
+ try {
135
+ closeSync(fd);
136
+ }
137
+ catch {
138
+ }
139
+ }
140
+ }
141
+ }
142
+ function detectExtensionlessImagePaths(input, cwd, alreadyDetected) {
143
+ const found = new Map();
144
+ const regex = new RegExp(EXTENSIONLESS_CANDIDATE.source, EXTENSIONLESS_CANDIDATE.flags);
145
+ let match;
146
+ while ((match = regex.exec(input)) !== null) {
147
+ const raw = match[0];
148
+ const inner = (match[1] ?? match[2] ?? match[3] ?? '').replace(/\\ /g, ' ');
149
+ if (!inner || inner.includes('://'))
150
+ continue;
151
+ if (/\.(?:png|jpe?g|gif|webp)$/i.test(inner))
152
+ continue;
153
+ const resolvedPath = path.isAbsolute(inner)
154
+ ? inner
155
+ : path.resolve(cwd, inner);
156
+ if (alreadyDetected.has(resolvedPath) || found.has(resolvedPath))
157
+ continue;
158
+ const header = sniffFileHeader(resolvedPath);
159
+ if (!header || !sniffImageMimeType(header))
160
+ continue;
161
+ found.set(resolvedPath, [raw]);
162
+ }
163
+ return found;
164
+ }
113
165
  export function autoAttachImages(input, cwd, existing = []) {
114
- const max = 2;
166
+ const max = MAX_IMAGES_PER_MESSAGE;
115
167
  const rawsByPath = detectImagePaths(input, cwd);
168
+ for (const [resolvedPath, rawForms] of detectExtensionlessImagePaths(input, cwd, new Set(rawsByPath.keys()))) {
169
+ rawsByPath.set(resolvedPath, rawForms);
170
+ }
116
171
  let sanitizedInput = input;
117
172
  const attachments = [];
118
173
  const errors = [];
119
174
  const maxExistingIndex = existing.reduce((highest, a) => Math.max(highest, a.index ?? 0), 0);
175
+ let budgetUsed = totalAttachmentBytes(existing);
120
176
  for (const [resolvedPath, rawForms] of rawsByPath) {
121
177
  if (existing.length + attachments.length >= max)
122
178
  break;
@@ -124,13 +180,24 @@ export function autoAttachImages(input, cwd, existing = []) {
124
180
  continue;
125
181
  try {
126
182
  const loaded = loadImageFromFile(resolvedPath);
127
- const idx = maxExistingIndex + attachments.length + 1;
183
+ const bytes = approximateBase64DecodedBytes(loaded.base64Data);
184
+ if (budgetUsed + bytes > MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE) {
185
+ errors.push(`${path.basename(resolvedPath)} was not attached: it would put this message over the ${Math.round(MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE / 1024 / 1024)}MB combined image limit.`);
186
+ continue;
187
+ }
188
+ budgetUsed += bytes;
189
+ const cached = tryCacheAttachmentBytes({
190
+ base64Data: loaded.base64Data,
191
+ mimeType: loaded.mimeType,
192
+ });
193
+ const idx = cached?.index ?? maxExistingIndex + attachments.length + 1;
128
194
  attachments.push({
129
195
  index: idx,
130
196
  mimeType: loaded.mimeType,
131
197
  base64Data: loaded.base64Data,
132
198
  source: 'file',
133
199
  filePath: resolvedPath,
200
+ ...(cached ? { cachePath: cached.cachePath } : {}),
134
201
  });
135
202
  for (const raw of rawForms) {
136
203
  sanitizedInput = sanitizedInput.replace(raw, `[Image #${idx}]`);
@@ -0,0 +1,199 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { getClientStateDir } from '../client-state.js';
5
+ import { MAX_IMAGE_SIZE_BYTES, imageExtensionForMime, isSupportedImageMimeType, sniffImageMimeType, } from './image-limits.js';
6
+ const IMAGE_FILE_MODE = 0o600;
7
+ const IMAGE_DIR_MODE = 0o700;
8
+ export const SESSION_IMAGE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
9
+ let activeSessionId = null;
10
+ export function setImageStoreSession(sessionId) {
11
+ activeSessionId = String(sessionId ?? '').trim() || null;
12
+ contentIndex.clear();
13
+ }
14
+ const contentIndex = new Map();
15
+ function imageContentKey(base64Data) {
16
+ return createHash('sha256').update(base64Data).digest('hex');
17
+ }
18
+ function rememberStoredImage(base64Data, cachePath) {
19
+ contentIndex.set(imageContentKey(base64Data), cachePath);
20
+ }
21
+ export function findStoredImageByContent(base64Data) {
22
+ const cachePath = contentIndex.get(imageContentKey(base64Data));
23
+ if (!cachePath)
24
+ return undefined;
25
+ if (!existsSync(cachePath)) {
26
+ contentIndex.delete(imageContentKey(base64Data));
27
+ return undefined;
28
+ }
29
+ return cachePath;
30
+ }
31
+ export function getImageStoreSession() {
32
+ return activeSessionId;
33
+ }
34
+ function safeSessionDirName(sessionId) {
35
+ const normalized = String(sessionId ?? '').trim();
36
+ if (!/^[a-zA-Z0-9_-]+$/.test(normalized)) {
37
+ throw new Error(`Invalid session id "${sessionId}".`);
38
+ }
39
+ return normalized;
40
+ }
41
+ export function getSessionImageBaseDir(env = process.env) {
42
+ return path.join(getClientStateDir(env), 'sessions', 'images');
43
+ }
44
+ export function getSessionImageDir(sessionId, env = process.env) {
45
+ return path.join(getSessionImageBaseDir(env), safeSessionDirName(sessionId));
46
+ }
47
+ function nextImageNumber(dir) {
48
+ if (!existsSync(dir))
49
+ return 1;
50
+ let highest = 0;
51
+ for (const name of readdirSync(dir)) {
52
+ const parsed = Number.parseInt(path.basename(name, path.extname(name)), 10);
53
+ if (Number.isInteger(parsed) && parsed > highest)
54
+ highest = parsed;
55
+ }
56
+ return highest + 1;
57
+ }
58
+ function ensureSessionImageDir(sessionId, env) {
59
+ const dir = getSessionImageDir(sessionId, env);
60
+ mkdirSync(dir, { recursive: true, mode: IMAGE_DIR_MODE });
61
+ return dir;
62
+ }
63
+ export function storeSessionImageBytes({ sessionId, base64Data, mimeType, env = process.env, }) {
64
+ const dir = ensureSessionImageDir(sessionId, env);
65
+ const index = nextImageNumber(dir);
66
+ const cachePath = path.join(dir, `${index}${imageExtensionForMime(mimeType)}`);
67
+ writeFileSync(cachePath, Buffer.from(base64Data, 'base64'), {
68
+ mode: IMAGE_FILE_MODE,
69
+ });
70
+ rememberStoredImage(base64Data, cachePath);
71
+ return { cachePath, mimeType, base64Data, index };
72
+ }
73
+ export function tryCacheAttachmentBytes({ base64Data, mimeType, env = process.env, }) {
74
+ if (!activeSessionId)
75
+ return undefined;
76
+ try {
77
+ const stored = storeSessionImageBytes({
78
+ sessionId: activeSessionId,
79
+ base64Data,
80
+ mimeType,
81
+ env,
82
+ });
83
+ return { cachePath: stored.cachePath, index: stored.index };
84
+ }
85
+ catch {
86
+ return undefined;
87
+ }
88
+ }
89
+ export function isSessionStorePath(candidate, env = process.env) {
90
+ if (!activeSessionId)
91
+ return false;
92
+ const dir = getSessionImageDir(activeSessionId, env);
93
+ const resolved = path.resolve(candidate);
94
+ return (resolved.startsWith(dir + path.sep) && path.dirname(resolved) === dir);
95
+ }
96
+ export function readSessionImageByIndex(index, env = process.env) {
97
+ if (!activeSessionId || !Number.isInteger(index) || index < 1)
98
+ return null;
99
+ const dir = getSessionImageDir(activeSessionId, env);
100
+ if (!existsSync(dir))
101
+ return null;
102
+ try {
103
+ for (const name of readdirSync(dir)) {
104
+ if (Number.parseInt(path.basename(name, path.extname(name)), 10) === index) {
105
+ return readSessionImage(path.join(dir, name));
106
+ }
107
+ }
108
+ }
109
+ catch {
110
+ return null;
111
+ }
112
+ return null;
113
+ }
114
+ export class SessionImageError extends Error {
115
+ code;
116
+ constructor(message, code) {
117
+ super(message);
118
+ this.code = code;
119
+ this.name = 'SessionImageError';
120
+ }
121
+ }
122
+ export function storeSessionImageFromPath({ sessionId, sourcePath, env = process.env, }) {
123
+ const resolved = path.resolve(sourcePath);
124
+ if (!existsSync(resolved) || !statSync(resolved).isFile()) {
125
+ throw new SessionImageError(`Image file not found: ${resolved}`, 'NOT_FOUND');
126
+ }
127
+ const size = statSync(resolved).size;
128
+ if (size > MAX_IMAGE_SIZE_BYTES) {
129
+ throw new SessionImageError(`Image file exceeds ${Math.round(MAX_IMAGE_SIZE_BYTES / 1024 / 1024)}MB limit (${(size / 1024 / 1024).toFixed(1)}MB): ${resolved}`, 'TOO_LARGE');
130
+ }
131
+ const bytes = readFileSync(resolved);
132
+ const sniffed = sniffImageMimeType(bytes);
133
+ if (!sniffed || !isSupportedImageMimeType(sniffed)) {
134
+ throw new SessionImageError(`Not a supported image file: ${resolved}. Supported: PNG, JPEG, GIF, WebP.`, 'UNSUPPORTED');
135
+ }
136
+ const dir = ensureSessionImageDir(sessionId, env);
137
+ const index = nextImageNumber(dir);
138
+ const cachePath = path.join(dir, `${index}${imageExtensionForMime(sniffed)}`);
139
+ writeFileSync(cachePath, bytes, { mode: IMAGE_FILE_MODE });
140
+ const base64Data = bytes.toString('base64');
141
+ rememberStoredImage(base64Data, cachePath);
142
+ return { cachePath, mimeType: sniffed, base64Data, index };
143
+ }
144
+ export function readSessionImage(cachePath) {
145
+ try {
146
+ if (!existsSync(cachePath))
147
+ return null;
148
+ const bytes = readFileSync(cachePath);
149
+ const sniffed = sniffImageMimeType(bytes);
150
+ if (!sniffed)
151
+ return null;
152
+ const parsed = Number.parseInt(path.basename(cachePath, path.extname(cachePath)), 10);
153
+ const base64Data = bytes.toString('base64');
154
+ rememberStoredImage(base64Data, cachePath);
155
+ return {
156
+ cachePath,
157
+ mimeType: sniffed,
158
+ base64Data,
159
+ index: Number.isInteger(parsed) ? parsed : 0,
160
+ };
161
+ }
162
+ catch {
163
+ return null;
164
+ }
165
+ }
166
+ export function pruneSessionImages(sessionId, env = process.env) {
167
+ try {
168
+ rmSync(getSessionImageDir(sessionId, env), { recursive: true, force: true });
169
+ }
170
+ catch {
171
+ }
172
+ }
173
+ export function sweepOrphanSessionImages({ activeSessionIds, maxAgeMs = SESSION_IMAGE_MAX_AGE_MS, env = process.env, now = Date.now(), }) {
174
+ const baseDir = getSessionImageBaseDir(env);
175
+ if (!existsSync(baseDir))
176
+ return 0;
177
+ let removed = 0;
178
+ let entries;
179
+ try {
180
+ entries = readdirSync(baseDir);
181
+ }
182
+ catch {
183
+ return 0;
184
+ }
185
+ for (const entry of entries) {
186
+ if (activeSessionIds.has(entry))
187
+ continue;
188
+ const dir = path.join(baseDir, entry);
189
+ try {
190
+ if (now - statSync(dir).mtimeMs < maxAgeMs)
191
+ continue;
192
+ rmSync(dir, { recursive: true, force: true });
193
+ removed += 1;
194
+ }
195
+ catch {
196
+ }
197
+ }
198
+ return removed;
199
+ }
@@ -66,8 +66,8 @@ const HELP_MARKDOWN = [
66
66
  ' picks it up at its next step instead of waiting for the turn to finish, and',
67
67
  ' the Your messages panel tracks it from queued to delivered. **↑** brings it',
68
68
  ' back for editing and **Esc**/**Ctrl+C** discards it; both unlock the',
69
- ' composer for the next message. Messages with images cannot be sent mid-turn',
70
- ' and are sent with the next prompt instead.',
69
+ ' composer for the next message. **Ctrl+V** pastes a screenshot onto the',
70
+ ' queued message, and it is delivered into the running turn with the text.',
71
71
  `- **Paste** into the composer with your terminal's paste shortcut (\`${PASTE_SHORTCUT}\``,
72
72
  ' on this system) or by right-clicking the composer.',
73
73
  '- **Copy** from the transcript by dragging to select; double-click copies a',
@@ -3,6 +3,7 @@ import { createHash } from 'node:crypto';
3
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { getClientStateDir } from './client-state.js';
6
+ import { findStoredImageByContent, pruneSessionImages, readSessionImage, sweepOrphanSessionImages, } from './core/session-image-store.js';
6
7
  import { normalizeAssistantEditJournal } from './edit-journal.js';
7
8
  import { createSessionGrants } from './permissions.js';
8
9
  import { cloneSessionSafetyState, createSessionSafetyState, mergeLocalSessionSafetyState, normalizeSessionSafetyState, } from './session-safety.js';
@@ -85,6 +86,39 @@ function normalizeBranch(value) {
85
86
  .trim();
86
87
  return text ? text.slice(0, 120) : null;
87
88
  }
89
+ function dehydrateHistoryImages(history) {
90
+ return history.map((entry) => ({
91
+ ...entry,
92
+ parts: (entry.parts ?? []).map((part) => {
93
+ if (!part?.inlineData?.data)
94
+ return part;
95
+ const cachePath = part.imageCachePath ?? findStoredImageByContent(part.inlineData.data);
96
+ if (!cachePath)
97
+ return part;
98
+ return {
99
+ imageRef: { cachePath, mimeType: part.inlineData.mimeType },
100
+ text: '[image stored in this session]',
101
+ };
102
+ }),
103
+ }));
104
+ }
105
+ function rehydrateHistoryImages(history) {
106
+ return history.map((entry) => ({
107
+ ...entry,
108
+ parts: (entry.parts ?? []).map((part) => {
109
+ const ref = part?.imageRef;
110
+ if (!ref?.cachePath)
111
+ return part;
112
+ const stored = readSessionImage(String(ref.cachePath));
113
+ if (!stored)
114
+ return { text: part.text ?? '[image no longer available]' };
115
+ return {
116
+ inlineData: { mimeType: stored.mimeType, data: stored.base64Data },
117
+ imageCachePath: ref.cachePath,
118
+ };
119
+ }),
120
+ }));
121
+ }
88
122
  function normalizeHistory(value) {
89
123
  if (!Array.isArray(value))
90
124
  return [];
@@ -154,14 +188,42 @@ function assertSessionNameAvailable(rootDir, name, sessionId, env = process.env)
154
188
  throw new Error(`Session name "${name}" is already used by ${duplicate.id}. Use --session "${name}" to resume it or choose a different name.`);
155
189
  }
156
190
  }
191
+ function listAllSessionIds(env = process.env) {
192
+ const ids = new Set();
193
+ const projectsDir = path.join(getSessionBaseDir(env), 'sessions', 'projects');
194
+ if (!existsSync(projectsDir))
195
+ return ids;
196
+ try {
197
+ for (const project of readdirSync(projectsDir)) {
198
+ const dir = path.join(projectsDir, project);
199
+ try {
200
+ for (const file of readdirSync(dir)) {
201
+ if (file.endsWith('.json'))
202
+ ids.add(path.basename(file, '.json'));
203
+ }
204
+ }
205
+ catch {
206
+ }
207
+ }
208
+ }
209
+ catch {
210
+ return ids;
211
+ }
212
+ return ids;
213
+ }
157
214
  export function pruneSavedSessions(rootDir, env = process.env) {
158
215
  const snapshots = loadAllSnapshots(rootDir, env);
159
216
  const keep = new Set(snapshots.slice(0, MAX_RECENT_SESSIONS).map((snapshot) => snapshot.id));
160
217
  for (const snapshot of snapshots.slice(MAX_RECENT_SESSIONS)) {
161
218
  if (!keep.has(snapshot.id)) {
162
219
  rmSync(getSessionPath(rootDir, snapshot.id, env), { force: true });
220
+ pruneSessionImages(snapshot.id, env);
163
221
  }
164
222
  }
223
+ sweepOrphanSessionImages({
224
+ activeSessionIds: listAllSessionIds(env),
225
+ env,
226
+ });
165
227
  }
166
228
  export function readGitBranch(rootDir) {
167
229
  try {
@@ -22,6 +22,7 @@ import { shellJobKill } from './shell-job-kill.js';
22
22
  import { strReplace } from './str-replace.js';
23
23
  import { undoEdit } from './undo-edit.js';
24
24
  import { updateTodos } from './update-todos.js';
25
+ import { readImageFile } from './read-image-file.js';
25
26
  import { writeFile } from './write-file.js';
26
27
  export const TOOL_MAP = {
27
28
  search_code: (context, args) => searchCode(context.projectIndex, args),
@@ -50,6 +51,7 @@ export const TOOL_MAP = {
50
51
  shell_job_output: shellJobOutput,
51
52
  shell_job_kill: shellJobKill,
52
53
  update_todos: updateTodos,
54
+ analyze_image: (context, args) => readImageFile(context, args),
53
55
  };
54
56
  function invalidToolCall(error) {
55
57
  return {
@@ -0,0 +1,85 @@
1
+ import path from 'node:path';
2
+ import { normalizeProjectRelativePath } from '../artifact-policy.js';
3
+ import { SessionImageError, getImageStoreSession, isSessionStorePath, readSessionImage, readSessionImageByIndex, storeSessionImageFromPath, } from '../core/session-image-store.js';
4
+ export async function readImageFile(context, args) {
5
+ const rawPath = String(args.path ?? args.filePath ?? args.file_path ?? '').trim();
6
+ if (!rawPath) {
7
+ const requested = Number(args.imageIndex ?? args.image_index ?? args.index);
8
+ const stored = Number.isInteger(requested)
9
+ ? readSessionImageByIndex(requested)
10
+ : null;
11
+ if (!stored) {
12
+ return {
13
+ ok: false,
14
+ error: Number.isInteger(requested)
15
+ ? `Image #${requested} is not in this session's image store.`
16
+ : 'path is required and must name an image file on this machine.',
17
+ failureCategory: Number.isInteger(requested)
18
+ ? 'not_found'
19
+ : 'missing_required_argument',
20
+ };
21
+ }
22
+ return {
23
+ ok: true,
24
+ imageBytes: {
25
+ base64Data: stored.base64Data,
26
+ mimeType: stored.mimeType,
27
+ cachePath: stored.cachePath,
28
+ index: stored.index,
29
+ },
30
+ };
31
+ }
32
+ let resolved;
33
+ if (path.isAbsolute(rawPath)) {
34
+ resolved = rawPath;
35
+ }
36
+ else if (normalizeProjectRelativePath(context.rootDir, rawPath)) {
37
+ resolved = path.resolve(context.rootDir, rawPath);
38
+ }
39
+ else {
40
+ return {
41
+ ok: false,
42
+ error: `Refusing to access path outside the project root: ${rawPath}`,
43
+ failureCategory: 'permission_denied',
44
+ };
45
+ }
46
+ try {
47
+ const stored = isSessionStorePath(resolved)
48
+ ? readSessionImage(resolved)
49
+ : storeSessionImageFromPath({
50
+ sessionId: getImageStoreSession() ?? 'unbound',
51
+ sourcePath: resolved,
52
+ });
53
+ if (!stored) {
54
+ return {
55
+ ok: false,
56
+ error: `Not a readable image: ${resolved}`,
57
+ failureCategory: 'not_found',
58
+ };
59
+ }
60
+ return {
61
+ ok: true,
62
+ imageBytes: {
63
+ base64Data: stored.base64Data,
64
+ mimeType: stored.mimeType,
65
+ filePath: resolved,
66
+ cachePath: stored.cachePath,
67
+ index: stored.index,
68
+ },
69
+ };
70
+ }
71
+ catch (err) {
72
+ if (err instanceof SessionImageError) {
73
+ return {
74
+ ok: false,
75
+ error: err.message,
76
+ failureCategory: err.code === 'NOT_FOUND' ? 'not_found' : 'invalid_argument',
77
+ };
78
+ }
79
+ return {
80
+ ok: false,
81
+ error: `Could not read image: ${err?.message ?? err}`,
82
+ failureCategory: 'tool_exception',
83
+ };
84
+ }
85
+ }
@@ -10,6 +10,7 @@ import { isTurnCancelledError } from '../api/chat.js';
10
10
  import { getJobBufferedOutput, getJobOutputPreview, hasRunningBackgroundJobs, killAllBackgroundJobs, killBackgroundJob, listBackgroundJobs, setBackgroundJobSession, setBackgroundJobUpdateHook, } from '../background-jobs.js';
11
11
  import { clearTodos, listTodos, setTodoSession } from '../todo-list.js';
12
12
  import { setScratchSession } from '../scratch-dir.js';
13
+ import { setImageStoreSession } from '../core/session-image-store.js';
13
14
  import { cancelActiveCommand } from '../executor.js';
14
15
  import { isTurnFailureMarker } from '../turn-failure-marker.js';
15
16
  import { clearCliAuthConfig } from '../api/auth.js';
@@ -1347,6 +1348,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1347
1348
  await withTuiMode(async () => {
1348
1349
  setBackgroundJobSession(session.sessionId);
1349
1350
  setScratchSession(session.sessionId);
1351
+ setImageStoreSession(session.sessionId);
1350
1352
  setTodoSession(session.sessionId);
1351
1353
  const store = createShellStore(createInitialShellState(session, serverModels, debugUi));
1352
1354
  store.replaceTranscript(createSessionTranscript(session));
@@ -2163,6 +2165,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2163
2165
  applySessionSnapshot(session, snapshot);
2164
2166
  setBackgroundJobSession(session.sessionId);
2165
2167
  setScratchSession(session.sessionId);
2168
+ setImageStoreSession(session.sessionId);
2166
2169
  syncBackgroundJobsState();
2167
2170
  setTodoSession(session.sessionId);
2168
2171
  syncTodosState();
@@ -2267,14 +2270,11 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2267
2270
  const text = (queued.pastedChunks.length
2268
2271
  ? expandPastedChunks(queued.body, queued.pastedChunks)
2269
2272
  : queued.body).trim();
2270
- if (!text)
2273
+ const images = queued.imageAttachments.filter((attachment) => queued.body.includes(`[Image #${attachment.index}]`));
2274
+ if (!text && images.length === 0)
2271
2275
  return;
2272
2276
  const turnId = activeServerTurnId;
2273
- const blockedReason = queued.imageAttachments.length > 0
2274
- ? 'waiting — images go with the next prompt'
2275
- : !turnId
2276
- ? 'waiting for the turn to end'
2277
- : null;
2277
+ const blockedReason = !turnId ? 'waiting for the turn to end' : null;
2278
2278
  const existingRow = blockedRows.get(queued);
2279
2279
  if (existingRow !== undefined) {
2280
2280
  upsertTurnMessage(existingRow, {
@@ -2331,6 +2331,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2331
2331
  turnId,
2332
2332
  text,
2333
2333
  messageId,
2334
+ imageAttachments: images,
2334
2335
  });
2335
2336
  }
2336
2337
  catch (error) {
@@ -2360,9 +2361,10 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2360
2361
  return;
2361
2362
  }
2362
2363
  const pending = store.getState();
2364
+ const body = String(rawInput ?? '');
2363
2365
  const snapshot = {
2364
- body: String(rawInput ?? ''),
2365
- imageAttachments: pending.imageAttachments,
2366
+ body,
2367
+ imageAttachments: pending.imageAttachments.filter((attachment) => body.includes(`[Image #${attachment.index}]`)),
2366
2368
  pastedChunks: pending.pastedChunks,
2367
2369
  };
2368
2370
  scheduleLiveFrameRemount();
@@ -2377,11 +2379,14 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2377
2379
  }));
2378
2380
  return;
2379
2381
  }
2380
- const imageAttachments = store.getState().imageAttachments;
2382
+ const imageAttachments = store
2383
+ .getState()
2384
+ .imageAttachments.filter((attachment) => input.includes(`[Image #${attachment.index}]`));
2381
2385
  scheduleLiveFrameRemount();
2382
2386
  store.update((current) => ({
2383
2387
  ...current,
2384
2388
  cursor: 0,
2389
+ imageAttachments: [],
2385
2390
  input: '',
2386
2391
  pastedChunks: [],
2387
2392
  promptHistory: appendPromptToHistory(current.promptHistory, input),
@@ -2564,6 +2569,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2564
2569
  startNewConversation(session);
2565
2570
  setBackgroundJobSession(session.sessionId);
2566
2571
  setScratchSession(session.sessionId);
2572
+ setImageStoreSession(session.sessionId);
2567
2573
  setTodoSession(session.sessionId);
2568
2574
  clearTodos();
2569
2575
  syncBackgroundJobsState();
@@ -2975,6 +2981,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2975
2981
  killAllBackgroundJobs();
2976
2982
  setBackgroundJobSession(null);
2977
2983
  setScratchSession(null);
2984
+ setImageStoreSession(null);
2978
2985
  setTodoSession(null);
2979
2986
  setBackgroundJobUpdateHook(null);
2980
2987
  await bridge.close();
@@ -1,4 +1,6 @@
1
1
  import { readClipboardImage, readClipboardText } from '../../core/clipboard.js';
2
+ import { MAX_IMAGES_PER_MESSAGE, MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE, approximateBase64DecodedBytes, totalAttachmentBytes, } from '../../core/image-limits.js';
3
+ import { tryCacheAttachmentBytes } from '../../core/session-image-store.js';
2
4
  import { applySlashCommandSuggestion, buildModelPickerOptions, deleteAtCursor, deleteBeforeCursor, getInputCommandToken, getNextApprovalCursor, getNextModelPickerIndex, getSlashCommandSuggestions, insertAtCursor, isExactSlashCommandToken, navigatePromptHistory, shouldRemountLiveFrameForComposerInputChange, } from '../repl.js';
3
5
  import { buildPastePlaceholder, shouldCollapsePaste, } from '../paste-collapse.js';
4
6
  import { handleUserInputPromptEvent, } from './user-input.js';
@@ -446,7 +448,7 @@ export function handleShellKeyEvent(store, handlers, event) {
446
448
  void handlers.onFireQueuedMessage?.();
447
449
  return;
448
450
  }
449
- if (!key.upArrow) {
451
+ if (!key.upArrow && !isClipboardImagePasteKey(key)) {
450
452
  return;
451
453
  }
452
454
  }
@@ -577,12 +579,16 @@ export function handleShellKeyEvent(store, handlers, event) {
577
579
  }
578
580
  if (isClipboardImagePasteKey(key)) {
579
581
  const current = store.getState();
580
- if (current.busy)
581
- return;
582
- const liveAttachments = current.imageAttachments.filter((attachment) => current.input.includes(`[Image #${attachment.index}]`));
583
- if (liveAttachments.length >= 2) {
582
+ const target = current.queuedMessage
583
+ ? {
584
+ body: current.queuedMessage.body,
585
+ attachments: current.queuedMessage.imageAttachments,
586
+ }
587
+ : { body: current.input, attachments: current.imageAttachments };
588
+ const liveAttachments = target.attachments.filter((attachment) => target.body.includes(`[Image #${attachment.index}]`));
589
+ if (liveAttachments.length >= MAX_IMAGES_PER_MESSAGE) {
584
590
  store.appendEntry({
585
- body: 'Maximum of 2 images per message. Send the current message first.',
591
+ body: `Maximum of ${MAX_IMAGES_PER_MESSAGE} images per message. Send the current message first.`,
586
592
  kind: 'error',
587
593
  title: 'Image',
588
594
  });
@@ -590,22 +596,49 @@ export function handleShellKeyEvent(store, handlers, event) {
590
596
  }
591
597
  try {
592
598
  const clipResult = readClipboardImage();
599
+ if (totalAttachmentBytes(liveAttachments) +
600
+ approximateBase64DecodedBytes(clipResult.base64Data) >
601
+ MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE) {
602
+ store.appendEntry({
603
+ body: `This image would put the message over the ${Math.round(MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE / 1024 / 1024)}MB combined image limit. Send the current message first.`,
604
+ kind: 'error',
605
+ title: 'Image',
606
+ });
607
+ return;
608
+ }
593
609
  const idx = liveAttachments.length === 0
594
610
  ? 1
595
611
  : Math.max(...liveAttachments.map((attachment) => attachment.index)) + 1;
596
- store.update((shellState) => ({
597
- ...shellState,
598
- imageAttachments: [
599
- ...liveAttachments,
600
- {
601
- index: idx,
602
- mimeType: clipResult.mimeType,
603
- base64Data: clipResult.base64Data,
604
- source: 'clipboard',
605
- },
606
- ],
607
- ...insertAtCursor(shellState, `[Image #${idx}] `),
608
- }));
612
+ const cached = tryCacheAttachmentBytes({
613
+ base64Data: clipResult.base64Data,
614
+ mimeType: clipResult.mimeType,
615
+ });
616
+ const attachment = {
617
+ index: cached?.index ?? idx,
618
+ mimeType: clipResult.mimeType,
619
+ base64Data: clipResult.base64Data,
620
+ source: 'clipboard',
621
+ ...(cached ? { cachePath: cached.cachePath } : {}),
622
+ };
623
+ const marker = attachment.index;
624
+ store.update((shellState) => {
625
+ const nextAttachments = [...liveAttachments, attachment];
626
+ if (shellState.queuedMessage) {
627
+ return {
628
+ ...shellState,
629
+ queuedMessage: {
630
+ ...shellState.queuedMessage,
631
+ body: `${shellState.queuedMessage.body} [Image #${marker}]`.trim(),
632
+ imageAttachments: nextAttachments,
633
+ },
634
+ };
635
+ }
636
+ return {
637
+ ...shellState,
638
+ imageAttachments: nextAttachments,
639
+ ...insertAtCursor(shellState, `[Image #${marker}] `),
640
+ };
641
+ });
609
642
  }
610
643
  catch (error) {
611
644
  if (error?.code === 'NO_IMAGE') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thegitai/cli",
3
- "version": "1.0.0-preview.20",
3
+ "version": "1.0.0-preview.22",
4
4
  "description": "TheGitAI is an AI coding agent for your terminal. It indexes your repository, writes and edits files, runs commands, and builds features with you.",
5
5
  "keywords": [
6
6
  "ai",
@@ -37,10 +37,10 @@
37
37
  "@lydell/node-pty-linux-x64": "1.1.0",
38
38
  "@lydell/node-pty-win32-arm64": "1.1.0",
39
39
  "@lydell/node-pty-win32-x64": "1.1.0",
40
- "@thegitai/tui-darwin-arm64": "1.0.0-preview.20",
41
- "@thegitai/tui-darwin-x64": "1.0.0-preview.20",
42
- "@thegitai/tui-linux-x64": "1.0.0-preview.20",
43
- "@thegitai/tui-win32-x64": "1.0.0-preview.20",
40
+ "@thegitai/tui-darwin-arm64": "1.0.0-preview.22",
41
+ "@thegitai/tui-darwin-x64": "1.0.0-preview.22",
42
+ "@thegitai/tui-linux-x64": "1.0.0-preview.22",
43
+ "@thegitai/tui-win32-x64": "1.0.0-preview.22",
44
44
  "@vscode/ripgrep": "1.18.0"
45
45
  },
46
46
  "publishConfig": {