feat(coding-agent): introduced automatic dictation submission triggers

- Added `stt.submitTrigger` setting to control automatic dictation submission.
- Introduced `evaluateSubmitTrigger` to process sentence punctuation and spoken cues.
- Integrated trigger evaluation into batch and streaming `STTController` pipelines.
- Implemented trailing word trimming to strip trigger words like "submit" before sending.
- Added comprehensive unit tests for all trigger evaluation behaviors.
This commit is contained in:
can1357
2026-06-30 16:16:39 +02:00
parent 720fb3f120
commit 168bdae5da
6 changed files with 287 additions and 3 deletions
@@ -3,6 +3,7 @@ import { DEFAULT_SHARE_URL } from "@oh-my-pi/pi-wire";
import { SHAPE_VARIANT_NAMES } from "@oh-my-pi/snapcompact";
import { DEFAULT_RELAY_URL } from "../collab/protocol";
import { DEFAULT_STT_MODEL_KEY, STT_MODEL_OPTIONS, STT_MODEL_VALUES } from "../stt/models";
import { STT_SUBMIT_TRIGGER_OPTIONS, STT_SUBMIT_TRIGGER_VALUES } from "../stt/submit-trigger";
import { AUTO_THINKING, getConfiguredThinkingLevelMetadata, getThinkingLevelMetadata } from "../thinking";
import {
TINY_MODEL_DEVICE_DEFAULT,
@@ -1794,6 +1795,19 @@ export const SETTINGS_SCHEMA = {
options: STT_MODEL_OPTIONS,
},
},
"stt.submitTrigger": {
type: "enum",
values: STT_SUBMIT_TRIGGER_VALUES,
default: "never",
ui: {
tab: "interaction",
group: "Speech",
label: "TTS Submit Trigger",
description:
"Choose when speech dictation automatically submits: Never, Release (2+ words), Release with complete sentence, or When I Say Submit.",
options: STT_SUBMIT_TRIGGER_OPTIONS,
},
},
// ────────────────────────────────────────────────────────────────────────
// Context
@@ -4081,7 +4095,7 @@ export const SETTINGS_SCHEMA = {
group: "Subagents",
label: "Soft Subagent Request Budget",
description:
"Soft per-subagent request budget (assistant requests per run). Crossing it injects one steering notice asking the subagent to wrap up; at 1.5x the budget the run is aborted gracefully, salvaging partial output. 0 disables the guard. Bundled explore/quick_task agents use a lower built-in budget.",
"Soft per-subagent request budget (assistant requests per run). Crossing it injects one steering notice asking the subagent to wrap up; at 1.5x the budget the run is aborted gracefully, salvaging partial output. 0 disables the guard. Bundled explore/sonic agents use a lower built-in budget.",
options: [
{ value: "0", label: "Disabled" },
{ value: "40", label: "40 requests" },
+1
View File
@@ -3,5 +3,6 @@ export * from "./asr-protocol";
export * from "./downloader";
export * from "./models";
export * from "./stt-controller";
export * from "./submit-trigger";
export * from "./transcriber";
export * from "./wav";
@@ -15,6 +15,7 @@ import {
startStreamingRecording,
verifyRecordingFile,
} from "./recorder";
import { evaluateSubmitTrigger, type SttSubmitTrigger } from "./submit-trigger";
import { transcribe } from "./transcriber";
export type SttState = "idle" | "recording" | "transcribing";
@@ -33,6 +34,10 @@ interface Editor {
setVolatileText(text: string): void;
clearVolatileText(): void;
commitVolatileText(text: string): void;
getText(): string;
setText(text: string): void;
submit(): void;
deleteBeforeCursor(count: number): void;
}
export class STTController {
@@ -53,6 +58,7 @@ export class STTController {
#streamEditor: Editor | null = null;
#streamCommitted = false;
#streamAbort: AbortController | null = null;
#streamUtterance = "";
get state(): SttState {
return this.#state;
@@ -190,6 +196,7 @@ export class STTController {
const language = settings.get("stt.language") as string | undefined;
this.#streamEditor = editor;
this.#streamCommitted = false;
this.#streamUtterance = "";
this.#streamAbort = new AbortController();
const stream = sttClient.startStream(modelKey, {
language: language || undefined,
@@ -205,6 +212,7 @@ export class STTController {
if (prefixed) {
this.#streamEditor?.commitVolatileText(prefixed);
this.#streamCommitted = true;
this.#streamUtterance += prefixed;
} else {
this.#streamEditor?.clearVolatileText();
}
@@ -266,13 +274,27 @@ export class STTController {
return;
}
if (!this.#streamCommitted && finalText) {
this.#streamEditor?.commitVolatileText(this.#prefixed(finalText));
const prefixed = this.#prefixed(finalText);
this.#streamEditor?.commitVolatileText(prefixed);
this.#streamCommitted = true;
this.#streamUtterance = prefixed;
} else {
this.#streamEditor?.clearVolatileText();
}
options.requestRender?.();
if (!failed) options.showStatus(this.#streamCommitted ? "" : "No speech detected.");
if (this.#streamCommitted && !failed && this.#streamEditor) {
const trigger = settings.get("stt.submitTrigger") as SttSubmitTrigger;
const { submit, trimTrailing } = evaluateSubmitTrigger(this.#streamUtterance, trigger);
if (trimTrailing > 0) {
this.#streamEditor.deleteBeforeCursor(trimTrailing);
}
if (submit) {
this.#streamEditor.submit();
}
}
this.#cleanupStream();
this.#setState("idle", options);
}
@@ -283,6 +305,7 @@ export class STTController {
this.#streamEditor = null;
this.#streamCommitted = false;
this.#streamAbort = null;
this.#streamUtterance = "";
}
// ── Batch (single-shot) ─────────────────────────────────────────
@@ -327,8 +350,16 @@ export class STTController {
this.#transcriptionAbort = null;
if (this.#disposed) return;
if (text.length > 0) {
editor.insertText(text);
const trigger = settings.get("stt.submitTrigger") as SttSubmitTrigger;
const { submit, trimTrailing } = evaluateSubmitTrigger(text, trigger);
const textToInsert = trimTrailing > 0 ? text.slice(0, -trimTrailing) : text;
if (textToInsert.length > 0) {
editor.insertText(textToInsert);
}
options.showStatus("");
if (submit) {
editor.submit();
}
} else {
options.showStatus("No speech detected.");
}
@@ -0,0 +1,74 @@
/**
* TTS/STT Submit Trigger options and evaluation logic.
*/
export const STT_SUBMIT_TRIGGER_VALUES = ["never", "release", "release-complete", "say-submit"] as const;
export type SttSubmitTrigger = (typeof STT_SUBMIT_TRIGGER_VALUES)[number];
export const STT_SUBMIT_TRIGGER_OPTIONS = [
{
value: "never",
label: "Never",
description: "Never automatically submit; insert dictation and remain in editor.",
},
{
value: "release",
label: "Release",
description: "Submit on release if the utterance has 2+ words to avoid accidental sends.",
},
{
value: "release-complete",
label: "Release with complete sentence",
description: "Submit on release if the utterance ends with sentence-terminal punctuation (. ? ! etc.).",
},
{
value: "say-submit",
label: "When I Say Submit",
description: "Submit if the utterance ends with a word containing 'submit' (strips that word before submitting).",
},
] satisfies ReadonlyArray<{ value: SttSubmitTrigger; label: string; description: string }>;
/**
* Evaluate the submit trigger against a transcribed utterance.
* Returns whether to submit, and the number of characters to trim from the end of the utterance.
*/
export function evaluateSubmitTrigger(
utterance: string,
trigger: SttSubmitTrigger,
): { submit: boolean; trimTrailing: number } {
const trimmed = utterance.trim();
if (!trimmed) {
return { submit: false, trimTrailing: 0 };
}
if (trigger === "never") {
return { submit: false, trimTrailing: 0 };
}
if (trigger === "release") {
// Split by whitespace and count words
const words = trimmed.split(/\s+/).filter(Boolean);
const submit = words.length >= 2;
return { submit, trimTrailing: 0 };
}
if (trigger === "release-complete") {
// Matches typical sentence terminators: . ? ! ... or full-width equivalents, optionally followed by space
const hasTerminalPunctuation = /[.?!…。?!]\s*$/.test(trimmed);
return { submit: hasTerminalPunctuation, trimTrailing: 0 };
}
if (trigger === "say-submit") {
// Matches space followed by any word containing "submit" (case-insensitive), optionally followed by punctuation/spaces
// Also handles the case where "submit" is the only word in the utterance (no leading space)
const match = utterance.match(/(?:^|\s+)(\S*submit\S*)[.?!…。?!]*\s*$/i);
if (match && match.index !== undefined) {
const trimTrailing = utterance.length - match.index;
return { submit: true, trimTrailing };
}
return { submit: false, trimTrailing: 0 };
}
return { submit: false, trimTrailing: 0 };
}
@@ -73,6 +73,10 @@ describe("STTController preflight", () => {
setVolatileText: vi.fn(),
clearVolatileText: vi.fn(),
commitVolatileText: vi.fn(),
getText: vi.fn().mockReturnValue(""),
setText: vi.fn(),
submit: vi.fn(),
deleteBeforeCursor: vi.fn(),
};
}
@@ -0,0 +1,160 @@
import { describe, expect, it } from "bun:test";
import { evaluateSubmitTrigger } from "../src/stt/submit-trigger";
describe("STT Submit Trigger Evaluation", () => {
describe("never trigger", () => {
it("should never submit", () => {
expect(evaluateSubmitTrigger("hello world", "never")).toEqual({
submit: false,
trimTrailing: 0,
});
expect(evaluateSubmitTrigger("submit", "never")).toEqual({
submit: false,
trimTrailing: 0,
});
expect(evaluateSubmitTrigger("", "never")).toEqual({
submit: false,
trimTrailing: 0,
});
});
});
describe("release trigger", () => {
it("should only submit if utterance has 2+ words", () => {
expect(evaluateSubmitTrigger("hello", "release")).toEqual({
submit: false,
trimTrailing: 0,
});
expect(evaluateSubmitTrigger(" hello ", "release")).toEqual({
submit: false,
trimTrailing: 0,
});
expect(evaluateSubmitTrigger("hello world", "release")).toEqual({
submit: true,
trimTrailing: 0,
});
expect(evaluateSubmitTrigger("hello world!", "release")).toEqual({
submit: true,
trimTrailing: 0,
});
expect(evaluateSubmitTrigger("one two three", "release")).toEqual({
submit: true,
trimTrailing: 0,
});
expect(evaluateSubmitTrigger("", "release")).toEqual({
submit: false,
trimTrailing: 0,
});
});
});
describe("release-complete trigger", () => {
it("should submit only if utterance ends with terminal punctuation", () => {
expect(evaluateSubmitTrigger("hello", "release-complete")).toEqual({
submit: false,
trimTrailing: 0,
});
expect(evaluateSubmitTrigger("hello world", "release-complete")).toEqual({
submit: false,
trimTrailing: 0,
});
expect(evaluateSubmitTrigger("hello.", "release-complete")).toEqual({
submit: true,
trimTrailing: 0,
});
expect(evaluateSubmitTrigger("hello?", "release-complete")).toEqual({
submit: true,
trimTrailing: 0,
});
expect(evaluateSubmitTrigger("hello!", "release-complete")).toEqual({
submit: true,
trimTrailing: 0,
});
expect(evaluateSubmitTrigger("hello...", "release-complete")).toEqual({
submit: true,
trimTrailing: 0,
});
// Full-width punctuation
expect(evaluateSubmitTrigger("hello。", "release-complete")).toEqual({
submit: true,
trimTrailing: 0,
});
expect(evaluateSubmitTrigger("hello?", "release-complete")).toEqual({
submit: true,
trimTrailing: 0,
});
expect(evaluateSubmitTrigger("hello!", "release-complete")).toEqual({
submit: true,
trimTrailing: 0,
});
expect(evaluateSubmitTrigger("hello…", "release-complete")).toEqual({
submit: true,
trimTrailing: 0,
});
expect(evaluateSubmitTrigger("", "release-complete")).toEqual({
submit: false,
trimTrailing: 0,
});
});
});
describe("say-submit trigger", () => {
it("should submit and trim trailing word when last word contains submit", () => {
// Single word
expect(evaluateSubmitTrigger("submit", "say-submit")).toEqual({
submit: true,
trimTrailing: 6,
});
expect(evaluateSubmitTrigger("SUBMIT", "say-submit")).toEqual({
submit: true,
trimTrailing: 6,
});
expect(evaluateSubmitTrigger("submit!", "say-submit")).toEqual({
submit: true,
trimTrailing: 7,
});
// Multi word
expect(evaluateSubmitTrigger("please submit", "say-submit")).toEqual({
submit: true,
trimTrailing: 7, // " submit" has length 7
});
expect(evaluateSubmitTrigger("please submit.", "say-submit")).toEqual({
submit: true,
trimTrailing: 8, // " submit." has length 8
});
expect(evaluateSubmitTrigger("please submit?", "say-submit")).toEqual({
submit: true,
trimTrailing: 8,
});
expect(evaluateSubmitTrigger("please submit ", "say-submit")).toEqual({
submit: true,
trimTrailing: 9, // " submit " has length 9
});
// Word containing submit
expect(evaluateSubmitTrigger("please autosubmit", "say-submit")).toEqual({
submit: true,
trimTrailing: 11, // " autosubmit" has length 11
});
expect(evaluateSubmitTrigger("please submitting", "say-submit")).toEqual({
submit: true,
trimTrailing: 11,
});
// Negative cases
expect(evaluateSubmitTrigger("submit please", "say-submit")).toEqual({
submit: false,
trimTrailing: 0,
});
expect(evaluateSubmitTrigger("hello", "say-submit")).toEqual({
submit: false,
trimTrailing: 0,
});
expect(evaluateSubmitTrigger("", "say-submit")).toEqual({
submit: false,
trimTrailing: 0,
});
});
});
});