gitxplain 0.1.8 → 0.1.9
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 +190 -4
- package/cli/index.js +430 -117
- package/cli/services/aiService.js +234 -28
- package/cli/services/cacheService.js +92 -1
- package/cli/services/clipboardService.js +6 -1
- package/cli/services/colorSupport.js +31 -0
- package/cli/services/commitService.js +105 -23
- package/cli/services/configService.js +18 -2
- package/cli/services/envLoader.js +2 -2
- package/cli/services/gitService.js +369 -23
- package/cli/services/hookService.js +36 -4
- package/cli/services/mergeService.js +33 -27
- package/cli/services/outputFormatter.js +23 -73
- package/cli/services/pipelineService.js +112 -0
- package/cli/services/promptService.js +8 -1
- package/cli/services/splitService.js +1 -21
- package/cli/services/usageService.js +158 -0
- package/package.json +2 -2
- package/prompts/blame.txt +29 -0
- package/prompts/changelog.txt +36 -0
- package/prompts/conflict.txt +33 -0
- package/prompts/pr-description.txt +40 -0
- package/prompts/refactor.txt +29 -0
- package/prompts/stash.txt +34 -0
- package/prompts/test-suggest.txt +29 -0
- package/IMPLEMENTATION.md +0 -225
- package/cli/services/chatService.js +0 -683
- package/cli/services/gitConnectionService.js +0 -267
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import process from "node:process";
|
|
2
2
|
import { createCacheKey, readCache, writeCache } from "./cacheService.js";
|
|
3
3
|
import { buildPrompt } from "./promptService.js";
|
|
4
|
+
import { appendUsageRecord, estimateCostUsd, resolvePricing } from "./usageService.js";
|
|
4
5
|
|
|
5
6
|
const SUPPORTED_PROVIDERS = new Set([
|
|
6
7
|
"openai",
|
|
@@ -8,9 +9,15 @@ const SUPPORTED_PROVIDERS = new Set([
|
|
|
8
9
|
"openrouter",
|
|
9
10
|
"gemini",
|
|
10
11
|
"ollama",
|
|
11
|
-
"chutes"
|
|
12
|
+
"chutes",
|
|
13
|
+
"anthropic",
|
|
14
|
+
"mistral",
|
|
15
|
+
"azure-openai"
|
|
12
16
|
]);
|
|
13
17
|
const SYSTEM_PROMPT = "You explain Git commits clearly and accurately for developers.";
|
|
18
|
+
const REQUEST_TIMEOUT_MS = 30000;
|
|
19
|
+
const REQUEST_RETRIES = 2;
|
|
20
|
+
const RETRYABLE_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
14
21
|
|
|
15
22
|
export function getProviderConfig(providerOverride, modelOverride) {
|
|
16
23
|
const provider = (providerOverride ?? process.env.LLM_PROVIDER ?? "openai").toLowerCase();
|
|
@@ -71,6 +78,36 @@ export function getProviderConfig(providerOverride, modelOverride) {
|
|
|
71
78
|
};
|
|
72
79
|
}
|
|
73
80
|
|
|
81
|
+
if (provider === "anthropic") {
|
|
82
|
+
return {
|
|
83
|
+
provider,
|
|
84
|
+
apiKey: process.env.ANTHROPIC_API_KEY,
|
|
85
|
+
baseUrl: process.env.ANTHROPIC_BASE_URL ?? "https://api.anthropic.com/v1",
|
|
86
|
+
model: modelOverride ?? process.env.ANTHROPIC_MODEL ?? process.env.LLM_MODEL ?? "claude-3-5-haiku-latest"
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (provider === "mistral") {
|
|
91
|
+
return {
|
|
92
|
+
provider,
|
|
93
|
+
apiKey: process.env.MISTRAL_API_KEY,
|
|
94
|
+
baseUrl: process.env.MISTRAL_BASE_URL ?? "https://api.mistral.ai/v1",
|
|
95
|
+
model: modelOverride ?? process.env.MISTRAL_MODEL ?? process.env.LLM_MODEL ?? "mistral-small-latest"
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (provider === "azure-openai") {
|
|
100
|
+
const deployment = process.env.AZURE_OPENAI_DEPLOYMENT ?? modelOverride ?? process.env.AZURE_OPENAI_MODEL ?? process.env.LLM_MODEL;
|
|
101
|
+
return {
|
|
102
|
+
provider,
|
|
103
|
+
apiKey: process.env.AZURE_OPENAI_API_KEY,
|
|
104
|
+
baseUrl: process.env.AZURE_OPENAI_BASE_URL,
|
|
105
|
+
model: process.env.AZURE_OPENAI_MODEL ?? deployment,
|
|
106
|
+
deployment,
|
|
107
|
+
apiVersion: process.env.AZURE_OPENAI_API_VERSION ?? "2024-10-21"
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
74
111
|
return {
|
|
75
112
|
provider,
|
|
76
113
|
apiKey: process.env.OLLAMA_API_KEY ?? "ollama",
|
|
@@ -84,16 +121,32 @@ export function validateProviderConfig(config) {
|
|
|
84
121
|
throw new Error(`No model configured for provider "${config.provider}".`);
|
|
85
122
|
}
|
|
86
123
|
|
|
124
|
+
if (config.provider === "azure-openai") {
|
|
125
|
+
if (!config.baseUrl) {
|
|
126
|
+
throw new Error('Missing base URL for provider "azure-openai". Set AZURE_OPENAI_BASE_URL.');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!config.deployment) {
|
|
130
|
+
throw new Error('Missing deployment for provider "azure-openai". Set AZURE_OPENAI_DEPLOYMENT.');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
87
134
|
if (config.provider !== "ollama" && !config.apiKey) {
|
|
88
135
|
throw new Error(`Missing API key for provider "${config.provider}".`);
|
|
89
136
|
}
|
|
90
137
|
}
|
|
91
138
|
|
|
92
139
|
function buildOpenAICompatibleHeaders(config) {
|
|
93
|
-
const headers =
|
|
94
|
-
"
|
|
95
|
-
|
|
96
|
-
|
|
140
|
+
const headers =
|
|
141
|
+
config.provider === "azure-openai"
|
|
142
|
+
? {
|
|
143
|
+
"Content-Type": "application/json",
|
|
144
|
+
"api-key": config.apiKey
|
|
145
|
+
}
|
|
146
|
+
: {
|
|
147
|
+
"Content-Type": "application/json",
|
|
148
|
+
Authorization: `Bearer ${config.apiKey}`
|
|
149
|
+
};
|
|
97
150
|
|
|
98
151
|
if (config.provider === "openrouter") {
|
|
99
152
|
headers["HTTP-Referer"] = process.env.OPENROUTER_SITE_URL ?? "https://github.com";
|
|
@@ -119,6 +172,61 @@ function extractGeminiText(data) {
|
|
|
119
172
|
.join("\n");
|
|
120
173
|
}
|
|
121
174
|
|
|
175
|
+
function extractAnthropicContent(data) {
|
|
176
|
+
return (data.content ?? [])
|
|
177
|
+
.filter((item) => item?.type === "text" && typeof item.text === "string")
|
|
178
|
+
.map((item) => item.text)
|
|
179
|
+
.join("\n")
|
|
180
|
+
.trim();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function sleep(ms) {
|
|
184
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function isRetryableError(error) {
|
|
188
|
+
return error?.name === "AbortError" || error?.cause?.code === "UND_ERR_CONNECT_TIMEOUT";
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function fetchWithRetry(url, init, options = {}) {
|
|
192
|
+
const retries = options.retries ?? REQUEST_RETRIES;
|
|
193
|
+
const timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
194
|
+
|
|
195
|
+
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
196
|
+
const controller = new AbortController();
|
|
197
|
+
const timeout = setTimeout(() => controller.abort(new Error("Request timed out")), timeoutMs);
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const response = await fetch(url, {
|
|
201
|
+
...init,
|
|
202
|
+
signal: controller.signal
|
|
203
|
+
});
|
|
204
|
+
clearTimeout(timeout);
|
|
205
|
+
|
|
206
|
+
if (RETRYABLE_STATUS_CODES.has(response.status) && attempt < retries) {
|
|
207
|
+
await sleep(250 * (attempt + 1));
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return response;
|
|
212
|
+
} catch (error) {
|
|
213
|
+
clearTimeout(timeout);
|
|
214
|
+
|
|
215
|
+
if (attempt >= retries || !isRetryableError(error)) {
|
|
216
|
+
if (error?.name === "AbortError") {
|
|
217
|
+
throw new Error(`Request timed out after ${timeoutMs}ms.`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
await sleep(250 * (attempt + 1));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
throw new Error("Request failed after retries.");
|
|
228
|
+
}
|
|
229
|
+
|
|
122
230
|
async function consumeSseStream(response, getChunkText, onChunk) {
|
|
123
231
|
const reader = response.body?.getReader();
|
|
124
232
|
if (!reader) {
|
|
@@ -151,7 +259,13 @@ async function consumeSseStream(response, getChunkText, onChunk) {
|
|
|
151
259
|
continue;
|
|
152
260
|
}
|
|
153
261
|
|
|
154
|
-
|
|
262
|
+
let parsed;
|
|
263
|
+
try {
|
|
264
|
+
parsed = JSON.parse(line);
|
|
265
|
+
} catch {
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
|
|
155
269
|
const chunkText = getChunkText(parsed);
|
|
156
270
|
if (!chunkText) {
|
|
157
271
|
continue;
|
|
@@ -168,21 +282,102 @@ async function consumeSseStream(response, getChunkText, onChunk) {
|
|
|
168
282
|
|
|
169
283
|
async function requestOpenAICompatible(config, prompt, options) {
|
|
170
284
|
const startedAt = Date.now();
|
|
171
|
-
const
|
|
285
|
+
const endpoint =
|
|
286
|
+
config.provider === "azure-openai"
|
|
287
|
+
? `${config.baseUrl}/openai/deployments/${encodeURIComponent(config.deployment)}/chat/completions?api-version=${encodeURIComponent(config.apiVersion)}`
|
|
288
|
+
: `${config.baseUrl}/chat/completions`;
|
|
289
|
+
const body = {
|
|
290
|
+
messages: [
|
|
291
|
+
{
|
|
292
|
+
role: "system",
|
|
293
|
+
content: SYSTEM_PROMPT
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
role: "user",
|
|
297
|
+
content: prompt
|
|
298
|
+
}
|
|
299
|
+
],
|
|
300
|
+
temperature: 0.2,
|
|
301
|
+
stream: options.stream === true
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
if (config.provider !== "azure-openai") {
|
|
305
|
+
body.model = config.model;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const response = await fetchWithRetry(endpoint, {
|
|
172
309
|
method: "POST",
|
|
173
310
|
headers: buildOpenAICompatibleHeaders(config),
|
|
311
|
+
body: JSON.stringify(body)
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
if (!response.ok) {
|
|
315
|
+
const errorText = await response.text();
|
|
316
|
+
throw new Error(`${config.provider} request failed (${response.status}): ${errorText}`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (options.stream) {
|
|
320
|
+
const explanation = await consumeSseStream(
|
|
321
|
+
response,
|
|
322
|
+
(data) => {
|
|
323
|
+
const content = data.choices?.[0]?.delta?.content;
|
|
324
|
+
if (typeof content === "string") {
|
|
325
|
+
return content;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (Array.isArray(content)) {
|
|
329
|
+
return content.map((item) => item.text ?? "").join("");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return "";
|
|
333
|
+
},
|
|
334
|
+
options.onChunk
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
explanation,
|
|
339
|
+
responseMeta: {
|
|
340
|
+
provider: config.provider,
|
|
341
|
+
model: config.model,
|
|
342
|
+
cacheHit: false,
|
|
343
|
+
latencyMs: Date.now() - startedAt,
|
|
344
|
+
usage: null
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const data = await response.json();
|
|
350
|
+
return {
|
|
351
|
+
explanation: extractOpenAIContent(data),
|
|
352
|
+
responseMeta: {
|
|
353
|
+
provider: config.provider,
|
|
354
|
+
model: config.model,
|
|
355
|
+
cacheHit: false,
|
|
356
|
+
latencyMs: Date.now() - startedAt,
|
|
357
|
+
usage: extractUsage(data)
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function requestAnthropic(config, prompt, options) {
|
|
363
|
+
const startedAt = Date.now();
|
|
364
|
+
const response = await fetchWithRetry(`${config.baseUrl}/messages`, {
|
|
365
|
+
method: "POST",
|
|
366
|
+
headers: {
|
|
367
|
+
"Content-Type": "application/json",
|
|
368
|
+
"x-api-key": config.apiKey,
|
|
369
|
+
"anthropic-version": "2023-06-01"
|
|
370
|
+
},
|
|
174
371
|
body: JSON.stringify({
|
|
175
372
|
model: config.model,
|
|
373
|
+
system: SYSTEM_PROMPT,
|
|
176
374
|
messages: [
|
|
177
|
-
{
|
|
178
|
-
role: "system",
|
|
179
|
-
content: SYSTEM_PROMPT
|
|
180
|
-
},
|
|
181
375
|
{
|
|
182
376
|
role: "user",
|
|
183
377
|
content: prompt
|
|
184
378
|
}
|
|
185
379
|
],
|
|
380
|
+
max_tokens: 2048,
|
|
186
381
|
temperature: 0.2,
|
|
187
382
|
stream: options.stream === true
|
|
188
383
|
})
|
|
@@ -190,20 +385,15 @@ async function requestOpenAICompatible(config, prompt, options) {
|
|
|
190
385
|
|
|
191
386
|
if (!response.ok) {
|
|
192
387
|
const errorText = await response.text();
|
|
193
|
-
throw new Error(
|
|
388
|
+
throw new Error(`anthropic request failed (${response.status}): ${errorText}`);
|
|
194
389
|
}
|
|
195
390
|
|
|
196
391
|
if (options.stream) {
|
|
197
392
|
const explanation = await consumeSseStream(
|
|
198
393
|
response,
|
|
199
394
|
(data) => {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
return content;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
if (Array.isArray(content)) {
|
|
206
|
-
return content.map((item) => item.text ?? "").join("");
|
|
395
|
+
if (data.type === "content_block_delta") {
|
|
396
|
+
return data.delta?.text ?? "";
|
|
207
397
|
}
|
|
208
398
|
|
|
209
399
|
return "";
|
|
@@ -225,13 +415,13 @@ async function requestOpenAICompatible(config, prompt, options) {
|
|
|
225
415
|
|
|
226
416
|
const data = await response.json();
|
|
227
417
|
return {
|
|
228
|
-
explanation:
|
|
418
|
+
explanation: extractAnthropicContent(data) || "No explanation returned by the model.",
|
|
229
419
|
responseMeta: {
|
|
230
420
|
provider: config.provider,
|
|
231
421
|
model: config.model,
|
|
232
422
|
cacheHit: false,
|
|
233
423
|
latencyMs: Date.now() - startedAt,
|
|
234
|
-
usage:
|
|
424
|
+
usage: data.usage ?? null
|
|
235
425
|
}
|
|
236
426
|
};
|
|
237
427
|
}
|
|
@@ -242,7 +432,7 @@ async function requestGemini(config, prompt, options) {
|
|
|
242
432
|
? `${config.baseUrl}/models/${config.model}:streamGenerateContent?alt=sse&key=${encodeURIComponent(config.apiKey)}`
|
|
243
433
|
: `${config.baseUrl}/models/${config.model}:generateContent?key=${encodeURIComponent(config.apiKey)}`;
|
|
244
434
|
|
|
245
|
-
const response = await
|
|
435
|
+
const response = await fetchWithRetry(endpoint, {
|
|
246
436
|
method: "POST",
|
|
247
437
|
headers: {
|
|
248
438
|
"Content-Type": "application/json"
|
|
@@ -309,6 +499,7 @@ export async function generateExplanation({
|
|
|
309
499
|
providerOverride,
|
|
310
500
|
modelOverride,
|
|
311
501
|
maxDiffLines,
|
|
502
|
+
noCache = false,
|
|
312
503
|
stream = false,
|
|
313
504
|
onChunk = null,
|
|
314
505
|
onStart = null
|
|
@@ -330,7 +521,7 @@ export async function generateExplanation({
|
|
|
330
521
|
model: config.model,
|
|
331
522
|
prompt
|
|
332
523
|
});
|
|
333
|
-
const cached = readCache(cacheKey);
|
|
524
|
+
const cached = noCache ? null : readCache(cacheKey);
|
|
334
525
|
|
|
335
526
|
if (cached) {
|
|
336
527
|
return {
|
|
@@ -347,13 +538,28 @@ export async function generateExplanation({
|
|
|
347
538
|
const result =
|
|
348
539
|
config.provider === "gemini"
|
|
349
540
|
? await requestGemini(config, prompt, requestOptions)
|
|
350
|
-
:
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
541
|
+
: config.provider === "anthropic"
|
|
542
|
+
? await requestAnthropic(config, prompt, requestOptions)
|
|
543
|
+
: await requestOpenAICompatible(config, prompt, requestOptions);
|
|
544
|
+
|
|
545
|
+
const estimatedCostUsd = estimateCostUsd(result.responseMeta.usage, resolvePricing(config));
|
|
546
|
+
result.responseMeta.estimatedCostUsd = estimatedCostUsd;
|
|
547
|
+
|
|
548
|
+
appendUsageRecord({
|
|
549
|
+
provider: result.responseMeta.provider,
|
|
550
|
+
model: result.responseMeta.model,
|
|
551
|
+
usage: result.responseMeta.usage,
|
|
552
|
+
latencyMs: result.responseMeta.latencyMs,
|
|
553
|
+
estimatedCostUsd
|
|
355
554
|
});
|
|
356
555
|
|
|
556
|
+
if (!noCache) {
|
|
557
|
+
writeCache(cacheKey, {
|
|
558
|
+
explanation: result.explanation,
|
|
559
|
+
responseMeta: result.responseMeta
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
357
563
|
return {
|
|
358
564
|
explanation: result.explanation,
|
|
359
565
|
promptMeta,
|
|
@@ -1,12 +1,19 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
5
|
|
|
6
|
+
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
7
|
+
const MAX_CACHE_FILES = 200;
|
|
8
|
+
|
|
6
9
|
function getCacheDir() {
|
|
7
10
|
return path.join(os.homedir(), ".gitxplain", "cache");
|
|
8
11
|
}
|
|
9
12
|
|
|
13
|
+
export function getCacheDirectory() {
|
|
14
|
+
return getCacheDir();
|
|
15
|
+
}
|
|
16
|
+
|
|
10
17
|
export function createCacheKey(parts) {
|
|
11
18
|
const hash = createHash("sha256");
|
|
12
19
|
hash.update(JSON.stringify(parts));
|
|
@@ -17,6 +24,52 @@ function getCachePath(cacheKey) {
|
|
|
17
24
|
return path.join(getCacheDir(), `${cacheKey}.json`);
|
|
18
25
|
}
|
|
19
26
|
|
|
27
|
+
function listCacheEntries() {
|
|
28
|
+
const dir = getCacheDir();
|
|
29
|
+
if (!existsSync(dir)) {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return readdirSync(dir)
|
|
34
|
+
.filter((name) => name.endsWith(".json"))
|
|
35
|
+
.map((name) => {
|
|
36
|
+
const filePath = path.join(dir, name);
|
|
37
|
+
const stats = statSync(filePath);
|
|
38
|
+
return {
|
|
39
|
+
filePath,
|
|
40
|
+
mtimeMs: stats.mtimeMs,
|
|
41
|
+
sizeBytes: stats.size
|
|
42
|
+
};
|
|
43
|
+
})
|
|
44
|
+
.sort((left, right) => left.mtimeMs - right.mtimeMs);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isExpired(mtimeMs) {
|
|
48
|
+
return Date.now() - mtimeMs > CACHE_TTL_MS;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function pruneCache() {
|
|
52
|
+
const entries = listCacheEntries();
|
|
53
|
+
|
|
54
|
+
for (const entry of entries.filter((item) => isExpired(item.mtimeMs))) {
|
|
55
|
+
try {
|
|
56
|
+
unlinkSync(entry.filePath);
|
|
57
|
+
} catch {
|
|
58
|
+
// Best-effort cleanup only.
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const remaining = listCacheEntries();
|
|
63
|
+
const overflowCount = Math.max(0, remaining.length - MAX_CACHE_FILES);
|
|
64
|
+
for (const entry of remaining.slice(0, overflowCount)) {
|
|
65
|
+
try {
|
|
66
|
+
unlinkSync(entry.filePath);
|
|
67
|
+
} catch {
|
|
68
|
+
// Best-effort cleanup only.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
20
73
|
export function readCache(cacheKey) {
|
|
21
74
|
const filePath = getCachePath(cacheKey);
|
|
22
75
|
if (!existsSync(filePath)) {
|
|
@@ -24,6 +77,12 @@ export function readCache(cacheKey) {
|
|
|
24
77
|
}
|
|
25
78
|
|
|
26
79
|
try {
|
|
80
|
+
const stats = statSync(filePath);
|
|
81
|
+
if (isExpired(stats.mtimeMs)) {
|
|
82
|
+
unlinkSync(filePath);
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
27
86
|
return JSON.parse(readFileSync(filePath, "utf8"));
|
|
28
87
|
} catch {
|
|
29
88
|
return null;
|
|
@@ -34,4 +93,36 @@ export function writeCache(cacheKey, value) {
|
|
|
34
93
|
const dir = getCacheDir();
|
|
35
94
|
mkdirSync(dir, { recursive: true });
|
|
36
95
|
writeFileSync(getCachePath(cacheKey), JSON.stringify(value, null, 2), "utf8");
|
|
96
|
+
pruneCache();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function clearCache() {
|
|
100
|
+
const dir = getCacheDir();
|
|
101
|
+
const entries = listCacheEntries();
|
|
102
|
+
|
|
103
|
+
if (existsSync(dir)) {
|
|
104
|
+
rmSync(dir, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return entries.length;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function getCacheStats() {
|
|
111
|
+
const entries = listCacheEntries();
|
|
112
|
+
|
|
113
|
+
if (entries.length === 0) {
|
|
114
|
+
return {
|
|
115
|
+
entryCount: 0,
|
|
116
|
+
totalSizeBytes: 0,
|
|
117
|
+
oldestEntryIso: null,
|
|
118
|
+
newestEntryIso: null
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
entryCount: entries.length,
|
|
124
|
+
totalSizeBytes: entries.reduce((sum, entry) => sum + entry.sizeBytes, 0),
|
|
125
|
+
oldestEntryIso: new Date(entries[0].mtimeMs).toISOString(),
|
|
126
|
+
newestEntryIso: new Date(entries[entries.length - 1].mtimeMs).toISOString()
|
|
127
|
+
};
|
|
37
128
|
}
|
|
@@ -23,6 +23,11 @@ export function copyToClipboard(text) {
|
|
|
23
23
|
runClipboardCommand("wl-copy", [], text);
|
|
24
24
|
return;
|
|
25
25
|
} catch {
|
|
26
|
-
|
|
26
|
+
try {
|
|
27
|
+
runClipboardCommand("xclip", ["-selection", "clipboard"], text);
|
|
28
|
+
return;
|
|
29
|
+
} catch {
|
|
30
|
+
throw new Error("Clipboard copy failed on Linux. Install `wl-copy` or `xclip` and try again.");
|
|
31
|
+
}
|
|
27
32
|
}
|
|
28
33
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
|
|
3
|
+
export const ANSI = {
|
|
4
|
+
reset: "\u001b[0m",
|
|
5
|
+
bold: "\u001b[1m",
|
|
6
|
+
cyan: "\u001b[36m",
|
|
7
|
+
yellow: "\u001b[33m",
|
|
8
|
+
green: "\u001b[32m",
|
|
9
|
+
red: "\u001b[31m",
|
|
10
|
+
gray: "\u001b[90m"
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function supportsColor() {
|
|
14
|
+
if (process.env.FORCE_COLOR != null && process.env.FORCE_COLOR !== "0") {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (process.env.NO_COLOR != null) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return Boolean(process.stdout?.isTTY);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function colorize(text, color) {
|
|
26
|
+
if (!supportsColor()) {
|
|
27
|
+
return text;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return `${color}${text}${ANSI.reset}`;
|
|
31
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import process from "node:process";
|
|
2
1
|
import {
|
|
3
2
|
deletePaths,
|
|
4
3
|
fetchWorkingTreeData,
|
|
@@ -18,29 +17,10 @@ import {
|
|
|
18
17
|
resolveTreeSha,
|
|
19
18
|
writeCurrentIndexTree
|
|
20
19
|
} from "./gitService.js";
|
|
21
|
-
|
|
22
|
-
const ANSI = {
|
|
23
|
-
reset: "\u001b[0m",
|
|
24
|
-
bold: "\u001b[1m",
|
|
25
|
-
cyan: "\u001b[36m",
|
|
26
|
-
yellow: "\u001b[33m",
|
|
27
|
-
green: "\u001b[32m"
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
function supportsColor() {
|
|
31
|
-
return Boolean(process.stdout?.isTTY) && process.env.NO_COLOR == null;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function colorize(text, color) {
|
|
35
|
-
if (!supportsColor()) {
|
|
36
|
-
return text;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
return `${color}${text}${ANSI.reset}`;
|
|
40
|
-
}
|
|
20
|
+
import { ANSI, colorize } from "./colorSupport.js";
|
|
41
21
|
|
|
42
22
|
function extractJsonPayload(explanation) {
|
|
43
|
-
const fencedMatch = explanation.match(/```
|
|
23
|
+
const fencedMatch = explanation.match(/```[A-Za-z0-9_-]*\s*([\s\S]*?)\s*```/);
|
|
44
24
|
if (fencedMatch) {
|
|
45
25
|
return fencedMatch[1].trim();
|
|
46
26
|
}
|
|
@@ -59,6 +39,104 @@ function isNonEmptyString(value) {
|
|
|
59
39
|
return typeof value === "string" && value.trim() !== "";
|
|
60
40
|
}
|
|
61
41
|
|
|
42
|
+
function stripMarkdown(text) {
|
|
43
|
+
return text
|
|
44
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
45
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
46
|
+
.replace(/__([^_]+)__/g, "$1")
|
|
47
|
+
.replace(/\*([^*]+)\*/g, "$1")
|
|
48
|
+
.replace(/_([^_]+)_/g, "$1")
|
|
49
|
+
.trim();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseFilesLine(value) {
|
|
53
|
+
return value
|
|
54
|
+
.split(/[,\n]/)
|
|
55
|
+
.map((file) => stripMarkdown(file).trim())
|
|
56
|
+
.filter(Boolean);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseTextCommitPlan(explanation) {
|
|
60
|
+
const lines = explanation.split("\n").map((line) => line.trimEnd());
|
|
61
|
+
const commits = [];
|
|
62
|
+
let workingTreeSummary = null;
|
|
63
|
+
let reasonToCommit = null;
|
|
64
|
+
let currentCommit = null;
|
|
65
|
+
|
|
66
|
+
const pushCurrentCommit = () => {
|
|
67
|
+
if (!currentCommit) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
commits.push({
|
|
72
|
+
order: currentCommit.order,
|
|
73
|
+
message: currentCommit.message,
|
|
74
|
+
files: currentCommit.files,
|
|
75
|
+
description: currentCommit.description
|
|
76
|
+
});
|
|
77
|
+
currentCommit = null;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
for (const rawLine of lines) {
|
|
81
|
+
const line = stripMarkdown(rawLine.trim());
|
|
82
|
+
if (line === "") {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (/^(working tree summary|summary)\s*:/i.test(line)) {
|
|
87
|
+
workingTreeSummary = line.replace(/^(working tree summary|summary)\s*:/i, "").trim();
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (/^(reason to commit|reason)\s*:/i.test(line)) {
|
|
92
|
+
reasonToCommit = line.replace(/^(reason to commit|reason)\s*:/i, "").trim() || null;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const commitMatch = line.match(/^(?:[-*]\s*)?(\d+)\.\s+(.+)$/);
|
|
97
|
+
if (commitMatch) {
|
|
98
|
+
pushCurrentCommit();
|
|
99
|
+
currentCommit = {
|
|
100
|
+
order: Number.parseInt(commitMatch[1], 10),
|
|
101
|
+
message: commitMatch[2].trim(),
|
|
102
|
+
files: [],
|
|
103
|
+
description: ""
|
|
104
|
+
};
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!currentCommit) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (/^files?\s*:/i.test(line)) {
|
|
113
|
+
currentCommit.files.push(...parseFilesLine(line.replace(/^files?\s*:/i, "")));
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (/^(why|description)\s*:/i.test(line)) {
|
|
118
|
+
currentCommit.description = line.replace(/^(why|description)\s*:/i, "").trim();
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (currentCommit.description) {
|
|
123
|
+
currentCommit.description = `${currentCommit.description} ${line}`.trim();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
pushCurrentCommit();
|
|
128
|
+
|
|
129
|
+
if (!workingTreeSummary || commits.length === 0) {
|
|
130
|
+
throw new Error("Failed to parse commit plan: no JSON object found in model response.");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
working_tree_summary: workingTreeSummary,
|
|
135
|
+
reason_to_commit: reasonToCommit,
|
|
136
|
+
commits
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
62
140
|
function validateCommitEntry(entry, index) {
|
|
63
141
|
if (typeof entry !== "object" || entry == null || Array.isArray(entry)) {
|
|
64
142
|
throw new Error(`Failed to parse commit plan: commit ${index + 1} must be an object.`);
|
|
@@ -257,7 +335,11 @@ export function parseCommitPlan(explanation) {
|
|
|
257
335
|
try {
|
|
258
336
|
parsed = JSON.parse(extractJsonPayload(explanation));
|
|
259
337
|
} catch (error) {
|
|
260
|
-
|
|
338
|
+
try {
|
|
339
|
+
parsed = parseTextCommitPlan(explanation);
|
|
340
|
+
} catch {
|
|
341
|
+
throw new Error(`Failed to parse commit plan JSON: ${error.message}`);
|
|
342
|
+
}
|
|
261
343
|
}
|
|
262
344
|
|
|
263
345
|
if (typeof parsed !== "object" || parsed == null || Array.isArray(parsed)) {
|