@synmux/claude-commit 0.1.4 → 1.0.0

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 CHANGED
@@ -157,6 +157,7 @@ keys are valid at every level:
157
157
  },
158
158
  "maxChunkTokens": 600000,
159
159
  "charsPerToken": 3.5,
160
+ "skipArmored": false,
160
161
  "allowApiKey": false
161
162
  }
162
163
  ```
@@ -167,6 +168,23 @@ current Sonnet/Opus keep the full budget; Haiku, older pinned model ids, and
167
168
  unrecognised models are floored at 200k), so a single chunk can never overflow
168
169
  the model.
169
170
 
171
+ Chunk sizes come from a content-classified token estimate, not a flat
172
+ `charsPerToken` ratio: armored or encoded lines (age/gpg armor, base64 blobs,
173
+ git binary patches) tokenize at roughly **one token per character** on current
174
+ Claude models, so they are budgeted at that rate while ordinary text keeps the
175
+ configured ratio. If the backend still rejects a chunk as too long, cco
176
+ re-splits just that chunk with a halved budget and retries - the rejection is
177
+ free, so the API is the final arbiter.
178
+
179
+ `skipArmored` (or the `--skip-armored` flag) goes further and replaces each
180
+ run of armored lines with a one-line `[cco: N armored/encoded lines omitted]`
181
+ marker before summarizing. Ciphertext is unreadable to the model anyway, so
182
+ this is the recommended setting for encrypted-file repos - for example a
183
+ [chezmoi](https://www.chezmoi.io) source directory with age encryption, where
184
+ every `chezmoi re-add` re-encrypts nondeterministically and produces megabytes
185
+ of churned armor. Drop a `.claude-commit.json` with `{ "skipArmored": true }`
186
+ in the repo root to enable it per-repo.
187
+
170
188
  ## Development
171
189
 
172
190
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synmux/claude-commit",
3
- "version": "0.1.4",
3
+ "version": "1.0.0",
4
4
  "description": "Generate git commit messages with Claude, using your Claude Code subscription.",
5
5
  "main": "index.ts",
6
6
  "module": "index.ts",
@@ -40,7 +40,7 @@
40
40
  "skilld": "^2.0.0"
41
41
  },
42
42
  "peerDependencies": {
43
- "typescript": "^7.0.2"
43
+ "typescript": "^6.0.3"
44
44
  },
45
45
  "dependencies": {
46
46
  "@anthropic-ai/claude-agent-sdk": "^0.3.218",
package/src/agent.ts CHANGED
@@ -163,9 +163,14 @@ function describeAssistantError(code: string): string {
163
163
  * even when the `skills` option is omitted entirely.
164
164
  * - `tools: []` and `plugins: []` drop all built-in tools and plugins.
165
165
  *
166
- * Omitting any of these leaks the user's global Claude Code configuration
167
- * into the request - observed at ~850k tokens of MCP tool definitions, which
168
- * overflows the context window before the diff is even counted.
166
+ * Omitting any of these lets the user's global Claude Code configuration
167
+ * (MCP tool schemas, skill listings - easily hundreds of thousands of tokens
168
+ * on a busy setup) into every request; with all of them set, live probes
169
+ * measure ~170 input tokens per request. Historical note: the 2026-07
170
+ * "Prompt is too long" failures were ultimately caused by underestimating
171
+ * the token density of armored diff content (see `estimateDiffTokens`), not
172
+ * by this leak - the isolation is hygiene and cost control, not the fix for
173
+ * that bug.
169
174
  */
170
175
  export function buildQueryOptions(
171
176
  opts: RunPromptOptions,
package/src/cli.ts CHANGED
@@ -39,6 +39,7 @@ interface CliOptions {
39
39
  spinner?: boolean;
40
40
  config?: string;
41
41
  verbose?: boolean;
42
+ skipArmored?: boolean;
42
43
  }
43
44
 
44
45
  function buildProgram(): Command {
@@ -72,6 +73,11 @@ function buildProgram(): Command {
72
73
  .option("-p, --prompt <text>", "extra instructions appended to the prompt")
73
74
  .option("--model-summary <model>", "model used to summarize the diff")
74
75
  .option("--model-final <model>", "model used to write the final message")
76
+ .option(
77
+ "--skip-armored",
78
+ "omit armored/encoded lines (age/gpg armor, base64 blobs) from the " +
79
+ "summarized diff; recommended for chezmoi-style encrypted repos",
80
+ )
75
81
  .option("-d, --dry-run", "print the message to stdout without committing")
76
82
  .option("-y, --yes", "commit without asking for confirmation")
77
83
  .option("--no-spinner", "disable the progress spinner")
@@ -106,6 +112,7 @@ function flagsToConfig(opts: CliOptions): PartialConfig {
106
112
  if (opts.interactive !== undefined) cfg.interactive = opts.interactive;
107
113
  if (opts.template !== undefined) cfg.template = opts.template;
108
114
  if (opts.prompt !== undefined) cfg.customPrompt = opts.prompt;
115
+ if (opts.skipArmored !== undefined) cfg.skipArmored = opts.skipArmored;
109
116
  if (opts.count !== undefined && Number.isFinite(opts.count)) {
110
117
  cfg.interactiveCount = Math.max(1, opts.count);
111
118
  }
package/src/config.ts CHANGED
@@ -27,6 +27,7 @@ export const DEFAULT_CONFIG: Config = {
27
27
  },
28
28
  maxChunkTokens: 600_000,
29
29
  charsPerToken: 3.5,
30
+ skipArmored: false,
30
31
  allowApiKey: false,
31
32
  };
32
33
 
@@ -90,6 +91,7 @@ export function sanitizePartial(raw: unknown): PartialConfig {
90
91
  bool("gitmoji");
91
92
  bool("multiline");
92
93
  bool("interactive");
94
+ bool("skipArmored");
93
95
  bool("allowApiKey");
94
96
 
95
97
  if (typeof obj.template === "string") out.template = obj.template;
package/src/diff.ts CHANGED
@@ -8,6 +8,8 @@
8
8
  * every piece so each chunk remains a self-contained, interpretable diff.
9
9
  */
10
10
 
11
+ import { estimateDiffTokens, isOpaqueLine } from "./tokens";
12
+
11
13
  const FILE_HEADER = "diff --git ";
12
14
  const HUNK_HEADER = "@@";
13
15
 
@@ -152,3 +154,93 @@ export function splitDiff(diff: string, maxChars: number): string[] {
152
154
  }
153
155
  return packUnits(units, maxChars);
154
156
  }
157
+
158
+ /** Character budget for `text` such that its classified token estimate fits `maxTokens`. */
159
+ function charBudgetFor(
160
+ text: string,
161
+ maxTokens: number,
162
+ charsPerToken: number,
163
+ ): number {
164
+ const density =
165
+ text.length / Math.max(1, estimateDiffTokens(text, charsPerToken));
166
+ return Math.max(1, Math.floor(maxTokens * density));
167
+ }
168
+
169
+ /**
170
+ * Split a diff so that every chunk's *classified token estimate* fits
171
+ * `maxTokens`.
172
+ *
173
+ * `splitDiff` budgets in characters, but token density varies wildly by
174
+ * content: prose and code sit near the configured `charsPerToken` (~3.5)
175
+ * while base64/armor lines measure near 1 char/token. Sizing every chunk
176
+ * with one blended ratio lets an armor-heavy region overflow, so after an
177
+ * initial blended-density split, any chunk still over budget is re-split
178
+ * using its own (denser) ratio until everything fits or no further split is
179
+ * possible. An unsplittable oversized chunk is kept - the overflow retry in
180
+ * the generation pipeline is the backstop for that case.
181
+ */
182
+ /**
183
+ * Minimum consecutive opaque lines before a run is redacted. A lone long
184
+ * unbroken line (a URL, a hash pin, a long path) can carry real meaning; an
185
+ * armored blob never arrives alone.
186
+ */
187
+ const MIN_REDACT_RUN = 3;
188
+
189
+ /**
190
+ * Replace each run of opaque (armored/encoded) lines with a single marker
191
+ * line. The marker keeps the surrounding diff structure interpretable and
192
+ * tells the summary model what was elided, so it can still report that an
193
+ * encrypted file changed - without paying ~1 token per character to send
194
+ * ciphertext the model cannot read anyway.
195
+ */
196
+ export function redactOpaqueRuns(diff: string): string {
197
+ const out: string[] = [];
198
+ let run: string[] = [];
199
+ const flush = () => {
200
+ if (run.length >= MIN_REDACT_RUN) {
201
+ out.push(`[cco: ${run.length} armored/encoded lines omitted]`);
202
+ } else {
203
+ out.push(...run);
204
+ }
205
+ run = [];
206
+ };
207
+ for (const line of diff.split("\n")) {
208
+ if (isOpaqueLine(line)) {
209
+ run.push(line);
210
+ } else {
211
+ flush();
212
+ out.push(line);
213
+ }
214
+ }
215
+ flush();
216
+ return out.join("\n");
217
+ }
218
+
219
+ export function splitDiffToFit(
220
+ diff: string,
221
+ maxTokens: number,
222
+ charsPerToken: number,
223
+ ): string[] {
224
+ const queue = splitDiff(diff, charBudgetFor(diff, maxTokens, charsPerToken));
225
+ const fitted: string[] = [];
226
+ while (queue.length > 0) {
227
+ const chunk = queue.shift()!;
228
+ if (estimateDiffTokens(chunk, charsPerToken) <= maxTokens) {
229
+ fitted.push(chunk);
230
+ continue;
231
+ }
232
+ // Over budget: the chunk's own density is at least as dense as the
233
+ // blend it was sized with, so this budget is strictly smaller than the
234
+ // chunk - splitDiff will attempt a real split.
235
+ const pieces = splitDiff(
236
+ chunk,
237
+ charBudgetFor(chunk, maxTokens, charsPerToken),
238
+ );
239
+ if (pieces.length <= 1) {
240
+ fitted.push(chunk);
241
+ continue;
242
+ }
243
+ queue.unshift(...pieces);
244
+ }
245
+ return fitted;
246
+ }
package/src/errors.ts CHANGED
@@ -2,3 +2,14 @@
2
2
  export class ClaudeCommitError extends Error {
3
3
  override name = "ClaudeCommitError";
4
4
  }
5
+
6
+ /**
7
+ * True when `error` is the backend rejecting a request for exceeding the
8
+ * model's context window. Matched on message text because the Agent SDK
9
+ * surfaces the rejection only as an error string. Used to trigger a
10
+ * re-split-and-retry: the rejection happens before the model runs, so it is
11
+ * not billed.
12
+ */
13
+ export function isPromptTooLongError(error: unknown): boolean {
14
+ return error instanceof Error && /prompt is too long/i.test(error.message);
15
+ }
package/src/generate.ts CHANGED
@@ -5,14 +5,18 @@
5
5
  * ──final model──▶ commit message(s)
6
6
  *
7
7
  * The summary model (default `sonnet`) reads each diff chunk and writes a
8
- * factual summary; chunking keeps each request within the model's context
9
- * window. The final model (default `sonnet`) turns the summaries into the commit
8
+ * factual summary; chunks are sized by a content-classified token estimate
9
+ * (`splitDiffToFit`) so each request fits the model's context window, and a
10
+ * chunk the backend still rejects as too long is re-split with a halved
11
+ * budget and retried - the rejection happens before the model runs and is
12
+ * not billed, so the API acts as the final arbiter of token counts. The
13
+ * final model (default `sonnet`) turns the summaries into the commit
10
14
  * message(s), applying the configured formatting rules.
11
15
  */
12
16
  import { runPrompt } from "./agent";
13
- import { splitDiff } from "./diff";
14
- import { clampChunkTokens, tokensToChars } from "./tokens";
15
- import { ClaudeCommitError } from "./errors";
17
+ import { redactOpaqueRuns, splitDiffToFit } from "./diff";
18
+ import { clampChunkTokens } from "./tokens";
19
+ import { ClaudeCommitError, isPromptTooLongError } from "./errors";
16
20
  import {
17
21
  buildFinalSystem,
18
22
  buildFinalUser,
@@ -37,6 +41,12 @@ export interface GenerateOptions {
37
41
  count?: number;
38
42
  progress?: GenerateProgress;
39
43
  abortController?: AbortController;
44
+ /**
45
+ * Model runner used for every prompt; injectable so tests can exercise the
46
+ * pipeline (including overflow retries) without real model calls.
47
+ * Defaults to {@link runPrompt}.
48
+ */
49
+ runner?: typeof runPrompt;
40
50
  }
41
51
 
42
52
  export interface GenerateResult {
@@ -50,49 +60,100 @@ export interface GenerateResult {
50
60
  costUsd: number;
51
61
  }
52
62
 
63
+ /**
64
+ * Floor for overflow-retry halving. Below this a chunk is essentially
65
+ * prompt-sized already, so a "prompt is too long" rejection indicates
66
+ * something other than chunk sizing and is surfaced instead of retried.
67
+ */
68
+ const MIN_RETRY_CHUNK_TOKENS = 8_000;
69
+
53
70
  /** Run the full pipeline over a staged diff. */
54
71
  export async function generateCommit(
55
72
  diff: string,
56
73
  config: Config,
57
74
  options: GenerateOptions = {},
58
75
  ): Promise<GenerateResult> {
59
- const { count = 1, progress = {}, abortController } = options;
76
+ const {
77
+ count = 1,
78
+ progress = {},
79
+ abortController,
80
+ runner = runPrompt,
81
+ } = options;
82
+
83
+ const effectiveDiff = config.skipArmored ? redactOpaqueRuns(diff) : diff;
60
84
 
61
85
  // The configured chunk budget is clamped to the summary model's context
62
86
  // window so a single chunk (plus prompt scaffolding and response headroom)
63
- // can never overflow it, whatever `maxChunkTokens` says.
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.
64
91
  const chunkTokens = clampChunkTokens(
65
92
  config.models.summary,
66
93
  config.maxChunkTokens,
67
94
  );
68
- const maxChars = tokensToChars(chunkTokens, config.charsPerToken);
69
- const chunks = splitDiff(diff, maxChars);
95
+ const chunks = splitDiffToFit(
96
+ effectiveDiff,
97
+ chunkTokens,
98
+ config.charsPerToken,
99
+ );
70
100
  if (chunks.length === 0) {
71
101
  throw new ClaudeCommitError("There are no staged changes to summarize.");
72
102
  }
73
103
 
74
- // Stage 1: summarize each chunk.
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.
75
109
  const summarySystem = buildSummarySystem();
76
110
  const summaries: string[] = [];
77
111
  let costUsd = 0;
78
112
 
79
- for (let i = 0; i < chunks.length; i++) {
113
+ const queue = chunks.map((chunk) => ({ chunk, tokenBudget: chunkTokens }));
114
+ while (queue.length > 0) {
115
+ const task = queue.shift()!;
116
+ const position = summaries.length;
117
+ const total = summaries.length + queue.length + 1;
80
118
  progress.onPhase?.(
81
- chunks.length > 1
82
- ? `Reading diff (part ${i + 1}/${chunks.length})`
119
+ total > 1
120
+ ? `Reading diff (part ${position + 1}/${total})`
83
121
  : "Reading diff",
84
122
  );
85
- const result = await runPrompt(
86
- buildSummaryUser(chunks[i]!, i, chunks.length),
87
- {
88
- model: config.models.summary,
89
- system: summarySystem,
90
- allowApiKey: config.allowApiKey,
91
- ...(abortController ? { abortController } : {}),
92
- },
93
- );
94
- summaries.push(result.text);
95
- costUsd += result.costUsd;
123
+ try {
124
+ const result = await runner(
125
+ buildSummaryUser(task.chunk, position, total),
126
+ {
127
+ model: config.models.summary,
128
+ system: summarySystem,
129
+ allowApiKey: config.allowApiKey,
130
+ ...(abortController ? { abortController } : {}),
131
+ },
132
+ );
133
+ summaries.push(result.text);
134
+ costUsd += result.costUsd;
135
+ } catch (error) {
136
+ const halvedBudget = Math.floor(task.tokenBudget / 2);
137
+ if (
138
+ !isPromptTooLongError(error) ||
139
+ halvedBudget < MIN_RETRY_CHUNK_TOKENS
140
+ ) {
141
+ throw error;
142
+ }
143
+ const pieces = splitDiffToFit(
144
+ task.chunk,
145
+ halvedBudget,
146
+ config.charsPerToken,
147
+ );
148
+ if (pieces.length === 1 && pieces[0] === task.chunk) {
149
+ // Nothing left to split on (a single oversized hunk): retrying the
150
+ // identical request would loop forever, so surface the error.
151
+ throw error;
152
+ }
153
+ queue.unshift(
154
+ ...pieces.map((chunk) => ({ chunk, tokenBudget: halvedBudget })),
155
+ );
156
+ }
96
157
  }
97
158
 
98
159
  // Stage 2: write the commit message(s) from the summaries.
@@ -126,7 +187,7 @@ export async function generateCommit(
126
187
  let lastError: unknown;
127
188
  for (const attempt of attempts) {
128
189
  try {
129
- const result = await runPrompt(
190
+ const result = await runner(
130
191
  buildFinalUser(summaries, count, attempt.structured),
131
192
  {
132
193
  ...baseOpts,
@@ -171,7 +232,14 @@ export async function generateCommit(
171
232
  throw new ClaudeCommitError("The model did not produce a commit message.");
172
233
  }
173
234
 
174
- return { messages: deduped, summaries, chunkCount: chunks.length, costUsd };
235
+ // Report chunks actually processed: overflow retries can split further
236
+ // than the initial estimate planned.
237
+ return {
238
+ messages: deduped,
239
+ summaries,
240
+ chunkCount: summaries.length,
241
+ costUsd,
242
+ };
175
243
  }
176
244
 
177
245
  function dedupe(items: string[]): string[] {
package/src/tokens.ts CHANGED
@@ -60,3 +60,50 @@ export function clampChunkTokens(
60
60
  contextWindowTokens(model) - CONTEXT_RESERVE_TOKENS,
61
61
  );
62
62
  }
63
+
64
+ /**
65
+ * Chars-per-token for "opaque" content: base64/base85 armor (age, gpg, git
66
+ * binary patches), long hashes, and similar high-entropy runs. Measured
67
+ * against the live API on age-armor diff content (2026-07-23): ~1.14
68
+ * chars/token - the current Claude tokenizer finds almost no merges in
69
+ * random base64. Note that generic BPE vocabularies (tiktoken-class)
70
+ * compress base64 roughly 3x better, so swapping in a third-party "real"
71
+ * tokenizer would underestimate this content class just like a plain
72
+ * chars/3.5 heuristic does. 1.0 leaves a small safety margin under the
73
+ * measured value.
74
+ */
75
+ export const OPAQUE_CHARS_PER_TOKEN = 1.0;
76
+
77
+ /**
78
+ * A diff line whose content (after an optional one-character diff marker) is
79
+ * one long unbroken run with no whitespace - the signature of encoded blobs
80
+ * rather than prose or code. Misclassifying dense text (e.g. minified JS) as
81
+ * opaque merely over-reserves, which is the safe direction.
82
+ */
83
+ const OPAQUE_LINE = /^[+\- ]?\S{40,}$/;
84
+
85
+ /** Whether a single diff line should be estimated at the opaque ratio. */
86
+ export function isOpaqueLine(line: string): boolean {
87
+ return OPAQUE_LINE.test(line);
88
+ }
89
+
90
+ /**
91
+ * Estimate tokens for diff text with per-line content classification:
92
+ * opaque lines at {@link OPAQUE_CHARS_PER_TOKEN}, everything else at the
93
+ * configured `charsPerToken`. A single blended ratio underestimates
94
+ * armor-heavy diffs more than threefold, which is exactly how a chunk that
95
+ * looks within budget can overflow the model's real context window.
96
+ */
97
+ export function estimateDiffTokens(
98
+ text: string,
99
+ charsPerToken: number,
100
+ ): number {
101
+ if (charsPerToken <= 0) throw new Error("charsPerToken must be positive");
102
+ let tokens = 0;
103
+ for (const line of text.split("\n")) {
104
+ const lineChars = line.length + 1; // account for the newline
105
+ tokens +=
106
+ lineChars / (isOpaqueLine(line) ? OPAQUE_CHARS_PER_TOKEN : charsPerToken);
107
+ }
108
+ return Math.ceil(tokens);
109
+ }
package/src/types.ts CHANGED
@@ -49,6 +49,15 @@ export interface Config {
49
49
  maxChunkTokens: number;
50
50
  /** Approximate characters-per-token ratio used for chunk-size estimation. */
51
51
  charsPerToken: number;
52
+ /**
53
+ * Replace runs of armored/encoded diff lines (age/gpg armor, base64 blobs,
54
+ * git binary patch bodies) with a one-line `[... lines omitted]` marker
55
+ * before summarizing. Ciphertext is unreadable to the model and tokenizes
56
+ * at roughly one token per character, so skipping it makes commits in
57
+ * encrypted-file repos (e.g. chezmoi with age) fast and cheap without
58
+ * losing anything a summary could actually use.
59
+ */
60
+ skipArmored: boolean;
52
61
  /**
53
62
  * Allow API credentials from the environment (`ANTHROPIC_API_KEY` /
54
63
  * `ANTHROPIC_AUTH_TOKEN`) to be used, billing pay-as-you-go instead of the