@oh-my-pi/pi-coding-agent 16.2.8 → 16.2.11
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 +59 -0
- package/dist/cli.js +3160 -3096
- package/dist/types/config/settings-schema.d.ts +41 -13
- package/dist/types/extensibility/skills.d.ts +29 -0
- package/dist/types/modes/components/mcp-add-wizard.d.ts +8 -0
- package/dist/types/modes/components/todo-reminder.d.ts +3 -1
- package/dist/types/modes/controllers/mcp-command-controller.d.ts +9 -0
- package/dist/types/modes/controllers/tool-args-reveal.d.ts +5 -0
- package/dist/types/modes/interactive-mode.d.ts +0 -1
- package/dist/types/modes/skill-command.d.ts +1 -1
- package/dist/types/modes/types.d.ts +0 -1
- package/dist/types/session/agent-session.d.ts +1 -1
- package/dist/types/stt/asr-client.d.ts +7 -3
- package/dist/types/stt/index.d.ts +1 -0
- package/dist/types/stt/stt-controller.d.ts +2 -0
- package/dist/types/stt/submit-trigger.d.ts +30 -0
- package/dist/types/task/index.d.ts +1 -1
- package/dist/types/task/types.d.ts +6 -6
- package/dist/types/tiny/models.d.ts +22 -8
- package/package.json +14 -13
- package/scripts/bundle-dist.ts +23 -4
- package/scripts/generate-docs-index.ts +116 -24
- package/src/async/job-manager.ts +27 -3
- package/src/cli/grep-cli.ts +1 -1
- package/src/commit/agentic/agent.ts +1 -1
- package/src/commit/agentic/prompts/system.md +1 -1
- package/src/commit/agentic/tools/analyze-file.ts +2 -2
- package/src/config/model-discovery.ts +118 -76
- package/src/config/settings-schema.ts +15 -1
- package/src/debug/profiler.ts +7 -1
- package/src/extensibility/skills.ts +77 -0
- package/src/internal-urls/docs-index.generated.txt +2 -2
- package/src/lsp/config.ts +17 -3
- package/src/mcp/oauth-flow.ts +35 -8
- package/src/modes/acp/acp-agent.ts +6 -9
- package/src/modes/components/mcp-add-wizard.ts +43 -3
- package/src/modes/components/model-selector.ts +21 -9
- package/src/modes/components/todo-reminder.ts +5 -1
- package/src/modes/controllers/event-controller.ts +40 -15
- package/src/modes/controllers/mcp-command-controller.ts +84 -3
- package/src/modes/controllers/selector-controller.ts +57 -35
- package/src/modes/controllers/tool-args-reveal.ts +12 -0
- package/src/modes/interactive-mode.ts +5 -10
- package/src/modes/rpc/rpc-mode.ts +5 -8
- package/src/modes/skill-command.ts +8 -20
- package/src/modes/types.ts +0 -1
- package/src/prompts/agents/tester.md +107 -0
- package/src/prompts/system/orchestrate-notice.md +2 -2
- package/src/prompts/system/system-prompt.md +2 -5
- package/src/prompts/system/thinking-loop-redirect.md +10 -0
- package/src/prompts/system/workflow-notice.md +1 -1
- package/src/prompts/tools/task.md +2 -9
- package/src/session/agent-session.ts +53 -18
- package/src/stt/asr-client.ts +87 -27
- package/src/stt/downloader.ts +8 -2
- package/src/stt/index.ts +1 -0
- package/src/stt/stt-controller.ts +31 -2
- package/src/stt/submit-trigger.ts +74 -0
- package/src/task/agents.ts +4 -4
- package/src/task/executor.ts +2 -4
- package/src/task/index.ts +32 -10
- package/src/task/types.ts +5 -5
- package/src/tiny/models.ts +10 -0
- package/src/tools/ast-grep.ts +34 -12
- package/src/tools/grep.ts +11 -8
- package/src/utils/git.ts +22 -1
- package/src/prompts/agents/oracle.md +0 -54
|
@@ -250,6 +250,7 @@ import planModeToolDecisionReminderPrompt from "../prompts/system/plan-mode-tool
|
|
|
250
250
|
type: "text",
|
|
251
251
|
};
|
|
252
252
|
import sideChannelNoToolsReminder from "../prompts/system/side-channel-no-tools.md" with { type: "text" };
|
|
253
|
+
import thinkingLoopRedirectTemplate from "../prompts/system/thinking-loop-redirect.md" with { type: "text" };
|
|
253
254
|
import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
|
|
254
255
|
import ttsrToolReminderTemplate from "../prompts/system/ttsr-tool-reminder.md" with { type: "text" };
|
|
255
256
|
import unexpectedStopRetryTemplate from "../prompts/system/unexpected-stop-retry.md" with { type: "text" };
|
|
@@ -346,6 +347,9 @@ const SESSION_STOP_CONTINUATION_CAP = 8;
|
|
|
346
347
|
const GEMINI_HEADER_INTERRUPT_REASON = "Interrupted: emit a tool call instead of more planning";
|
|
347
348
|
/** `customType` for the hidden tool-call reminder injected after the interrupt. */
|
|
348
349
|
const GEMINI_TOOL_REMINDER_TYPE = "gemini-tool-call-reminder";
|
|
350
|
+
/** `customType` for the hidden redirect notice injected into a turn retried after a
|
|
351
|
+
* thinking/response loop. Steers the model off the repeated content; never displayed. */
|
|
352
|
+
const THINKING_LOOP_REDIRECT_TYPE = "thinking-loop-redirect";
|
|
349
353
|
|
|
350
354
|
// A side-channel assistant response is signed for the hidden prompt/history that
|
|
351
355
|
// produced it. If we persist that response under a different user turn, native
|
|
@@ -2882,10 +2886,10 @@ export class AgentSession {
|
|
|
2882
2886
|
* the mid-run-compaction planner can ask "is this turn message already on
|
|
2883
2887
|
* the branch?" in O(1) instead of re-walking the branch per check.
|
|
2884
2888
|
*
|
|
2885
|
-
* The
|
|
2886
|
-
*
|
|
2887
|
-
*
|
|
2888
|
-
*
|
|
2889
|
+
* The mid-run ordering check uses key identity alone: same-key content
|
|
2890
|
+
* variants are one logical message at this boundary, because otherwise a
|
|
2891
|
+
* display-side rewrite can make the assistant look missing after its tool
|
|
2892
|
+
* results have already persisted.
|
|
2889
2893
|
*
|
|
2890
2894
|
* Pre-#3629 the equivalent was `sessionManager.getBranch()` called twice
|
|
2891
2895
|
* per turn message, each call rebuilding the path via O(n²) `unshift` and
|
|
@@ -2893,17 +2897,14 @@ export class AgentSession {
|
|
|
2893
2897
|
* per `onTurnEnd` on a long session and the load-bearing source of the
|
|
2894
2898
|
* `ui.loop-blocked` warnings in the bug report.
|
|
2895
2899
|
*/
|
|
2896
|
-
#
|
|
2897
|
-
const
|
|
2900
|
+
#indexPersistedMessageKeys(): Set<string> {
|
|
2901
|
+
const keys = new Set<string>();
|
|
2898
2902
|
for (const entry of this.sessionManager.getBranch()) {
|
|
2899
2903
|
if (entry.type !== "message") continue;
|
|
2900
2904
|
const key = sessionMessagePersistenceKey(entry.message);
|
|
2901
|
-
if (key
|
|
2902
|
-
const existing = index.get(key);
|
|
2903
|
-
if (existing) existing.push(entry.message);
|
|
2904
|
-
else index.set(key, [entry.message]);
|
|
2905
|
+
if (key !== undefined) keys.add(key);
|
|
2905
2906
|
}
|
|
2906
|
-
return
|
|
2907
|
+
return keys;
|
|
2907
2908
|
}
|
|
2908
2909
|
|
|
2909
2910
|
/**
|
|
@@ -3003,17 +3004,17 @@ export class AgentSession {
|
|
|
3003
3004
|
// JSON-compared every entry per turn message, which on long sessions
|
|
3004
3005
|
// turned each `onTurnEnd` into a seconds-long sync block (the
|
|
3005
3006
|
// `ui.loop-blocked` warnings tagged `subagent:*` in the bug report).
|
|
3006
|
-
const
|
|
3007
|
+
const branchKeys = this.#indexPersistedMessageKeys();
|
|
3007
3008
|
const turnKeys = turnMessages.map(sessionMessagePersistenceKey);
|
|
3008
3009
|
const persistedKeys = new Set<string>();
|
|
3009
3010
|
for (let index = 0; index < turnMessages.length; index++) {
|
|
3010
3011
|
const key = turnKeys[index];
|
|
3011
3012
|
if (key === undefined) continue;
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
//
|
|
3015
|
-
//
|
|
3016
|
-
if (
|
|
3013
|
+
// Mid-run ordering is keyed by logical identity. A persisted display
|
|
3014
|
+
// variant (for example, redacted/deobfuscated content) must still count;
|
|
3015
|
+
// otherwise the assistant can look missing while later tool results are
|
|
3016
|
+
// present, producing a false out-of-order skip.
|
|
3017
|
+
if (branchKeys.has(key)) {
|
|
3017
3018
|
persistedKeys.add(key);
|
|
3018
3019
|
}
|
|
3019
3020
|
}
|
|
@@ -8087,7 +8088,7 @@ export class AgentSession {
|
|
|
8087
8088
|
*/
|
|
8088
8089
|
async setModelTemporary(
|
|
8089
8090
|
model: Model,
|
|
8090
|
-
thinkingLevel?:
|
|
8091
|
+
thinkingLevel?: ConfiguredThinkingLevel,
|
|
8091
8092
|
options?: { ephemeral?: boolean },
|
|
8092
8093
|
): Promise<void> {
|
|
8093
8094
|
const previousEditMode = this.#resolveActiveEditMode();
|
|
@@ -12582,6 +12583,11 @@ export class AgentSession {
|
|
|
12582
12583
|
// Remove the failed assistant message from active context before retrying.
|
|
12583
12584
|
this.#removeAssistantMessageFromActiveContext(message);
|
|
12584
12585
|
|
|
12586
|
+
// A thinking/response loop retried into identical context loops again. Inject a
|
|
12587
|
+
// hidden redirect so the retried turn sees a directive to break the repeated
|
|
12588
|
+
// pattern instead of re-sampling the same stalled reasoning.
|
|
12589
|
+
this.#maybeInjectThinkingLoopRedirect(id);
|
|
12590
|
+
|
|
12585
12591
|
// Wait with exponential backoff (abortable).
|
|
12586
12592
|
const retryAbortController = new AbortController();
|
|
12587
12593
|
this.#retryAbortController?.abort();
|
|
@@ -12615,6 +12621,35 @@ export class AgentSession {
|
|
|
12615
12621
|
return true;
|
|
12616
12622
|
}
|
|
12617
12623
|
|
|
12624
|
+
/**
|
|
12625
|
+
* Inject a hidden redirect notice when a thinking/response loop is being retried, so
|
|
12626
|
+
* the retried turn carries an instruction to break the repeated pattern instead of
|
|
12627
|
+
* re-sampling the same stalled context. Injected on every {@link AIError.Flag.ThinkingLoop}
|
|
12628
|
+
* retry (the failed assistant is dropped each attempt, so the notice does not accumulate
|
|
12629
|
+
* unboundedly). No-op unless `id` carries the ThinkingLoop flag and the loop guard is
|
|
12630
|
+
* enabled. The notice is generic on purpose — the detector's detail can quote raw model
|
|
12631
|
+
* text, which must not be interpolated into a higher-priority developer message.
|
|
12632
|
+
*/
|
|
12633
|
+
#maybeInjectThinkingLoopRedirect(id: number): void {
|
|
12634
|
+
if (!AIError.is(id, AIError.Flag.ThinkingLoop)) return;
|
|
12635
|
+
if (this.settings.get("model.loopGuard.enabled") !== true) return;
|
|
12636
|
+
this.agent.appendMessage({
|
|
12637
|
+
role: "custom",
|
|
12638
|
+
customType: THINKING_LOOP_REDIRECT_TYPE,
|
|
12639
|
+
content: thinkingLoopRedirectTemplate,
|
|
12640
|
+
display: false,
|
|
12641
|
+
attribution: "agent",
|
|
12642
|
+
timestamp: Date.now(),
|
|
12643
|
+
});
|
|
12644
|
+
this.sessionManager.appendCustomMessageEntry(
|
|
12645
|
+
THINKING_LOOP_REDIRECT_TYPE,
|
|
12646
|
+
thinkingLoopRedirectTemplate,
|
|
12647
|
+
false,
|
|
12648
|
+
undefined,
|
|
12649
|
+
"agent",
|
|
12650
|
+
);
|
|
12651
|
+
}
|
|
12652
|
+
|
|
12618
12653
|
/**
|
|
12619
12654
|
* Cancel in-progress retry.
|
|
12620
12655
|
*/
|
package/src/stt/asr-client.ts
CHANGED
|
@@ -4,12 +4,12 @@ import {
|
|
|
4
4
|
createWorkerHandle,
|
|
5
5
|
createWorkerSubprocess,
|
|
6
6
|
logWorkerMessage,
|
|
7
|
+
type RefCountedWorkerHandle,
|
|
7
8
|
resolveWorkerSpawnCmd,
|
|
8
9
|
SMOKE_TEST_TIMEOUT_MS,
|
|
9
10
|
type SpawnedSubprocess,
|
|
10
11
|
smokeTestWorker,
|
|
11
12
|
spawnWorkerOrUnavailable,
|
|
12
|
-
type WorkerHandle,
|
|
13
13
|
} from "../subprocess/worker-client";
|
|
14
14
|
import { tinyWorkerEnv } from "../tiny/title-client";
|
|
15
15
|
import { safeSend } from "../utils/ipc";
|
|
@@ -18,7 +18,7 @@ import type { SttModelKey } from "./models";
|
|
|
18
18
|
|
|
19
19
|
type PendingRequest =
|
|
20
20
|
| { kind: "transcribe"; modelKey: SttModelKey; resolve: (text: string) => void; reject: (error: Error) => void }
|
|
21
|
-
| { kind: "download"; modelKey: SttModelKey; resolve: (
|
|
21
|
+
| { kind: "download"; modelKey: SttModelKey; resolve: (result: SttDownloadResult) => void };
|
|
22
22
|
|
|
23
23
|
export interface SttTranscribeOptions {
|
|
24
24
|
language?: string;
|
|
@@ -30,6 +30,11 @@ export interface SttDownloadOptions {
|
|
|
30
30
|
onProgress?: (event: SttProgressEvent) => void;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
export interface SttDownloadResult {
|
|
34
|
+
ok: boolean;
|
|
35
|
+
error?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
33
38
|
/** Live streaming session handle returned by {@link SttClient.startStream}. */
|
|
34
39
|
export interface SttStreamHandle {
|
|
35
40
|
/** Feed 16 kHz mono float samples as the recorder produces them. */
|
|
@@ -79,30 +84,55 @@ export function createSttSubprocess(): SpawnedSubprocess<SttWorkerOutbound> {
|
|
|
79
84
|
|
|
80
85
|
function wrapSubprocess(
|
|
81
86
|
spawned: SpawnedSubprocess<SttWorkerOutbound>,
|
|
82
|
-
):
|
|
87
|
+
): RefCountedWorkerHandle<SttWorkerInbound, SttWorkerOutbound> {
|
|
83
88
|
const { proc } = spawned;
|
|
84
|
-
return
|
|
89
|
+
return {
|
|
90
|
+
...createWorkerHandle<SttWorkerInbound, SttWorkerOutbound>(spawned, message => safeSend(proc, message, "stt")),
|
|
91
|
+
ref() {
|
|
92
|
+
try {
|
|
93
|
+
proc.ref();
|
|
94
|
+
} catch {
|
|
95
|
+
// Already gone.
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
unref() {
|
|
99
|
+
try {
|
|
100
|
+
proc.unref();
|
|
101
|
+
} catch {
|
|
102
|
+
// Already gone.
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function spawnInlineUnavailableWorker(error: unknown): RefCountedWorkerHandle<SttWorkerInbound, SttWorkerOutbound> {
|
|
109
|
+
return {
|
|
110
|
+
...createUnavailableWorker<SttWorkerInbound, SttWorkerOutbound>(error),
|
|
111
|
+
ref() {},
|
|
112
|
+
unref() {},
|
|
113
|
+
};
|
|
85
114
|
}
|
|
86
115
|
|
|
87
|
-
function spawnSttWorker():
|
|
116
|
+
function spawnSttWorker(): RefCountedWorkerHandle<SttWorkerInbound, SttWorkerOutbound> {
|
|
88
117
|
return spawnWorkerOrUnavailable(
|
|
89
118
|
() => wrapSubprocess(createSttSubprocess()),
|
|
90
|
-
|
|
119
|
+
spawnInlineUnavailableWorker,
|
|
91
120
|
"stt worker spawn failed; speech-to-text disabled",
|
|
92
121
|
);
|
|
93
122
|
}
|
|
94
123
|
|
|
95
124
|
export class SttClient {
|
|
96
|
-
#worker:
|
|
125
|
+
#worker: RefCountedWorkerHandle<SttWorkerInbound, SttWorkerOutbound> | null = null;
|
|
97
126
|
#unsubscribeMessage: (() => void) | null = null;
|
|
98
127
|
#unsubscribeError: (() => void) | null = null;
|
|
99
128
|
#pending = new Map<string, PendingRequest>();
|
|
100
129
|
#streams = new Map<string, StreamState>();
|
|
101
130
|
#progressListeners = new Set<(event: SttProgressEvent) => void>();
|
|
102
131
|
#nextRequestId = 0;
|
|
103
|
-
#
|
|
132
|
+
#refed = false;
|
|
133
|
+
#spawnWorker: () => RefCountedWorkerHandle<SttWorkerInbound, SttWorkerOutbound>;
|
|
104
134
|
|
|
105
|
-
constructor(spawnWorker: () =>
|
|
135
|
+
constructor(spawnWorker: () => RefCountedWorkerHandle<SttWorkerInbound, SttWorkerOutbound> = spawnSttWorker) {
|
|
106
136
|
this.#spawnWorker = spawnWorker;
|
|
107
137
|
}
|
|
108
138
|
|
|
@@ -121,11 +151,11 @@ export class SttClient {
|
|
|
121
151
|
const worker = this.#ensureWorker();
|
|
122
152
|
const id = String(++this.#nextRequestId);
|
|
123
153
|
const { promise, resolve, reject } = Promise.withResolvers<string>();
|
|
124
|
-
this.#
|
|
154
|
+
this.#addPending(id, { kind: "transcribe", modelKey, resolve, reject });
|
|
125
155
|
const abort = (): void => {
|
|
126
156
|
const pending = this.#pending.get(id);
|
|
127
157
|
if (pending?.kind !== "transcribe") return;
|
|
128
|
-
this.#
|
|
158
|
+
this.#deletePending(id);
|
|
129
159
|
pending.reject(new DOMException("The operation was aborted.", "AbortError"));
|
|
130
160
|
};
|
|
131
161
|
options.signal?.addEventListener("abort", abort, { once: true });
|
|
@@ -134,7 +164,7 @@ export class SttClient {
|
|
|
134
164
|
return await promise;
|
|
135
165
|
} finally {
|
|
136
166
|
options.signal?.removeEventListener("abort", abort);
|
|
137
|
-
this.#
|
|
167
|
+
this.#deletePending(id);
|
|
138
168
|
}
|
|
139
169
|
}
|
|
140
170
|
|
|
@@ -163,6 +193,7 @@ export class SttClient {
|
|
|
163
193
|
settled = true;
|
|
164
194
|
this.#streams.delete(id);
|
|
165
195
|
signal?.removeEventListener("abort", onAbort);
|
|
196
|
+
this.#syncWorkerRef();
|
|
166
197
|
apply();
|
|
167
198
|
};
|
|
168
199
|
this.#streams.set(id, {
|
|
@@ -173,6 +204,7 @@ export class SttClient {
|
|
|
173
204
|
reject,
|
|
174
205
|
finish,
|
|
175
206
|
});
|
|
207
|
+
this.#syncWorkerRef();
|
|
176
208
|
worker.send({ type: "stream_start", id, modelKey, language: options.language });
|
|
177
209
|
const handle: SttStreamHandle = {
|
|
178
210
|
pushAudio: audio => {
|
|
@@ -193,19 +225,19 @@ export class SttClient {
|
|
|
193
225
|
return handle;
|
|
194
226
|
}
|
|
195
227
|
|
|
196
|
-
async downloadModel(modelKey: SttModelKey, options: SttDownloadOptions = {}): Promise<
|
|
197
|
-
if (options.signal?.aborted) return false;
|
|
228
|
+
async downloadModel(modelKey: SttModelKey, options: SttDownloadOptions = {}): Promise<SttDownloadResult> {
|
|
229
|
+
if (options.signal?.aborted) return { ok: false };
|
|
198
230
|
const unsubscribe = options.onProgress ? this.onProgress(options.onProgress) : undefined;
|
|
199
231
|
try {
|
|
200
232
|
const worker = this.#ensureWorker();
|
|
201
233
|
const id = String(++this.#nextRequestId);
|
|
202
|
-
const { promise, resolve } = Promise.withResolvers<
|
|
203
|
-
this.#
|
|
234
|
+
const { promise, resolve } = Promise.withResolvers<SttDownloadResult>();
|
|
235
|
+
this.#addPending(id, { kind: "download", modelKey, resolve });
|
|
204
236
|
const abort = (): void => {
|
|
205
237
|
const pending = this.#pending.get(id);
|
|
206
238
|
if (pending?.kind !== "download") return;
|
|
207
|
-
this.#
|
|
208
|
-
pending.resolve(false);
|
|
239
|
+
this.#deletePending(id);
|
|
240
|
+
pending.resolve({ ok: false });
|
|
209
241
|
};
|
|
210
242
|
options.signal?.addEventListener("abort", abort, { once: true });
|
|
211
243
|
try {
|
|
@@ -213,14 +245,15 @@ export class SttClient {
|
|
|
213
245
|
return await promise;
|
|
214
246
|
} finally {
|
|
215
247
|
options.signal?.removeEventListener("abort", abort);
|
|
216
|
-
this.#
|
|
248
|
+
this.#deletePending(id);
|
|
217
249
|
}
|
|
218
250
|
} catch (error) {
|
|
251
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
219
252
|
logger.debug("stt: local model download failed", {
|
|
220
253
|
modelKey,
|
|
221
|
-
error:
|
|
254
|
+
error: message,
|
|
222
255
|
});
|
|
223
|
-
return false;
|
|
256
|
+
return { ok: false, error: message };
|
|
224
257
|
} finally {
|
|
225
258
|
unsubscribe?.();
|
|
226
259
|
}
|
|
@@ -236,9 +269,10 @@ export class SttClient {
|
|
|
236
269
|
for (const pending of this.#pending.values()) {
|
|
237
270
|
this.#emitProgress({ modelKey: pending.modelKey, status: "error" });
|
|
238
271
|
if (pending.kind === "transcribe") pending.reject(new Error("stt worker terminated"));
|
|
239
|
-
else pending.resolve(false);
|
|
272
|
+
else pending.resolve({ ok: false });
|
|
240
273
|
}
|
|
241
274
|
this.#pending.clear();
|
|
275
|
+
this.#refed = false;
|
|
242
276
|
this.#failStreams(new Error("stt worker terminated"));
|
|
243
277
|
try {
|
|
244
278
|
await worker?.terminate();
|
|
@@ -247,7 +281,7 @@ export class SttClient {
|
|
|
247
281
|
}
|
|
248
282
|
}
|
|
249
283
|
|
|
250
|
-
#ensureWorker():
|
|
284
|
+
#ensureWorker(): RefCountedWorkerHandle<SttWorkerInbound, SttWorkerOutbound> {
|
|
251
285
|
if (this.#worker) return this.#worker;
|
|
252
286
|
const worker = this.#spawnWorker();
|
|
253
287
|
this.#worker = worker;
|
|
@@ -256,6 +290,32 @@ export class SttClient {
|
|
|
256
290
|
return worker;
|
|
257
291
|
}
|
|
258
292
|
|
|
293
|
+
/** Register a pending request and keep the worker referenced while work is in flight. */
|
|
294
|
+
#addPending(id: string, request: PendingRequest): void {
|
|
295
|
+
this.#pending.set(id, request);
|
|
296
|
+
this.#syncWorkerRef();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Drop a pending request and unref the worker once no request or stream is active. */
|
|
300
|
+
#deletePending(id: string): void {
|
|
301
|
+
if (this.#pending.delete(id)) this.#syncWorkerRef();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* STT workers start unreferenced so an idle warm model never blocks exit.
|
|
306
|
+
* Setup/download commands must keep the worker alive while awaiting IPC, or
|
|
307
|
+
* Bun can drain the event loop immediately after `Preparing Speech-to-Text`.
|
|
308
|
+
*/
|
|
309
|
+
#syncWorkerRef(): void {
|
|
310
|
+
const worker = this.#worker;
|
|
311
|
+
if (!worker) return;
|
|
312
|
+
const shouldRef = this.#pending.size > 0 || this.#streams.size > 0;
|
|
313
|
+
if (shouldRef === this.#refed) return;
|
|
314
|
+
this.#refed = shouldRef;
|
|
315
|
+
if (shouldRef) worker.ref();
|
|
316
|
+
else worker.unref();
|
|
317
|
+
}
|
|
318
|
+
|
|
259
319
|
#handleMessage(message: SttWorkerOutbound): void {
|
|
260
320
|
if (message.type === "log") {
|
|
261
321
|
logWorkerMessage(message);
|
|
@@ -287,19 +347,19 @@ export class SttClient {
|
|
|
287
347
|
}
|
|
288
348
|
return;
|
|
289
349
|
}
|
|
290
|
-
this.#
|
|
350
|
+
this.#deletePending(message.id);
|
|
291
351
|
if (message.type === "transcription") {
|
|
292
352
|
if (pending.kind === "transcribe") pending.resolve(message.text);
|
|
293
353
|
return;
|
|
294
354
|
}
|
|
295
355
|
if (message.type === "downloaded") {
|
|
296
|
-
if (pending.kind === "download") pending.resolve(true);
|
|
356
|
+
if (pending.kind === "download") pending.resolve({ ok: true });
|
|
297
357
|
return;
|
|
298
358
|
}
|
|
299
359
|
// message.type === "error"
|
|
300
360
|
this.#emitProgress({ modelKey: pending.modelKey, status: "error" });
|
|
301
361
|
if (pending.kind === "transcribe") pending.reject(new Error(message.error));
|
|
302
|
-
else pending.resolve(false);
|
|
362
|
+
else pending.resolve({ ok: false, error: message.error });
|
|
303
363
|
}
|
|
304
364
|
|
|
305
365
|
#emitProgress(event: SttProgressEvent): void {
|
|
@@ -318,7 +378,7 @@ export class SttClient {
|
|
|
318
378
|
for (const pending of this.#pending.values()) {
|
|
319
379
|
this.#emitProgress({ modelKey: pending.modelKey, status: "error" });
|
|
320
380
|
if (pending.kind === "transcribe") pending.reject(error);
|
|
321
|
-
else pending.resolve(false);
|
|
381
|
+
else pending.resolve({ ok: false, error: error.message });
|
|
322
382
|
}
|
|
323
383
|
this.#pending.clear();
|
|
324
384
|
this.#failStreams(error);
|
package/src/stt/downloader.ts
CHANGED
|
@@ -90,7 +90,7 @@ export async function downloadSttModel(
|
|
|
90
90
|
): Promise<void> {
|
|
91
91
|
const spec = resolveSttModelSpec(key);
|
|
92
92
|
const files = new Map<string, { loaded: number; total: number }>();
|
|
93
|
-
const
|
|
93
|
+
const result = await sttClient.downloadModel(spec.key, {
|
|
94
94
|
signal: options?.signal,
|
|
95
95
|
onProgress: event => {
|
|
96
96
|
if ((event.status === "progress" || event.status === "progress_total") && event.file) {
|
|
@@ -117,7 +117,13 @@ export async function downloadSttModel(
|
|
|
117
117
|
});
|
|
118
118
|
},
|
|
119
119
|
});
|
|
120
|
-
if (!ok)
|
|
120
|
+
if (!result.ok) {
|
|
121
|
+
const detail = result.error ? `: ${result.error}` : ". Check your network connection.";
|
|
122
|
+
throw new Error(`Failed to download speech model (${spec.repo})${detail}`);
|
|
123
|
+
}
|
|
124
|
+
if (!(await isSttModelCached(spec.key))) {
|
|
125
|
+
throw new Error(`Speech model download finished without required files (${spec.repo}).`);
|
|
126
|
+
}
|
|
121
127
|
}
|
|
122
128
|
|
|
123
129
|
// ── Public API ─────────────────────────────────────────────────────
|
package/src/stt/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
startStreamingRecording,
|
|
16
16
|
verifyRecordingFile,
|
|
17
17
|
} from "./recorder";
|
|
18
|
+
import { evaluateSubmitTrigger } from "./submit-trigger";
|
|
18
19
|
import { transcribe } from "./transcriber";
|
|
19
20
|
|
|
20
21
|
export type SttState = "idle" | "recording" | "transcribing";
|
|
@@ -33,6 +34,8 @@ interface Editor {
|
|
|
33
34
|
setVolatileText(text: string): void;
|
|
34
35
|
clearVolatileText(): void;
|
|
35
36
|
commitVolatileText(text: string): void;
|
|
37
|
+
submit(): void;
|
|
38
|
+
deleteBeforeCursor(count: number): void;
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
export class STTController {
|
|
@@ -53,6 +56,7 @@ export class STTController {
|
|
|
53
56
|
#streamEditor: Editor | null = null;
|
|
54
57
|
#streamCommitted = false;
|
|
55
58
|
#streamAbort: AbortController | null = null;
|
|
59
|
+
#streamUtterance = "";
|
|
56
60
|
|
|
57
61
|
get state(): SttState {
|
|
58
62
|
return this.#state;
|
|
@@ -190,6 +194,7 @@ export class STTController {
|
|
|
190
194
|
const language = settings.get("stt.language") as string | undefined;
|
|
191
195
|
this.#streamEditor = editor;
|
|
192
196
|
this.#streamCommitted = false;
|
|
197
|
+
this.#streamUtterance = "";
|
|
193
198
|
this.#streamAbort = new AbortController();
|
|
194
199
|
const stream = sttClient.startStream(modelKey, {
|
|
195
200
|
language: language || undefined,
|
|
@@ -205,6 +210,7 @@ export class STTController {
|
|
|
205
210
|
if (prefixed) {
|
|
206
211
|
this.#streamEditor?.commitVolatileText(prefixed);
|
|
207
212
|
this.#streamCommitted = true;
|
|
213
|
+
this.#streamUtterance += prefixed;
|
|
208
214
|
} else {
|
|
209
215
|
this.#streamEditor?.clearVolatileText();
|
|
210
216
|
}
|
|
@@ -266,13 +272,27 @@ export class STTController {
|
|
|
266
272
|
return;
|
|
267
273
|
}
|
|
268
274
|
if (!this.#streamCommitted && finalText) {
|
|
269
|
-
this.#
|
|
275
|
+
const prefixed = this.#prefixed(finalText);
|
|
276
|
+
this.#streamEditor?.commitVolatileText(prefixed);
|
|
270
277
|
this.#streamCommitted = true;
|
|
278
|
+
this.#streamUtterance = prefixed;
|
|
271
279
|
} else {
|
|
272
280
|
this.#streamEditor?.clearVolatileText();
|
|
273
281
|
}
|
|
274
282
|
options.requestRender?.();
|
|
275
283
|
if (!failed) options.showStatus(this.#streamCommitted ? "" : "No speech detected.");
|
|
284
|
+
|
|
285
|
+
if (this.#streamCommitted && !failed && this.#streamEditor) {
|
|
286
|
+
const trigger = settings.get("stt.submitTrigger");
|
|
287
|
+
const { submit, trimTrailing } = evaluateSubmitTrigger(this.#streamUtterance, trigger);
|
|
288
|
+
if (trimTrailing > 0) {
|
|
289
|
+
this.#streamEditor.deleteBeforeCursor(trimTrailing);
|
|
290
|
+
}
|
|
291
|
+
if (submit) {
|
|
292
|
+
this.#streamEditor.submit();
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
276
296
|
this.#cleanupStream();
|
|
277
297
|
this.#setState("idle", options);
|
|
278
298
|
}
|
|
@@ -283,6 +303,7 @@ export class STTController {
|
|
|
283
303
|
this.#streamEditor = null;
|
|
284
304
|
this.#streamCommitted = false;
|
|
285
305
|
this.#streamAbort = null;
|
|
306
|
+
this.#streamUtterance = "";
|
|
286
307
|
}
|
|
287
308
|
|
|
288
309
|
// ── Batch (single-shot) ─────────────────────────────────────────
|
|
@@ -327,8 +348,16 @@ export class STTController {
|
|
|
327
348
|
this.#transcriptionAbort = null;
|
|
328
349
|
if (this.#disposed) return;
|
|
329
350
|
if (text.length > 0) {
|
|
330
|
-
|
|
351
|
+
const trigger = settings.get("stt.submitTrigger");
|
|
352
|
+
const { submit, trimTrailing } = evaluateSubmitTrigger(text, trigger);
|
|
353
|
+
const textToInsert = trimTrailing > 0 ? text.slice(0, -trimTrailing) : text;
|
|
354
|
+
if (textToInsert.length > 0) {
|
|
355
|
+
editor.insertText(textToInsert);
|
|
356
|
+
}
|
|
331
357
|
options.showStatus("");
|
|
358
|
+
if (submit) {
|
|
359
|
+
editor.submit();
|
|
360
|
+
}
|
|
332
361
|
} else {
|
|
333
362
|
options.showStatus("No speech detected.");
|
|
334
363
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TTS/STT Submit Trigger options and evaluation logic.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const STT_SUBMIT_TRIGGER_VALUES = ["never", "release", "release-complete", "say-submit"] as const;
|
|
6
|
+
|
|
7
|
+
export type SttSubmitTrigger = (typeof STT_SUBMIT_TRIGGER_VALUES)[number];
|
|
8
|
+
|
|
9
|
+
export const STT_SUBMIT_TRIGGER_OPTIONS = [
|
|
10
|
+
{
|
|
11
|
+
value: "never",
|
|
12
|
+
label: "Never",
|
|
13
|
+
description: "Never automatically submit; insert dictation and remain in editor.",
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
value: "release",
|
|
17
|
+
label: "Release",
|
|
18
|
+
description: "Submit on release if the utterance has 2+ words to avoid accidental sends.",
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
value: "release-complete",
|
|
22
|
+
label: "Release with complete sentence",
|
|
23
|
+
description: "Submit on release if the utterance ends with sentence-terminal punctuation (. ? ! etc.).",
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
value: "say-submit",
|
|
27
|
+
label: "When I Say Submit",
|
|
28
|
+
description: "Submit if the utterance ends with a word containing 'submit' (strips that word before submitting).",
|
|
29
|
+
},
|
|
30
|
+
] satisfies ReadonlyArray<{ value: SttSubmitTrigger; label: string; description: string }>;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Evaluate the submit trigger against a transcribed utterance.
|
|
34
|
+
* Returns whether to submit, and the number of characters to trim from the end of the utterance.
|
|
35
|
+
*/
|
|
36
|
+
export function evaluateSubmitTrigger(
|
|
37
|
+
utterance: string,
|
|
38
|
+
trigger: SttSubmitTrigger,
|
|
39
|
+
): { submit: boolean; trimTrailing: number } {
|
|
40
|
+
const trimmed = utterance.trim();
|
|
41
|
+
if (!trimmed) {
|
|
42
|
+
return { submit: false, trimTrailing: 0 };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (trigger === "never") {
|
|
46
|
+
return { submit: false, trimTrailing: 0 };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (trigger === "release") {
|
|
50
|
+
// Split by whitespace and count words
|
|
51
|
+
const words = trimmed.split(/\s+/).filter(Boolean);
|
|
52
|
+
const submit = words.length >= 2;
|
|
53
|
+
return { submit, trimTrailing: 0 };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (trigger === "release-complete") {
|
|
57
|
+
// Matches typical sentence terminators: . ? ! ... or full-width equivalents, optionally followed by space
|
|
58
|
+
const hasTerminalPunctuation = /[.?!…。?!]\s*$/.test(trimmed);
|
|
59
|
+
return { submit: hasTerminalPunctuation, trimTrailing: 0 };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (trigger === "say-submit") {
|
|
63
|
+
// Matches space followed by any word containing "submit" (case-insensitive), optionally followed by punctuation/spaces
|
|
64
|
+
// Also handles the case where "submit" is the only word in the utterance (no leading space)
|
|
65
|
+
const match = utterance.match(/(?:^|\s+)(\S*submit\S*)[.?!…。?!]*\s*$/i);
|
|
66
|
+
if (match && match.index !== undefined) {
|
|
67
|
+
const trimTrailing = utterance.length - match.index;
|
|
68
|
+
return { submit: true, trimTrailing };
|
|
69
|
+
}
|
|
70
|
+
return { submit: false, trimTrailing: 0 };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { submit: false, trimTrailing: 0 };
|
|
74
|
+
}
|
package/src/task/agents.ts
CHANGED
|
@@ -11,11 +11,11 @@ import exploreMd from "../prompts/agents/explore.md" with { type: "text" };
|
|
|
11
11
|
// Embed agent markdown files at build time
|
|
12
12
|
import agentFrontmatterTemplate from "../prompts/agents/frontmatter.md" with { type: "text" };
|
|
13
13
|
import librarianMd from "../prompts/agents/librarian.md" with { type: "text" };
|
|
14
|
-
import oracleMd from "../prompts/agents/oracle.md" with { type: "text" };
|
|
15
14
|
|
|
16
15
|
import planMd from "../prompts/agents/plan.md" with { type: "text" };
|
|
17
16
|
import reviewerMd from "../prompts/agents/reviewer.md" with { type: "text" };
|
|
18
17
|
import taskMd from "../prompts/agents/task.md" with { type: "text" };
|
|
18
|
+
import testerMd from "../prompts/agents/tester.md" with { type: "text" };
|
|
19
19
|
|
|
20
20
|
import type { AgentDefinition, AgentSource } from "./types";
|
|
21
21
|
|
|
@@ -47,7 +47,7 @@ const EMBEDDED_AGENT_DEFS: EmbeddedAgentDef[] = [
|
|
|
47
47
|
{ fileName: "designer.md", template: designerMd },
|
|
48
48
|
{ fileName: "reviewer.md", template: reviewerMd },
|
|
49
49
|
{ fileName: "librarian.md", template: librarianMd },
|
|
50
|
-
{ fileName: "
|
|
50
|
+
{ fileName: "tester.md", template: testerMd },
|
|
51
51
|
{
|
|
52
52
|
fileName: "task.md",
|
|
53
53
|
frontmatter: {
|
|
@@ -59,9 +59,9 @@ const EMBEDDED_AGENT_DEFS: EmbeddedAgentDef[] = [
|
|
|
59
59
|
template: taskMd,
|
|
60
60
|
},
|
|
61
61
|
{
|
|
62
|
-
fileName: "
|
|
62
|
+
fileName: "sonic.md",
|
|
63
63
|
frontmatter: {
|
|
64
|
-
name: "
|
|
64
|
+
name: "sonic",
|
|
65
65
|
description: "Low-reasoning agent for strictly mechanical updates or data collection only",
|
|
66
66
|
model: "pi/smol",
|
|
67
67
|
thinkingLevel: Effort.Medium,
|
package/src/task/executor.ts
CHANGED
|
@@ -87,7 +87,7 @@ const MCP_CALL_TIMEOUT_MS = 60_000;
|
|
|
87
87
|
*/
|
|
88
88
|
export const SOFT_REQUEST_BUDGET: Record<string, number> = {
|
|
89
89
|
explore: 40,
|
|
90
|
-
|
|
90
|
+
sonic: 40,
|
|
91
91
|
default: 90,
|
|
92
92
|
};
|
|
93
93
|
|
|
@@ -2053,9 +2053,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
2053
2053
|
? formatModelSelectorValue(formatModelStringWithRouting(model), resolvedThinkingLevel)
|
|
2054
2054
|
: formatModelStringWithRouting(model);
|
|
2055
2055
|
}
|
|
2056
|
-
const effectiveThinkingLevel =
|
|
2057
|
-
? resolvedThinkingLevel
|
|
2058
|
-
: (thinkingLevel ?? resolvedThinkingLevel);
|
|
2056
|
+
const effectiveThinkingLevel = thinkingLevel ?? resolvedThinkingLevel;
|
|
2059
2057
|
resolvedAt = performance.now();
|
|
2060
2058
|
|
|
2061
2059
|
const effectiveCwd = worktree ?? cwd;
|