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:
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user