@synmux/claude-commit 1.0.2 → 1.0.4
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/CHANGELOG.md +204 -0
- package/README.md +242 -6
- package/index.ts +33 -2
- package/package.json +32 -18
- package/src/agent.ts +36 -43
- package/src/cli.ts +137 -12
- package/src/config.ts +91 -6
- package/src/diff.ts +348 -14
- package/src/generate.ts +295 -49
- 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 +192 -28
- package/src/tokens.ts +47 -9
- package/src/types.ts +147 -3
- package/src/ui/spinner.ts +1 -1
package/src/agent.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* The model-call layer: {@link runPrompt} turns one prompt into one text
|
|
3
|
+
* completion, routing to whichever backend the model name asks for.
|
|
4
|
+
*
|
|
5
|
+
* A bare model name goes to Claude, through the Agent SDK, below. An
|
|
6
|
+
* `ollama:`-prefixed one goes to `src/ollama.ts` instead. Both return the
|
|
7
|
+
* same {@link ModelResult}, so the pipeline in `src/generate.ts` - and the
|
|
8
|
+
* injectable runner its tests use - never learns which provider ran.
|
|
9
|
+
*
|
|
10
|
+
* The Claude path, in detail:
|
|
4
11
|
*
|
|
5
12
|
* The Agent SDK spawns a bundled `claude` binary, so authentication follows
|
|
6
13
|
* Claude Code's own resolution order over the environment we hand it. By
|
|
@@ -19,47 +26,12 @@ import {
|
|
|
19
26
|
type SDKMessage,
|
|
20
27
|
} from "@anthropic-ai/claude-agent-sdk";
|
|
21
28
|
import { ClaudeCommitError } from "./errors";
|
|
22
|
-
import
|
|
29
|
+
import { parseModelRef } from "./models";
|
|
30
|
+
import { runOllamaPrompt } from "./ollama";
|
|
31
|
+
import type { ModelResult, RunPromptOptions } from "./types";
|
|
23
32
|
|
|
24
|
-
export
|
|
25
|
-
/** Model string (alias like `sonnet`, `haiku`, or a full model id). */
|
|
26
|
-
model: string;
|
|
27
|
-
/** Full custom system prompt. */
|
|
28
|
-
system: string;
|
|
29
|
-
/** Receives assistant text as it streams in (enables partial messages). */
|
|
30
|
-
onText?: (delta: string) => void;
|
|
31
|
-
/** Abort the in-flight request. */
|
|
32
|
-
abortController?: AbortController;
|
|
33
|
-
/** Receives the underlying CLI's stderr (for `--verbose`). */
|
|
34
|
-
onStderr?: (data: string) => void;
|
|
35
|
-
/**
|
|
36
|
-
* Sampling temperature. Passed to the model via `CLAUDE_CODE_EXTRA_BODY`.
|
|
37
|
-
* Used to add variety when generating several interactive options. Models
|
|
38
|
-
* that don't accept a temperature override will reject the request, so the
|
|
39
|
-
* caller should be prepared to retry without it.
|
|
40
|
-
*/
|
|
41
|
-
temperature?: number;
|
|
42
|
-
/**
|
|
43
|
-
* Request a structured JSON response matching this schema. The parsed object
|
|
44
|
-
* is returned on {@link ModelResult.structured}. Models that don't support
|
|
45
|
-
* structured outputs will reject the request, so the caller should be
|
|
46
|
-
* prepared to retry without it.
|
|
47
|
-
*/
|
|
48
|
-
outputFormat?: { type: "json_schema"; schema: Record<string, unknown> };
|
|
49
|
-
/**
|
|
50
|
-
* Allow API credentials from the environment to reach the SDK subprocess.
|
|
51
|
-
* Defaults to false: `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` are
|
|
52
|
-
* stripped so the run is billed to the Claude subscription.
|
|
53
|
-
*/
|
|
54
|
-
allowApiKey?: boolean;
|
|
55
|
-
}
|
|
33
|
+
export type { RunPromptOptions } from "./types";
|
|
56
34
|
|
|
57
|
-
/**
|
|
58
|
-
* Environment variables that carry Claude API credentials. Their presence
|
|
59
|
-
* switches the spawned `claude` binary from subscription auth to
|
|
60
|
-
* pay-as-you-go API billing, so they are stripped from the subprocess
|
|
61
|
-
* environment unless the user opts in via the `allowApiKey` config option.
|
|
62
|
-
*/
|
|
63
35
|
export const GATED_CREDENTIAL_VARS = [
|
|
64
36
|
"ANTHROPIC_API_KEY",
|
|
65
37
|
"ANTHROPIC_AUTH_TOKEN",
|
|
@@ -195,11 +167,11 @@ export function buildQueryOptions(
|
|
|
195
167
|
}
|
|
196
168
|
|
|
197
169
|
/**
|
|
198
|
-
* Run a single prompt
|
|
170
|
+
* Run a single prompt against a Claude model via the Agent SDK.
|
|
199
171
|
*
|
|
200
172
|
* Throws {@link ClaudeCommitError} on any model/authentication/quota failure.
|
|
201
173
|
*/
|
|
202
|
-
export async function
|
|
174
|
+
export async function runClaudePrompt(
|
|
203
175
|
prompt: string,
|
|
204
176
|
opts: RunPromptOptions,
|
|
205
177
|
): Promise<ModelResult> {
|
|
@@ -285,3 +257,24 @@ export async function runPrompt(
|
|
|
285
257
|
...(structured !== undefined ? { structured } : {}),
|
|
286
258
|
};
|
|
287
259
|
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Run a single prompt against whichever provider `opts.model` names, and
|
|
263
|
+
* return its text response.
|
|
264
|
+
*
|
|
265
|
+
* This is the single seam every caller uses; `generate.ts` accepts a
|
|
266
|
+
* replacement of exactly this shape so the pipeline can be tested without a
|
|
267
|
+
* model of either kind.
|
|
268
|
+
*
|
|
269
|
+
* Throws {@link ClaudeCommitError} on any model, authentication, transport
|
|
270
|
+
* or quota failure.
|
|
271
|
+
*/
|
|
272
|
+
export async function runPrompt(
|
|
273
|
+
prompt: string,
|
|
274
|
+
opts: RunPromptOptions,
|
|
275
|
+
): Promise<ModelResult> {
|
|
276
|
+
const { provider } = parseModelRef(opts.model);
|
|
277
|
+
return provider === "ollama"
|
|
278
|
+
? runOllamaPrompt(prompt, opts)
|
|
279
|
+
: runClaudePrompt(prompt, opts);
|
|
280
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -14,12 +14,17 @@ import {
|
|
|
14
14
|
} from "./git";
|
|
15
15
|
import { presentCredentialVars } from "./agent";
|
|
16
16
|
import { loadFileConfig, resolveConfig } from "./config";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
generateCommit,
|
|
19
|
+
type IgnoreStats,
|
|
20
|
+
type LowPriorityStats,
|
|
21
|
+
type OllamaContextWindow,
|
|
22
|
+
} from "./generate";
|
|
18
23
|
import { Spinner } from "./ui/spinner";
|
|
19
24
|
import { confirmCommit, editInEditor } from "./ui/editor";
|
|
20
25
|
import { color } from "./ui/colors";
|
|
21
26
|
import { ClaudeCommitError } from "./errors";
|
|
22
|
-
import type { ModelConfig, PartialConfig } from "./types";
|
|
27
|
+
import type { ModelConfig, OllamaConfig, PartialConfig } from "./types";
|
|
23
28
|
|
|
24
29
|
export const VERSION = getVersion();
|
|
25
30
|
|
|
@@ -40,9 +45,17 @@ interface CliOptions {
|
|
|
40
45
|
config?: string;
|
|
41
46
|
verbose?: boolean;
|
|
42
47
|
skipArmored?: boolean;
|
|
48
|
+
filenamesOnly?: boolean;
|
|
49
|
+
/** `false` when `--no-low-priority-paths` was passed (Commander's negated-flag shape). */
|
|
50
|
+
lowPriorityPaths?: boolean;
|
|
51
|
+
/** `false` when `--no-ignore` was passed (Commander's negated-flag shape). */
|
|
52
|
+
ignore?: boolean;
|
|
53
|
+
ollamaHost?: string;
|
|
54
|
+
ollamaContext?: number | "auto";
|
|
43
55
|
}
|
|
44
56
|
|
|
45
|
-
|
|
57
|
+
/** Build the Commander program. Exported for tests. */
|
|
58
|
+
export function buildProgram(): Command {
|
|
46
59
|
const program = new Command();
|
|
47
60
|
program
|
|
48
61
|
.name("cco")
|
|
@@ -71,6 +84,10 @@ function buildProgram(): Command {
|
|
|
71
84
|
'template for the first line, e.g. "[PROJ-1] {message}"',
|
|
72
85
|
)
|
|
73
86
|
.option("-p, --prompt <text>", "extra instructions appended to the prompt")
|
|
87
|
+
.option(
|
|
88
|
+
"-f, --filenames-only",
|
|
89
|
+
"skip summarisation and use only filenames (faster, less useful messages)",
|
|
90
|
+
)
|
|
74
91
|
.option("--model-summary <model>", "model used to summarize the diff")
|
|
75
92
|
.option("--model-final <model>", "model used to write the final message")
|
|
76
93
|
.option(
|
|
@@ -78,6 +95,26 @@ function buildProgram(): Command {
|
|
|
78
95
|
"omit armored/encoded lines (age/gpg armor, base64 blobs) from the " +
|
|
79
96
|
"summarized diff; recommended for chezmoi-style encrypted repos",
|
|
80
97
|
)
|
|
98
|
+
.option(
|
|
99
|
+
"--no-low-priority-paths",
|
|
100
|
+
'ignore the "lowPriorityPaths" config for this run, so every change ' +
|
|
101
|
+
"weighs the same",
|
|
102
|
+
)
|
|
103
|
+
.option(
|
|
104
|
+
"--no-ignore",
|
|
105
|
+
'disregard the "ignore" config for this run, so every staged change is ' +
|
|
106
|
+
"read",
|
|
107
|
+
)
|
|
108
|
+
.option(
|
|
109
|
+
"--ollama-host <url>",
|
|
110
|
+
"base URL of the Ollama server for ollama: models",
|
|
111
|
+
)
|
|
112
|
+
.option(
|
|
113
|
+
"--ollama-context <tokens|auto>",
|
|
114
|
+
"context window for Ollama models: a token count, or auto to use " +
|
|
115
|
+
"the server's own choice for this machine",
|
|
116
|
+
parseContextFlag,
|
|
117
|
+
)
|
|
81
118
|
.option("-d, --dry-run", "print the message to stdout without committing")
|
|
82
119
|
.option("-y, --yes", "commit without asking for confirmation")
|
|
83
120
|
.option("--no-spinner", "disable the progress spinner")
|
|
@@ -88,22 +125,39 @@ function buildProgram(): Command {
|
|
|
88
125
|
[
|
|
89
126
|
"",
|
|
90
127
|
"Authentication:",
|
|
91
|
-
"
|
|
92
|
-
" `claude login`). ANTHROPIC_API_KEY /
|
|
93
|
-
|
|
128
|
+
" Claude models use the Claude Agent SDK with your Claude Code",
|
|
129
|
+
" subscription (run `claude login`). ANTHROPIC_API_KEY /",
|
|
130
|
+
" ANTHROPIC_AUTH_TOKEN are ignored unless the config sets",
|
|
131
|
+
' "allowApiKey": true (pay-as-you-go billing).',
|
|
132
|
+
"",
|
|
133
|
+
"Ollama models:",
|
|
134
|
+
" Prefix a model with `ollama:` to run it on a local Ollama server,",
|
|
135
|
+
" e.g. --model-summary ollama:ornith-1.5:35b. Everything after the",
|
|
136
|
+
" prefix is the Ollama model name, tag included. The server needs no",
|
|
137
|
+
" credential; point cco at it with --ollama-host or $OLLAMA_HOST.",
|
|
94
138
|
"",
|
|
95
139
|
"Examples:",
|
|
96
140
|
" cco generate and commit a message for staged changes",
|
|
97
141
|
" cco -a -c stage everything and write a Conventional Commit",
|
|
98
142
|
" cco -i pick from several options interactively",
|
|
99
143
|
" cco --dry-run | cat print a message without committing",
|
|
144
|
+
" cco --model-summary ollama:ornith-1.5:35b",
|
|
145
|
+
" read the diff locally, write the message with Claude",
|
|
100
146
|
].join("\n"),
|
|
101
147
|
);
|
|
102
148
|
return program;
|
|
103
149
|
}
|
|
104
150
|
|
|
105
|
-
/**
|
|
106
|
-
function
|
|
151
|
+
/** `--ollama-context` accepts a token count or the literal `auto`. */
|
|
152
|
+
function parseContextFlag(value: string): number | "auto" {
|
|
153
|
+
return value.trim().toLowerCase() === "auto" ? "auto" : parseInt(value, 10);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Map parsed CLI flags onto a partial config (only set keys the user
|
|
158
|
+
* provided). Exported for tests.
|
|
159
|
+
*/
|
|
160
|
+
export function flagsToConfig(opts: CliOptions): PartialConfig {
|
|
107
161
|
const cfg: PartialConfig = {};
|
|
108
162
|
if (opts.conventional !== undefined)
|
|
109
163
|
cfg.conventionalCommits = opts.conventional;
|
|
@@ -113,6 +167,22 @@ function flagsToConfig(opts: CliOptions): PartialConfig {
|
|
|
113
167
|
if (opts.template !== undefined) cfg.template = opts.template;
|
|
114
168
|
if (opts.prompt !== undefined) cfg.customPrompt = opts.prompt;
|
|
115
169
|
if (opts.skipArmored !== undefined) cfg.skipArmored = opts.skipArmored;
|
|
170
|
+
if (opts.filenamesOnly !== undefined) cfg.filenamesOnly = opts.filenamesOnly;
|
|
171
|
+
// A negated flag arrives as `false`; an empty list overrides any
|
|
172
|
+
// configured patterns because lists replace rather than merge.
|
|
173
|
+
if (opts.lowPriorityPaths === false) cfg.lowPriorityPaths = [];
|
|
174
|
+
if (opts.ignore === false) cfg.ignore = [];
|
|
175
|
+
const ollama: Partial<OllamaConfig> = {};
|
|
176
|
+
if (opts.ollamaHost) ollama.host = opts.ollamaHost;
|
|
177
|
+
if (opts.ollamaContext === "auto") {
|
|
178
|
+
ollama.context = "auto";
|
|
179
|
+
} else if (
|
|
180
|
+
opts.ollamaContext !== undefined &&
|
|
181
|
+
Number.isFinite(opts.ollamaContext)
|
|
182
|
+
) {
|
|
183
|
+
ollama.context = Math.max(1, opts.ollamaContext);
|
|
184
|
+
}
|
|
185
|
+
if (Object.keys(ollama).length) cfg.ollama = ollama;
|
|
116
186
|
if (opts.count !== undefined && Number.isFinite(opts.count)) {
|
|
117
187
|
cfg.interactiveCount = Math.max(1, opts.count);
|
|
118
188
|
}
|
|
@@ -258,7 +328,7 @@ async function runNonInteractive(
|
|
|
258
328
|
const useSpinner = opts.spinner !== false && process.stderr.isTTY;
|
|
259
329
|
const spinner = new Spinner(useSpinner, config.spinner);
|
|
260
330
|
|
|
261
|
-
spinner.start("Reading diff");
|
|
331
|
+
spinner.start(config.filenamesOnly ? "Reading filenames" : "Reading diff");
|
|
262
332
|
let result;
|
|
263
333
|
try {
|
|
264
334
|
result = await generateCommit(diff, config, {
|
|
@@ -275,14 +345,29 @@ async function runNonInteractive(
|
|
|
275
345
|
process.stderr.write(
|
|
276
346
|
color(
|
|
277
347
|
"90",
|
|
278
|
-
`${result.chunkCount} chunk(s), cost $${result.costUsd.toFixed(4)}`,
|
|
348
|
+
`${config.filenamesOnly ? "filenames only (summariser skipped)" : `${result.chunkCount} chunk(s)`}, cost $${result.costUsd.toFixed(4)}`,
|
|
279
349
|
) + "\n",
|
|
280
350
|
);
|
|
281
|
-
for (const
|
|
351
|
+
for (const window of result.ollamaContexts) {
|
|
352
|
+
process.stderr.write(color("90", describeOllamaContext(window)) + "\n");
|
|
353
|
+
}
|
|
354
|
+
if (config.ignore.length > 0) {
|
|
282
355
|
process.stderr.write(
|
|
283
|
-
color("90",
|
|
356
|
+
color("90", describeIgnoreStats(result.ignored)) + "\n",
|
|
284
357
|
);
|
|
285
358
|
}
|
|
359
|
+
if (config.lowPriorityPaths.length > 0) {
|
|
360
|
+
process.stderr.write(
|
|
361
|
+
color("90", describeLowPriorityStats(result.lowPriority)) + "\n",
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
for (const [index, summary] of result.summaries.entries()) {
|
|
365
|
+
const label =
|
|
366
|
+
summary.priority === "low"
|
|
367
|
+
? `--- summary ${index + 1} (low priority) ---`
|
|
368
|
+
: `--- summary ${index + 1} ---`;
|
|
369
|
+
process.stderr.write(color("90", `${label}\n${summary.text}`) + "\n");
|
|
370
|
+
}
|
|
286
371
|
}
|
|
287
372
|
|
|
288
373
|
let message = result.messages[0]!;
|
|
@@ -318,6 +403,46 @@ async function runNonInteractive(
|
|
|
318
403
|
return 0;
|
|
319
404
|
}
|
|
320
405
|
|
|
406
|
+
/**
|
|
407
|
+
* One verbose line saying how the low-priority patterns applied. Without it
|
|
408
|
+
* a pattern that matched nothing and one that matched everything (and was
|
|
409
|
+
* promoted) are indistinguishable from the message alone.
|
|
410
|
+
*/
|
|
411
|
+
export function describeLowPriorityStats(stats: LowPriorityStats): string {
|
|
412
|
+
const files = `${stats.totalFiles} file${stats.totalFiles === 1 ? "" : "s"}`;
|
|
413
|
+
if (stats.matchedFiles === 0) {
|
|
414
|
+
return `low-priority paths: matched none of ${files}`;
|
|
415
|
+
}
|
|
416
|
+
if (stats.promoted) {
|
|
417
|
+
return `low-priority paths: matched all ${files} - nothing else changed, so treated as primary`;
|
|
418
|
+
}
|
|
419
|
+
return `low-priority paths: matched ${stats.matchedFiles} of ${files}`;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* One verbose line per Ollama model naming the context window it ran with
|
|
424
|
+
* and where the number came from. With `"auto"` this is the only place the
|
|
425
|
+
* server's choice is visible, and it is the first thing to check when a
|
|
426
|
+
* summary reads as if it saw half the diff.
|
|
427
|
+
*/
|
|
428
|
+
export function describeOllamaContext(window: OllamaContextWindow): string {
|
|
429
|
+
const source =
|
|
430
|
+
window.source === "auto" ? "chosen by the server" : "from config";
|
|
431
|
+
return `ollama: ${window.model} context ${window.tokens} tokens (${source})`;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* One verbose line saying how the ignore patterns applied. The counts are
|
|
436
|
+
* the only way to tell a pattern that quietly matched nothing from one that
|
|
437
|
+
* quietly removed half the commit.
|
|
438
|
+
*/
|
|
439
|
+
export function describeIgnoreStats(stats: IgnoreStats): string {
|
|
440
|
+
const files = `${stats.totalFiles} file${stats.totalFiles === 1 ? "" : "s"}`;
|
|
441
|
+
return stats.ignoredFiles === 0
|
|
442
|
+
? `ignore: matched none of ${files}`
|
|
443
|
+
: `ignore: dropped ${stats.ignoredFiles} of ${files} before reading`;
|
|
444
|
+
}
|
|
445
|
+
|
|
321
446
|
function firstLine(text: string): string {
|
|
322
447
|
return text.split("\n", 1)[0] ?? text;
|
|
323
448
|
}
|
package/src/config.ts
CHANGED
|
@@ -11,7 +11,8 @@ import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
12
|
import { ClaudeCommitError } from "./errors";
|
|
13
13
|
import { DEFAULT_SPINNER, isSpinnerName } from "./ui/spinner";
|
|
14
|
-
import
|
|
14
|
+
import { DEFAULT_OLLAMA_CONTEXT, DEFAULT_OLLAMA_HOST } from "./models";
|
|
15
|
+
import type { Config, ModelConfig, OllamaConfig, PartialConfig } from "./types";
|
|
15
16
|
|
|
16
17
|
export const DEFAULT_CONFIG: Config = {
|
|
17
18
|
conventionalCommits: false,
|
|
@@ -29,7 +30,15 @@ export const DEFAULT_CONFIG: Config = {
|
|
|
29
30
|
},
|
|
30
31
|
maxChunkTokens: 600_000,
|
|
31
32
|
charsPerToken: 3.5,
|
|
33
|
+
filenamesOnly: false,
|
|
32
34
|
skipArmored: false,
|
|
35
|
+
lowPriorityPaths: [],
|
|
36
|
+
ignore: [],
|
|
37
|
+
ollama: {
|
|
38
|
+
host: DEFAULT_OLLAMA_HOST,
|
|
39
|
+
context: DEFAULT_OLLAMA_CONTEXT,
|
|
40
|
+
keepAlive: null,
|
|
41
|
+
},
|
|
33
42
|
allowApiKey: false,
|
|
34
43
|
};
|
|
35
44
|
|
|
@@ -72,10 +81,28 @@ async function findGlobalConfigFile(
|
|
|
72
81
|
return undefined;
|
|
73
82
|
}
|
|
74
83
|
|
|
75
|
-
/**
|
|
84
|
+
/**
|
|
85
|
+
* Deep-ish merge of a partial config over a base config: `models` and
|
|
86
|
+
* `ollama` are merged key by key; the path lists (`lowPriorityPaths`,
|
|
87
|
+
* `ignore`) are replaced whole - a higher layer's list wins outright, so a
|
|
88
|
+
* project can drop a global pattern - and copied so the result never
|
|
89
|
+
* aliases the base's array.
|
|
90
|
+
*/
|
|
76
91
|
export function mergeConfig(base: Config, override: PartialConfig): Config {
|
|
77
92
|
const models: ModelConfig = { ...base.models, ...(override.models ?? {}) };
|
|
78
|
-
const
|
|
93
|
+
const ollama: OllamaConfig = { ...base.ollama, ...(override.ollama ?? {}) };
|
|
94
|
+
const lowPriorityPaths = [
|
|
95
|
+
...(override.lowPriorityPaths ?? base.lowPriorityPaths),
|
|
96
|
+
];
|
|
97
|
+
const ignore = [...(override.ignore ?? base.ignore)];
|
|
98
|
+
const merged: Config = {
|
|
99
|
+
...base,
|
|
100
|
+
...override,
|
|
101
|
+
models,
|
|
102
|
+
ollama,
|
|
103
|
+
lowPriorityPaths,
|
|
104
|
+
ignore,
|
|
105
|
+
};
|
|
79
106
|
return merged;
|
|
80
107
|
}
|
|
81
108
|
|
|
@@ -94,6 +121,7 @@ export function sanitizePartial(raw: unknown): PartialConfig {
|
|
|
94
121
|
bool("multiline");
|
|
95
122
|
bool("interactive");
|
|
96
123
|
bool("skipArmored");
|
|
124
|
+
bool("filenamesOnly");
|
|
97
125
|
bool("allowApiKey");
|
|
98
126
|
|
|
99
127
|
if (typeof obj.template === "string") out.template = obj.template;
|
|
@@ -127,18 +155,64 @@ export function sanitizePartial(raw: unknown): PartialConfig {
|
|
|
127
155
|
if (typeof obj.charsPerToken === "number" && obj.charsPerToken > 0) {
|
|
128
156
|
out.charsPerToken = obj.charsPerToken;
|
|
129
157
|
}
|
|
158
|
+
// An explicit empty list is meaningful for either path option: it clears
|
|
159
|
+
// patterns inherited from a lower layer, so it is kept rather than
|
|
160
|
+
// treated as "unset".
|
|
161
|
+
if (Array.isArray(obj.lowPriorityPaths)) {
|
|
162
|
+
out.lowPriorityPaths = cleanPatternList(obj.lowPriorityPaths);
|
|
163
|
+
}
|
|
164
|
+
if (Array.isArray(obj.ignore)) {
|
|
165
|
+
out.ignore = cleanPatternList(obj.ignore);
|
|
166
|
+
}
|
|
130
167
|
|
|
131
168
|
if (obj.models && typeof obj.models === "object") {
|
|
132
169
|
const m = obj.models as Record<string, unknown>;
|
|
133
170
|
const models: Partial<ModelConfig> = {};
|
|
134
|
-
|
|
135
|
-
|
|
171
|
+
// A blank model name is not an override, it is a mistake: leaving the
|
|
172
|
+
// key unset keeps the layer below, which is a working model.
|
|
173
|
+
if (typeof m.summary === "string" && m.summary.trim() !== "") {
|
|
174
|
+
models.summary = m.summary.trim();
|
|
175
|
+
}
|
|
176
|
+
if (typeof m.final === "string" && m.final.trim() !== "") {
|
|
177
|
+
models.final = m.final.trim();
|
|
178
|
+
}
|
|
136
179
|
if (Object.keys(models).length) out.models = models;
|
|
137
180
|
}
|
|
138
181
|
|
|
182
|
+
if (obj.ollama && typeof obj.ollama === "object") {
|
|
183
|
+
const o = obj.ollama as Record<string, unknown>;
|
|
184
|
+
const ollama: Partial<OllamaConfig> = {};
|
|
185
|
+
if (typeof o.host === "string" && o.host.trim() !== "") {
|
|
186
|
+
ollama.host = o.host.trim();
|
|
187
|
+
}
|
|
188
|
+
if (typeof o.context === "number" && o.context > 0) {
|
|
189
|
+
ollama.context = Math.floor(o.context);
|
|
190
|
+
} else if (o.context === "auto") {
|
|
191
|
+
ollama.context = "auto";
|
|
192
|
+
}
|
|
193
|
+
if (typeof o.keepAlive === "string" || typeof o.keepAlive === "number") {
|
|
194
|
+
ollama.keepAlive = o.keepAlive;
|
|
195
|
+
} else if (o.keepAlive === null) {
|
|
196
|
+
ollama.keepAlive = null;
|
|
197
|
+
}
|
|
198
|
+
if (Object.keys(ollama).length) out.ollama = ollama;
|
|
199
|
+
}
|
|
200
|
+
|
|
139
201
|
return out;
|
|
140
202
|
}
|
|
141
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Clean one raw path-pattern list: drop non-strings and blanks, trim the
|
|
206
|
+
* rest. Shared by `lowPriorityPaths` and `ignore`, which take the same
|
|
207
|
+
* pattern language (see `src/paths.ts`).
|
|
208
|
+
*/
|
|
209
|
+
function cleanPatternList(raw: unknown[]): string[] {
|
|
210
|
+
return raw
|
|
211
|
+
.filter((entry): entry is string => typeof entry === "string")
|
|
212
|
+
.map((entry) => entry.trim())
|
|
213
|
+
.filter((entry) => entry !== "");
|
|
214
|
+
}
|
|
215
|
+
|
|
142
216
|
async function readJsonIfExists(path: string): Promise<unknown | undefined> {
|
|
143
217
|
const file = Bun.file(path);
|
|
144
218
|
if (!(await file.exists())) return undefined;
|
|
@@ -233,7 +307,11 @@ export async function loadFileConfig(
|
|
|
233
307
|
return result;
|
|
234
308
|
}
|
|
235
309
|
|
|
236
|
-
/**
|
|
310
|
+
/**
|
|
311
|
+
* Merge two partial configs: `models` and `ollama` are merged key by key;
|
|
312
|
+
* every other key, including both path lists, is taken whole from the
|
|
313
|
+
* override when present.
|
|
314
|
+
*/
|
|
237
315
|
export function mergePartial(
|
|
238
316
|
base: PartialConfig,
|
|
239
317
|
override: PartialConfig,
|
|
@@ -242,6 +320,13 @@ export function mergePartial(
|
|
|
242
320
|
if (base.models || override.models) {
|
|
243
321
|
out.models = { ...base.models, ...override.models };
|
|
244
322
|
}
|
|
323
|
+
if (base.ollama || override.ollama) {
|
|
324
|
+
out.ollama = { ...base.ollama, ...override.ollama };
|
|
325
|
+
}
|
|
326
|
+
const lowPriorityPaths = override.lowPriorityPaths ?? base.lowPriorityPaths;
|
|
327
|
+
if (lowPriorityPaths) out.lowPriorityPaths = [...lowPriorityPaths];
|
|
328
|
+
const ignore = override.ignore ?? base.ignore;
|
|
329
|
+
if (ignore) out.ignore = [...ignore];
|
|
245
330
|
return out;
|
|
246
331
|
}
|
|
247
332
|
|