@oh-my-pi/pi-coding-agent 16.3.8 → 16.3.10
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 +37 -0
- package/dist/cli.js +3050 -3048
- package/dist/types/commands/say.d.ts +6 -1
- package/dist/types/commit/agentic/lock-files.d.ts +36 -0
- package/dist/types/config/model-discovery.d.ts +3 -2
- package/dist/types/modes/interactive-mode.d.ts +5 -3
- package/dist/types/modes/session-observer-registry.d.ts +3 -1
- package/dist/types/session/agent-session.d.ts +11 -7
- package/dist/types/task/renderer.d.ts +0 -1
- package/dist/types/tools/browser/run-cancellation.d.ts +11 -0
- package/dist/types/tools/browser/tab-protocol.d.ts +1 -0
- package/dist/types/tools/tool-errors.d.ts +1 -1
- package/package.json +12 -12
- package/src/commands/say.ts +73 -30
- package/src/commit/agentic/index.ts +3 -1
- package/src/commit/agentic/lock-files.ts +107 -0
- package/src/commit/agentic/tools/git-overview.ts +1 -20
- package/src/config/model-discovery.ts +6 -3
- package/src/config/model-registry.ts +18 -7
- package/src/discovery/claude.ts +12 -8
- package/src/extensibility/skills.ts +8 -5
- package/src/modes/components/__tests__/skill-message.test.ts +2 -0
- package/src/modes/components/agent-hub.ts +17 -2
- package/src/modes/components/skill-message.ts +1 -1
- package/src/modes/index.ts +0 -7
- package/src/modes/interactive-mode.ts +54 -18
- package/src/modes/session-observer-registry.ts +11 -8
- package/src/sdk.ts +18 -0
- package/src/session/agent-session.ts +25 -13
- package/src/task/render.test.ts +134 -1
- package/src/task/render.ts +24 -6
- package/src/task/renderer.ts +0 -1
- package/src/tools/browser/cmux/cmux-tab.ts +7 -3
- package/src/tools/browser/run-cancellation.ts +28 -8
- package/src/tools/browser/tab-protocol.ts +1 -1
- package/src/tools/browser/tab-supervisor.ts +4 -4
- package/src/tools/browser/tab-worker.ts +19 -7
- package/src/tools/irc.ts +18 -2
- package/src/tools/tool-errors.ts +3 -3
- package/src/tts/speakable.ts +19 -9
|
@@ -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
|
|
42
|
-
maxTokens
|
|
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>[]>;
|
|
@@ -33,7 +33,7 @@ import { TranscriptContainer } from "./components/transcript-container.js";
|
|
|
33
33
|
import { EventController } from "./controllers/event-controller.js";
|
|
34
34
|
import { type LoopLimitRuntime } from "./loop-limit.js";
|
|
35
35
|
import { OAuthManualInputManager } from "./oauth-manual-input.js";
|
|
36
|
-
import type
|
|
36
|
+
import { type ObservableSession } from "./session-observer-registry.js";
|
|
37
37
|
import type { Theme } from "./theme/theme.js";
|
|
38
38
|
import type { CompactionQueuedMessage, InteractiveModeContext, InteractiveModeInitOptions, InteractiveSelectorDialogOptions, SubmittedUserInput, TodoItem, TodoPhase } from "./types.js";
|
|
39
39
|
/**
|
|
@@ -63,7 +63,7 @@ export interface InteractiveModeOptions {
|
|
|
63
63
|
}
|
|
64
64
|
/**
|
|
65
65
|
* Build the anchored subagent HUD block: a bold accent "Subagents" header plus
|
|
66
|
-
*
|
|
66
|
+
* a bounded set of running-agent rows in the same `Id: description` shape the
|
|
67
67
|
* inline task rows use (muted task preview when no description was given).
|
|
68
68
|
* Layout mirrors the Todos HUD exactly: unindented header, then
|
|
69
69
|
* `renderTreeList` rows (dim connectors) shifted right by one space.
|
|
@@ -217,7 +217,9 @@ export declare class InteractiveMode implements InteractiveModeContext {
|
|
|
217
217
|
finishPendingSubmission(input: SubmittedUserInput): void;
|
|
218
218
|
updateEditorBorderColor(): void;
|
|
219
219
|
/** Refresh the running-subagents status badge from the active local or collab registry. */
|
|
220
|
-
syncRunningSubagentBadge(
|
|
220
|
+
syncRunningSubagentBadge(options?: {
|
|
221
|
+
requestRender?: boolean;
|
|
222
|
+
}): void;
|
|
221
223
|
rebuildChatFromMessages(): void;
|
|
222
224
|
/**
|
|
223
225
|
* Render the ctrl+p model-role cycle chip track into its own anchored
|
|
@@ -21,10 +21,12 @@ export interface ObservableSession {
|
|
|
21
21
|
/** Latest progress snapshot from the subagent executor */
|
|
22
22
|
progress?: AgentProgress;
|
|
23
23
|
}
|
|
24
|
+
/** Coarse source of an observer change; callers use it to separate lifecycle work from high-frequency progress. */
|
|
25
|
+
export type SessionObserverChangeKind = "main" | "reset" | "lifecycle" | "progress";
|
|
24
26
|
export declare class SessionObserverRegistry {
|
|
25
27
|
#private;
|
|
26
28
|
/** Add a change listener. Returns unsubscribe function. */
|
|
27
|
-
onChange(cb: () => void): () => void;
|
|
29
|
+
onChange(cb: (kind: SessionObserverChangeKind) => void): () => void;
|
|
28
30
|
setMainSession(sessionFile?: string): void;
|
|
29
31
|
getSessions(): ObservableSession[];
|
|
30
32
|
getActiveSubagentCount(): number;
|
|
@@ -423,6 +423,7 @@ export interface SessionStats {
|
|
|
423
423
|
};
|
|
424
424
|
premiumRequests: number;
|
|
425
425
|
cost: number;
|
|
426
|
+
contextUsage?: ContextUsage;
|
|
426
427
|
}
|
|
427
428
|
/** Advisor statistics for /advisor status command. */
|
|
428
429
|
export interface AdvisorStats {
|
|
@@ -1160,15 +1161,18 @@ export declare class AgentSession {
|
|
|
1160
1161
|
/** Whether there are pending Python messages waiting to be flushed */
|
|
1161
1162
|
get hasPendingPythonMessages(): boolean;
|
|
1162
1163
|
/**
|
|
1163
|
-
* Surfaces
|
|
1164
|
+
* Surfaces and consumes pending IRC incoming records before the next model
|
|
1164
1165
|
* step can inject them automatically.
|
|
1165
1166
|
*
|
|
1166
|
-
*
|
|
1167
|
-
*
|
|
1168
|
-
*
|
|
1169
|
-
*
|
|
1170
|
-
*/
|
|
1171
|
-
drainPendingIrcInboxMessages(agentId: string
|
|
1167
|
+
* Tool results already expose the formatted body to the model. Leaving the
|
|
1168
|
+
* same record in either pending IRC queue would deliver it a second time at
|
|
1169
|
+
* the next step boundary — including on `peek`, which is why inbox peeks
|
|
1170
|
+
* also drain here.
|
|
1171
|
+
*/
|
|
1172
|
+
drainPendingIrcInboxMessages(agentId: string, opts?: {
|
|
1173
|
+
from?: string;
|
|
1174
|
+
limit?: number;
|
|
1175
|
+
}): IrcMessage[];
|
|
1172
1176
|
/**
|
|
1173
1177
|
* Deliver an IRC message into this session (recipient side; called by the
|
|
1174
1178
|
* IrcBus). Emits the `irc_message` session event for UI cards and injects
|
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marks a run-scoped promise as observed without changing its behavior for awaited callers.
|
|
3
|
+
*
|
|
4
|
+
* Browser run teardown aborts can reject promises created for evaluated code after user code
|
|
5
|
+
* has stopped observing them (for example fire-and-forget `wait()`/facade calls). In 16.3.0
|
|
6
|
+
* those zero-consumer rejections reached the process-level `unhandledRejection` handler and
|
|
7
|
+
* killed every subagent sharing the process (issues #4499/#4672). Attaching a no-op rejection
|
|
8
|
+
* handler at creation makes the promise observed while returning the original promise so callers
|
|
9
|
+
* that do await it still receive the rejection.
|
|
10
|
+
*/
|
|
11
|
+
export declare function markHandled<T>(promise: Promise<T>): Promise<T>;
|
|
1
12
|
/** Sleeps inside evaluated browser code while honoring the owning run's cancellation signal. */
|
|
2
13
|
export declare function waitForBrowserRun(ms: number, signal: AbortSignal): Promise<void>;
|
|
3
14
|
/** Binds a long-lived browser facade to one evaluated run's abort signal. */
|
|
@@ -19,7 +19,7 @@ export declare class ToolError extends Error {
|
|
|
19
19
|
*/
|
|
20
20
|
export declare class ToolAbortError extends Error {
|
|
21
21
|
static readonly MESSAGE = "Operation aborted";
|
|
22
|
-
constructor(message?: string);
|
|
22
|
+
constructor(message?: string, options?: ErrorOptions);
|
|
23
23
|
}
|
|
24
24
|
/**
|
|
25
25
|
* Throw ToolAbortError if the signal is aborted.
|
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.
|
|
4
|
+
"version": "16.3.10",
|
|
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.
|
|
60
|
-
"@oh-my-pi/omp-stats": "16.3.
|
|
61
|
-
"@oh-my-pi/pi-agent-core": "16.3.
|
|
62
|
-
"@oh-my-pi/pi-ai": "16.3.
|
|
63
|
-
"@oh-my-pi/pi-catalog": "16.3.
|
|
64
|
-
"@oh-my-pi/pi-mnemopi": "16.3.
|
|
65
|
-
"@oh-my-pi/pi-natives": "16.3.
|
|
66
|
-
"@oh-my-pi/pi-tui": "16.3.
|
|
67
|
-
"@oh-my-pi/pi-utils": "16.3.
|
|
68
|
-
"@oh-my-pi/pi-wire": "16.3.
|
|
69
|
-
"@oh-my-pi/snapcompact": "16.3.
|
|
59
|
+
"@oh-my-pi/hashline": "16.3.10",
|
|
60
|
+
"@oh-my-pi/omp-stats": "16.3.10",
|
|
61
|
+
"@oh-my-pi/pi-agent-core": "16.3.10",
|
|
62
|
+
"@oh-my-pi/pi-ai": "16.3.10",
|
|
63
|
+
"@oh-my-pi/pi-catalog": "16.3.10",
|
|
64
|
+
"@oh-my-pi/pi-mnemopi": "16.3.10",
|
|
65
|
+
"@oh-my-pi/pi-natives": "16.3.10",
|
|
66
|
+
"@oh-my-pi/pi-tui": "16.3.10",
|
|
67
|
+
"@oh-my-pi/pi-utils": "16.3.10",
|
|
68
|
+
"@oh-my-pi/pi-wire": "16.3.10",
|
|
69
|
+
"@oh-my-pi/snapcompact": "16.3.10",
|
|
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",
|
package/src/commands/say.ts
CHANGED
|
@@ -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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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 {
|
|
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({
|
|
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
|
-
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
71
|
-
const
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
155
|
-
maxTokens
|
|
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
|
-
|
|
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
|
-
|
|
890
|
-
|
|
891
|
-
|
|
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.
|
|
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");
|