gitxplain 0.1.6 → 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.
@@ -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
- "Content-Type": "application/json",
95
- Authorization: `Bearer ${config.apiKey}`
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
- const parsed = JSON.parse(line);
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 response = await fetch(`${config.baseUrl}/chat/completions`, {
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(`${config.provider} request failed (${response.status}): ${errorText}`);
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
- const content = data.choices?.[0]?.delta?.content;
201
- if (typeof content === "string") {
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: extractOpenAIContent(data),
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: extractUsage(data)
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 fetch(endpoint, {
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
- : await requestOpenAICompatible(config, prompt, requestOptions);
351
-
352
- writeCache(cacheKey, {
353
- explanation: result.explanation,
354
- responseMeta: result.responseMeta
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
- runClipboardCommand("xclip", ["-selection", "clipboard"], text);
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(/```(?:json)?\s*([\s\S]*?)\s*```/i);
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
- throw new Error(`Failed to parse commit plan JSON: ${error.message}`);
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)) {