@synmux/claude-commit 0.1.3 → 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 +18 -0
- package/package.json +2 -2
- package/src/agent.ts +8 -3
- package/src/cli.ts +9 -1
- package/src/config.ts +2 -0
- package/src/diff.ts +92 -0
- package/src/errors.ts +11 -0
- package/src/generate.ts +94 -26
- package/src/tokens.ts +47 -0
- package/src/types.ts +9 -0
- package/src/utils.ts +5 -0
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.
|
|
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": "^
|
|
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
|
|
167
|
-
*
|
|
168
|
-
*
|
|
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
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* non-interactive and interactive flows.
|
|
4
4
|
*/
|
|
5
5
|
import { Command } from "commander";
|
|
6
|
+
import { getVersion } from "./utils";
|
|
6
7
|
import {
|
|
7
8
|
commit,
|
|
8
9
|
getStagedDiff,
|
|
@@ -20,7 +21,7 @@ import { color } from "./ui/colors";
|
|
|
20
21
|
import { ClaudeCommitError } from "./errors";
|
|
21
22
|
import type { ModelConfig, PartialConfig } from "./types";
|
|
22
23
|
|
|
23
|
-
export const VERSION =
|
|
24
|
+
export const VERSION = getVersion();
|
|
24
25
|
|
|
25
26
|
interface CliOptions {
|
|
26
27
|
interactive?: boolean;
|
|
@@ -38,6 +39,7 @@ interface CliOptions {
|
|
|
38
39
|
spinner?: boolean;
|
|
39
40
|
config?: string;
|
|
40
41
|
verbose?: boolean;
|
|
42
|
+
skipArmored?: boolean;
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
function buildProgram(): Command {
|
|
@@ -71,6 +73,11 @@ function buildProgram(): Command {
|
|
|
71
73
|
.option("-p, --prompt <text>", "extra instructions appended to the prompt")
|
|
72
74
|
.option("--model-summary <model>", "model used to summarize the diff")
|
|
73
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
|
+
)
|
|
74
81
|
.option("-d, --dry-run", "print the message to stdout without committing")
|
|
75
82
|
.option("-y, --yes", "commit without asking for confirmation")
|
|
76
83
|
.option("--no-spinner", "disable the progress spinner")
|
|
@@ -105,6 +112,7 @@ function flagsToConfig(opts: CliOptions): PartialConfig {
|
|
|
105
112
|
if (opts.interactive !== undefined) cfg.interactive = opts.interactive;
|
|
106
113
|
if (opts.template !== undefined) cfg.template = opts.template;
|
|
107
114
|
if (opts.prompt !== undefined) cfg.customPrompt = opts.prompt;
|
|
115
|
+
if (opts.skipArmored !== undefined) cfg.skipArmored = opts.skipArmored;
|
|
108
116
|
if (opts.count !== undefined && Number.isFinite(opts.count)) {
|
|
109
117
|
cfg.interactiveCount = Math.max(1, opts.count);
|
|
110
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;
|
|
9
|
-
*
|
|
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 {
|
|
14
|
-
import { clampChunkTokens
|
|
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 {
|
|
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
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
? `Reading diff (part ${
|
|
119
|
+
total > 1
|
|
120
|
+
? `Reading diff (part ${position + 1}/${total})`
|
|
83
121
|
: "Reading diff",
|
|
84
122
|
);
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
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
|
-
|
|
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
|