@synmux/claude-commit 1.0.1 → 1.0.3
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 +208 -5
- package/index.ts +31 -2
- package/package.json +28 -16
- package/src/agent.ts +36 -43
- package/src/cli.ts +129 -10
- package/src/config.ts +89 -6
- package/src/diff.ts +339 -14
- package/src/generate.ts +270 -47
- package/src/git.ts +38 -4
- package/src/models.ts +95 -0
- package/src/ollama.ts +502 -0
- package/src/paths.ts +139 -0
- package/src/prompts.ts +148 -27
- package/src/tokens.ts +47 -9
- package/src/types.ts +141 -3
- package/src/ui/spinner.ts +1 -1
package/src/generate.ts
CHANGED
|
@@ -1,9 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The commit-message pipeline:
|
|
3
3
|
*
|
|
4
|
-
* diff ──
|
|
4
|
+
* diff ──ignore──▶ ──partition──▶ primary diff, low-priority diff
|
|
5
|
+
* ──split──▶ [chunk, chunk, ...] ──summary model──▶ [summary, ...]
|
|
5
6
|
* ──final model──▶ commit message(s)
|
|
6
7
|
*
|
|
8
|
+
* The `ignore` patterns run first and remove file sections outright, so
|
|
9
|
+
* ignored content is never chunked, never sent and never paid for. Those
|
|
10
|
+
* files are still committed - `ignore` governs what the model reads, not
|
|
11
|
+
* what git stages - but when it matches *everything* there is nothing left
|
|
12
|
+
* to describe and the run stops rather than inventing a message.
|
|
13
|
+
*
|
|
14
|
+
* The remaining diff is partitioned by the configured `lowPriorityPaths`: file
|
|
15
|
+
* sections under those paths (generated docs, lockfiles, ...) form a
|
|
16
|
+
* low-priority partition that is summarised after, and more briefly than,
|
|
17
|
+
* the primary one, and the final model is told which is which so the
|
|
18
|
+
* subject line describes the primary changes. When every file is low
|
|
19
|
+
* priority the partition is promoted and the run is identical to one with
|
|
20
|
+
* no patterns configured.
|
|
21
|
+
*
|
|
7
22
|
* The summary model (default `sonnet`) reads each diff chunk and writes a
|
|
8
23
|
* factual summary; chunks are sized by a content-classified token estimate
|
|
9
24
|
* (`splitDiffToFit`) so each request fits the model's context window, and a
|
|
@@ -14,7 +29,15 @@
|
|
|
14
29
|
* message(s), applying the configured formatting rules.
|
|
15
30
|
*/
|
|
16
31
|
import { runPrompt } from "./agent";
|
|
17
|
-
import {
|
|
32
|
+
import { isOllamaModel } from "./models";
|
|
33
|
+
import { resolveOllamaContext } from "./ollama";
|
|
34
|
+
import {
|
|
35
|
+
applyIgnorePatterns,
|
|
36
|
+
partitionDiff,
|
|
37
|
+
redactOpaqueRuns,
|
|
38
|
+
splitDiffToFit,
|
|
39
|
+
} from "./diff";
|
|
40
|
+
import { createPathMatcher } from "./paths";
|
|
18
41
|
import { clampChunkTokens } from "./tokens";
|
|
19
42
|
import { ClaudeCommitError, isPromptTooLongError } from "./errors";
|
|
20
43
|
import {
|
|
@@ -24,10 +47,16 @@ import {
|
|
|
24
47
|
buildSummaryUser,
|
|
25
48
|
cleanMessage,
|
|
26
49
|
extractMessages,
|
|
50
|
+
hasLowPrioritySummaries,
|
|
27
51
|
MESSAGES_SCHEMA,
|
|
28
52
|
parseOptions,
|
|
29
53
|
} from "./prompts";
|
|
30
|
-
import type {
|
|
54
|
+
import type {
|
|
55
|
+
ChangePriority,
|
|
56
|
+
Config,
|
|
57
|
+
DiffSummary,
|
|
58
|
+
OllamaConfig,
|
|
59
|
+
} from "./types";
|
|
31
60
|
|
|
32
61
|
export interface GenerateProgress {
|
|
33
62
|
/** Called when a new phase of work begins (for spinner labels). */
|
|
@@ -47,17 +76,97 @@ export interface GenerateOptions {
|
|
|
47
76
|
* Defaults to {@link runPrompt}.
|
|
48
77
|
*/
|
|
49
78
|
runner?: typeof runPrompt;
|
|
79
|
+
/**
|
|
80
|
+
* Resolves an `ollama:` model's context window, called once per model
|
|
81
|
+
* per run before any chunk is sized; injectable so tests can exercise an
|
|
82
|
+
* `"auto"` configuration without a server. Defaults to
|
|
83
|
+
* {@link resolveOllamaContext}.
|
|
84
|
+
*/
|
|
85
|
+
resolveOllamaContext?: typeof resolveOllamaContext;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The context window one Ollama model ran with during this run. */
|
|
89
|
+
export interface OllamaContextWindow {
|
|
90
|
+
/** The model string as configured, prefix included. */
|
|
91
|
+
model: string;
|
|
92
|
+
tokens: number;
|
|
93
|
+
/** Whether the number was configured or chosen by the server (`"auto"`). */
|
|
94
|
+
source: "config" | "auto";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** How the `ignore` patterns applied to this diff (for `--verbose`). */
|
|
98
|
+
export interface IgnoreStats {
|
|
99
|
+
/** File sections dropped before any model saw them. */
|
|
100
|
+
ignoredFiles: number;
|
|
101
|
+
/** File sections in the staged diff with a recognisable path. */
|
|
102
|
+
totalFiles: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** How the `lowPriorityPaths` patterns applied to this diff (for `--verbose`). */
|
|
106
|
+
export interface LowPriorityStats {
|
|
107
|
+
/** File sections whose paths all matched a pattern. */
|
|
108
|
+
matchedFiles: number;
|
|
109
|
+
/** File sections in the diff with a recognisable path. */
|
|
110
|
+
totalFiles: number;
|
|
111
|
+
/** Every file matched, so the changes were treated as primary after all. */
|
|
112
|
+
promoted: boolean;
|
|
50
113
|
}
|
|
51
114
|
|
|
52
115
|
export interface GenerateResult {
|
|
53
116
|
/** Candidate commit messages (length 1 in non-interactive mode). */
|
|
54
117
|
messages: string[];
|
|
55
|
-
/** The intermediate summaries
|
|
56
|
-
summaries:
|
|
57
|
-
/** Number of diff chunks the summary stage processed. */
|
|
118
|
+
/** The intermediate summaries, primary first, each tagged with its priority. */
|
|
119
|
+
summaries: DiffSummary[];
|
|
120
|
+
/** Number of diff chunks the summary stage processed, across both partitions. */
|
|
58
121
|
chunkCount: number;
|
|
59
122
|
/** Total cost across all model calls, in USD. */
|
|
60
123
|
costUsd: number;
|
|
124
|
+
/** How the low-priority patterns applied to this diff. */
|
|
125
|
+
lowPriority: LowPriorityStats;
|
|
126
|
+
/** How the ignore patterns applied to this diff. */
|
|
127
|
+
ignored: IgnoreStats;
|
|
128
|
+
/** The context window each Ollama model ran with, in order of first use. */
|
|
129
|
+
ollamaContexts: OllamaContextWindow[];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Resolves each `ollama:` model's context window once and hands back an
|
|
134
|
+
* {@link OllamaConfig} with the number pinned in place of `"auto"`, so the
|
|
135
|
+
* runner never repeats the probe. Claude models get `undefined`: they
|
|
136
|
+
* neither need nor understand the block.
|
|
137
|
+
*/
|
|
138
|
+
class OllamaContextResolver {
|
|
139
|
+
private readonly windows = new Map<string, Promise<number>>();
|
|
140
|
+
readonly resolved: OllamaContextWindow[] = [];
|
|
141
|
+
|
|
142
|
+
constructor(
|
|
143
|
+
private readonly config: OllamaConfig,
|
|
144
|
+
private readonly resolve: typeof resolveOllamaContext,
|
|
145
|
+
private readonly signal?: AbortSignal,
|
|
146
|
+
) {}
|
|
147
|
+
|
|
148
|
+
/** The Ollama settings to run `model` with, or `undefined` for a Claude model. */
|
|
149
|
+
async settingsFor(model: string): Promise<OllamaConfig | undefined> {
|
|
150
|
+
if (!isOllamaModel(model)) return undefined;
|
|
151
|
+
const tokens = await this.windowFor(model);
|
|
152
|
+
return { ...this.config, context: tokens };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private windowFor(model: string): Promise<number> {
|
|
156
|
+
let pending = this.windows.get(model);
|
|
157
|
+
if (!pending) {
|
|
158
|
+
pending = this.resolve(model, this.config, this.signal).then((tokens) => {
|
|
159
|
+
this.resolved.push({
|
|
160
|
+
model,
|
|
161
|
+
tokens,
|
|
162
|
+
source: this.config.context === "auto" ? "auto" : "config",
|
|
163
|
+
});
|
|
164
|
+
return tokens;
|
|
165
|
+
});
|
|
166
|
+
this.windows.set(model, pending);
|
|
167
|
+
}
|
|
168
|
+
return pending;
|
|
169
|
+
}
|
|
61
170
|
}
|
|
62
171
|
|
|
63
172
|
/**
|
|
@@ -67,47 +176,59 @@ export interface GenerateResult {
|
|
|
67
176
|
*/
|
|
68
177
|
const MIN_RETRY_CHUNK_TOKENS = 8_000;
|
|
69
178
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
179
|
+
interface PartitionSummaryOptions {
|
|
180
|
+
config: Config;
|
|
181
|
+
runner: typeof runPrompt;
|
|
182
|
+
progress: GenerateProgress;
|
|
183
|
+
contexts: OllamaContextResolver;
|
|
184
|
+
abortController?: AbortController;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Spinner label for one chunk of a partition. */
|
|
188
|
+
function readingLabel(
|
|
189
|
+
priority: ChangePriority,
|
|
190
|
+
position: number,
|
|
191
|
+
total: number,
|
|
192
|
+
): string {
|
|
193
|
+
const subject = priority === "low" ? "low-priority diff" : "diff";
|
|
194
|
+
return total > 1
|
|
195
|
+
? `Reading ${subject} (part ${position + 1}/${total})`
|
|
196
|
+
: `Reading ${subject}`;
|
|
197
|
+
}
|
|
82
198
|
|
|
83
|
-
|
|
199
|
+
/**
|
|
200
|
+
* Stage 1 for one partition: split it into chunks and summarise each, via a
|
|
201
|
+
* work queue so an oversized chunk can be re-split and retried in place.
|
|
202
|
+
* The estimate is calibrated, but only the backend knows the true token
|
|
203
|
+
* count; its "prompt is too long" rejection is free, so treat it as the
|
|
204
|
+
* final arbiter: halve the budget, re-split just that chunk, and continue
|
|
205
|
+
* where we left off. Returns no summaries for an empty partition.
|
|
206
|
+
*/
|
|
207
|
+
async function summarizePartition(
|
|
208
|
+
diff: string,
|
|
209
|
+
priority: ChangePriority,
|
|
210
|
+
options: PartitionSummaryOptions,
|
|
211
|
+
): Promise<{ summaries: DiffSummary[]; costUsd: number }> {
|
|
212
|
+
const { config, runner, progress, contexts, abortController } = options;
|
|
84
213
|
|
|
85
214
|
// The configured chunk budget is clamped to the summary model's context
|
|
86
215
|
// window so a single chunk (plus prompt scaffolding and response headroom)
|
|
87
|
-
// can never overflow it, whatever `maxChunkTokens` says.
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
216
|
+
// can never overflow it, whatever `maxChunkTokens` says. For an Ollama
|
|
217
|
+
// model that window is resolved here first - possibly by asking the
|
|
218
|
+
// server - so the chunks and the request agree on the same number.
|
|
219
|
+
// Chunks are sized by a content-classified token estimate: opaque content
|
|
220
|
+
// (age/gpg armor, binary patches) measures near 1 char/token, so a plain
|
|
221
|
+
// chars-based budget underestimates armor-heavy diffs more than threefold.
|
|
222
|
+
const ollama = await contexts.settingsFor(config.models.summary);
|
|
91
223
|
const chunkTokens = clampChunkTokens(
|
|
92
224
|
config.models.summary,
|
|
93
225
|
config.maxChunkTokens,
|
|
226
|
+
typeof ollama?.context === "number" ? ollama.context : undefined,
|
|
94
227
|
);
|
|
95
|
-
const chunks = splitDiffToFit(
|
|
96
|
-
effectiveDiff,
|
|
97
|
-
chunkTokens,
|
|
98
|
-
config.charsPerToken,
|
|
99
|
-
);
|
|
100
|
-
if (chunks.length === 0) {
|
|
101
|
-
throw new ClaudeCommitError("There are no staged changes to summarize.");
|
|
102
|
-
}
|
|
228
|
+
const chunks = splitDiffToFit(diff, chunkTokens, config.charsPerToken);
|
|
103
229
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
// only the backend knows the true token count; its "prompt is too long"
|
|
107
|
-
// rejection is free, so treat it as the final arbiter: halve the budget,
|
|
108
|
-
// re-split just that chunk, and continue where we left off.
|
|
109
|
-
const summarySystem = buildSummarySystem();
|
|
110
|
-
const summaries: string[] = [];
|
|
230
|
+
const summarySystem = buildSummarySystem(priority);
|
|
231
|
+
const summaries: DiffSummary[] = [];
|
|
111
232
|
let costUsd = 0;
|
|
112
233
|
|
|
113
234
|
const queue = chunks.map((chunk) => ({ chunk, tokenBudget: chunkTokens }));
|
|
@@ -115,22 +236,19 @@ export async function generateCommit(
|
|
|
115
236
|
const task = queue.shift()!;
|
|
116
237
|
const position = summaries.length;
|
|
117
238
|
const total = summaries.length + queue.length + 1;
|
|
118
|
-
progress.onPhase?.(
|
|
119
|
-
total > 1
|
|
120
|
-
? `Reading diff (part ${position + 1}/${total})`
|
|
121
|
-
: "Reading diff",
|
|
122
|
-
);
|
|
239
|
+
progress.onPhase?.(readingLabel(priority, position, total));
|
|
123
240
|
try {
|
|
124
241
|
const result = await runner(
|
|
125
|
-
buildSummaryUser(task.chunk, position, total),
|
|
242
|
+
buildSummaryUser(task.chunk, position, total, priority),
|
|
126
243
|
{
|
|
127
244
|
model: config.models.summary,
|
|
128
245
|
system: summarySystem,
|
|
129
246
|
allowApiKey: config.allowApiKey,
|
|
247
|
+
...(ollama ? { ollama } : {}),
|
|
130
248
|
...(abortController ? { abortController } : {}),
|
|
131
249
|
},
|
|
132
250
|
);
|
|
133
|
-
summaries.push(result.text);
|
|
251
|
+
summaries.push({ priority, text: result.text });
|
|
134
252
|
costUsd += result.costUsd;
|
|
135
253
|
} catch (error) {
|
|
136
254
|
const halvedBudget = Math.floor(task.tokenBudget / 2);
|
|
@@ -156,6 +274,83 @@ export async function generateCommit(
|
|
|
156
274
|
}
|
|
157
275
|
}
|
|
158
276
|
|
|
277
|
+
return { summaries, costUsd };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Run the full pipeline over a staged diff. */
|
|
281
|
+
export async function generateCommit(
|
|
282
|
+
diff: string,
|
|
283
|
+
config: Config,
|
|
284
|
+
options: GenerateOptions = {},
|
|
285
|
+
): Promise<GenerateResult> {
|
|
286
|
+
const {
|
|
287
|
+
count = 1,
|
|
288
|
+
progress = {},
|
|
289
|
+
abortController,
|
|
290
|
+
runner = runPrompt,
|
|
291
|
+
resolveOllamaContext: resolveContext = resolveOllamaContext,
|
|
292
|
+
} = options;
|
|
293
|
+
const contexts = new OllamaContextResolver(
|
|
294
|
+
config.ollama,
|
|
295
|
+
resolveContext,
|
|
296
|
+
abortController?.signal,
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
// Ignore first: dropped sections cost nothing downstream. Unlike a
|
|
300
|
+
// low-priority partition, an ignored one has nowhere to be promoted to,
|
|
301
|
+
// so matching every file is a dead end rather than a special case.
|
|
302
|
+
const ignoreResult = applyIgnorePatterns(
|
|
303
|
+
diff,
|
|
304
|
+
createPathMatcher(config.ignore),
|
|
305
|
+
);
|
|
306
|
+
const ignored: IgnoreStats = {
|
|
307
|
+
ignoredFiles: ignoreResult.ignoredFiles,
|
|
308
|
+
totalFiles: ignoreResult.totalFiles,
|
|
309
|
+
};
|
|
310
|
+
if (ignoreResult.diff.trim() === "" && ignoreResult.ignoredFiles > 0) {
|
|
311
|
+
throw new ClaudeCommitError(describeFullyIgnored(ignored));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const effectiveDiff = config.skipArmored
|
|
315
|
+
? redactOpaqueRuns(ignoreResult.diff)
|
|
316
|
+
: ignoreResult.diff;
|
|
317
|
+
const partition = partitionDiff(
|
|
318
|
+
effectiveDiff,
|
|
319
|
+
createPathMatcher(config.lowPriorityPaths),
|
|
320
|
+
);
|
|
321
|
+
if (partition.primary.trim() === "") {
|
|
322
|
+
throw new ClaudeCommitError("There are no staged changes to summarize.");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Stage 1: summarise the primary partition first - fail fast on the part
|
|
326
|
+
// that matters - then the low-priority one (skipped when empty).
|
|
327
|
+
const partitionOptions: PartitionSummaryOptions = {
|
|
328
|
+
config,
|
|
329
|
+
runner,
|
|
330
|
+
progress,
|
|
331
|
+
contexts,
|
|
332
|
+
...(abortController ? { abortController } : {}),
|
|
333
|
+
};
|
|
334
|
+
const primaryStage = await summarizePartition(
|
|
335
|
+
partition.primary,
|
|
336
|
+
"primary",
|
|
337
|
+
partitionOptions,
|
|
338
|
+
);
|
|
339
|
+
const lowPriorityStage =
|
|
340
|
+
partition.lowPriority.trim() === ""
|
|
341
|
+
? { summaries: [], costUsd: 0 }
|
|
342
|
+
: await summarizePartition(
|
|
343
|
+
partition.lowPriority,
|
|
344
|
+
"low",
|
|
345
|
+
partitionOptions,
|
|
346
|
+
);
|
|
347
|
+
const summaries = [...primaryStage.summaries, ...lowPriorityStage.summaries];
|
|
348
|
+
if (summaries.length === 0) {
|
|
349
|
+
throw new ClaudeCommitError("There are no staged changes to summarize.");
|
|
350
|
+
}
|
|
351
|
+
let costUsd = primaryStage.costUsd + lowPriorityStage.costUsd;
|
|
352
|
+
const hasLowPriority = hasLowPrioritySummaries(summaries);
|
|
353
|
+
|
|
159
354
|
// Stage 2: write the commit message(s) from the summaries.
|
|
160
355
|
//
|
|
161
356
|
// Prefer a structured (JSON-schema) response so parsing is robust regardless
|
|
@@ -168,9 +363,11 @@ export async function generateCommit(
|
|
|
168
363
|
count > 1 ? "Writing commit options" : "Writing commit message",
|
|
169
364
|
);
|
|
170
365
|
|
|
366
|
+
const finalOllama = await contexts.settingsFor(config.models.final);
|
|
171
367
|
const baseOpts = {
|
|
172
368
|
model: config.models.final,
|
|
173
369
|
allowApiKey: config.allowApiKey,
|
|
370
|
+
...(finalOllama ? { ollama: finalOllama } : {}),
|
|
174
371
|
...(abortController ? { abortController } : {}),
|
|
175
372
|
};
|
|
176
373
|
const temperature =
|
|
@@ -191,7 +388,7 @@ export async function generateCommit(
|
|
|
191
388
|
buildFinalUser(summaries, count, attempt.structured),
|
|
192
389
|
{
|
|
193
390
|
...baseOpts,
|
|
194
|
-
system: buildFinalSystem(config, attempt.structured),
|
|
391
|
+
system: buildFinalSystem(config, attempt.structured, hasLowPriority),
|
|
195
392
|
...(attempt.structured
|
|
196
393
|
? {
|
|
197
394
|
outputFormat: {
|
|
@@ -225,7 +422,7 @@ export async function generateCommit(
|
|
|
225
422
|
|
|
226
423
|
const cleaned = (messages ?? [])
|
|
227
424
|
.map(cleanMessage)
|
|
228
|
-
.filter((
|
|
425
|
+
.filter((message) => message.length > 0);
|
|
229
426
|
const deduped = dedupe(cleaned);
|
|
230
427
|
if (deduped.length === 0) {
|
|
231
428
|
if (lastError instanceof ClaudeCommitError) throw lastError;
|
|
@@ -239,9 +436,35 @@ export async function generateCommit(
|
|
|
239
436
|
summaries,
|
|
240
437
|
chunkCount: summaries.length,
|
|
241
438
|
costUsd,
|
|
439
|
+
lowPriority: {
|
|
440
|
+
matchedFiles: partition.matchedFiles,
|
|
441
|
+
totalFiles: partition.totalFiles,
|
|
442
|
+
promoted: partition.promoted,
|
|
443
|
+
},
|
|
444
|
+
ignored,
|
|
445
|
+
ollamaContexts: contexts.resolved,
|
|
242
446
|
};
|
|
243
447
|
}
|
|
244
448
|
|
|
449
|
+
/**
|
|
450
|
+
* The error for a commit whose every changed file matched `ignore`.
|
|
451
|
+
*
|
|
452
|
+
* There is no sensible fallback here. Describing the ignored files anyway
|
|
453
|
+
* would contradict the directive the user wrote; committing an empty or
|
|
454
|
+
* invented message would be worse. Naming the directive and the count makes
|
|
455
|
+
* the cause obvious, because the alternative - a run that mysteriously
|
|
456
|
+
* reports no staged changes when `git status` plainly disagrees - is the
|
|
457
|
+
* kind of bug people spend an afternoon on.
|
|
458
|
+
*/
|
|
459
|
+
export function describeFullyIgnored(stats: IgnoreStats): string {
|
|
460
|
+
const files = `${stats.ignoredFiles} staged file${stats.ignoredFiles === 1 ? "" : "s"}`;
|
|
461
|
+
return (
|
|
462
|
+
`Every one of the ${files} matches an "ignore" pattern, so there is ` +
|
|
463
|
+
`nothing left to describe. Narrow the patterns, or pass --no-ignore to ` +
|
|
464
|
+
`write a message about these changes for this commit.`
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
245
468
|
function dedupe(items: string[]): string[] {
|
|
246
469
|
const seen = new Set<string>();
|
|
247
470
|
const out: string[] = [];
|
package/src/git.ts
CHANGED
|
@@ -47,14 +47,48 @@ export async function getRepoRoot(): Promise<string> {
|
|
|
47
47
|
return (await git(["rev-parse", "--show-toplevel"])).trim();
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* Flags that pin the shape and membership of every staged-change reader
|
|
52
|
+
* against user diff settings. The list is exhaustive, not illustrative:
|
|
53
|
+
*
|
|
54
|
+
* - `--no-relative`: with `diff.relative=true`, running from a subdirectory
|
|
55
|
+
* would both strip leading path segments (breaking `lowPriorityPaths`
|
|
56
|
+
* matching, which is always repository-root-relative) and omit staged
|
|
57
|
+
* files outside that directory entirely, so the message would describe a
|
|
58
|
+
* subset of what gets committed.
|
|
59
|
+
* - `--no-ext-diff`: `diff.external` / `GIT_EXTERNAL_DIFF` / a gitattributes
|
|
60
|
+
* `diff=<driver>` replace the diff body wholesale - a difftastic-style
|
|
61
|
+
* driver emits no `diff --git` headers at all, and a driver can even forge
|
|
62
|
+
* headers that attach one file's changes to another path.
|
|
63
|
+
* - `--ignore-submodules=none`: `diff.ignoreSubmodules=all` erases a staged
|
|
64
|
+
* submodule bump from all three readers.
|
|
65
|
+
* - `--submodule=short`: `diff.submodule=log|diff` replace a submodule's
|
|
66
|
+
* `diff --git` section with a header-less `Submodule <path> <a>..<b>:`
|
|
67
|
+
* block, which the section splitter would glue onto the preceding file's
|
|
68
|
+
* section (and priority).
|
|
69
|
+
* - The `a/`/`b/` prefixes are forced so `diff.noprefix` /
|
|
70
|
+
* `diff.mnemonicPrefix` cannot change the header format the diff parser
|
|
71
|
+
* (`sectionPaths` in `src/diff.ts`) expects.
|
|
72
|
+
*/
|
|
73
|
+
const STAGED_DIFF_FLAGS = [
|
|
74
|
+
"--cached",
|
|
75
|
+
"--no-color",
|
|
76
|
+
"--no-relative",
|
|
77
|
+
"--no-ext-diff",
|
|
78
|
+
"--ignore-submodules=none",
|
|
79
|
+
"--submodule=short",
|
|
80
|
+
"--src-prefix=a/",
|
|
81
|
+
"--dst-prefix=b/",
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
/** The unified diff of staged changes (`git diff --cached`), repository-root-relative. */
|
|
51
85
|
export async function getStagedDiff(): Promise<string> {
|
|
52
|
-
return git(["diff",
|
|
86
|
+
return git(["diff", ...STAGED_DIFF_FLAGS]);
|
|
53
87
|
}
|
|
54
88
|
|
|
55
89
|
/** Parsed list of staged files with their status codes. */
|
|
56
90
|
export async function getStagedFiles(): Promise<FileChange[]> {
|
|
57
|
-
const out = await git(["diff",
|
|
91
|
+
const out = await git(["diff", ...STAGED_DIFF_FLAGS, "--name-status"]);
|
|
58
92
|
return out
|
|
59
93
|
.split("\n")
|
|
60
94
|
.map((line) => line.trim())
|
|
@@ -75,7 +109,7 @@ export async function stageAll(): Promise<void> {
|
|
|
75
109
|
|
|
76
110
|
/** A short one-line stat summary of staged changes (for display). */
|
|
77
111
|
export async function getStagedStat(): Promise<string> {
|
|
78
|
-
return (await git(["diff",
|
|
112
|
+
return (await git(["diff", ...STAGED_DIFF_FLAGS, "--stat"])).trimEnd();
|
|
79
113
|
}
|
|
80
114
|
|
|
81
115
|
/**
|
package/src/models.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model-string parsing: which provider serves a configured model name.
|
|
3
|
+
*
|
|
4
|
+
* A model string is either a Claude model (an alias like `sonnet`, or a full
|
|
5
|
+
* `claude-*` id) or, with an `ollama:` prefix, a model on a local or
|
|
6
|
+
* self-hosted Ollama server. Everything after the prefix is the Ollama model
|
|
7
|
+
* name **verbatim**, which matters because Ollama names carry their own
|
|
8
|
+
* colon: `ollama:ornith-1.5:35b` is the model `ornith-1.5:35b`, not
|
|
9
|
+
* `ornith-1.5` with some tag `35b` cco is expected to reassemble. Only the
|
|
10
|
+
* first `ollama:` is consumed.
|
|
11
|
+
*
|
|
12
|
+
* The prefix is matched case-insensitively - it is cco's own syntax, and
|
|
13
|
+
* `Ollama:` is an easy thing to type - while the model name is passed
|
|
14
|
+
* through with its case intact, because Ollama's registry is case-sensitive.
|
|
15
|
+
*
|
|
16
|
+
* This module is deliberately free of transport and SDK imports so that
|
|
17
|
+
* anything needing to know *which* provider a name refers to (chunk sizing
|
|
18
|
+
* in `src/tokens.ts`, for one) can ask without pulling in a backend.
|
|
19
|
+
*/
|
|
20
|
+
import { ClaudeCommitError } from "./errors";
|
|
21
|
+
|
|
22
|
+
/** Marks a model name as belonging to an Ollama server. Case-insensitive. */
|
|
23
|
+
export const OLLAMA_PREFIX = "ollama:";
|
|
24
|
+
|
|
25
|
+
/** Which backend serves a model. */
|
|
26
|
+
export type ModelProvider = "claude" | "ollama";
|
|
27
|
+
|
|
28
|
+
/** A model string resolved into a provider and the name that provider expects. */
|
|
29
|
+
export interface ModelRef {
|
|
30
|
+
provider: ModelProvider;
|
|
31
|
+
/** The model name to send to the provider, with any cco prefix removed. */
|
|
32
|
+
name: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Default Ollama base URL, used when neither the config nor `$OLLAMA_HOST`
|
|
37
|
+
* names one. This is Ollama's own default listen address.
|
|
38
|
+
*/
|
|
39
|
+
export const DEFAULT_OLLAMA_HOST = "http://localhost:11434";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Default `ollama.context`: ask the server what window it would run the
|
|
43
|
+
* model with on this machine, rather than guess (see `probeOllamaContext`
|
|
44
|
+
* in `src/ollama.ts`). Ollama's choice is made from available VRAM and
|
|
45
|
+
* capped at the model's trained maximum, so it is the largest window the
|
|
46
|
+
* server believes it can actually load.
|
|
47
|
+
*/
|
|
48
|
+
export const DEFAULT_OLLAMA_CONTEXT = "auto" as const;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The window assumed for an `ollama:` model when nothing better is known:
|
|
52
|
+
* the synchronous fallback in `contextWindowTokens` for callers that size
|
|
53
|
+
* chunks without first resolving the context. The pipeline never relies on
|
|
54
|
+
* it - it resolves `"auto"` to a real number before sizing - so this only
|
|
55
|
+
* matters to direct library use. 32768 is Ollama's middle VRAM tier.
|
|
56
|
+
*/
|
|
57
|
+
export const DEFAULT_OLLAMA_CONTEXT_TOKENS = 32_768;
|
|
58
|
+
|
|
59
|
+
/** Whether `model` names an Ollama model (i.e. carries the `ollama:` prefix). */
|
|
60
|
+
export function isOllamaModel(model: string): boolean {
|
|
61
|
+
return model.trim().toLowerCase().startsWith(OLLAMA_PREFIX);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve a configured model string into its provider and provider-side name.
|
|
66
|
+
*
|
|
67
|
+
* Throws {@link ClaudeCommitError} for a name that no provider could serve:
|
|
68
|
+
* an empty string, or an `ollama:` prefix with nothing after it.
|
|
69
|
+
*/
|
|
70
|
+
export function parseModelRef(model: string): ModelRef {
|
|
71
|
+
const trimmed = model.trim();
|
|
72
|
+
if (trimmed === "") {
|
|
73
|
+
throw new ClaudeCommitError(
|
|
74
|
+
"No model configured. Set a model name, or an Ollama model as " +
|
|
75
|
+
`"${OLLAMA_PREFIX}<name>:<tag>".`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (!isOllamaModel(trimmed)) {
|
|
79
|
+
return { provider: "claude", name: trimmed };
|
|
80
|
+
}
|
|
81
|
+
const name = trimmed.slice(OLLAMA_PREFIX.length).trim();
|
|
82
|
+
if (name === "") {
|
|
83
|
+
throw new ClaudeCommitError(
|
|
84
|
+
`"${model}" names no Ollama model. Write the model after the prefix, ` +
|
|
85
|
+
`e.g. "${OLLAMA_PREFIX}ornith-1.5:35b".`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return { provider: "ollama", name };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A model string as it should appear in an error or a `--verbose` line. */
|
|
92
|
+
export function describeModel(model: string): string {
|
|
93
|
+
const trimmed = model.trim();
|
|
94
|
+
return isOllamaModel(trimmed) ? `${trimmed} (Ollama)` : `${trimmed} (Claude)`;
|
|
95
|
+
}
|