@oh-my-pi/pi-coding-agent 16.3.7 → 16.3.9

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.
@@ -1,19 +1,24 @@
1
1
  import { Command } from "@oh-my-pi/pi-utils/cli";
2
2
  export default class Say extends Command {
3
+ #private;
3
4
  static description: string;
4
5
  static args: {
5
6
  text: import("@oh-my-pi/pi-utils/cli").ArgDescriptor & {
6
- required: true;
7
7
  description: string;
8
8
  };
9
9
  };
10
10
  static flags: {
11
11
  voice: import("@oh-my-pi/pi-utils/cli").FlagDescriptor<"string"> & {
12
12
  description: string;
13
+ options: readonly string[];
13
14
  };
14
15
  model: import("@oh-my-pi/pi-utils/cli").FlagDescriptor<"string"> & {
15
16
  description: string;
16
17
  };
18
+ file: import("@oh-my-pi/pi-utils/cli").FlagDescriptor<"string"> & {
19
+ char: string;
20
+ description: string;
21
+ };
17
22
  out: import("@oh-my-pi/pi-utils/cli").FlagDescriptor<"string"> & {
18
23
  char: string;
19
24
  description: string;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Lock-file handling for the split-commit workflow.
3
+ *
4
+ * The commit agent hides these machine-generated files from analysis so the
5
+ * model does not waste tokens on them and does not treat them as evidence for
6
+ * commit boundaries. That leaves them staged but unseen: without deterministic
7
+ * post-plan placement the split validator rejects the plan with
8
+ * `Split commit plan missing staged files: <lockfile>`, and the executor
9
+ * (`git stage.reset` -> per-group `stage.hunks`) would silently drop the file
10
+ * if the validator were skipped. See issue #4632.
11
+ */
12
+ import type { SplitCommitPlan } from "./state.js";
13
+ /**
14
+ * Lock file basename -> ordered sibling manifests. Order matters: the first
15
+ * manifest present in a commit group's changes wins.
16
+ */
17
+ export declare const LOCK_FILE_MANIFESTS: Readonly<Record<string, readonly string[]>>;
18
+ /**
19
+ * Lock-file basenames the commit agent excludes from `git_overview` output and
20
+ * from split-commit validation. Derived from {@link LOCK_FILE_MANIFESTS} so a
21
+ * single edit keeps both the analysis filter and the post-plan pairing in sync.
22
+ */
23
+ export declare const EXCLUDED_LOCK_FILES: ReadonlySet<string>;
24
+ /**
25
+ * Attach staged lock files the model never saw to the split plan.
26
+ *
27
+ * Placement precedence per lock file:
28
+ * 1. commit group that touches a sibling manifest (same directory)
29
+ * 2. commit group that touches a manifest in any directory
30
+ * 3. last commit group (fallback)
31
+ *
32
+ * Mutates {@link plan} in place. No-ops on an empty plan, on lock files
33
+ * already present in some commit group, and on staged files that are not
34
+ * recognized lock files.
35
+ */
36
+ export declare function assignLockFilesToPlan(plan: SplitCommitPlan, stagedFiles: readonly string[]): void;
@@ -38,8 +38,9 @@ export interface DiscoveryContext {
38
38
  getBearerApiKeyResolver(provider: string): Promise<ApiKey | undefined>;
39
39
  }
40
40
  type LlamaCppDiscoveredModelRuntimeMetadata = {
41
- contextWindow: number;
42
- maxTokens: number;
41
+ contextWindow?: number;
42
+ maxTokens?: number;
43
+ input?: ("text" | "image")[];
43
44
  };
44
45
  export declare function discoverModelsByProviderType(providerConfig: DiscoveryProviderConfig, ctx: DiscoveryContext): Promise<Model<Api>[]>;
45
46
  export declare function discoverOllamaModels(providerConfig: DiscoveryProviderConfig, ctx: DiscoveryContext): Promise<Model<Api>[]>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "16.3.7",
4
+ "version": "16.3.9",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -56,17 +56,17 @@
56
56
  "@agentclientprotocol/sdk": "0.25.0",
57
57
  "@babel/parser": "^7.29.7",
58
58
  "@mozilla/readability": "^0.6.0",
59
- "@oh-my-pi/hashline": "16.3.7",
60
- "@oh-my-pi/omp-stats": "16.3.7",
61
- "@oh-my-pi/pi-agent-core": "16.3.7",
62
- "@oh-my-pi/pi-ai": "16.3.7",
63
- "@oh-my-pi/pi-catalog": "16.3.7",
64
- "@oh-my-pi/pi-mnemopi": "16.3.7",
65
- "@oh-my-pi/pi-natives": "16.3.7",
66
- "@oh-my-pi/pi-tui": "16.3.7",
67
- "@oh-my-pi/pi-utils": "16.3.7",
68
- "@oh-my-pi/pi-wire": "16.3.7",
69
- "@oh-my-pi/snapcompact": "16.3.7",
59
+ "@oh-my-pi/hashline": "16.3.9",
60
+ "@oh-my-pi/omp-stats": "16.3.9",
61
+ "@oh-my-pi/pi-agent-core": "16.3.9",
62
+ "@oh-my-pi/pi-ai": "16.3.9",
63
+ "@oh-my-pi/pi-catalog": "16.3.9",
64
+ "@oh-my-pi/pi-mnemopi": "16.3.9",
65
+ "@oh-my-pi/pi-natives": "16.3.9",
66
+ "@oh-my-pi/pi-tui": "16.3.9",
67
+ "@oh-my-pi/pi-utils": "16.3.9",
68
+ "@oh-my-pi/pi-wire": "16.3.9",
69
+ "@oh-my-pi/snapcompact": "16.3.9",
70
70
  "@opentelemetry/api": "^1.9.1",
71
71
  "@opentelemetry/context-async-hooks": "^2.7.1",
72
72
  "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
@@ -85,7 +85,7 @@
85
85
  "lru-cache": "11.5.1",
86
86
  "mammoth": "^1.12.0",
87
87
  "mupdf": "^1.27.0",
88
- "puppeteer-core": "^25.1.0",
88
+ "puppeteer-core": "25.3.0",
89
89
  "turndown": "7.2.4",
90
90
  "turndown-plugin-gfm": "1.0.2",
91
91
  "zod": "^4"
@@ -1,17 +1,20 @@
1
1
  /**
2
2
  * Synthesize text with the local TTS engine and play it (or save it with --out).
3
3
  *
4
- * Demonstrates the on-device speech stack end to end: the first run downloads
5
- * the configured local model, synthesis happens in the TTS worker subprocess,
6
- * and the resulting WAV is either played through the speakers or written to disk.
4
+ * Text comes from the argument or --file. Input is segmented into
5
+ * sentence-sized chunks ({@link SpeakableStream}) and synthesized through the
6
+ * streaming TTS worker, so arbitrarily long text plays gaplessly instead of
7
+ * hitting Kokoro's single-call ~510-phoneme truncation. --out concatenates the
8
+ * streamed segments into one WAV. The first run downloads the configured local
9
+ * model into the worker's cache.
7
10
  */
8
- import * as os from "node:os";
9
- import * as path from "node:path";
10
- import { getProjectDir, Snowflake } from "@oh-my-pi/pi-utils";
11
+ import { getProjectDir } from "@oh-my-pi/pi-utils";
11
12
  import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli";
12
13
  import chalk from "chalk";
13
14
  import { Settings, settings } from "../config/settings";
14
- import { playAudioFile, removeTempFile } from "../tts/player";
15
+ import { TTS_LOCAL_VOICE_VALUES } from "../tts/models";
16
+ import { SpeakableStream } from "../tts/speakable";
17
+ import { StreamingAudioPlayer } from "../tts/streaming-player";
15
18
  import { shutdownTtsClient, ttsClient } from "../tts/tts-client";
16
19
  import { encodeWav } from "../tts/wav";
17
20
 
@@ -19,24 +22,28 @@ export default class Say extends Command {
19
22
  static description = "Synthesize text with the local TTS engine and play it through the speakers";
20
23
 
21
24
  static args = {
22
- text: Args.string({ required: true, description: "Text to speak" }),
25
+ text: Args.string({ description: "Text to speak (or use --file)" }),
23
26
  };
24
27
 
25
28
  static flags = {
26
- voice: Flags.string({ description: "Voice id" }),
29
+ voice: Flags.string({ description: "Voice id", options: TTS_LOCAL_VOICE_VALUES }),
27
30
  model: Flags.string({ description: "Local TTS model key" }),
31
+ file: Flags.string({ char: "f", description: "Read the text to speak from this file" }),
28
32
  out: Flags.string({ char: "o", description: "Write WAV to this path instead of playing" }),
29
33
  };
30
34
 
31
35
  static examples = [
32
36
  'omp say "hello world"',
37
+ "omp say --file notes.md --voice bm_fable",
33
38
  'omp say "hello world" --out /tmp/hello.wav',
34
- 'omp say "bonjour" --voice af_heart --model kokoro',
35
39
  ];
36
40
 
37
41
  async run(): Promise<void> {
38
42
  const { args, flags } = await this.parse(Say);
39
- const text = args.text ?? "";
43
+ if (args.text && flags.file) {
44
+ process.stderr.write(chalk.red("error: pass either text or --file, not both\n"));
45
+ process.exit(1);
46
+ }
40
47
 
41
48
  await Settings.init({ cwd: getProjectDir() });
42
49
  const model = flags.model ?? settings.get("tts.localModel");
@@ -55,23 +62,42 @@ export default class Say extends Command {
55
62
  });
56
63
 
57
64
  try {
58
- const audio = await ttsClient.synthesize(model, text, { voice });
59
- if (!audio) {
60
- process.stderr.write(
61
- chalk.red(
62
- `error: could not synthesize with local TTS model "${model}". ` +
63
- "Run `omp setup speech` to install it.\n",
64
- ),
65
- );
65
+ const text = flags.file ? await Bun.file(flags.file).text() : (args.text ?? "");
66
+ const splitter = new SpeakableStream();
67
+ const segments = [...splitter.push(text), ...splitter.flush()];
68
+ if (segments.length === 0) {
69
+ process.stderr.write(chalk.red("error: nothing speakable in the input\n"));
66
70
  exitCode = 1;
67
71
  return;
68
72
  }
69
73
 
70
- const wav = encodeWav(audio.pcm, audio.sampleRate);
71
- const durationSec = audio.pcm.length / audio.sampleRate;
74
+ const stream = ttsClient.synthesizeStream(model, { voice });
75
+ for (const segment of segments) stream.push(segment);
76
+ stream.end();
72
77
 
73
78
  if (flags.out) {
79
+ const pcms: Float32Array[] = [];
80
+ let total = 0;
81
+ let sampleRate = 0;
82
+ for await (const chunk of stream.chunks) {
83
+ pcms.push(chunk.pcm);
84
+ total += chunk.pcm.length;
85
+ sampleRate = chunk.sampleRate;
86
+ }
87
+ if (total === 0) {
88
+ this.#synthesisFailed(model);
89
+ exitCode = 1;
90
+ return;
91
+ }
92
+ const pcm = new Float32Array(total);
93
+ let offset = 0;
94
+ for (const part of pcms) {
95
+ pcm.set(part, offset);
96
+ offset += part.length;
97
+ }
98
+ const wav = encodeWav(pcm, sampleRate);
74
99
  await Bun.write(flags.out, wav);
100
+ const durationSec = total / sampleRate;
75
101
  process.stdout.write(
76
102
  `${chalk.green("saved")} ${flags.out} ` +
77
103
  `${chalk.dim(`(${voice}, ${model}, ${durationSec.toFixed(1)}s, ${wav.byteLength} bytes)`)}\n`,
@@ -79,16 +105,25 @@ export default class Say extends Command {
79
105
  return;
80
106
  }
81
107
 
82
- const tmp = path.join(os.tmpdir(), `omp-say-${Snowflake.next()}.wav`);
83
- await Bun.write(tmp, wav);
84
- try {
85
- await playAudioFile(tmp);
86
- process.stdout.write(
87
- `${chalk.green("spoke")} ${chalk.dim(`(${voice}, ${model}, ${durationSec.toFixed(1)}s)`)}\n`,
88
- );
89
- } finally {
90
- await removeTempFile(tmp);
108
+ const player = new StreamingAudioPlayer();
109
+ let spoken = 0;
110
+ let seconds = 0;
111
+ for await (const chunk of stream.chunks) {
112
+ player.start(chunk.sampleRate);
113
+ player.write(chunk.pcm);
114
+ spoken++;
115
+ seconds += chunk.pcm.length / chunk.sampleRate;
91
116
  }
117
+ if (spoken === 0) {
118
+ player.stop();
119
+ this.#synthesisFailed(model);
120
+ exitCode = 1;
121
+ return;
122
+ }
123
+ await player.end();
124
+ process.stdout.write(
125
+ `${chalk.green("spoke")} ${chalk.dim(`(${voice}, ${model}, ${seconds.toFixed(1)}s, ${spoken} segments)`)}\n`,
126
+ );
92
127
  } catch (err) {
93
128
  process.stderr.write(chalk.red(`error: ${err instanceof Error ? err.message : String(err)}\n`));
94
129
  exitCode = 1;
@@ -99,4 +134,12 @@ export default class Say extends Command {
99
134
 
100
135
  if (exitCode !== 0) process.exit(exitCode);
101
136
  }
137
+
138
+ #synthesisFailed(model: string): void {
139
+ process.stderr.write(
140
+ chalk.red(
141
+ `error: could not synthesize with local TTS model "${model}". Run \`omp setup speech\` to install it.\n`,
142
+ ),
143
+ );
144
+ }
102
145
  }
@@ -13,6 +13,7 @@ import { discoverAuthStorage, discoverContextFiles } from "../../sdk";
13
13
  import * as git from "../../utils/git";
14
14
  import { type ExistingChangelogEntries, runCommitAgentSession } from "./agent";
15
15
  import { generateFallbackProposal } from "./fallback";
16
+ import { assignLockFilesToPlan } from "./lock-files";
16
17
  import splitConfirmPrompt from "./prompts/split-confirm.md" with { type: "text" };
17
18
  import type { CommitAgentState, CommitProposal, HunkSelector, SplitCommitPlan } from "./state";
18
19
  import { computeDependencyOrder } from "./topo-sort";
@@ -230,6 +231,7 @@ async function runSplitCommit(
230
231
  appendFilesToLastCommit(plan, ctx.additionalFiles);
231
232
  }
232
233
  const stagedFiles = await git.diff.changedFiles(ctx.cwd, { cached: true });
234
+ assignLockFilesToPlan(plan, stagedFiles);
233
235
  const plannedFiles = new Set(plan.commits.flatMap(commit => commit.changes.map(change => change.path)));
234
236
  const missingFiles = stagedFiles.filter(file => !plannedFiles.has(file));
235
237
  if (missingFiles.length > 0) {
@@ -266,7 +268,7 @@ async function runSplitCommit(
266
268
  throw new Error(order.error);
267
269
  }
268
270
 
269
- const stagedDiff = await git.diff(ctx.cwd, { cached: true });
271
+ const stagedDiff = await git.diff(ctx.cwd, { cached: true, binary: true });
270
272
  await git.stage.reset(ctx.cwd);
271
273
  for (const commitIndex of order) {
272
274
  const commit = plan.commits[commitIndex];
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Lock-file handling for the split-commit workflow.
3
+ *
4
+ * The commit agent hides these machine-generated files from analysis so the
5
+ * model does not waste tokens on them and does not treat them as evidence for
6
+ * commit boundaries. That leaves them staged but unseen: without deterministic
7
+ * post-plan placement the split validator rejects the plan with
8
+ * `Split commit plan missing staged files: <lockfile>`, and the executor
9
+ * (`git stage.reset` -> per-group `stage.hunks`) would silently drop the file
10
+ * if the validator were skipped. See issue #4632.
11
+ */
12
+
13
+ import type { SplitCommitPlan } from "./state";
14
+
15
+ /**
16
+ * Lock file basename -> ordered sibling manifests. Order matters: the first
17
+ * manifest present in a commit group's changes wins.
18
+ */
19
+ export const LOCK_FILE_MANIFESTS: Readonly<Record<string, readonly string[]>> = {
20
+ "Cargo.lock": ["Cargo.toml"],
21
+ "package-lock.json": ["package.json"],
22
+ "yarn.lock": ["package.json"],
23
+ "pnpm-lock.yaml": ["package.json"],
24
+ "bun.lock": ["package.json"],
25
+ "bun.lockb": ["package.json"],
26
+ "go.sum": ["go.mod"],
27
+ "poetry.lock": ["pyproject.toml"],
28
+ "Pipfile.lock": ["Pipfile"],
29
+ "uv.lock": ["pyproject.toml"],
30
+ "composer.lock": ["composer.json"],
31
+ "Gemfile.lock": ["Gemfile"],
32
+ "flake.lock": ["flake.nix"],
33
+ "pubspec.lock": ["pubspec.yaml"],
34
+ "Podfile.lock": ["Podfile"],
35
+ "mix.lock": ["mix.exs"],
36
+ "gradle.lockfile": ["build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"],
37
+ };
38
+
39
+ /**
40
+ * Lock-file basenames the commit agent excludes from `git_overview` output and
41
+ * from split-commit validation. Derived from {@link LOCK_FILE_MANIFESTS} so a
42
+ * single edit keeps both the analysis filter and the post-plan pairing in sync.
43
+ */
44
+ export const EXCLUDED_LOCK_FILES: ReadonlySet<string> = new Set(Object.keys(LOCK_FILE_MANIFESTS));
45
+
46
+ /**
47
+ * Attach staged lock files the model never saw to the split plan.
48
+ *
49
+ * Placement precedence per lock file:
50
+ * 1. commit group that touches a sibling manifest (same directory)
51
+ * 2. commit group that touches a manifest in any directory
52
+ * 3. last commit group (fallback)
53
+ *
54
+ * Mutates {@link plan} in place. No-ops on an empty plan, on lock files
55
+ * already present in some commit group, and on staged files that are not
56
+ * recognized lock files.
57
+ */
58
+ export function assignLockFilesToPlan(plan: SplitCommitPlan, stagedFiles: readonly string[]): void {
59
+ if (plan.commits.length === 0) return;
60
+
61
+ const planned = new Set(plan.commits.flatMap(commit => commit.changes.map(change => change.path)));
62
+ const orphanedLockFiles: string[] = [];
63
+ for (const file of stagedFiles) {
64
+ if (planned.has(file)) continue;
65
+ const parts = file.split("/");
66
+ const basename = parts[parts.length - 1];
67
+ if (EXCLUDED_LOCK_FILES.has(basename)) orphanedLockFiles.push(file);
68
+ }
69
+ if (orphanedLockFiles.length === 0) return;
70
+
71
+ for (const lockFile of orphanedLockFiles) {
72
+ const parts = lockFile.split("/");
73
+ const basename = parts[parts.length - 1];
74
+ const dir = parts.slice(0, -1).join("/");
75
+ const manifests = LOCK_FILE_MANIFESTS[basename] ?? [];
76
+ const targetIndex = findManifestCommitIndex(plan, dir, manifests);
77
+ plan.commits[targetIndex].changes.push({ path: lockFile, hunks: { type: "all" } });
78
+ planned.add(lockFile);
79
+ }
80
+ }
81
+
82
+ function findManifestCommitIndex(plan: SplitCommitPlan, lockDir: string, manifests: readonly string[]): number {
83
+ // Prefer a manifest in the same directory as the lock file — the strongest
84
+ // semantic signal (e.g. workspace-crate `Cargo.toml` next to `Cargo.lock`).
85
+ for (const manifestName of manifests) {
86
+ for (let i = 0; i < plan.commits.length; i++) {
87
+ for (const change of plan.commits[i].changes) {
88
+ const parts = change.path.split("/");
89
+ const basename = parts[parts.length - 1];
90
+ const dir = parts.slice(0, -1).join("/");
91
+ if (basename === manifestName && dir === lockDir) return i;
92
+ }
93
+ }
94
+ }
95
+ // Fall back to any matching manifest — a monorepo may lock at repo root
96
+ // while the manifest sits under a subpath.
97
+ for (const manifestName of manifests) {
98
+ for (let i = 0; i < plan.commits.length; i++) {
99
+ for (const change of plan.commits[i].changes) {
100
+ const parts = change.path.split("/");
101
+ if (parts[parts.length - 1] === manifestName) return i;
102
+ }
103
+ }
104
+ }
105
+ // Nothing matched: attach to the last commit so the file still ships.
106
+ return plan.commits.length - 1;
107
+ }
@@ -3,26 +3,7 @@ import type { CommitAgentState, GitOverviewSnapshot } from "../../../commit/agen
3
3
  import { extractScopeCandidates } from "../../../commit/analysis/scope";
4
4
  import type { CustomTool } from "../../../extensibility/custom-tools/types";
5
5
  import * as git from "../../../utils/git";
6
-
7
- const EXCLUDED_LOCK_FILES = new Set([
8
- "Cargo.lock",
9
- "package-lock.json",
10
- "yarn.lock",
11
- "pnpm-lock.yaml",
12
- "bun.lock",
13
- "bun.lockb",
14
- "go.sum",
15
- "poetry.lock",
16
- "Pipfile.lock",
17
- "uv.lock",
18
- "composer.lock",
19
- "Gemfile.lock",
20
- "flake.lock",
21
- "pubspec.lock",
22
- "Podfile.lock",
23
- "mix.lock",
24
- "gradle.lockfile",
25
- ]);
6
+ import { EXCLUDED_LOCK_FILES } from "../lock-files";
26
7
 
27
8
  function isExcludedFile(path: string): boolean {
28
9
  const basename = path.split("/").pop() ?? path;
@@ -151,8 +151,9 @@ type LlamaCppDiscoveredServerMetadata = {
151
151
  };
152
152
 
153
153
  type LlamaCppDiscoveredModelRuntimeMetadata = {
154
- contextWindow: number;
155
- maxTokens: number;
154
+ contextWindow?: number;
155
+ maxTokens?: number;
156
+ input?: ("text" | "image")[];
156
157
  };
157
158
 
158
159
  type LlamaCppModelListEntry = {
@@ -594,12 +595,14 @@ export async function discoverLlamaCppModelRuntimeMetadata(
594
595
  entry.configuredContextWindow ??
595
596
  serverMetadata?.contextWindow ??
596
597
  entry.trainingContextWindow;
598
+ const input = serverMetadata?.input;
597
599
  if (contextWindow === undefined) {
598
- return undefined;
600
+ return input === undefined ? undefined : { input };
599
601
  }
600
602
  return {
601
603
  contextWindow,
602
604
  maxTokens: resolveLlamaCppMaxTokens(contextWindow, serverMetadata?.maxTokens),
605
+ ...(input !== undefined ? { input } : {}),
603
606
  };
604
607
  };
605
608
  try {
@@ -866,12 +866,13 @@ export class ModelRegistry {
866
866
  if (runtimeMetadata === undefined) {
867
867
  return this.find(model.provider, model.id) ?? model;
868
868
  }
869
- const { contextWindow, maxTokens } = runtimeMetadata;
869
+ const { contextWindow, maxTokens, input } = runtimeMetadata;
870
870
  const current = this.find(model.provider, model.id) ?? model;
871
871
  const override = this.#resolveLiveModelOverride(current);
872
872
  const customModel = this.#resolveLiveCustomModelOverlay(current);
873
873
  const patch: ModelPatch = {};
874
874
  if (
875
+ contextWindow !== undefined &&
875
876
  override?.contextWindow === undefined &&
876
877
  customModel?.contextWindow === undefined &&
877
878
  current.contextWindow !== contextWindow
@@ -884,15 +885,25 @@ export class ModelRegistry {
884
885
  patch.contextWindow ??
885
886
  current.contextWindow ??
886
887
  contextWindow;
887
- const effectiveMaxTokens = Math.min(maxTokens, effectiveContextWindow);
888
+ if (maxTokens !== undefined && effectiveContextWindow !== undefined) {
889
+ const effectiveMaxTokens = Math.min(maxTokens, effectiveContextWindow);
890
+ if (
891
+ override?.maxTokens === undefined &&
892
+ customModel?.maxTokens === undefined &&
893
+ current.maxTokens !== effectiveMaxTokens
894
+ ) {
895
+ patch.maxTokens = effectiveMaxTokens;
896
+ }
897
+ }
888
898
  if (
889
- override?.maxTokens === undefined &&
890
- customModel?.maxTokens === undefined &&
891
- current.maxTokens !== effectiveMaxTokens
899
+ input !== undefined &&
900
+ override?.input === undefined &&
901
+ customModel?.input === undefined &&
902
+ (current.input.length !== input.length || current.input.some((value, index) => value !== input[index]))
892
903
  ) {
893
- patch.maxTokens = effectiveMaxTokens;
904
+ patch.input = input;
894
905
  }
895
- if (patch.contextWindow === undefined && patch.maxTokens === undefined) {
906
+ if (patch.contextWindow === undefined && patch.maxTokens === undefined && patch.input === undefined) {
896
907
  return current;
897
908
  }
898
909
  const patched = applyModelPatch(current, patch, "merge");
@@ -167,17 +167,21 @@ async function loadContextFiles(ctx: LoadContext): Promise<LoadResult<ContextFil
167
167
  async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
168
168
  const userSkillsDir = path.join(getUserClaude(ctx), "skills");
169
169
 
170
- // Walk up from cwd finding .claude/skills/ in ancestors
170
+ // Walk up from cwd finding .claude/skills/ in ancestors. Skip $HOME:
171
+ // that path is already scanned as the Claude user source below, and scanning
172
+ // it again as project would bypass enableClaudeUser when project skills stay enabled.
171
173
  const projectScans: Promise<LoadResult<Skill>>[] = [];
172
174
  let current = ctx.cwd;
173
175
  while (true) {
174
- projectScans.push(
175
- scanSkillsFromDir(ctx, {
176
- dir: path.join(current, CONFIG_DIR, "skills"),
177
- providerId: PROVIDER_ID,
178
- level: "project",
179
- }),
180
- );
176
+ if (current !== ctx.home) {
177
+ projectScans.push(
178
+ scanSkillsFromDir(ctx, {
179
+ dir: path.join(current, CONFIG_DIR, "skills"),
180
+ providerId: PROVIDER_ID,
181
+ level: "project",
182
+ }),
183
+ );
184
+ }
181
185
  if (current === (ctx.repoRoot ?? ctx.home)) break;
182
186
  const parent = path.dirname(current);
183
187
  if (parent === current) break; // filesystem root
@@ -190,12 +190,18 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise<LoadS
190
190
  const disabledSkillNames = new Set(
191
191
  (disabledExtensions ?? []).filter(id => id.startsWith("skill:")).map(id => id.slice(6)),
192
192
  );
193
- // Filter skills by source and patterns first
194
- const filteredSkills = result.items.filter(capSkill => {
193
+ // Select authored skills from the pre-dedup superset. `loadCapability`
194
+ // dedupes before source toggles, so a disabled high-priority provider must
195
+ // not hide an enabled lower-priority provider with the same skill name.
196
+ const seenAuthoredSkillNames = new Set<string>();
197
+ const filteredSkills = result.all.filter(capSkill => {
198
+ if (capSkill._source.provider === MANAGED_SKILLS_PROVIDER_ID) return false;
195
199
  if (disabledSkillNames.has(capSkill.name)) return false;
196
200
  if (!isSourceEnabled(capSkill._source)) return false;
197
201
  if (matchesIgnorePatterns(capSkill.name)) return false;
198
202
  if (!matchesIncludePatterns(capSkill.name)) return false;
203
+ if (seenAuthoredSkillNames.has(capSkill.name)) return false;
204
+ seenAuthoredSkillNames.add(capSkill.name);
199
205
  return true;
200
206
  });
201
207
 
@@ -213,9 +219,6 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise<LoadS
213
219
  // Process skills with resolved paths
214
220
  for (let i = 0; i < filteredSkills.length; i++) {
215
221
  const capSkill = filteredSkills[i];
216
- // Managed (auto-learn) skills are resolved dead-last (below) so any
217
- // authored skill of the same name — from ANY provider or custom dir — wins.
218
- if (capSkill._source.provider === MANAGED_SKILLS_PROVIDER_ID) continue;
219
222
  const resolvedPath = realPaths[i];
220
223
 
221
224
  // Skip silently if we've already loaded this exact file (via symlink)
@@ -318,15 +318,20 @@ export class SpeakableStream {
318
318
  this.#drain(out);
319
319
  }
320
320
 
321
- /** Emit every buffered character, force-splitting anything over the cap. */
321
+ /**
322
+ * Emit every buffered character. Runs the bounded streaming segmenter first
323
+ * so a large buffer (paste-sized delta, one-shot push) prefers sentence and
324
+ * clause cuts within {@link MAX_SEGMENT}, instead of word-splitting whole
325
+ * paragraphs at the cap ("…a big jump is" / "coming"). Not byte-identical to
326
+ * char-by-char streaming — the soft-clause latency cut can fire earlier
327
+ * there — but every segment obeys the same cap and boundary preferences.
328
+ * {@link #extract} leaves at most MAX_SEGMENT behind, emitted as the
329
+ * trailing segment.
330
+ */
322
331
  #drain(out: string[]): void {
323
- let text = this.#buf;
332
+ this.#extract(out);
333
+ const text = this.#buf;
324
334
  this.#buf = "";
325
- while (text.length > MAX_SEGMENT) {
326
- const cut = findForcedCut(text, MAX_SEGMENT);
327
- this.#emit(text.slice(0, cut), out);
328
- text = text.slice(cut);
329
- }
330
335
  this.#emit(text, out);
331
336
  }
332
337
 
@@ -335,14 +340,19 @@ export class SpeakableStream {
335
340
  for (;;) {
336
341
  const buf = this.#buf;
337
342
  const min = this.#spoke ? MIN_SEGMENT : FIRST_SEGMENT_MIN;
343
+ // Bounded: a sentence past MAX_SEGMENT risks Kokoro's ~510-phoneme
344
+ // truncation — fall through to clause/word cuts instead.
338
345
  const sentence = findSentenceCut(buf, min);
339
- if (sentence !== -1) {
346
+ if (sentence !== -1 && sentence <= MAX_SEGMENT) {
340
347
  this.#cut(sentence, out);
341
348
  continue;
342
349
  }
343
350
  if (!this.#spoke && buf.length >= FIRST_CLAUSE_MIN) {
351
+ // Bounded like the sentence branch: in a one-shot buffer the earliest
352
+ // clause can lie far past the cap; per-char streaming would have
353
+ // force-cut at FIRST_FORCED_MAX long before seeing it.
344
354
  const clause = findClauseCut(buf, FIRST_SEGMENT_MIN);
345
- if (clause !== -1) {
355
+ if (clause !== -1 && clause <= FIRST_FORCED_MAX) {
346
356
  this.#cut(clause, out);
347
357
  continue;
348
358
  }