@thegitai/cli 1.0.0-preview.2 → 1.0.0-preview.21
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 +32 -4
- package/dist/bin/ai.js +57 -291
- package/dist/src/agent-mode.js +1 -1
- package/dist/src/api/auth.js +2 -2
- package/dist/src/api/browser-login.js +72 -3
- package/dist/src/api/chat.js +236 -33
- package/dist/src/api/contracts.js +55 -1
- package/dist/src/api/http.js +16 -3
- package/dist/src/api/models.js +9 -4
- package/dist/src/core/clipboard.js +7 -13
- package/dist/src/core/image-limits.js +56 -0
- package/dist/src/core/image-path-extractor.js +70 -3
- package/dist/src/core/session-image-store.js +199 -0
- package/dist/src/executor.js +1 -1
- package/dist/src/help-text.js +51 -11
- package/dist/src/permissions.js +243 -0
- package/dist/src/project-index.js +13 -1
- package/dist/src/session-store.js +119 -20
- package/dist/src/session.js +14 -3
- package/dist/src/tool-executor.js +2 -2
- package/dist/src/tools/delete-file.js +14 -0
- package/dist/src/tools/index.js +2 -0
- package/dist/src/tools/patch-file.js +12 -16
- package/dist/src/tools/read-image-file.js +85 -0
- package/dist/src/tools/replace-document-text.js +28 -18
- package/dist/src/tools/run-command.js +13 -27
- package/dist/src/tools/run-node-script.js +11 -26
- package/dist/src/tools/str-replace.js +12 -16
- package/dist/src/tools/write-file.js +66 -0
- package/dist/src/turn-failure-marker.js +11 -0
- package/dist/src/ui/prompt-history-store.js +1 -1
- package/dist/src/ui/repl.js +579 -154
- package/dist/src/ui/tui/bridge.js +10 -0
- package/dist/src/ui/tui/build-frame.js +535 -159
- package/dist/src/ui/tui/markdown-render.js +81 -73
- package/dist/src/ui/tui/shell-input.js +206 -63
- package/dist/src/ui/tui/terminal-theme.js +28 -0
- package/dist/src/ui/tui/terminal-title.js +3 -0
- package/dist/src/ui/tui/terminal-writes.js +48 -0
- package/dist/src/ui/tui/text.js +158 -4
- package/dist/src/ui/tui/user-input.js +568 -0
- package/dist/src/utils.js +9 -0
- package/package.json +18 -6
- package/dist/src/markdown-renderer.js +0 -112
package/dist/src/api/chat.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { drainBackgroundJobNotifications } from '../background-jobs.js';
|
|
2
2
|
import { createPromptCheckpoint, sanitizeSessionSafetyForServer, } from '../session-safety.js';
|
|
3
|
-
import { applySessionSnapshot, snapshotFromSession, } from '../session-store.js';
|
|
3
|
+
import { applySessionSnapshot, saveSessionState, snapshotFromSession, } from '../session-store.js';
|
|
4
4
|
import { executeLocalToolCall } from '../tool-executor.js';
|
|
5
|
+
import { isUserInputQuestionArray } from './contracts.js';
|
|
5
6
|
import { createTraceContext, normalizeServerUrl, readErrorResponse, } from './http.js';
|
|
6
7
|
import { collectClientEnvironment } from '../client-environment.js';
|
|
7
8
|
import { collectProjectOrientation } from '../project-orientation.js';
|
|
8
9
|
import { autoAttachImages } from '../core/image-path-extractor.js';
|
|
10
|
+
import { formatTurnFailureMarker } from '../turn-failure-marker.js';
|
|
9
11
|
export class TurnCancelledError extends Error {
|
|
10
12
|
name = 'TurnCancelledError';
|
|
11
13
|
constructor(message = 'Turn cancelled.') {
|
|
@@ -17,11 +19,13 @@ export class ChatTurnFailedError extends Error {
|
|
|
17
19
|
category;
|
|
18
20
|
retryable;
|
|
19
21
|
traceId;
|
|
20
|
-
|
|
22
|
+
partialSnapshot;
|
|
23
|
+
constructor(message, category = 'unknown_error', retryable = false, traceId = '', partialSnapshot) {
|
|
21
24
|
super(traceId ? `${message}\nTrace ID: ${traceId}` : message);
|
|
22
25
|
this.category = category;
|
|
23
26
|
this.retryable = retryable;
|
|
24
27
|
this.traceId = traceId;
|
|
28
|
+
this.partialSnapshot = partialSnapshot;
|
|
25
29
|
}
|
|
26
30
|
}
|
|
27
31
|
export function isTurnCancelledError(error) {
|
|
@@ -51,6 +55,7 @@ function parseSseBlock(block) {
|
|
|
51
55
|
return { event, data: text };
|
|
52
56
|
}
|
|
53
57
|
}
|
|
58
|
+
let toolStateSeqCounter = 0;
|
|
54
59
|
function toolStateFromSession(session) {
|
|
55
60
|
return {
|
|
56
61
|
autoYes: session.autoYes,
|
|
@@ -93,17 +98,30 @@ export function preserveCancelledTurnInput(session, input) {
|
|
|
93
98
|
return;
|
|
94
99
|
break;
|
|
95
100
|
}
|
|
96
|
-
session.history.push({
|
|
101
|
+
session.history.push({
|
|
102
|
+
role: 'user',
|
|
103
|
+
parts: [{ text }],
|
|
104
|
+
kind: 'turnStart',
|
|
105
|
+
userInput: text,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
function appendTurnFailureMarker(session, category) {
|
|
109
|
+
session.history.push({
|
|
110
|
+
role: 'model',
|
|
111
|
+
parts: [{ text: formatTurnFailureMarker(category) }],
|
|
112
|
+
});
|
|
97
113
|
}
|
|
98
114
|
function preserveFailedTurnInput(session, input, category) {
|
|
99
115
|
const text = input.trim();
|
|
100
116
|
if (!text)
|
|
101
117
|
return;
|
|
102
|
-
session.history.push({ role: 'user', parts: [{ text }], kind: 'turnStart' });
|
|
103
118
|
session.history.push({
|
|
104
|
-
role: '
|
|
105
|
-
parts: [{ text
|
|
119
|
+
role: 'user',
|
|
120
|
+
parts: [{ text }],
|
|
121
|
+
kind: 'turnStart',
|
|
122
|
+
userInput: text,
|
|
106
123
|
});
|
|
124
|
+
appendTurnFailureMarker(session, category);
|
|
107
125
|
}
|
|
108
126
|
function historyHasToolCall(session, callId) {
|
|
109
127
|
return session.history.some((entry) => (entry.parts ?? []).some((part) => String(part?.functionCall?.id ?? '') === callId));
|
|
@@ -143,11 +161,16 @@ function publicStatusMessage(data) {
|
|
|
143
161
|
? event.toolName
|
|
144
162
|
: 'tool';
|
|
145
163
|
if (event.phase === 'thinking')
|
|
146
|
-
return '
|
|
164
|
+
return 'Exploring options...';
|
|
165
|
+
if (event.phase === 'analyzing_image') {
|
|
166
|
+
return (event.imageCount ?? 1) > 1 ? 'Analyzing images...' : 'Analyzing image...';
|
|
167
|
+
}
|
|
147
168
|
if (event.phase === 'running_tool')
|
|
148
169
|
return `Running ${toolName}...`;
|
|
149
170
|
if (event.phase === 'waiting_for_tool')
|
|
150
171
|
return `Running ${toolName} locally...`;
|
|
172
|
+
if (event.phase === 'waiting_for_user_input')
|
|
173
|
+
return 'Waiting for your input...';
|
|
151
174
|
return null;
|
|
152
175
|
}
|
|
153
176
|
function normalizeShellJobToolCall(call) {
|
|
@@ -184,6 +207,7 @@ async function postToolResult({ config, turnId, event, result, session, fetchImp
|
|
|
184
207
|
toolCallId: event.call.id,
|
|
185
208
|
result,
|
|
186
209
|
toolState: toolStateFromSession(session),
|
|
210
|
+
toolStateSeq: ++toolStateSeqCounter,
|
|
187
211
|
};
|
|
188
212
|
const trace = createTraceContext(traceId);
|
|
189
213
|
const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/chat/turn/${encodeURIComponent(turnId)}/tool-result`, {
|
|
@@ -202,6 +226,75 @@ async function postToolResult({ config, turnId, event, result, session, fetchImp
|
|
|
202
226
|
throw await readErrorResponse(response, trace.traceId);
|
|
203
227
|
}
|
|
204
228
|
}
|
|
229
|
+
async function postUserInputResult({ config, turnId, requestId, result, fetchImpl, traceId, }) {
|
|
230
|
+
const payload = {
|
|
231
|
+
requestId,
|
|
232
|
+
result,
|
|
233
|
+
};
|
|
234
|
+
const trace = createTraceContext(traceId);
|
|
235
|
+
const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/chat/turn/${encodeURIComponent(turnId)}/user-input-result`, {
|
|
236
|
+
method: 'POST',
|
|
237
|
+
headers: {
|
|
238
|
+
authorization: `Bearer ${config.token}`,
|
|
239
|
+
'content-type': 'application/json',
|
|
240
|
+
...trace.headers,
|
|
241
|
+
},
|
|
242
|
+
body: JSON.stringify(payload),
|
|
243
|
+
});
|
|
244
|
+
if (response.status === 410) {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (!response.ok) {
|
|
248
|
+
throw await readErrorResponse(response, trace.traceId);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
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
|
+
};
|
|
257
|
+
const trace = createTraceContext(traceId);
|
|
258
|
+
const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/chat/turn/${encodeURIComponent(turnId)}/interject`, {
|
|
259
|
+
method: 'POST',
|
|
260
|
+
headers: {
|
|
261
|
+
authorization: `Bearer ${config.token}`,
|
|
262
|
+
'content-type': 'application/json',
|
|
263
|
+
...trace.headers,
|
|
264
|
+
},
|
|
265
|
+
body: JSON.stringify(payload),
|
|
266
|
+
});
|
|
267
|
+
if (response.status === 410) {
|
|
268
|
+
return 'stale';
|
|
269
|
+
}
|
|
270
|
+
if (!response.ok) {
|
|
271
|
+
throw await readErrorResponse(response, trace.traceId);
|
|
272
|
+
}
|
|
273
|
+
return 'delivered';
|
|
274
|
+
}
|
|
275
|
+
const turnIdOverrides = new WeakMap();
|
|
276
|
+
function enterServerTurnId(session, serverSessionTurnId) {
|
|
277
|
+
const active = turnIdOverrides.get(session);
|
|
278
|
+
if (active) {
|
|
279
|
+
active.depth += 1;
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
turnIdOverrides.set(session, {
|
|
283
|
+
previousTurnId: session.turnState.id,
|
|
284
|
+
depth: 1,
|
|
285
|
+
});
|
|
286
|
+
session.turnState.id = serverSessionTurnId;
|
|
287
|
+
}
|
|
288
|
+
function exitServerTurnId(session) {
|
|
289
|
+
const active = turnIdOverrides.get(session);
|
|
290
|
+
if (!active)
|
|
291
|
+
return;
|
|
292
|
+
active.depth -= 1;
|
|
293
|
+
if (active.depth === 0) {
|
|
294
|
+
session.turnState.id = active.previousTurnId;
|
|
295
|
+
turnIdOverrides.delete(session);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
205
298
|
async function executeAndPostToolResult({ config, projectIndex, session, event, input, fetchImpl, signal, traceId, }) {
|
|
206
299
|
const turnId = String(event?.turnId ?? '').trim();
|
|
207
300
|
if (!turnId || !event?.call?.id || !event.call.name) {
|
|
@@ -210,10 +303,9 @@ async function executeAndPostToolResult({ config, projectIndex, session, event,
|
|
|
210
303
|
if (signal?.aborted) {
|
|
211
304
|
throw new TurnCancelledError();
|
|
212
305
|
}
|
|
213
|
-
const previousTurnId = session.turnState.id;
|
|
214
306
|
const serverSessionTurnId = String(event.sessionTurnId ?? '').trim();
|
|
215
307
|
if (serverSessionTurnId) {
|
|
216
|
-
session
|
|
308
|
+
enterServerTurnId(session, serverSessionTurnId);
|
|
217
309
|
if (!session.clientState.safety.checkpoints.some((checkpoint) => checkpoint.turnId === serverSessionTurnId)) {
|
|
218
310
|
createPromptCheckpoint(session.clientState.safety, 'prompt boundary', serverSessionTurnId);
|
|
219
311
|
}
|
|
@@ -236,10 +328,12 @@ async function executeAndPostToolResult({ config, projectIndex, session, event,
|
|
|
236
328
|
});
|
|
237
329
|
}
|
|
238
330
|
finally {
|
|
239
|
-
|
|
331
|
+
if (serverSessionTurnId) {
|
|
332
|
+
exitServerTurnId(session);
|
|
333
|
+
}
|
|
240
334
|
}
|
|
241
335
|
}
|
|
242
|
-
async function consumeTurnStream({ response, config, projectIndex, session, input, fetchImpl, signal, traceId, }) {
|
|
336
|
+
async function consumeTurnStream({ response, config, projectIndex, session, input, fetchImpl, signal, traceId, onTurnStart, onInterjectionDelivered, }) {
|
|
243
337
|
if (!response.body) {
|
|
244
338
|
throw new Error('Server returned an empty chat stream.');
|
|
245
339
|
}
|
|
@@ -249,8 +343,50 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
|
|
|
249
343
|
const finalResult = {
|
|
250
344
|
current: null,
|
|
251
345
|
};
|
|
346
|
+
const pendingParallelTools = [];
|
|
347
|
+
let firstParallelFailure = null;
|
|
348
|
+
let rejectOnParallelFailure = null;
|
|
349
|
+
const parallelToolFailure = new Promise((_, reject) => {
|
|
350
|
+
rejectOnParallelFailure = reject;
|
|
351
|
+
});
|
|
352
|
+
parallelToolFailure.catch(() => { });
|
|
353
|
+
function recordParallelFailure(error) {
|
|
354
|
+
const failure = error ?? new Error('Local tool execution failed.');
|
|
355
|
+
if (firstParallelFailure == null) {
|
|
356
|
+
firstParallelFailure = failure;
|
|
357
|
+
rejectOnParallelFailure?.(failure);
|
|
358
|
+
}
|
|
359
|
+
return failure;
|
|
360
|
+
}
|
|
361
|
+
async function drainParallelTools() {
|
|
362
|
+
if (!pendingParallelTools.length)
|
|
363
|
+
return;
|
|
364
|
+
const pending = pendingParallelTools.splice(0);
|
|
365
|
+
const outcomes = await Promise.all(pending);
|
|
366
|
+
for (const outcome of outcomes) {
|
|
367
|
+
if (outcome != null)
|
|
368
|
+
throw outcome;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
252
371
|
async function handleEvent(event) {
|
|
372
|
+
if (event.event === 'turn-start') {
|
|
373
|
+
const turnId = String(event.data?.turnId ?? '').trim();
|
|
374
|
+
if (turnId)
|
|
375
|
+
onTurnStart?.(turnId);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (event.event === 'interjection-delivered') {
|
|
379
|
+
onInterjectionDelivered?.(event.data);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
253
382
|
if (event.event === 'status') {
|
|
383
|
+
const data = event.data;
|
|
384
|
+
if (data?.phase === 'analyzing_image') {
|
|
385
|
+
session.onImageAnalysis?.(Math.max(1, Number(data.imageCount ?? 1) || 1));
|
|
386
|
+
}
|
|
387
|
+
else if (data?.phase) {
|
|
388
|
+
session.onImageAnalysis?.(0);
|
|
389
|
+
}
|
|
254
390
|
const message = publicStatusMessage(event.data);
|
|
255
391
|
if (message)
|
|
256
392
|
session.onStatus(message);
|
|
@@ -260,11 +396,26 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
|
|
|
260
396
|
return;
|
|
261
397
|
}
|
|
262
398
|
if (event.event === 'tool-call') {
|
|
399
|
+
const data = event.data;
|
|
400
|
+
if (data?.parallelSafe === true) {
|
|
401
|
+
pendingParallelTools.push(executeAndPostToolResult({
|
|
402
|
+
config,
|
|
403
|
+
projectIndex,
|
|
404
|
+
session,
|
|
405
|
+
event: data,
|
|
406
|
+
input,
|
|
407
|
+
fetchImpl,
|
|
408
|
+
signal,
|
|
409
|
+
traceId,
|
|
410
|
+
}).then(() => null, (error) => recordParallelFailure(error)));
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
await drainParallelTools();
|
|
263
414
|
await executeAndPostToolResult({
|
|
264
415
|
config,
|
|
265
416
|
projectIndex,
|
|
266
417
|
session,
|
|
267
|
-
event:
|
|
418
|
+
event: data,
|
|
268
419
|
input,
|
|
269
420
|
fetchImpl,
|
|
270
421
|
signal,
|
|
@@ -279,36 +430,75 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
|
|
|
279
430
|
}
|
|
280
431
|
return;
|
|
281
432
|
}
|
|
433
|
+
if (event.event === 'user-input-request') {
|
|
434
|
+
await drainParallelTools();
|
|
435
|
+
const data = event.data;
|
|
436
|
+
const turnId = String(data?.turnId ?? '').trim();
|
|
437
|
+
const requestId = String(data?.requestId ?? '').trim();
|
|
438
|
+
if (!turnId ||
|
|
439
|
+
!requestId ||
|
|
440
|
+
!isUserInputQuestionArray(data?.questions)) {
|
|
441
|
+
throw new Error('Server emitted an invalid user-input request.');
|
|
442
|
+
}
|
|
443
|
+
if (!session.requestUserInput) {
|
|
444
|
+
throw new Error('Interactive user input is unavailable in this client.');
|
|
445
|
+
}
|
|
446
|
+
const result = await session.requestUserInput({ questions: data.questions }, signal);
|
|
447
|
+
if (signal?.aborted) {
|
|
448
|
+
throw new TurnCancelledError();
|
|
449
|
+
}
|
|
450
|
+
await postUserInputResult({
|
|
451
|
+
config,
|
|
452
|
+
turnId,
|
|
453
|
+
requestId,
|
|
454
|
+
result,
|
|
455
|
+
fetchImpl,
|
|
456
|
+
traceId,
|
|
457
|
+
});
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
282
460
|
if (event.event === 'result') {
|
|
461
|
+
await drainParallelTools();
|
|
283
462
|
finalResult.current = event.data;
|
|
284
463
|
return;
|
|
285
464
|
}
|
|
286
465
|
if (event.event === 'cancelled' || event.event === 'error') {
|
|
466
|
+
await drainParallelTools().catch(() => { });
|
|
287
467
|
const message = String(event.data?.message ?? 'Server chat failed.');
|
|
288
468
|
if (event.event === 'cancelled') {
|
|
289
469
|
throw new TurnCancelledError(message);
|
|
290
470
|
}
|
|
291
|
-
throw new ChatTurnFailedError(message, typeof event.data?.category === 'string' ? event.data.category : 'unknown_error', Boolean(event.data?.retryable), typeof event.data?.traceId === 'string' ? event.data.traceId : traceId);
|
|
471
|
+
throw new ChatTurnFailedError(message, typeof event.data?.category === 'string' ? event.data.category : 'unknown_error', Boolean(event.data?.retryable), typeof event.data?.traceId === 'string' ? event.data.traceId : traceId, event.data?.snapshot);
|
|
292
472
|
}
|
|
293
473
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
474
|
+
try {
|
|
475
|
+
while (true) {
|
|
476
|
+
if (signal?.aborted) {
|
|
477
|
+
await reader.cancel().catch(() => { });
|
|
478
|
+
throw new TurnCancelledError();
|
|
479
|
+
}
|
|
480
|
+
const read = await Promise.race([reader.read(), parallelToolFailure]);
|
|
481
|
+
if (read.done)
|
|
482
|
+
break;
|
|
483
|
+
buffer += decoder.decode(read.value, { stream: true });
|
|
484
|
+
let separatorIndex = buffer.indexOf('\n\n');
|
|
485
|
+
while (separatorIndex !== -1) {
|
|
486
|
+
const block = buffer.slice(0, separatorIndex);
|
|
487
|
+
buffer = buffer.slice(separatorIndex + 2);
|
|
488
|
+
const event = parseSseBlock(block);
|
|
489
|
+
if (event)
|
|
490
|
+
await handleEvent(event);
|
|
491
|
+
separatorIndex = buffer.indexOf('\n\n');
|
|
492
|
+
}
|
|
311
493
|
}
|
|
494
|
+
await drainParallelTools();
|
|
495
|
+
}
|
|
496
|
+
catch (error) {
|
|
497
|
+
await reader.cancel().catch(() => { });
|
|
498
|
+
throw error;
|
|
499
|
+
}
|
|
500
|
+
finally {
|
|
501
|
+
await drainParallelTools().catch(() => { });
|
|
312
502
|
}
|
|
313
503
|
buffer += decoder.decode();
|
|
314
504
|
const tail = buffer.trim();
|
|
@@ -322,7 +512,7 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
|
|
|
322
512
|
}
|
|
323
513
|
return finalResult.current;
|
|
324
514
|
}
|
|
325
|
-
export async function sendServerUserMessage({ config, projectIndex, session, input, imageAttachments = [], fetchImpl = globalThis.fetch, signal, }) {
|
|
515
|
+
export async function sendServerUserMessage({ config, projectIndex, session, input, imageAttachments = [], fetchImpl = globalThis.fetch, signal, onTurnStart, onInterjectionDelivered, }) {
|
|
326
516
|
const autoAttach = autoAttachImages(input, session.rootDir, imageAttachments);
|
|
327
517
|
const requestImageAttachments = autoAttach.attachments.length > 0
|
|
328
518
|
? [...imageAttachments, ...autoAttach.attachments]
|
|
@@ -379,6 +569,8 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
|
|
|
379
569
|
fetchImpl,
|
|
380
570
|
signal,
|
|
381
571
|
traceId: trace.traceId,
|
|
572
|
+
onTurnStart,
|
|
573
|
+
onInterjectionDelivered,
|
|
382
574
|
});
|
|
383
575
|
applySessionSnapshot(session, result.snapshot, { preserveAgentMode: true });
|
|
384
576
|
return {
|
|
@@ -395,8 +587,19 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
|
|
|
395
587
|
? error
|
|
396
588
|
: new TurnCancelledError();
|
|
397
589
|
}
|
|
398
|
-
|
|
399
|
-
|
|
590
|
+
const category = error instanceof ChatTurnFailedError ? error.category : 'unknown_error';
|
|
591
|
+
const partial = error instanceof ChatTurnFailedError ? error.partialSnapshot : undefined;
|
|
592
|
+
if (partial) {
|
|
593
|
+
applySessionSnapshot(session, partial, { preserveAgentMode: true });
|
|
594
|
+
}
|
|
595
|
+
else {
|
|
596
|
+
session.history.length = preTurnHistoryLength;
|
|
597
|
+
preserveFailedTurnInput(session, requestInputBase, category);
|
|
598
|
+
}
|
|
599
|
+
try {
|
|
600
|
+
saveSessionState(session, session.env);
|
|
601
|
+
}
|
|
602
|
+
catch { }
|
|
400
603
|
throw error;
|
|
401
604
|
}
|
|
402
605
|
finally {
|
|
@@ -1 +1,55 @@
|
|
|
1
|
-
|
|
1
|
+
function nonEmptyString(value) {
|
|
2
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
3
|
+
}
|
|
4
|
+
const USER_INPUT_ID_PATTERN = /^[a-z][a-z0-9_]*$/;
|
|
5
|
+
export function isUserInputQuestionArray(value) {
|
|
6
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > 4) {
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
const questionIds = new Set();
|
|
10
|
+
return value.every((question) => {
|
|
11
|
+
if (!question || typeof question !== 'object' || Array.isArray(question)) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
const candidate = question;
|
|
15
|
+
if (!nonEmptyString(candidate.id) ||
|
|
16
|
+
!USER_INPUT_ID_PATTERN.test(candidate.id) ||
|
|
17
|
+
questionIds.has(candidate.id) ||
|
|
18
|
+
!nonEmptyString(candidate.header) ||
|
|
19
|
+
Array.from(candidate.header).length > 12 ||
|
|
20
|
+
!nonEmptyString(candidate.question) ||
|
|
21
|
+
typeof candidate.multiSelect !== 'boolean' ||
|
|
22
|
+
!Array.isArray(candidate.options) ||
|
|
23
|
+
candidate.options.length < 2 ||
|
|
24
|
+
candidate.options.length > 4) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
questionIds.add(candidate.id);
|
|
28
|
+
const optionIds = new Set();
|
|
29
|
+
const optionsValid = candidate.options.every((option) => {
|
|
30
|
+
if (!option || typeof option !== 'object' || Array.isArray(option)) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
const item = option;
|
|
34
|
+
if (!nonEmptyString(item.id) ||
|
|
35
|
+
!USER_INPUT_ID_PATTERN.test(item.id) ||
|
|
36
|
+
optionIds.has(item.id) ||
|
|
37
|
+
!nonEmptyString(item.label) ||
|
|
38
|
+
!nonEmptyString(item.description)) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
optionIds.add(item.id);
|
|
42
|
+
return true;
|
|
43
|
+
});
|
|
44
|
+
if (!optionsValid) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
if (candidate.recommendedOptionId !== undefined &&
|
|
48
|
+
(!nonEmptyString(candidate.recommendedOptionId) ||
|
|
49
|
+
candidate.recommendedOptionId !==
|
|
50
|
+
candidate.options[0].id)) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
});
|
|
55
|
+
}
|
package/dist/src/api/http.js
CHANGED
|
@@ -6,11 +6,13 @@ export const CLIENT_PLATFORM_HEADER = 'x-thegitai-client-platform';
|
|
|
6
6
|
export class ServerApiError extends Error {
|
|
7
7
|
status;
|
|
8
8
|
traceId;
|
|
9
|
-
|
|
9
|
+
code;
|
|
10
|
+
constructor(message, status, traceId, code = '') {
|
|
10
11
|
super(traceId ? `${message}\nTrace ID: ${traceId}` : message);
|
|
11
12
|
this.name = 'ServerApiError';
|
|
12
13
|
this.status = status;
|
|
13
14
|
this.traceId = traceId;
|
|
15
|
+
this.code = code;
|
|
14
16
|
}
|
|
15
17
|
}
|
|
16
18
|
export function createTraceId() {
|
|
@@ -96,9 +98,20 @@ export async function readJsonResponse(response) {
|
|
|
96
98
|
export function failureMessage(data, status) {
|
|
97
99
|
return String(data?.error?.message ?? data?.message ?? `Request failed with ${status}`);
|
|
98
100
|
}
|
|
101
|
+
export function failureCode(data) {
|
|
102
|
+
return typeof data?.error?.code === 'string' ? data.error.code : '';
|
|
103
|
+
}
|
|
104
|
+
export function isAuthenticationError(error) {
|
|
105
|
+
return error instanceof ServerApiError && error.status === 401;
|
|
106
|
+
}
|
|
107
|
+
export function authenticationErrorMessage(error) {
|
|
108
|
+
return error.code === 'AUTH_TOKEN_EXPIRED'
|
|
109
|
+
? 'Your login expired after 24 hours of inactivity. Run `ai login` and resume this saved session.'
|
|
110
|
+
: 'Your login is no longer valid. Run `ai login` and resume this saved session.';
|
|
111
|
+
}
|
|
99
112
|
export async function readErrorResponse(response, traceId = response.headers.get(TRACE_ID_HEADER) ?? '') {
|
|
100
113
|
const data = await readJsonResponse(response);
|
|
101
|
-
return new ServerApiError(failureMessage(data, response.status), response.status, traceId);
|
|
114
|
+
return new ServerApiError(failureMessage(data, response.status), response.status, traceId, failureCode(data));
|
|
102
115
|
}
|
|
103
116
|
export async function authorizedJson({ config, path, method = 'GET', body = null, headers = {}, fetchImpl = globalThis.fetch, timeoutMs = REQUEST_TIMEOUT_MS, }) {
|
|
104
117
|
const trace = createTraceContext();
|
|
@@ -115,7 +128,7 @@ export async function authorizedJson({ config, path, method = 'GET', body = null
|
|
|
115
128
|
});
|
|
116
129
|
const data = await readJsonResponse(response);
|
|
117
130
|
if (!response.ok) {
|
|
118
|
-
throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
|
|
131
|
+
throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId, failureCode(data));
|
|
119
132
|
}
|
|
120
133
|
return data;
|
|
121
134
|
}
|
package/dist/src/api/models.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { getClientStateDir } from '../client-state.js';
|
|
4
|
-
import { REQUEST_TIMEOUT_MS, ServerApiError, createTraceContext, failureMessage, normalizeServerUrl, readJsonResponse, retryTransient, } from './http.js';
|
|
4
|
+
import { REQUEST_TIMEOUT_MS, ServerApiError, createTraceContext, failureCode, failureMessage, normalizeServerUrl, readJsonResponse, retryTransient, } from './http.js';
|
|
5
5
|
function sanitizeModelInfo(raw) {
|
|
6
6
|
if (!raw || typeof raw !== 'object') {
|
|
7
7
|
return null;
|
|
@@ -9,10 +9,15 @@ function sanitizeModelInfo(raw) {
|
|
|
9
9
|
const value = raw;
|
|
10
10
|
const id = Number(value.id);
|
|
11
11
|
const label = String(value.label ?? '').trim();
|
|
12
|
-
|
|
12
|
+
const costRating = Number(value.costRating);
|
|
13
|
+
const description = String(value.description ?? '').trim();
|
|
14
|
+
if (!Number.isInteger(id) || id <= 0 || !label || !isCostRating(costRating)) {
|
|
13
15
|
return null;
|
|
14
16
|
}
|
|
15
|
-
return { id, label };
|
|
17
|
+
return { id, label, costRating, description };
|
|
18
|
+
}
|
|
19
|
+
function isCostRating(value) {
|
|
20
|
+
return Number.isInteger(value) && value >= 1 && value <= 3;
|
|
16
21
|
}
|
|
17
22
|
export function getModelsCachePath(env = process.env) {
|
|
18
23
|
return path.join(getClientStateDir(env), 'models.json');
|
|
@@ -70,7 +75,7 @@ export async function fetchServerModels({ config, fetchImpl = globalThis.fetch,
|
|
|
70
75
|
});
|
|
71
76
|
const data = (await readJsonResponse(response));
|
|
72
77
|
if (!response.ok) {
|
|
73
|
-
throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
|
|
78
|
+
throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId, failureCode(data));
|
|
74
79
|
}
|
|
75
80
|
const models = Array.isArray(data?.models)
|
|
76
81
|
? data.models.map(sanitizeModelInfo).filter(Boolean)
|
|
@@ -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
|
-
|
|
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
|
|
277
|
-
const mimeType =
|
|
268
|
+
const buf = readFileSync(resolved);
|
|
269
|
+
const mimeType = sniffImageMimeType(buf);
|
|
278
270
|
if (!mimeType) {
|
|
279
|
-
|
|
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
|
+
}
|