@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/src/generate.ts CHANGED
@@ -1,9 +1,24 @@
1
1
  /**
2
2
  * The commit-message pipeline:
3
3
  *
4
- * diff ──split──▶ [chunk, chunk, ...] ──summary model──▶ [summary, ...]
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 { redactOpaqueRuns, splitDiffToFit } from "./diff";
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 { Config } from "./types";
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 (useful for `--verbose`). */
56
- summaries: string[];
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
- /** Run the full pipeline over a staged diff. */
71
- export async function generateCommit(
72
- diff: string,
73
- config: Config,
74
- options: GenerateOptions = {},
75
- ): Promise<GenerateResult> {
76
- const {
77
- count = 1,
78
- progress = {},
79
- abortController,
80
- runner = runPrompt,
81
- } = options;
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
- const effectiveDiff = config.skipArmored ? redactOpaqueRuns(diff) : diff;
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. Chunks are sized
88
- // by a content-classified token estimate: opaque content (age/gpg armor,
89
- // binary patches) measures near 1 char/token, so a plain chars-based
90
- // budget underestimates armor-heavy diffs more than threefold.
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
- // Stage 1: summarize each chunk, via a work queue so an oversized chunk
105
- // can be re-split and retried in place. The estimate is calibrated, but
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((m) => m.length > 0);
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
- /** The unified diff of staged changes (`git diff --cached`). */
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", "--cached", "--no-color"]);
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", "--cached", "--name-status"]);
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", "--cached", "--stat", "--no-color"])).trimEnd();
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
+ }