@thegitai/cli 1.0.0-beta.1
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/LICENSE +20 -0
- package/README.md +30 -0
- package/dist/bin/ai.js +438 -0
- package/dist/parsers/tree-sitter-c-sharp.wasm +0 -0
- package/dist/parsers/tree-sitter-c.wasm +0 -0
- package/dist/parsers/tree-sitter-cpp.wasm +0 -0
- package/dist/parsers/tree-sitter-css.wasm +0 -0
- package/dist/parsers/tree-sitter-go.wasm +0 -0
- package/dist/parsers/tree-sitter-html.wasm +0 -0
- package/dist/parsers/tree-sitter-java.wasm +0 -0
- package/dist/parsers/tree-sitter-javascript.wasm +0 -0
- package/dist/parsers/tree-sitter-objc.wasm +0 -0
- package/dist/parsers/tree-sitter-php.wasm +0 -0
- package/dist/parsers/tree-sitter-python.wasm +0 -0
- package/dist/parsers/tree-sitter-ruby.wasm +0 -0
- package/dist/parsers/tree-sitter-rust.wasm +0 -0
- package/dist/parsers/tree-sitter-tsx.wasm +0 -0
- package/dist/parsers/tree-sitter-typescript.wasm +0 -0
- package/dist/src/agent-mode.js +142 -0
- package/dist/src/api/auth.js +81 -0
- package/dist/src/api/browser-login.js +184 -0
- package/dist/src/api/chat.js +346 -0
- package/dist/src/api/contracts.js +1 -0
- package/dist/src/api/http.js +44 -0
- package/dist/src/api/index.js +11 -0
- package/dist/src/api/models.js +110 -0
- package/dist/src/api/sessions.js +72 -0
- package/dist/src/artifact-policy.js +207 -0
- package/dist/src/client-state.js +14 -0
- package/dist/src/core/clipboard.js +208 -0
- package/dist/src/core/open-url.js +32 -0
- package/dist/src/edit-journal.js +133 -0
- package/dist/src/executor.js +924 -0
- package/dist/src/extractors/cpp.js +18 -0
- package/dist/src/extractors/csharp.js +16 -0
- package/dist/src/extractors/css.js +12 -0
- package/dist/src/extractors/go.js +27 -0
- package/dist/src/extractors/index.js +52 -0
- package/dist/src/extractors/java.js +14 -0
- package/dist/src/extractors/javascript.js +33 -0
- package/dist/src/extractors/objc.js +14 -0
- package/dist/src/extractors/php.js +20 -0
- package/dist/src/extractors/python.js +11 -0
- package/dist/src/extractors/ruby.js +13 -0
- package/dist/src/extractors/rust.js +17 -0
- package/dist/src/extractors/utils.js +58 -0
- package/dist/src/help-text.js +125 -0
- package/dist/src/markdown-renderer.js +112 -0
- package/dist/src/patcher.js +279 -0
- package/dist/src/project-index.js +221 -0
- package/dist/src/repo-map-languages.js +100 -0
- package/dist/src/runtime-mode.js +35 -0
- package/dist/src/scanner.js +362 -0
- package/dist/src/secret-preview.js +137 -0
- package/dist/src/session-exit.js +17 -0
- package/dist/src/session-safety.js +1012 -0
- package/dist/src/session-store.js +266 -0
- package/dist/src/session.js +93 -0
- package/dist/src/tool-executor.js +188 -0
- package/dist/src/tools/code-intel.js +472 -0
- package/dist/src/tools/delete-file.js +27 -0
- package/dist/src/tools/exec-utils.js +17 -0
- package/dist/src/tools/find-symbol.js +70 -0
- package/dist/src/tools/get-diagnostics.js +22 -0
- package/dist/src/tools/grep-code.js +331 -0
- package/dist/src/tools/hover-symbol.js +95 -0
- package/dist/src/tools/index.js +73 -0
- package/dist/src/tools/list-checkpoints.js +11 -0
- package/dist/src/tools/list-directories.js +16 -0
- package/dist/src/tools/list-files.js +13 -0
- package/dist/src/tools/list-session-edits.js +9 -0
- package/dist/src/tools/list-symbols.js +55 -0
- package/dist/src/tools/patch-file.js +88 -0
- package/dist/src/tools/path-listing.js +83 -0
- package/dist/src/tools/read-document.js +111 -0
- package/dist/src/tools/read-file.js +109 -0
- package/dist/src/tools/restore-checkpoint.js +100 -0
- package/dist/src/tools/ripgrep.js +29 -0
- package/dist/src/tools/run-command.js +94 -0
- package/dist/src/tools/run-node-script.js +210 -0
- package/dist/src/tools/search-code.js +37 -0
- package/dist/src/tools/shell-diagnostics.js +707 -0
- package/dist/src/tools/signature-help.js +118 -0
- package/dist/src/tools/str-replace.js +193 -0
- package/dist/src/tools/types.js +1 -0
- package/dist/src/tools/undo-edit.js +202 -0
- package/dist/src/tools/write-file.js +59 -0
- package/dist/src/tree-sitter-runtime.js +135 -0
- package/dist/src/types.js +1 -0
- package/dist/src/ui/paste-collapse.js +22 -0
- package/dist/src/ui/prompt-history-store.js +96 -0
- package/dist/src/ui/repl.js +2238 -0
- package/dist/src/ui/tui/bridge.js +175 -0
- package/dist/src/ui/tui/build-frame.js +718 -0
- package/dist/src/ui/tui/markdown-render.js +455 -0
- package/dist/src/ui/tui/shell-input.js +488 -0
- package/dist/src/ui/tui/text.js +30 -0
- package/dist/src/ui/tui/types.js +1 -0
- package/dist/src/usage.js +47 -0
- package/dist/src/utils.js +38 -0
- package/package.json +38 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getClientStateDir } from '../client-state.js';
|
|
4
|
+
import { authorizedJson, failureMessage, normalizeServerUrl, readJsonResponse, } from './http.js';
|
|
5
|
+
export function getAuthConfigPath(env = process.env) {
|
|
6
|
+
const configured = String(env.THEGITAI_AUTH_CONFIG ?? '').trim();
|
|
7
|
+
if (configured) {
|
|
8
|
+
return path.resolve(configured);
|
|
9
|
+
}
|
|
10
|
+
return path.join(getClientStateDir(env), 'auth.json');
|
|
11
|
+
}
|
|
12
|
+
export function readCliAuthConfig(env = process.env) {
|
|
13
|
+
const filePath = getAuthConfigPath(env);
|
|
14
|
+
if (!existsSync(filePath))
|
|
15
|
+
return null;
|
|
16
|
+
const parsed = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
17
|
+
const serverUrl = String(parsed.serverUrl ?? '').trim();
|
|
18
|
+
const token = String(parsed.token ?? '').trim();
|
|
19
|
+
const email = String(parsed.email ?? '').trim();
|
|
20
|
+
const customerType = parsed.customerType === 'ADMIN' || parsed.customerType === 'USER'
|
|
21
|
+
? parsed.customerType
|
|
22
|
+
: undefined;
|
|
23
|
+
if (!serverUrl || !token || !email) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
serverUrl,
|
|
28
|
+
token,
|
|
29
|
+
email,
|
|
30
|
+
...(customerType ? { customerType } : {}),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function writeCliAuthConfig(config, env = process.env) {
|
|
34
|
+
const filePath = getAuthConfigPath(env);
|
|
35
|
+
mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
36
|
+
writeFileSync(filePath, `${JSON.stringify({
|
|
37
|
+
serverUrl: normalizeServerUrl(config.serverUrl),
|
|
38
|
+
token: config.token,
|
|
39
|
+
email: config.email,
|
|
40
|
+
...(config.customerType ? { customerType: config.customerType } : {}),
|
|
41
|
+
}, null, 2)}\n`, {
|
|
42
|
+
encoding: 'utf8',
|
|
43
|
+
mode: 0o600,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
export function clearCliAuthConfig(env = process.env) {
|
|
47
|
+
rmSync(getAuthConfigPath(env), { force: true });
|
|
48
|
+
}
|
|
49
|
+
export async function fetchWhoami({ config, fetchImpl = globalThis.fetch, }) {
|
|
50
|
+
const data = await fetchWhoamiResponse({ config, fetchImpl });
|
|
51
|
+
return data.customer;
|
|
52
|
+
}
|
|
53
|
+
export async function fetchWhoamiResponse({ config, fetchImpl = globalThis.fetch, }) {
|
|
54
|
+
const data = (await authorizedJson({
|
|
55
|
+
config,
|
|
56
|
+
path: '/v1/auth/whoami',
|
|
57
|
+
fetchImpl,
|
|
58
|
+
}));
|
|
59
|
+
if (!data?.customer?.email) {
|
|
60
|
+
throw new Error('Server returned an invalid whoami response.');
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
customer: data.customer,
|
|
64
|
+
debugUi: {
|
|
65
|
+
showSessionId: data.debugUi?.showSessionId === true,
|
|
66
|
+
},
|
|
67
|
+
...(data.usage ? { usage: data.usage } : {}),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export async function logoutFromServer({ config, fetchImpl = globalThis.fetch, }) {
|
|
71
|
+
const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/auth/logout`, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: {
|
|
74
|
+
authorization: `Bearer ${config.token}`,
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
if (!response.ok && response.status !== 401) {
|
|
78
|
+
const data = (await readJsonResponse(response));
|
|
79
|
+
throw new Error(failureMessage(data, response.status));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { openUrl } from '../core/open-url.js';
|
|
5
|
+
import { failureMessage, normalizeServerUrl, readJsonResponse, } from './http.js';
|
|
6
|
+
const DEFAULT_WEBSITE_URL = 'https://thegit.ai';
|
|
7
|
+
const DEFAULT_DEV_WEBSITE_URL = 'http://localhost:3002';
|
|
8
|
+
const DEFAULT_SERVER_URL = 'https://thegit.ai';
|
|
9
|
+
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
10
|
+
function shutDownServer(server) {
|
|
11
|
+
// Drop any lingering (keep-alive) connections so the event loop empties and
|
|
12
|
+
// the CLI exits instead of hanging after a successful login.
|
|
13
|
+
server.closeAllConnections?.();
|
|
14
|
+
server.close();
|
|
15
|
+
}
|
|
16
|
+
function isLocalhostUrl(url) {
|
|
17
|
+
if (!url)
|
|
18
|
+
return false;
|
|
19
|
+
try {
|
|
20
|
+
const host = new URL(url).hostname;
|
|
21
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function resolveWebsiteUrl(websiteUrl, env = process.env, serverUrl) {
|
|
28
|
+
const explicit = String(websiteUrl ?? '').trim() ||
|
|
29
|
+
String(env.THEGITAI_WEBSITE_URL ?? '').trim();
|
|
30
|
+
// With no explicit override, point at production — unless we're clearly in
|
|
31
|
+
// local dev (talking to a localhost server), in which case default to the
|
|
32
|
+
// local website so `ai login` works without any flags or env vars.
|
|
33
|
+
const value = explicit || (isLocalhostUrl(serverUrl) ? DEFAULT_DEV_WEBSITE_URL : DEFAULT_WEBSITE_URL);
|
|
34
|
+
const normalized = value.replace(/\/+$/, '');
|
|
35
|
+
if (!/^https?:\/\//i.test(normalized)) {
|
|
36
|
+
throw new Error('Website URL must start with http:// or https://.');
|
|
37
|
+
}
|
|
38
|
+
return normalized;
|
|
39
|
+
}
|
|
40
|
+
function defaultDeviceName() {
|
|
41
|
+
try {
|
|
42
|
+
return `${os.userInfo().username}@${os.hostname()}`;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return os.hostname();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** PKCE (RFC 7636, S256): a random verifier and its SHA-256 challenge. */
|
|
49
|
+
export function generatePkce() {
|
|
50
|
+
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
51
|
+
const challenge = crypto
|
|
52
|
+
.createHash('sha256')
|
|
53
|
+
.update(verifier, 'utf8')
|
|
54
|
+
.digest('base64url');
|
|
55
|
+
return { verifier, challenge };
|
|
56
|
+
}
|
|
57
|
+
function buildAuthUrl(websiteUrl, params) {
|
|
58
|
+
const url = new URL(`${websiteUrl}/cli-auth`);
|
|
59
|
+
url.searchParams.set('code_challenge', params.codeChallenge);
|
|
60
|
+
url.searchParams.set('device_name', params.deviceName);
|
|
61
|
+
if (params.redirectUri)
|
|
62
|
+
url.searchParams.set('redirect_uri', params.redirectUri);
|
|
63
|
+
if (params.state)
|
|
64
|
+
url.searchParams.set('state', params.state);
|
|
65
|
+
if (params.paste)
|
|
66
|
+
url.searchParams.set('mode', 'paste');
|
|
67
|
+
return url.toString();
|
|
68
|
+
}
|
|
69
|
+
const RESULT_PAGE = (heading, detail) => `<!doctype html><html><head><meta charset="utf-8"><title>TheGitAI CLI</title>` +
|
|
70
|
+
`<style>body{font-family:system-ui,sans-serif;background:#0c0d10;color:#e6e7ea;` +
|
|
71
|
+
`display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}` +
|
|
72
|
+
`.card{text-align:center;max-width:420px;padding:2rem}h1{font-size:1.4rem}` +
|
|
73
|
+
`p{color:#9aa0a6}</style></head><body><div class="card"><h1>${heading}</h1>` +
|
|
74
|
+
`<p>${detail}</p></div></body></html>`;
|
|
75
|
+
async function exchangeCodeForToken({ serverUrl, code, codeVerifier, fetchImpl, }) {
|
|
76
|
+
const response = await fetchImpl(`${serverUrl}/v1/cli/auth/token`, {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: { 'content-type': 'application/json' },
|
|
79
|
+
body: JSON.stringify({ code, code_verifier: codeVerifier }),
|
|
80
|
+
});
|
|
81
|
+
const data = (await readJsonResponse(response));
|
|
82
|
+
if (!response.ok) {
|
|
83
|
+
throw new Error(failureMessage(data, response.status));
|
|
84
|
+
}
|
|
85
|
+
const token = String(data?.token ?? '').trim();
|
|
86
|
+
const customer = data?.customer;
|
|
87
|
+
if (!token || !customer?.email) {
|
|
88
|
+
throw new Error('Server returned an invalid login response.');
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
serverUrl,
|
|
92
|
+
token,
|
|
93
|
+
email: customer.email,
|
|
94
|
+
customerType: customer.customer_type === 'ADMIN' ? 'ADMIN' : 'USER',
|
|
95
|
+
customer,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Browser-based login. Starts a loopback server so the website can redirect the
|
|
100
|
+
* one-time code back automatically; the code is then exchanged for a token
|
|
101
|
+
* using the PKCE verifier. With `noBrowser`, the user pastes the code instead.
|
|
102
|
+
* The CLI never sees the user's credentials.
|
|
103
|
+
*/
|
|
104
|
+
export async function loginViaBrowser(options) {
|
|
105
|
+
const serverUrl = normalizeServerUrl(options.serverUrl ?? DEFAULT_SERVER_URL);
|
|
106
|
+
const websiteUrl = resolveWebsiteUrl(options.websiteUrl, process.env, serverUrl);
|
|
107
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
108
|
+
const openBrowser = options.openBrowser ?? openUrl;
|
|
109
|
+
const onUrl = options.onUrl ?? (() => { });
|
|
110
|
+
const deviceName = options.deviceName ?? defaultDeviceName();
|
|
111
|
+
const { verifier, challenge } = generatePkce();
|
|
112
|
+
if (options.noBrowser) {
|
|
113
|
+
const authUrl = buildAuthUrl(websiteUrl, {
|
|
114
|
+
codeChallenge: challenge,
|
|
115
|
+
deviceName,
|
|
116
|
+
paste: true,
|
|
117
|
+
});
|
|
118
|
+
// Headless mode: only print the URL for the user to open on another device.
|
|
119
|
+
// Never launch a browser here — that is the whole point of --no-browser.
|
|
120
|
+
onUrl(authUrl);
|
|
121
|
+
if (!options.promptCode) {
|
|
122
|
+
throw new Error('No way to read the authorization code in this context.');
|
|
123
|
+
}
|
|
124
|
+
const code = (await options.promptCode()).trim();
|
|
125
|
+
if (!code) {
|
|
126
|
+
throw new Error('No authorization code was entered.');
|
|
127
|
+
}
|
|
128
|
+
return exchangeCodeForToken({ serverUrl, code, codeVerifier: verifier, fetchImpl });
|
|
129
|
+
}
|
|
130
|
+
const state = crypto.randomBytes(16).toString('base64url');
|
|
131
|
+
const server = http.createServer();
|
|
132
|
+
const codePromise = new Promise((resolve, reject) => {
|
|
133
|
+
const timer = setTimeout(() => {
|
|
134
|
+
shutDownServer(server);
|
|
135
|
+
reject(new Error('Timed out waiting for the browser login to complete.'));
|
|
136
|
+
}, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
137
|
+
server.on('request', (req, res) => {
|
|
138
|
+
const requestUrl = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
139
|
+
if (requestUrl.pathname !== '/callback') {
|
|
140
|
+
res.writeHead(404, { 'content-type': 'text/plain' });
|
|
141
|
+
res.end('Not found');
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const code = requestUrl.searchParams.get('code') ?? '';
|
|
145
|
+
const returnedState = requestUrl.searchParams.get('state') ?? '';
|
|
146
|
+
// `Connection: close` plus closeAllConnections() ensures the browser's
|
|
147
|
+
// keep-alive socket is torn down so the process can exit after login.
|
|
148
|
+
if (!code || returnedState !== state) {
|
|
149
|
+
res.writeHead(400, { 'content-type': 'text/html', connection: 'close' });
|
|
150
|
+
res.end(RESULT_PAGE('Login failed', 'The request could not be verified. Please run ai login again.'));
|
|
151
|
+
clearTimeout(timer);
|
|
152
|
+
shutDownServer(server);
|
|
153
|
+
reject(new Error('The login callback could not be verified.'));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
res.writeHead(200, { 'content-type': 'text/html', connection: 'close' });
|
|
157
|
+
res.end(RESULT_PAGE('You are signed in', 'You can close this tab and return to your terminal.'));
|
|
158
|
+
clearTimeout(timer);
|
|
159
|
+
shutDownServer(server);
|
|
160
|
+
resolve(code);
|
|
161
|
+
});
|
|
162
|
+
server.on('error', (err) => {
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
reject(err);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
await new Promise((resolve, reject) => {
|
|
168
|
+
server.once('error', reject);
|
|
169
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
170
|
+
});
|
|
171
|
+
const port = server.address().port;
|
|
172
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
173
|
+
const authUrl = buildAuthUrl(websiteUrl, {
|
|
174
|
+
codeChallenge: challenge,
|
|
175
|
+
deviceName,
|
|
176
|
+
redirectUri,
|
|
177
|
+
state,
|
|
178
|
+
});
|
|
179
|
+
onUrl(authUrl);
|
|
180
|
+
await openBrowser(authUrl).catch(() => false);
|
|
181
|
+
options.onWaiting?.();
|
|
182
|
+
const code = await codePromise;
|
|
183
|
+
return exchangeCodeForToken({ serverUrl, code, codeVerifier: verifier, fetchImpl });
|
|
184
|
+
}
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { createPromptCheckpoint, sanitizeSessionSafetyForServer, } from '../session-safety.js';
|
|
2
|
+
import { applySessionSnapshot, snapshotFromSession, } from '../session-store.js';
|
|
3
|
+
import { executeLocalToolCall } from '../tool-executor.js';
|
|
4
|
+
import { normalizeServerUrl, readErrorResponse, } from './http.js';
|
|
5
|
+
export class TurnCancelledError extends Error {
|
|
6
|
+
name = 'TurnCancelledError';
|
|
7
|
+
constructor(message = 'Turn cancelled.') {
|
|
8
|
+
super(message);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export class ChatTurnFailedError extends Error {
|
|
12
|
+
name = 'ChatTurnFailedError';
|
|
13
|
+
category;
|
|
14
|
+
retryable;
|
|
15
|
+
constructor(message, category = 'unknown_error', retryable = false) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.category = category;
|
|
18
|
+
this.retryable = retryable;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function isTurnCancelledError(error) {
|
|
22
|
+
return (error instanceof TurnCancelledError ||
|
|
23
|
+
(error instanceof Error &&
|
|
24
|
+
(error.name === 'AbortError' || error.name === 'TurnCancelledError')));
|
|
25
|
+
}
|
|
26
|
+
function parseSseBlock(block) {
|
|
27
|
+
let event = 'message';
|
|
28
|
+
const dataLines = [];
|
|
29
|
+
for (const line of block.split(/\r?\n/)) {
|
|
30
|
+
if (line.startsWith('event:')) {
|
|
31
|
+
event = line.slice('event:'.length).trim() || event;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (line.startsWith('data:')) {
|
|
35
|
+
dataLines.push(line.slice('data:'.length).trimStart());
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (!dataLines.length)
|
|
39
|
+
return null;
|
|
40
|
+
const text = dataLines.join('\n');
|
|
41
|
+
try {
|
|
42
|
+
return { event, data: JSON.parse(text) };
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return { event, data: text };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function toolStateFromSession(session) {
|
|
49
|
+
return {
|
|
50
|
+
autoYes: session.autoYes,
|
|
51
|
+
agentMode: session.agentMode,
|
|
52
|
+
editCounter: session.clientState.editCounter,
|
|
53
|
+
editJournal: session.clientState.editJournal,
|
|
54
|
+
stickyFilePaths: Array.from(session.clientState.stickyFilePaths),
|
|
55
|
+
safety: sanitizeSessionSafetyForServer(session.clientState.safety),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function snapshotForServer(session) {
|
|
59
|
+
const snapshot = snapshotFromSession(session);
|
|
60
|
+
snapshot.clientState.safety = sanitizeSessionSafetyForServer(snapshot.clientState.safety);
|
|
61
|
+
return snapshot;
|
|
62
|
+
}
|
|
63
|
+
function userHistoryText(entry) {
|
|
64
|
+
return (entry.parts ?? [])
|
|
65
|
+
.map((part) => (typeof part?.text === 'string' ? part.text : ''))
|
|
66
|
+
.filter(Boolean)
|
|
67
|
+
.join('\n')
|
|
68
|
+
.trim();
|
|
69
|
+
}
|
|
70
|
+
export function preserveCancelledTurnInput(session, input) {
|
|
71
|
+
const text = input.trim();
|
|
72
|
+
if (!text)
|
|
73
|
+
return;
|
|
74
|
+
for (let i = session.history.length - 1; i >= 0; i--) {
|
|
75
|
+
const entry = session.history[i];
|
|
76
|
+
if (!entry || entry.role !== 'user')
|
|
77
|
+
continue;
|
|
78
|
+
const prior = userHistoryText(entry);
|
|
79
|
+
if (!prior)
|
|
80
|
+
continue;
|
|
81
|
+
if (prior === text || prior.includes(text))
|
|
82
|
+
return;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
session.history.push({ role: 'user', parts: [{ text }], kind: 'turnStart' });
|
|
86
|
+
}
|
|
87
|
+
function preserveFailedTurnInput(session, input, category) {
|
|
88
|
+
const text = input.trim();
|
|
89
|
+
if (!text)
|
|
90
|
+
return;
|
|
91
|
+
session.history.push({ role: 'user', parts: [{ text }], kind: 'turnStart' });
|
|
92
|
+
session.history.push({
|
|
93
|
+
role: 'model',
|
|
94
|
+
parts: [{ text: `Turn failed before completion: ${category}.` }],
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
function historyHasToolCall(session, callId) {
|
|
98
|
+
return session.history.some((entry) => (entry.parts ?? []).some((part) => String(part?.functionCall?.id ?? '') === callId));
|
|
99
|
+
}
|
|
100
|
+
function preserveCancelledTurnToolResult(session, input, event, result) {
|
|
101
|
+
const callId = String(event.call.id ?? '').trim();
|
|
102
|
+
if (!callId || historyHasToolCall(session, callId))
|
|
103
|
+
return;
|
|
104
|
+
preserveCancelledTurnInput(session, input);
|
|
105
|
+
session.history.push({
|
|
106
|
+
role: 'model',
|
|
107
|
+
parts: [{ functionCall: event.call }],
|
|
108
|
+
});
|
|
109
|
+
session.history.push({
|
|
110
|
+
role: 'user',
|
|
111
|
+
parts: [
|
|
112
|
+
{
|
|
113
|
+
functionResponse: {
|
|
114
|
+
id: callId,
|
|
115
|
+
name: event.call.name,
|
|
116
|
+
response: result,
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
function publicStatusMessage(data) {
|
|
123
|
+
if (!data || typeof data !== 'object')
|
|
124
|
+
return null;
|
|
125
|
+
const event = data;
|
|
126
|
+
if (typeof event.publicText === 'string' &&
|
|
127
|
+
event.publicText.trim() &&
|
|
128
|
+
event.publicText.length <= 400) {
|
|
129
|
+
return event.publicText.trim();
|
|
130
|
+
}
|
|
131
|
+
const toolName = typeof event.toolName === 'string' && /^[a-z0-9_:-]+$/i.test(event.toolName)
|
|
132
|
+
? event.toolName
|
|
133
|
+
: 'tool';
|
|
134
|
+
if (event.phase === 'thinking')
|
|
135
|
+
return 'Thinking...';
|
|
136
|
+
if (event.phase === 'running_tool')
|
|
137
|
+
return `Running ${toolName}...`;
|
|
138
|
+
if (event.phase === 'waiting_for_tool')
|
|
139
|
+
return `Running ${toolName} locally...`;
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
async function postToolResult({ config, turnId, event, result, session, fetchImpl, }) {
|
|
143
|
+
const payload = {
|
|
144
|
+
toolCallId: event.call.id,
|
|
145
|
+
result,
|
|
146
|
+
toolState: toolStateFromSession(session),
|
|
147
|
+
};
|
|
148
|
+
const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/chat/turn/${encodeURIComponent(turnId)}/tool-result`, {
|
|
149
|
+
method: 'POST',
|
|
150
|
+
headers: {
|
|
151
|
+
authorization: `Bearer ${config.token}`,
|
|
152
|
+
'content-type': 'application/json',
|
|
153
|
+
},
|
|
154
|
+
body: JSON.stringify(payload),
|
|
155
|
+
});
|
|
156
|
+
if (response.status === 410) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (!response.ok) {
|
|
160
|
+
throw await readErrorResponse(response);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
async function executeAndPostToolResult({ config, projectIndex, session, event, input, fetchImpl, signal, }) {
|
|
164
|
+
const turnId = String(event?.turnId ?? '').trim();
|
|
165
|
+
if (!turnId || !event?.call?.id || !event.call.name) {
|
|
166
|
+
throw new Error('Server emitted an invalid tool-call event.');
|
|
167
|
+
}
|
|
168
|
+
if (signal?.aborted) {
|
|
169
|
+
throw new TurnCancelledError();
|
|
170
|
+
}
|
|
171
|
+
const previousTurnId = session.turnState.id;
|
|
172
|
+
const serverSessionTurnId = String(event.sessionTurnId ?? '').trim();
|
|
173
|
+
if (serverSessionTurnId) {
|
|
174
|
+
session.turnState.id = serverSessionTurnId;
|
|
175
|
+
if (!session.clientState.safety.checkpoints.some((checkpoint) => checkpoint.turnId === serverSessionTurnId)) {
|
|
176
|
+
createPromptCheckpoint(session.clientState.safety, 'prompt boundary', serverSessionTurnId);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
const rawResult = await executeLocalToolCall({ projectIndex }, session, event.call);
|
|
181
|
+
preserveCancelledTurnToolResult(session, input, event, rawResult);
|
|
182
|
+
if (signal?.aborted) {
|
|
183
|
+
throw new TurnCancelledError();
|
|
184
|
+
}
|
|
185
|
+
await postToolResult({
|
|
186
|
+
config,
|
|
187
|
+
turnId,
|
|
188
|
+
event,
|
|
189
|
+
result: rawResult,
|
|
190
|
+
session,
|
|
191
|
+
fetchImpl,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
session.turnState.id = previousTurnId;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async function consumeTurnStream({ response, config, projectIndex, session, input, fetchImpl, signal, }) {
|
|
199
|
+
if (!response.body) {
|
|
200
|
+
throw new Error('Server returned an empty chat stream.');
|
|
201
|
+
}
|
|
202
|
+
const decoder = new TextDecoder();
|
|
203
|
+
const reader = response.body.getReader();
|
|
204
|
+
let buffer = '';
|
|
205
|
+
const finalResult = {
|
|
206
|
+
current: null,
|
|
207
|
+
};
|
|
208
|
+
async function handleEvent(event) {
|
|
209
|
+
if (event.event === 'status') {
|
|
210
|
+
const message = publicStatusMessage(event.data);
|
|
211
|
+
if (message)
|
|
212
|
+
session.onStatus(message);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (event.event === 'context') {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (event.event === 'tool-call') {
|
|
219
|
+
await executeAndPostToolResult({
|
|
220
|
+
config,
|
|
221
|
+
projectIndex,
|
|
222
|
+
session,
|
|
223
|
+
event: event.data,
|
|
224
|
+
input,
|
|
225
|
+
fetchImpl,
|
|
226
|
+
signal,
|
|
227
|
+
});
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (event.event === 'tool-result') {
|
|
231
|
+
const data = event.data;
|
|
232
|
+
if (data?.call?.name) {
|
|
233
|
+
session.onToolEvent?.({ call: data.call, result: data.result });
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (event.event === 'result') {
|
|
238
|
+
finalResult.current = event.data;
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (event.event === 'cancelled' || event.event === 'error') {
|
|
242
|
+
const message = String(event.data?.message ?? 'Server chat failed.');
|
|
243
|
+
if (event.event === 'cancelled') {
|
|
244
|
+
throw new TurnCancelledError(message);
|
|
245
|
+
}
|
|
246
|
+
throw new ChatTurnFailedError(message, typeof event.data?.category === 'string' ? event.data.category : 'unknown_error', Boolean(event.data?.retryable));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
while (true) {
|
|
250
|
+
if (signal?.aborted) {
|
|
251
|
+
await reader.cancel().catch(() => { });
|
|
252
|
+
throw new TurnCancelledError();
|
|
253
|
+
}
|
|
254
|
+
const read = await reader.read();
|
|
255
|
+
if (read.done)
|
|
256
|
+
break;
|
|
257
|
+
buffer += decoder.decode(read.value, { stream: true });
|
|
258
|
+
let separatorIndex = buffer.indexOf('\n\n');
|
|
259
|
+
while (separatorIndex !== -1) {
|
|
260
|
+
const block = buffer.slice(0, separatorIndex);
|
|
261
|
+
buffer = buffer.slice(separatorIndex + 2);
|
|
262
|
+
const event = parseSseBlock(block);
|
|
263
|
+
if (event)
|
|
264
|
+
await handleEvent(event);
|
|
265
|
+
separatorIndex = buffer.indexOf('\n\n');
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
buffer += decoder.decode();
|
|
269
|
+
const tail = buffer.trim();
|
|
270
|
+
if (tail) {
|
|
271
|
+
const event = parseSseBlock(tail);
|
|
272
|
+
if (event)
|
|
273
|
+
await handleEvent(event);
|
|
274
|
+
}
|
|
275
|
+
if (!finalResult.current?.snapshot) {
|
|
276
|
+
throw new Error('Server returned an invalid chat result.');
|
|
277
|
+
}
|
|
278
|
+
return finalResult.current;
|
|
279
|
+
}
|
|
280
|
+
export async function sendServerUserMessage({ config, projectIndex, session, input, imageAttachments = [], fetchImpl = globalThis.fetch, signal, }) {
|
|
281
|
+
const request = {
|
|
282
|
+
modelId: session.modelId,
|
|
283
|
+
session: snapshotForServer(session),
|
|
284
|
+
input,
|
|
285
|
+
imageAttachments,
|
|
286
|
+
maxToolSteps: session.maxToolSteps,
|
|
287
|
+
autoYes: session.autoYes,
|
|
288
|
+
agentMode: session.agentMode,
|
|
289
|
+
};
|
|
290
|
+
const preTurnHistoryLength = session.history.length;
|
|
291
|
+
const preserveOnAbort = () => preserveCancelledTurnInput(session, input);
|
|
292
|
+
if (signal?.aborted) {
|
|
293
|
+
preserveOnAbort();
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
signal?.addEventListener('abort', preserveOnAbort, { once: true });
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/chat/turn`, {
|
|
300
|
+
method: 'POST',
|
|
301
|
+
headers: {
|
|
302
|
+
accept: 'text/event-stream',
|
|
303
|
+
authorization: `Bearer ${config.token}`,
|
|
304
|
+
'content-type': 'application/json',
|
|
305
|
+
},
|
|
306
|
+
body: JSON.stringify(request),
|
|
307
|
+
signal,
|
|
308
|
+
});
|
|
309
|
+
if (!response.ok) {
|
|
310
|
+
throw await readErrorResponse(response);
|
|
311
|
+
}
|
|
312
|
+
const result = await consumeTurnStream({
|
|
313
|
+
response,
|
|
314
|
+
config,
|
|
315
|
+
projectIndex,
|
|
316
|
+
session,
|
|
317
|
+
input,
|
|
318
|
+
fetchImpl,
|
|
319
|
+
signal,
|
|
320
|
+
});
|
|
321
|
+
applySessionSnapshot(session, result.snapshot, { preserveAgentMode: true });
|
|
322
|
+
return {
|
|
323
|
+
text: result.text,
|
|
324
|
+
waitingForApproval: result.waitingForApproval,
|
|
325
|
+
toolBudgetReached: result.toolBudgetReached,
|
|
326
|
+
usageSummary: result.usageSummary,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
catch (error) {
|
|
330
|
+
if (isTurnCancelledError(error)) {
|
|
331
|
+
preserveCancelledTurnInput(session, input);
|
|
332
|
+
throw error instanceof TurnCancelledError
|
|
333
|
+
? error
|
|
334
|
+
: new TurnCancelledError();
|
|
335
|
+
}
|
|
336
|
+
// Non-cancel failures (e.g. upstream connection errors) must not leave
|
|
337
|
+
// speculative cancelled-turn entries in history — otherwise the next
|
|
338
|
+
// request replays a malformed transcript to the server.
|
|
339
|
+
session.history.length = preTurnHistoryLength;
|
|
340
|
+
preserveFailedTurnInput(session, input, error instanceof ChatTurnFailedError ? error.category : 'unknown_error');
|
|
341
|
+
throw error;
|
|
342
|
+
}
|
|
343
|
+
finally {
|
|
344
|
+
signal?.removeEventListener('abort', preserveOnAbort);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const DEFAULT_SERVER_URL = 'https://thegit.ai';
|
|
2
|
+
export function normalizeServerUrl(serverUrl) {
|
|
3
|
+
const normalized = String(serverUrl || DEFAULT_SERVER_URL)
|
|
4
|
+
.trim()
|
|
5
|
+
.replace(/\/+$/, '');
|
|
6
|
+
if (!/^https?:\/\//i.test(normalized)) {
|
|
7
|
+
throw new Error('Server URL must start with http:// or https://.');
|
|
8
|
+
}
|
|
9
|
+
return normalized;
|
|
10
|
+
}
|
|
11
|
+
export async function readJsonResponse(response) {
|
|
12
|
+
const text = await response.text();
|
|
13
|
+
if (!text.trim())
|
|
14
|
+
return null;
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(text);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return { error: { message: text } };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function failureMessage(data, status) {
|
|
23
|
+
return String(data?.error?.message ?? data?.message ?? `Request failed with ${status}`);
|
|
24
|
+
}
|
|
25
|
+
export async function readErrorResponse(response) {
|
|
26
|
+
const data = await readJsonResponse(response);
|
|
27
|
+
return new Error(failureMessage(data, response.status));
|
|
28
|
+
}
|
|
29
|
+
export async function authorizedJson({ config, path, method = 'GET', body = null, headers = {}, fetchImpl = globalThis.fetch, }) {
|
|
30
|
+
const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}${path}`, {
|
|
31
|
+
method,
|
|
32
|
+
headers: {
|
|
33
|
+
authorization: `Bearer ${config.token}`,
|
|
34
|
+
...headers,
|
|
35
|
+
...(body === null ? {} : { 'content-type': 'application/json' }),
|
|
36
|
+
},
|
|
37
|
+
body: body === null ? undefined : JSON.stringify(body),
|
|
38
|
+
});
|
|
39
|
+
const data = await readJsonResponse(response);
|
|
40
|
+
if (!response.ok) {
|
|
41
|
+
throw new Error(failureMessage(data, response.status));
|
|
42
|
+
}
|
|
43
|
+
return data;
|
|
44
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import * as auth from './auth.js';
|
|
2
|
+
import * as chat from './chat.js';
|
|
3
|
+
import * as models from './models.js';
|
|
4
|
+
import * as sessions from './sessions.js';
|
|
5
|
+
export const ServerApi = {
|
|
6
|
+
auth,
|
|
7
|
+
chat,
|
|
8
|
+
models,
|
|
9
|
+
sessions,
|
|
10
|
+
};
|
|
11
|
+
export { auth, chat, models, sessions, };
|