@kolisachint/hoocode-agent 0.4.104 → 0.4.106
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 +32 -0
- package/dist/modes/interactive/components/voice-panel.d.ts +56 -0
- package/dist/modes/interactive/components/voice-panel.d.ts.map +1 -0
- package/dist/modes/interactive/components/voice-panel.js +195 -0
- package/dist/modes/interactive/components/voice-panel.js.map +1 -0
- package/dist/modes/interactive/interactive-mode.d.ts +39 -3
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +262 -40
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/modes/interactive/voice-transcribe.d.ts +83 -1
- package/dist/modes/interactive/voice-transcribe.d.ts.map +1 -1
- package/dist/modes/interactive/voice-transcribe.js +181 -0
- package/dist/modes/interactive/voice-transcribe.js.map +1 -1
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package.json +1 -1
- package/package.json +4 -4
|
@@ -59,8 +59,9 @@ import { ToolExecutionComponent } from "./components/tool-execution.js";
|
|
|
59
59
|
import { TreeSelectorComponent } from "./components/tree-selector.js";
|
|
60
60
|
import { UserMessageComponent } from "./components/user-message.js";
|
|
61
61
|
import { UserMessageSelectorComponent } from "./components/user-message-selector.js";
|
|
62
|
+
import { VoicePanel } from "./components/voice-panel.js";
|
|
62
63
|
import { getAvailableThemes, getAvailableThemesWithPaths, getEditorTheme, getMarkdownTheme, getThemeByName, initTheme, onThemeChange, setRegisteredThemes, setTheme, setThemeInstance, stopThemeWatcher, Theme, theme, } from "./theme/theme.js";
|
|
63
|
-
import { startVoiceTranscribe } from "./voice-transcribe.js";
|
|
64
|
+
import { startVoiceTranscribe, VoiceDaemon } from "./voice-transcribe.js";
|
|
64
65
|
function isExpandable(obj) {
|
|
65
66
|
return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function";
|
|
66
67
|
}
|
|
@@ -84,6 +85,18 @@ function isDeadTerminalError(error) {
|
|
|
84
85
|
const code = error.code;
|
|
85
86
|
return code !== undefined && DEAD_TERMINAL_ERROR_CODES.has(code);
|
|
86
87
|
}
|
|
88
|
+
// Trailing-silence window: how long a pause while speaking lasts before the
|
|
89
|
+
// capture auto-stops. Passed to `voicetools serve` via `--silence-ms` (see
|
|
90
|
+
// VoiceDaemon.spawn) so the binary's real cutoff matches the on-screen
|
|
91
|
+
// countdown this same value drives. Kept generous so a thinking pause mid-
|
|
92
|
+
// sentence doesn't cut the user off (the binary's own default is 600ms).
|
|
93
|
+
const VOICE_SILENCE_MS = 3000;
|
|
94
|
+
/** How long to keep the warm voice model in memory after the last capture
|
|
95
|
+
* completes. The daemon auto-shuts down after this window, releasing the
|
|
96
|
+
* ~900 MB resident model; the next ctrl+r pays a cold-start respawn cost. */
|
|
97
|
+
const VOICE_IDLE_TIMEOUT_MS = 60_000;
|
|
98
|
+
const VOICE_UNAVAILABLE_MESSAGE = "Voice input failed: voicetools binary unavailable and could not be downloaded. " +
|
|
99
|
+
"Install it, set VOICETOOLS_BIN, or ensure a published release exists for this platform.";
|
|
87
100
|
const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING = "Anthropic subscription auth: billed per token as extra usage, not plan limits.";
|
|
88
101
|
function isAnthropicSubscriptionAuthKey(apiKey) {
|
|
89
102
|
return typeof apiKey === "string" && apiKey.startsWith("sk-ant-oat");
|
|
@@ -116,8 +129,18 @@ export class InteractiveMode {
|
|
|
116
129
|
editor;
|
|
117
130
|
editorComponentFactory;
|
|
118
131
|
voiceSession;
|
|
132
|
+
// Persistent `voicetools serve` process, once probed successfully. Stays alive
|
|
133
|
+
// (and warm) across captures for the life of the interactive session.
|
|
134
|
+
voiceDaemon;
|
|
135
|
+
// Sticky for the session: set once `serve` is confirmed unsupported (old binary)
|
|
136
|
+
// so later presses skip straight to the per-press `transcribe` fallback.
|
|
137
|
+
voiceDaemonUnsupported = false;
|
|
119
138
|
/** True while the voicetools binary is being resolved/downloaded before a session starts. */
|
|
120
139
|
voiceStarting = false;
|
|
140
|
+
/** True while a capture (daemon or legacy) is in flight. */
|
|
141
|
+
voiceActive = false;
|
|
142
|
+
/** The live multi-line status panel for the current capture (undefined when idle). */
|
|
143
|
+
voicePanel;
|
|
121
144
|
autocompleteProvider;
|
|
122
145
|
autocompleteProviderWrappers = [];
|
|
123
146
|
fdPath;
|
|
@@ -2762,64 +2785,234 @@ export class InteractiveMode {
|
|
|
2762
2785
|
* we update the previous status line instead of appending new ones to avoid log spam.
|
|
2763
2786
|
*/
|
|
2764
2787
|
/**
|
|
2765
|
-
* Toggle voice-to-text capture.
|
|
2766
|
-
*
|
|
2767
|
-
*
|
|
2788
|
+
* Toggle voice-to-text capture.
|
|
2789
|
+
*
|
|
2790
|
+
* Prefers a persistent `voicetools serve` daemon: the first press probes for
|
|
2791
|
+
* support and (if found) loads models once, showing a "warming up" spinner;
|
|
2792
|
+
* every later press reuses the already-warm daemon and jumps straight to
|
|
2793
|
+
* Listening. Binaries without `serve` support fall back to spawning
|
|
2794
|
+
* `voicetools transcribe` per press, same as before. Pressing the shortcut
|
|
2795
|
+
* again while listening (or while still warming up) cancels.
|
|
2768
2796
|
*/
|
|
2769
2797
|
toggleVoiceTranscribe() {
|
|
2770
|
-
if (this.
|
|
2771
|
-
this.
|
|
2798
|
+
if (this.voiceActive) {
|
|
2799
|
+
if (this.voiceDaemon?.isReady) {
|
|
2800
|
+
this.voiceDaemon.cancel();
|
|
2801
|
+
}
|
|
2802
|
+
else {
|
|
2803
|
+
this.voiceSession?.stop();
|
|
2804
|
+
}
|
|
2772
2805
|
this.voiceSession = undefined;
|
|
2806
|
+
this.voiceActive = false;
|
|
2807
|
+
this.resetVoiceUI();
|
|
2808
|
+
return;
|
|
2809
|
+
}
|
|
2810
|
+
if (this.voiceStarting) {
|
|
2811
|
+
// A second press while resolving/warming up: honour the cancel. Any
|
|
2812
|
+
// daemon that finishes loading afterwards is kept warm for next time.
|
|
2773
2813
|
this.voiceStarting = false;
|
|
2774
|
-
this.
|
|
2814
|
+
this.resetVoiceUI();
|
|
2775
2815
|
return;
|
|
2776
2816
|
}
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2817
|
+
if (this.voiceDaemonUnsupported) {
|
|
2818
|
+
this.voiceStarting = true;
|
|
2819
|
+
this.showVoiceWarming("Starting voice input...");
|
|
2820
|
+
void this.resolveVoiceBin()
|
|
2821
|
+
.then((bin) => {
|
|
2822
|
+
if (!this.voiceStarting)
|
|
2823
|
+
return;
|
|
2824
|
+
this.voiceStarting = false;
|
|
2825
|
+
if (!bin) {
|
|
2826
|
+
this.resetVoiceUI();
|
|
2827
|
+
this.showError(VOICE_UNAVAILABLE_MESSAGE);
|
|
2828
|
+
return;
|
|
2829
|
+
}
|
|
2830
|
+
this.beginLegacyVoiceCapture(bin);
|
|
2831
|
+
})
|
|
2832
|
+
.catch((err) => {
|
|
2833
|
+
this.voiceStarting = false;
|
|
2834
|
+
this.resetVoiceUI();
|
|
2835
|
+
this.showError(`Voice input failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2836
|
+
});
|
|
2837
|
+
return;
|
|
2838
|
+
}
|
|
2839
|
+
if (this.voiceDaemon?.isReady) {
|
|
2840
|
+
this.beginDaemonVoiceCapture();
|
|
2841
|
+
return;
|
|
2842
|
+
}
|
|
2843
|
+
// No daemon yet: resolve the binary, then probe for `serve` support by
|
|
2844
|
+
// spawning it. `VoiceDaemon.spawn` doubles as the probe: it resolves to a
|
|
2845
|
+
// live daemon once READY arrives, to "unsupported" if the process exits
|
|
2846
|
+
// with no output at all (an old binary rejecting the unrecognized `serve`
|
|
2847
|
+
// subcommand), or to "error" if it printed a real ERROR first (e.g. no
|
|
2848
|
+
// model installed yet) — already surfaced via onError, so that case skips
|
|
2849
|
+
// the legacy fallback (it would just hit the same error) but leaves
|
|
2850
|
+
// daemon mode available to retry on the next press.
|
|
2786
2851
|
this.voiceStarting = true;
|
|
2787
|
-
this.
|
|
2852
|
+
this.showVoiceWarming("Warming up voice input...");
|
|
2788
2853
|
void this.resolveVoiceBin()
|
|
2789
|
-
.then((bin) => {
|
|
2790
|
-
// A second key press while resolving cleared the flag: honour the cancel.
|
|
2791
|
-
if (!this.voiceStarting)
|
|
2792
|
-
return;
|
|
2793
|
-
this.voiceStarting = false;
|
|
2854
|
+
.then(async (bin) => {
|
|
2794
2855
|
if (!bin) {
|
|
2795
|
-
this.
|
|
2796
|
-
|
|
2856
|
+
this.voiceStarting = false;
|
|
2857
|
+
this.resetVoiceUI();
|
|
2858
|
+
this.showError(VOICE_UNAVAILABLE_MESSAGE);
|
|
2797
2859
|
return;
|
|
2798
2860
|
}
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
if (status === "done") {
|
|
2803
|
-
this.voiceSession = undefined;
|
|
2804
|
-
}
|
|
2805
|
-
},
|
|
2806
|
-
onSegment: (text) => {
|
|
2807
|
-
// Inject via bracketed paste so the editor treats it as pasted text.
|
|
2808
|
-
this.editor.handleInput(`\x1b[200~${text} \x1b[201~`);
|
|
2809
|
-
},
|
|
2810
|
-
onError: (message) => {
|
|
2811
|
-
this.voiceSession = undefined;
|
|
2812
|
-
this.showError(`Voice input failed: ${message}`);
|
|
2813
|
-
},
|
|
2861
|
+
const result = await VoiceDaemon.spawn(bin, this.buildVoiceDaemonHandlers(), {
|
|
2862
|
+
silenceMs: VOICE_SILENCE_MS,
|
|
2863
|
+
idleTimeoutMs: VOICE_IDLE_TIMEOUT_MS,
|
|
2814
2864
|
});
|
|
2865
|
+
if (!result.ok) {
|
|
2866
|
+
if (result.reason === "unsupported") {
|
|
2867
|
+
this.voiceDaemonUnsupported = true;
|
|
2868
|
+
if (!this.voiceStarting) {
|
|
2869
|
+
this.resetVoiceUI();
|
|
2870
|
+
return;
|
|
2871
|
+
}
|
|
2872
|
+
this.voiceStarting = false;
|
|
2873
|
+
this.beginLegacyVoiceCapture(bin);
|
|
2874
|
+
return;
|
|
2875
|
+
}
|
|
2876
|
+
this.voiceStarting = false;
|
|
2877
|
+
this.resetVoiceUI();
|
|
2878
|
+
return;
|
|
2879
|
+
}
|
|
2880
|
+
this.voiceDaemon = result.daemon;
|
|
2881
|
+
if (!this.voiceStarting) {
|
|
2882
|
+
// Cancelled while warming up: keep the loaded daemon warm for next time.
|
|
2883
|
+
this.resetVoiceUI();
|
|
2884
|
+
return;
|
|
2885
|
+
}
|
|
2886
|
+
this.voiceStarting = false;
|
|
2887
|
+
this.beginDaemonVoiceCapture();
|
|
2815
2888
|
})
|
|
2816
2889
|
.catch((err) => {
|
|
2817
2890
|
this.voiceStarting = false;
|
|
2891
|
+
this.resetVoiceUI();
|
|
2818
2892
|
this.showError(`Voice input failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2819
2893
|
});
|
|
2820
2894
|
}
|
|
2821
2895
|
/**
|
|
2822
|
-
*
|
|
2896
|
+
* Build the (stable, reused-across-captures) handlers for the daemon.
|
|
2897
|
+
*
|
|
2898
|
+
* Every handler bails out once `voiceActive` is false: CANCEL is a soft
|
|
2899
|
+
* request (unlike the legacy path's `proc.kill()`, it doesn't sever the
|
|
2900
|
+
* pipe), so the daemon can still have a trailing PARTIAL/FINAL/DONE for the
|
|
2901
|
+
* just-cancelled capture in flight when the user presses cancel. Without
|
|
2902
|
+
* this guard that stale text would land in the editor after the panel had
|
|
2903
|
+
* already collapsed.
|
|
2904
|
+
*
|
|
2905
|
+
* v0.1.4 serve streams `PARTIAL <full growing hypothesis>` (live preview,
|
|
2906
|
+
* never committed) and ends with a single `FINAL <complete text>` (the one
|
|
2907
|
+
* commit to the editor). `SEGMENT` is still handled for the legacy
|
|
2908
|
+
* transcribe path and any binary that streams committed chunks directly.
|
|
2909
|
+
*/
|
|
2910
|
+
buildVoiceDaemonHandlers() {
|
|
2911
|
+
return {
|
|
2912
|
+
onSegment: (text) => {
|
|
2913
|
+
if (!this.voiceActive)
|
|
2914
|
+
return;
|
|
2915
|
+
this.commitVoiceText(text);
|
|
2916
|
+
},
|
|
2917
|
+
onPartial: (text) => {
|
|
2918
|
+
if (!this.voiceActive)
|
|
2919
|
+
return;
|
|
2920
|
+
this.voicePanel?.setPartial(text);
|
|
2921
|
+
},
|
|
2922
|
+
onFinal: (text) => {
|
|
2923
|
+
if (!this.voiceActive)
|
|
2924
|
+
return;
|
|
2925
|
+
this.commitVoiceText(text);
|
|
2926
|
+
},
|
|
2927
|
+
onStatus: (status) => {
|
|
2928
|
+
if (!this.voiceActive)
|
|
2929
|
+
return;
|
|
2930
|
+
if (status === "done") {
|
|
2931
|
+
this.voiceActive = false;
|
|
2932
|
+
this.resetVoiceUI();
|
|
2933
|
+
return;
|
|
2934
|
+
}
|
|
2935
|
+
if (status === "transcribing") {
|
|
2936
|
+
this.voicePanel?.setTranscribing();
|
|
2937
|
+
}
|
|
2938
|
+
else if (status === "listening") {
|
|
2939
|
+
this.voicePanel?.startListening();
|
|
2940
|
+
}
|
|
2941
|
+
},
|
|
2942
|
+
onLevel: (rms) => {
|
|
2943
|
+
if (!this.voiceActive)
|
|
2944
|
+
return;
|
|
2945
|
+
this.voicePanel?.pushLevel(rms);
|
|
2946
|
+
},
|
|
2947
|
+
onPhase: (phase) => {
|
|
2948
|
+
if (!this.voiceActive)
|
|
2949
|
+
return;
|
|
2950
|
+
if (phase === "silence") {
|
|
2951
|
+
this.voicePanel?.beginSilence(VOICE_SILENCE_MS);
|
|
2952
|
+
}
|
|
2953
|
+
else {
|
|
2954
|
+
this.voicePanel?.endSilence();
|
|
2955
|
+
}
|
|
2956
|
+
},
|
|
2957
|
+
onError: (message) => {
|
|
2958
|
+
this.voiceActive = false;
|
|
2959
|
+
this.resetVoiceUI();
|
|
2960
|
+
this.showError(`Voice input failed: ${message}`);
|
|
2961
|
+
},
|
|
2962
|
+
onCrash: (message) => {
|
|
2963
|
+
this.voiceActive = false;
|
|
2964
|
+
this.voiceDaemon = undefined;
|
|
2965
|
+
this.resetVoiceUI();
|
|
2966
|
+
this.showError(`Voice input daemon crashed: ${message}. It will restart on next use.`);
|
|
2967
|
+
},
|
|
2968
|
+
onIdle: () => {
|
|
2969
|
+
// Daemon auto-shut down after idle timeout: drop the reference so the
|
|
2970
|
+
// next ctrl+r respawns (cold start). No user-facing message — this is
|
|
2971
|
+
// an expected memory-reclamation event, not an error.
|
|
2972
|
+
this.voiceDaemon = undefined;
|
|
2973
|
+
},
|
|
2974
|
+
};
|
|
2975
|
+
}
|
|
2976
|
+
/** Inject decoded text into the editor via bracketed paste (with a trailing space). */
|
|
2977
|
+
commitVoiceText(text) {
|
|
2978
|
+
this.editor.handleInput(`\x1b[200~${text} \x1b[201~`);
|
|
2979
|
+
}
|
|
2980
|
+
beginDaemonVoiceCapture() {
|
|
2981
|
+
if (!this.voiceDaemon?.isReady)
|
|
2982
|
+
return;
|
|
2983
|
+
this.voiceActive = true;
|
|
2984
|
+
this.showVoicePanel().startListening();
|
|
2985
|
+
this.voiceDaemon.startCapture();
|
|
2986
|
+
}
|
|
2987
|
+
beginLegacyVoiceCapture(bin) {
|
|
2988
|
+
this.voiceActive = true;
|
|
2989
|
+
const panel = this.showVoicePanel();
|
|
2990
|
+
panel.startListening();
|
|
2991
|
+
this.voiceSession = startVoiceTranscribe(bin, {
|
|
2992
|
+
onStatus: (status) => {
|
|
2993
|
+
if (status === "done") {
|
|
2994
|
+
this.voiceActive = false;
|
|
2995
|
+
this.voiceSession = undefined;
|
|
2996
|
+
this.resetVoiceUI();
|
|
2997
|
+
return;
|
|
2998
|
+
}
|
|
2999
|
+
// Old binaries emit no LEVEL/PARTIAL, so the panel shows a spinner
|
|
3000
|
+
// for the batch phases; committed words still stream into the editor.
|
|
3001
|
+
if (status === "transcribing")
|
|
3002
|
+
panel.setTranscribing();
|
|
3003
|
+
},
|
|
3004
|
+
onSegment: (text) => {
|
|
3005
|
+
this.commitVoiceText(text);
|
|
3006
|
+
},
|
|
3007
|
+
onError: (message) => {
|
|
3008
|
+
this.voiceActive = false;
|
|
3009
|
+
this.voiceSession = undefined;
|
|
3010
|
+
this.resetVoiceUI();
|
|
3011
|
+
this.showError(`Voice input failed: ${message}`);
|
|
3012
|
+
},
|
|
3013
|
+
});
|
|
3014
|
+
}
|
|
3015
|
+
/** Resolve the `voicetools` binary path. Prefers an explicit VOICETOOLS_BIN
|
|
2823
3016
|
* override, otherwise resolves via the managed tools manager (bin dir / PATH /
|
|
2824
3017
|
* download from the published release). Returns undefined when unavailable.
|
|
2825
3018
|
*/
|
|
@@ -2829,6 +3022,28 @@ export class InteractiveMode {
|
|
|
2829
3022
|
return override;
|
|
2830
3023
|
return ensureTool("voicetools", true);
|
|
2831
3024
|
}
|
|
3025
|
+
/** Create (or reuse) the voice panel and mount it in the status container. */
|
|
3026
|
+
showVoicePanel() {
|
|
3027
|
+
if (!this.voicePanel) {
|
|
3028
|
+
this.statusContainer.clear();
|
|
3029
|
+
this.voicePanel = new VoicePanel(this.ui, keyHint("app.input.voiceTranscribe", "cancel"));
|
|
3030
|
+
this.statusContainer.addChild(this.voicePanel);
|
|
3031
|
+
}
|
|
3032
|
+
this.ui.requestRender();
|
|
3033
|
+
return this.voicePanel;
|
|
3034
|
+
}
|
|
3035
|
+
showVoiceWarming(message) {
|
|
3036
|
+
this.showVoicePanel().setWarming(message);
|
|
3037
|
+
}
|
|
3038
|
+
/** Collapse the voice panel back to nothing (idle) and stop its animation. */
|
|
3039
|
+
resetVoiceUI() {
|
|
3040
|
+
if (this.voicePanel) {
|
|
3041
|
+
this.voicePanel.dispose();
|
|
3042
|
+
this.voicePanel = undefined;
|
|
3043
|
+
}
|
|
3044
|
+
this.statusContainer.clear();
|
|
3045
|
+
this.ui.requestRender();
|
|
3046
|
+
}
|
|
2832
3047
|
showStatus(message) {
|
|
2833
3048
|
const children = this.chatContainer.children;
|
|
2834
3049
|
const last = children.length > 0 ? children[children.length - 1] : undefined;
|
|
@@ -4507,6 +4722,13 @@ export class InteractiveMode {
|
|
|
4507
4722
|
this.voiceSession.stop();
|
|
4508
4723
|
this.voiceSession = undefined;
|
|
4509
4724
|
}
|
|
4725
|
+
this.voiceDaemon?.shutdown();
|
|
4726
|
+
this.voiceDaemon = undefined;
|
|
4727
|
+
if (this.voicePanel) {
|
|
4728
|
+
this.voicePanel.dispose();
|
|
4729
|
+
this.voicePanel = undefined;
|
|
4730
|
+
}
|
|
4731
|
+
this.voiceActive = false;
|
|
4510
4732
|
this.voiceStarting = false;
|
|
4511
4733
|
if (this.settingsManager.getShowTerminalProgress()) {
|
|
4512
4734
|
this.ui.terminal.setProgress(false);
|