@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/ollama.ts
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Ollama backend: one prompt to one local (or self-hosted) model.
|
|
3
|
+
*
|
|
4
|
+
* cco speaks Ollama's **native** `/api/chat`, not either of its compatibility
|
|
5
|
+
* layers. The OpenAI layer has no field for the context length, and cco sizes
|
|
6
|
+
* every diff chunk against a context window, so a dialect that cannot state
|
|
7
|
+
* one is unusable here; the Anthropic layer exists to let Anthropic SDK
|
|
8
|
+
* clients point at Ollama, and cco does not talk raw Anthropic - it talks
|
|
9
|
+
* Agent SDK, which spawns its own binary. The native API gives
|
|
10
|
+
* `options.num_ctx`, structured output via `format`, and the usage counts
|
|
11
|
+
* that make a truncated prompt detectable.
|
|
12
|
+
*
|
|
13
|
+
* There is no SDK dependency: one `fetch` against one endpoint, so nothing
|
|
14
|
+
* here assumes a particular JavaScript runtime.
|
|
15
|
+
*
|
|
16
|
+
* ## Two failure modes worth knowing about
|
|
17
|
+
*
|
|
18
|
+
* **A prompt over the context window is truncated silently.** Ollama drops
|
|
19
|
+
* the oldest content, returns HTTP 200, and flags nothing. A summary written
|
|
20
|
+
* from half a diff is worse than no summary, so every request pins
|
|
21
|
+
* `options.num_ctx` to the same number the chunks were sized against, and
|
|
22
|
+
* the response's `prompt_eval_count` is checked against it afterwards.
|
|
23
|
+
* Where that number comes from is {@link resolveOllamaContext}: a configured
|
|
24
|
+
* token count, or - by default - the window Ollama itself picks for the
|
|
25
|
+
* model on this machine, read back from `/api/ps` after a preload.
|
|
26
|
+
* Reaching the limit means content was dropped, and cco raises an error
|
|
27
|
+
* whose text contains "prompt is too long" - the phrase
|
|
28
|
+
* {@link isPromptTooLongError} matches - so the pipeline's existing
|
|
29
|
+
* halve-and-re-split retry handles it exactly as it handles a Claude
|
|
30
|
+
* overflow. Ollama has no rejection of its own to trigger that path, so this
|
|
31
|
+
* synthesises one.
|
|
32
|
+
*
|
|
33
|
+
* **An error can arrive after HTTP 200.** In a streamed response it is a
|
|
34
|
+
* plain NDJSON line `{"error": "..."}` partway through, long after the
|
|
35
|
+
* status line said everything was fine, so every parsed line is checked for
|
|
36
|
+
* it rather than trusting the status code.
|
|
37
|
+
*/
|
|
38
|
+
import { ClaudeCommitError } from "./errors";
|
|
39
|
+
import {
|
|
40
|
+
DEFAULT_OLLAMA_CONTEXT,
|
|
41
|
+
DEFAULT_OLLAMA_HOST,
|
|
42
|
+
parseModelRef,
|
|
43
|
+
} from "./models";
|
|
44
|
+
import type { ModelResult, OllamaConfig, RunPromptOptions } from "./types";
|
|
45
|
+
|
|
46
|
+
/** Ollama settings with every default filled in; the context may still be `"auto"`. */
|
|
47
|
+
export interface ResolvedOllama {
|
|
48
|
+
host: string;
|
|
49
|
+
context: number | "auto";
|
|
50
|
+
keepAlive: string | number | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** {@link ResolvedOllama} after `"auto"` has been turned into a number. */
|
|
54
|
+
export interface OllamaRequestSettings {
|
|
55
|
+
host: string;
|
|
56
|
+
contextTokens: number;
|
|
57
|
+
keepAlive: string | number | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Normalise a base URL: add a scheme to a bare `host:port` and drop any
|
|
62
|
+
* trailing slash. Ollama's own `OLLAMA_HOST` convention allows the bare
|
|
63
|
+
* form, so `127.0.0.1:11434` has to mean what a user expects it to.
|
|
64
|
+
*/
|
|
65
|
+
export function normaliseOllamaHost(host: string): string {
|
|
66
|
+
const trimmed = host.trim().replace(/\/+$/, "");
|
|
67
|
+
if (trimmed === "") return DEFAULT_OLLAMA_HOST;
|
|
68
|
+
return /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)
|
|
69
|
+
? trimmed
|
|
70
|
+
: `http://${trimmed}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The Ollama base URL for this run: the configured `ollama.host`, else
|
|
75
|
+
* `$OLLAMA_HOST`, else {@link DEFAULT_OLLAMA_HOST}.
|
|
76
|
+
*/
|
|
77
|
+
export function resolveOllamaHost(
|
|
78
|
+
configured?: string,
|
|
79
|
+
env: Record<string, string | undefined> = process.env,
|
|
80
|
+
): string {
|
|
81
|
+
const candidate = configured?.trim() || env.OLLAMA_HOST?.trim() || "";
|
|
82
|
+
return normaliseOllamaHost(candidate);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Fill in defaults for any Ollama setting the config left out. A missing
|
|
87
|
+
* or unusable `context` becomes `"auto"`; turning that into a number is
|
|
88
|
+
* {@link resolveOllamaContext}'s job, because it takes a round trip.
|
|
89
|
+
*/
|
|
90
|
+
export function resolveOllamaConfig(
|
|
91
|
+
config: Partial<OllamaConfig> | undefined,
|
|
92
|
+
env: Record<string, string | undefined> = process.env,
|
|
93
|
+
): ResolvedOllama {
|
|
94
|
+
const context = config?.context;
|
|
95
|
+
return {
|
|
96
|
+
host: resolveOllamaHost(config?.host, env),
|
|
97
|
+
context:
|
|
98
|
+
typeof context === "number" && context > 0
|
|
99
|
+
? Math.floor(context)
|
|
100
|
+
: DEFAULT_OLLAMA_CONTEXT,
|
|
101
|
+
keepAlive: config?.keepAlive ?? null,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The one field of a `/api/ps` entry cco reads, plus the names it matches on. */
|
|
106
|
+
interface OllamaLoadedModel {
|
|
107
|
+
name?: string;
|
|
108
|
+
model?: string;
|
|
109
|
+
context_length?: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Ask Ollama what context window it would run `model` with on this machine.
|
|
114
|
+
*
|
|
115
|
+
* Two calls. The first is a chat request with no messages, which loads the
|
|
116
|
+
* model (a no-op if it is already resident) *without* a `num_ctx` - so the
|
|
117
|
+
* server applies its own choice, made from available VRAM (4k / 32k / 256k
|
|
118
|
+
* tiers, capped at the model's trained maximum). The second reads that
|
|
119
|
+
* choice back from `/api/ps`, which reports the window each loaded model is
|
|
120
|
+
* actually running with. The load was going to happen on the first real
|
|
121
|
+
* request anyway, so the only added cost is the `ps` round trip.
|
|
122
|
+
*
|
|
123
|
+
* This is deliberately not `/api/show`'s `context_length`, which is the
|
|
124
|
+
* *trained* maximum regardless of hardware - 131072 for a model this
|
|
125
|
+
* machine may only be able to run at 32768. The number the server picked
|
|
126
|
+
* is the one it can actually load.
|
|
127
|
+
*/
|
|
128
|
+
export async function probeOllamaContext(
|
|
129
|
+
model: string,
|
|
130
|
+
settings: { host: string; keepAlive: string | number | null },
|
|
131
|
+
signal?: AbortSignal,
|
|
132
|
+
): Promise<number> {
|
|
133
|
+
const { host, keepAlive } = settings;
|
|
134
|
+
const preload = await ollamaFetch(
|
|
135
|
+
`${host}/api/chat`,
|
|
136
|
+
{
|
|
137
|
+
method: "POST",
|
|
138
|
+
headers: { "Content-Type": "application/json" },
|
|
139
|
+
body: JSON.stringify({
|
|
140
|
+
model,
|
|
141
|
+
messages: [],
|
|
142
|
+
stream: false,
|
|
143
|
+
...(keepAlive !== null ? { keep_alive: keepAlive } : {}),
|
|
144
|
+
}),
|
|
145
|
+
},
|
|
146
|
+
host,
|
|
147
|
+
signal,
|
|
148
|
+
);
|
|
149
|
+
if (!preload.ok) {
|
|
150
|
+
throw new ClaudeCommitError(
|
|
151
|
+
await describeHttpFailure(preload, host, model),
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const ps = await ollamaFetch(
|
|
156
|
+
`${host}/api/ps`,
|
|
157
|
+
{ method: "GET" },
|
|
158
|
+
host,
|
|
159
|
+
signal,
|
|
160
|
+
);
|
|
161
|
+
if (!ps.ok) {
|
|
162
|
+
throw new ClaudeCommitError(await describeHttpFailure(ps, host, model));
|
|
163
|
+
}
|
|
164
|
+
const body = (await ps.json()) as { models?: OllamaLoadedModel[] };
|
|
165
|
+
const loaded = (body.models ?? []).find(
|
|
166
|
+
(entry) => entry.name === model || entry.model === model,
|
|
167
|
+
);
|
|
168
|
+
const contextLength = loaded?.context_length;
|
|
169
|
+
if (typeof contextLength !== "number" || contextLength <= 0) {
|
|
170
|
+
throw new ClaudeCommitError(
|
|
171
|
+
`Ollama loaded "${model}" but did not report its context window in ` +
|
|
172
|
+
`/api/ps, so cco cannot size the diff for it. Set "ollama.context" ` +
|
|
173
|
+
`to a token count to pin one.`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return Math.floor(contextLength);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The context window to use for `model`: the configured number, or the
|
|
181
|
+
* server's own choice when the config says `"auto"` (see
|
|
182
|
+
* {@link probeOllamaContext}). Callers that make several requests to the
|
|
183
|
+
* same model should resolve once and reuse the result.
|
|
184
|
+
*/
|
|
185
|
+
export async function resolveOllamaContext(
|
|
186
|
+
model: string,
|
|
187
|
+
config: Partial<OllamaConfig> | undefined,
|
|
188
|
+
signal?: AbortSignal,
|
|
189
|
+
): Promise<number> {
|
|
190
|
+
const resolved = resolveOllamaConfig(config);
|
|
191
|
+
if (resolved.context !== "auto") return resolved.context;
|
|
192
|
+
const { name } = parseModelRef(model);
|
|
193
|
+
return probeOllamaContext(name, resolved, signal);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** `fetch` with transport failures and cancellation turned into cco errors. */
|
|
197
|
+
async function ollamaFetch(
|
|
198
|
+
url: string,
|
|
199
|
+
init: RequestInit,
|
|
200
|
+
host: string,
|
|
201
|
+
signal?: AbortSignal,
|
|
202
|
+
): Promise<Response> {
|
|
203
|
+
try {
|
|
204
|
+
return await fetch(url, { ...init, ...(signal ? { signal } : {}) });
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (signal?.aborted) {
|
|
207
|
+
throw new ClaudeCommitError("Generation was cancelled.");
|
|
208
|
+
}
|
|
209
|
+
throw new ClaudeCommitError(describeTransportFailure(error, host));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The body of a native `/api/chat` request. */
|
|
214
|
+
export interface OllamaChatRequest {
|
|
215
|
+
model: string;
|
|
216
|
+
messages: Array<{ role: "system" | "user"; content: string }>;
|
|
217
|
+
stream: boolean;
|
|
218
|
+
format?: Record<string, unknown>;
|
|
219
|
+
keep_alive?: string | number;
|
|
220
|
+
options: Record<string, unknown>;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Build the `/api/chat` body for one prompt.
|
|
225
|
+
*
|
|
226
|
+
* Two things are deliberate. Sampling parameters go inside `options` - at the
|
|
227
|
+
* top level Ollama accepts and silently ignores them, so a misplaced
|
|
228
|
+
* `temperature` would look like a model that refuses to vary. And `think` is
|
|
229
|
+
* never sent at all: models disagree on whether reasoning can be switched
|
|
230
|
+
* off (gpt-oss cannot, and takes only a level; Granite uses its own field
|
|
231
|
+
* entirely), so asking is a needless way to earn a 400. Any `thinking` that
|
|
232
|
+
* comes back is dropped on the floor instead.
|
|
233
|
+
*/
|
|
234
|
+
export function buildChatRequest(
|
|
235
|
+
prompt: string,
|
|
236
|
+
opts: RunPromptOptions,
|
|
237
|
+
settings: OllamaRequestSettings,
|
|
238
|
+
): OllamaChatRequest {
|
|
239
|
+
const { name } = parseModelRef(opts.model);
|
|
240
|
+
const options: Record<string, unknown> = { num_ctx: settings.contextTokens };
|
|
241
|
+
if (opts.temperature != null) options.temperature = opts.temperature;
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
model: name,
|
|
245
|
+
messages: [
|
|
246
|
+
{ role: "system", content: opts.system },
|
|
247
|
+
{ role: "user", content: prompt },
|
|
248
|
+
],
|
|
249
|
+
// Stream only when someone is watching the text arrive. A single JSON
|
|
250
|
+
// body is easier to get right, and is what Ollama's own guidance
|
|
251
|
+
// recommends for structured output.
|
|
252
|
+
stream: Boolean(opts.onText),
|
|
253
|
+
...(opts.outputFormat ? { format: opts.outputFormat.schema } : {}),
|
|
254
|
+
...(settings.keepAlive !== null ? { keep_alive: settings.keepAlive } : {}),
|
|
255
|
+
options,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The fields of a chat response cco actually reads. */
|
|
260
|
+
interface OllamaChatChunk {
|
|
261
|
+
model?: string;
|
|
262
|
+
message?: { content?: string; thinking?: string };
|
|
263
|
+
done?: boolean;
|
|
264
|
+
done_reason?: string;
|
|
265
|
+
prompt_eval_count?: number;
|
|
266
|
+
prompt_eval_cached_count?: number;
|
|
267
|
+
eval_count?: number;
|
|
268
|
+
error?: string;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Turn a non-2xx response into a message that says what to do about it. */
|
|
272
|
+
async function describeHttpFailure(
|
|
273
|
+
response: Response,
|
|
274
|
+
host: string,
|
|
275
|
+
model: string,
|
|
276
|
+
): Promise<string> {
|
|
277
|
+
let detail = "";
|
|
278
|
+
try {
|
|
279
|
+
const body: unknown = await response.json();
|
|
280
|
+
if (body && typeof body === "object" && "error" in body) {
|
|
281
|
+
detail = String((body as { error: unknown }).error);
|
|
282
|
+
}
|
|
283
|
+
} catch {
|
|
284
|
+
/* a non-JSON error body tells us nothing extra */
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
switch (response.status) {
|
|
288
|
+
case 404:
|
|
289
|
+
return (
|
|
290
|
+
`Ollama has no model "${model}" on ${host}. Pull it first with ` +
|
|
291
|
+
`\`ollama pull ${model}\`, or check the exact name with \`ollama list\`.`
|
|
292
|
+
);
|
|
293
|
+
case 400:
|
|
294
|
+
return (
|
|
295
|
+
`Ollama rejected the request for "${model}"${detail ? `: ${detail}` : ""}. ` +
|
|
296
|
+
`Check the model supports plain chat completion (\`ollama show ${model}\`).`
|
|
297
|
+
);
|
|
298
|
+
case 401:
|
|
299
|
+
case 403:
|
|
300
|
+
return `Ollama at ${host} refused the request as unauthorised${detail ? `: ${detail}` : ""}.`;
|
|
301
|
+
case 429:
|
|
302
|
+
return `Ollama at ${host} is rate limiting requests. Try again shortly.`;
|
|
303
|
+
case 500:
|
|
304
|
+
return (
|
|
305
|
+
`Ollama failed to run "${model}"${detail ? `: ${detail}` : ""}. ` +
|
|
306
|
+
`This is often the model runner running out of memory - set ` +
|
|
307
|
+
`"ollama.context" to a smaller number or use a smaller model.`
|
|
308
|
+
);
|
|
309
|
+
case 503:
|
|
310
|
+
return `Ollama at ${host} has a full request queue. Try again shortly.`;
|
|
311
|
+
default:
|
|
312
|
+
return (
|
|
313
|
+
`Ollama at ${host} returned ${response.status} ${response.statusText}` +
|
|
314
|
+
(detail ? `: ${detail}` : "") +
|
|
315
|
+
"."
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Turn a transport-level failure into a message that says what to do about it. */
|
|
321
|
+
function describeTransportFailure(error: unknown, host: string): string {
|
|
322
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
323
|
+
if (
|
|
324
|
+
/econnrefused|failed to fetch|unable to connect|connection refused/i.test(
|
|
325
|
+
message,
|
|
326
|
+
)
|
|
327
|
+
) {
|
|
328
|
+
return (
|
|
329
|
+
`Cannot reach the Ollama server at ${host}. Start it with ` +
|
|
330
|
+
`\`ollama serve\`, or set "ollama.host" in your claude-commit config.`
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
return `Failed to call Ollama at ${host}: ${message}`;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Read one NDJSON stream, forwarding text deltas and returning the final
|
|
338
|
+
* chunk. Each line is checked for an `error` key: a mid-stream failure
|
|
339
|
+
* arrives that way, after the 200 has already been sent, so a status check
|
|
340
|
+
* alone would miss it.
|
|
341
|
+
*/
|
|
342
|
+
async function consumeStream(
|
|
343
|
+
response: Response,
|
|
344
|
+
onText: ((delta: string) => void) | undefined,
|
|
345
|
+
): Promise<{ content: string; final: OllamaChatChunk }> {
|
|
346
|
+
const body = response.body;
|
|
347
|
+
if (!body) throw new ClaudeCommitError("Ollama returned an empty response.");
|
|
348
|
+
|
|
349
|
+
const reader = body.getReader();
|
|
350
|
+
const decoder = new TextDecoder();
|
|
351
|
+
let buffer = "";
|
|
352
|
+
let content = "";
|
|
353
|
+
let final: OllamaChatChunk = {};
|
|
354
|
+
|
|
355
|
+
const handleLine = (line: string) => {
|
|
356
|
+
const trimmed = line.trim();
|
|
357
|
+
if (trimmed === "") return;
|
|
358
|
+
let chunk: OllamaChatChunk;
|
|
359
|
+
try {
|
|
360
|
+
chunk = JSON.parse(trimmed) as OllamaChatChunk;
|
|
361
|
+
} catch {
|
|
362
|
+
throw new ClaudeCommitError(
|
|
363
|
+
`Ollama sent a malformed response line: ${trimmed.slice(0, 200)}`,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
if (chunk.error) throw new ClaudeCommitError(`Ollama: ${chunk.error}`);
|
|
367
|
+
const delta = chunk.message?.content ?? "";
|
|
368
|
+
if (delta !== "") {
|
|
369
|
+
content += delta;
|
|
370
|
+
onText?.(delta);
|
|
371
|
+
}
|
|
372
|
+
if (chunk.done) final = chunk;
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
for (;;) {
|
|
376
|
+
const { value, done } = await reader.read();
|
|
377
|
+
if (done) break;
|
|
378
|
+
buffer += decoder.decode(value, { stream: true });
|
|
379
|
+
let newline: number;
|
|
380
|
+
while ((newline = buffer.indexOf("\n")) >= 0) {
|
|
381
|
+
const line = buffer.slice(0, newline);
|
|
382
|
+
buffer = buffer.slice(newline + 1);
|
|
383
|
+
handleLine(line);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
buffer += decoder.decode();
|
|
387
|
+
handleLine(buffer);
|
|
388
|
+
|
|
389
|
+
if (!final.done) {
|
|
390
|
+
throw new ClaudeCommitError(
|
|
391
|
+
"Ollama's response ended before the model finished.",
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
return { content, final };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Tokens the server reported for the prompt. `prompt_eval_cached_count` is
|
|
399
|
+
* documented as its own counter without saying whether cached tokens are
|
|
400
|
+
* also inside `prompt_eval_count`, so take the larger: under either reading
|
|
401
|
+
* that is the prompt's real size, and neither double-counts.
|
|
402
|
+
*/
|
|
403
|
+
function promptTokensOf(final: OllamaChatChunk): number {
|
|
404
|
+
return Math.max(
|
|
405
|
+
final.prompt_eval_count ?? 0,
|
|
406
|
+
final.prompt_eval_cached_count ?? 0,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Run a single prompt against an Ollama model and return its response.
|
|
412
|
+
*
|
|
413
|
+
* Throws {@link ClaudeCommitError} on any transport, model or truncation
|
|
414
|
+
* failure. `costUsd` is always zero: local inference is not billed, so a
|
|
415
|
+
* mixed-provider run's reported cost is exactly its Claude half.
|
|
416
|
+
*/
|
|
417
|
+
export async function runOllamaPrompt(
|
|
418
|
+
prompt: string,
|
|
419
|
+
opts: RunPromptOptions,
|
|
420
|
+
): Promise<ModelResult> {
|
|
421
|
+
const { name } = parseModelRef(opts.model);
|
|
422
|
+
const signal = opts.abortController?.signal;
|
|
423
|
+
// A caller that has already resolved `"auto"` (the pipeline does, once
|
|
424
|
+
// per model) passes a number through and pays nothing here; a direct
|
|
425
|
+
// caller with `"auto"` pays the probe on every call.
|
|
426
|
+
const base = resolveOllamaConfig(opts.ollama);
|
|
427
|
+
const resolved: OllamaRequestSettings = {
|
|
428
|
+
host: base.host,
|
|
429
|
+
keepAlive: base.keepAlive,
|
|
430
|
+
contextTokens: await resolveOllamaContext(opts.model, opts.ollama, signal),
|
|
431
|
+
};
|
|
432
|
+
const request = buildChatRequest(prompt, opts, resolved);
|
|
433
|
+
|
|
434
|
+
const response = await ollamaFetch(
|
|
435
|
+
`${resolved.host}/api/chat`,
|
|
436
|
+
{
|
|
437
|
+
method: "POST",
|
|
438
|
+
headers: { "Content-Type": "application/json" },
|
|
439
|
+
body: JSON.stringify(request),
|
|
440
|
+
},
|
|
441
|
+
resolved.host,
|
|
442
|
+
signal,
|
|
443
|
+
);
|
|
444
|
+
if (!response.ok) {
|
|
445
|
+
throw new ClaudeCommitError(
|
|
446
|
+
await describeHttpFailure(response, resolved.host, name),
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
let content: string;
|
|
451
|
+
let final: OllamaChatChunk;
|
|
452
|
+
if (request.stream) {
|
|
453
|
+
({ content, final } = await consumeStream(response, opts.onText));
|
|
454
|
+
} else {
|
|
455
|
+
final = (await response.json()) as OllamaChatChunk;
|
|
456
|
+
if (final.error) throw new ClaudeCommitError(`Ollama: ${final.error}`);
|
|
457
|
+
content = final.message?.content ?? "";
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// The prompt filled the window, which means Ollama dropped whatever did
|
|
461
|
+
// not fit rather than complaining. Phrase it so the pipeline's overflow
|
|
462
|
+
// retry recognises it and re-splits the chunk.
|
|
463
|
+
const promptTokens = promptTokensOf(final);
|
|
464
|
+
if (promptTokens > 0 && promptTokens >= resolved.contextTokens) {
|
|
465
|
+
throw new ClaudeCommitError(
|
|
466
|
+
`Ollama truncated the request to "${name}": the prompt is too long for ` +
|
|
467
|
+
`the ${resolved.contextTokens}-token context window ("ollama.context").`,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (final.done_reason === "length") {
|
|
472
|
+
throw new ClaudeCommitError(
|
|
473
|
+
`Ollama's reply from "${name}" was cut off at the context limit. ` +
|
|
474
|
+
`Raise "ollama.context" beyond ${resolved.contextTokens}, or use ` +
|
|
475
|
+
`a model with more room.`,
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const text = content.trim();
|
|
480
|
+
if (text === "") {
|
|
481
|
+
throw new ClaudeCommitError(`Ollama model "${name}" returned no text.`);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
let structured: unknown;
|
|
485
|
+
if (opts.outputFormat) {
|
|
486
|
+
try {
|
|
487
|
+
structured = JSON.parse(text);
|
|
488
|
+
} catch {
|
|
489
|
+
// Leave `structured` unset: the caller's fallback chain drops to a
|
|
490
|
+
// plain-text attempt, which is exactly the right response to a model
|
|
491
|
+
// or server that could not honour the schema (Ollama Cloud, for one,
|
|
492
|
+
// does not support `format` at all).
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return {
|
|
497
|
+
text,
|
|
498
|
+
costUsd: 0,
|
|
499
|
+
...(final.model ? { model: final.model } : {}),
|
|
500
|
+
...(structured !== undefined ? { structured } : {}),
|
|
501
|
+
};
|
|
502
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path matching for the path-list configuration options - `lowPriorityPaths`
|
|
3
|
+
* (weigh these changes less) and `ignore` (do not read these changes at all).
|
|
4
|
+
* Both take the same pattern language, so both compile through here.
|
|
5
|
+
*
|
|
6
|
+
* Patterns follow gitignore conventions rather than raw glob semantics,
|
|
7
|
+
* because that is what users reach for when they write
|
|
8
|
+
* `.agents/skills/*-skilld` and expect it to cover every file beneath each
|
|
9
|
+
* matching directory. `Bun.Glob` does the wildcard work (no dependency, `*`
|
|
10
|
+
* matches dotfiles, `**` crosses directories, braces expand, `\` escapes);
|
|
11
|
+
* this module adds the gitignore-style rules on top:
|
|
12
|
+
*
|
|
13
|
+
* - A pattern containing a `/` (anywhere but the end) is anchored at the
|
|
14
|
+
* repository root and matches a path when the glob matches the path
|
|
15
|
+
* itself **or any ancestor directory** of it.
|
|
16
|
+
* - A pattern without a `/` matches when the glob matches **any path
|
|
17
|
+
* segment** - the file's basename or any ancestor directory's name - so
|
|
18
|
+
* `bun.lock` or `*-skilld` apply at any depth.
|
|
19
|
+
* - A leading `/` or `./` anchors a pattern that would otherwise be bare; a
|
|
20
|
+
* trailing `/` is accepted (gitignore's "directory only" marker) and
|
|
21
|
+
* ignored, since the ancestor rule already covers a directory's contents.
|
|
22
|
+
* - A leading `!` negates: patterns are evaluated in order and the last one
|
|
23
|
+
* that matches decides, so `["docs/**", "!docs/adr/**"]` selects
|
|
24
|
+
* docs except the ADRs. `\!` matches a literal leading bang.
|
|
25
|
+
*
|
|
26
|
+
* Paths are always repository-root-relative with `/` separators, which is
|
|
27
|
+
* what git emits on every platform (`getStagedDiff` forces `--no-relative`);
|
|
28
|
+
* a backslash in a path is a filename character, never a separator. The
|
|
29
|
+
* anchored/bare decision is made on the whole pattern text, so a `/` inside
|
|
30
|
+
* a brace group anchors every alternative - prefer one pattern per intent.
|
|
31
|
+
*
|
|
32
|
+
* An ill-formed pattern never throws, but `Bun.Glob` parses it rather than
|
|
33
|
+
* rejecting it, so it does not reliably match nothing: an unbalanced `{` is
|
|
34
|
+
* treated as its first alternative (`{docs,build` matches `docs` at any
|
|
35
|
+
* depth and never `build`), while an unterminated `[` matches nothing at
|
|
36
|
+
* all, not even its own text (write `\[abc` for that). No construction-time
|
|
37
|
+
* check can catch this - gitignore does not validate either - so the
|
|
38
|
+
* failure mode is a silently mis-classified diff, made visible by the
|
|
39
|
+
* `--verbose` match counts rather than prevented.
|
|
40
|
+
*/
|
|
41
|
+
import { Glob } from "bun";
|
|
42
|
+
|
|
43
|
+
/** A predicate over repository-relative paths. */
|
|
44
|
+
export type PathMatcher = (path: string) => boolean;
|
|
45
|
+
|
|
46
|
+
interface CompiledPattern {
|
|
47
|
+
glob: Glob;
|
|
48
|
+
/** Match against the path and its ancestors (true) or against each segment (false). */
|
|
49
|
+
anchored: boolean;
|
|
50
|
+
/** A `!` pattern: a match un-marks the path instead of marking it. */
|
|
51
|
+
negated: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Normalise a repository-relative path: no leading `./` or `/`. */
|
|
55
|
+
function normalisePath(path: string): string {
|
|
56
|
+
let normalised = path;
|
|
57
|
+
while (normalised.startsWith("./")) normalised = normalised.slice(2);
|
|
58
|
+
return normalised.replace(/^\/+/, "");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Compile one raw pattern, or `null` when nothing remains after normalising. */
|
|
62
|
+
function compilePattern(raw: string): CompiledPattern | null {
|
|
63
|
+
let pattern = raw.trim();
|
|
64
|
+
if (pattern === "") return null;
|
|
65
|
+
|
|
66
|
+
let negated = false;
|
|
67
|
+
if (pattern.startsWith("!")) {
|
|
68
|
+
negated = true;
|
|
69
|
+
pattern = pattern.slice(1).trim();
|
|
70
|
+
if (pattern === "") return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let anchored = false;
|
|
74
|
+
// `./x` is what shell completion produces at the repo root: anchor it.
|
|
75
|
+
while (pattern.startsWith("./")) {
|
|
76
|
+
anchored = true;
|
|
77
|
+
pattern = pattern.slice(2);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// gitignore's trailing slash ("directory only") - drop it; the ancestor
|
|
81
|
+
// rule already makes a directory pattern cover everything beneath it.
|
|
82
|
+
while (pattern.length > 1 && pattern.endsWith("/")) {
|
|
83
|
+
pattern = pattern.slice(0, -1);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (pattern.startsWith("/")) {
|
|
87
|
+
anchored = true;
|
|
88
|
+
pattern = pattern.replace(/^\/+/, "");
|
|
89
|
+
}
|
|
90
|
+
if (pattern === "") return null;
|
|
91
|
+
if (pattern.includes("/")) anchored = true;
|
|
92
|
+
|
|
93
|
+
return { glob: new Glob(pattern), anchored, negated };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function matchesCompiled(
|
|
97
|
+
segments: string[],
|
|
98
|
+
compiled: CompiledPattern,
|
|
99
|
+
): boolean {
|
|
100
|
+
if (compiled.anchored) {
|
|
101
|
+
// The path itself first, then each ancestor directory, longest first.
|
|
102
|
+
for (let length = segments.length; length >= 1; length--) {
|
|
103
|
+
if (compiled.glob.match(segments.slice(0, length).join("/"))) {
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
return segments.some((segment) => compiled.glob.match(segment));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Build a matcher for a list of gitignore-style patterns. Compile once per
|
|
114
|
+
* run and reuse it across every path in the diff.
|
|
115
|
+
*/
|
|
116
|
+
export function createPathMatcher(patterns: string[]): PathMatcher {
|
|
117
|
+
const compiled = patterns
|
|
118
|
+
.map(compilePattern)
|
|
119
|
+
.filter((entry): entry is CompiledPattern => entry !== null);
|
|
120
|
+
if (compiled.length === 0) return () => false;
|
|
121
|
+
|
|
122
|
+
return (path: string): boolean => {
|
|
123
|
+
const segments = normalisePath(path)
|
|
124
|
+
.split("/")
|
|
125
|
+
.filter((segment) => segment !== "");
|
|
126
|
+
if (segments.length === 0) return false;
|
|
127
|
+
// gitignore semantics: the last pattern that matches decides.
|
|
128
|
+
let verdict = false;
|
|
129
|
+
for (const entry of compiled) {
|
|
130
|
+
if (matchesCompiled(segments, entry)) verdict = !entry.negated;
|
|
131
|
+
}
|
|
132
|
+
return verdict;
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Whether `path` matches any of the gitignore-style `patterns`. */
|
|
137
|
+
export function matchesPathPatterns(path: string, patterns: string[]): boolean {
|
|
138
|
+
return createPathMatcher(patterns)(path);
|
|
139
|
+
}
|