@kolisachint/hoocode-agent 0.4.103 → 0.4.105

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.
Files changed (29) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/config.d.ts +0 -6
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +0 -14
  5. package/dist/config.js.map +1 -1
  6. package/dist/migrations.d.ts.map +1 -1
  7. package/dist/migrations.js +2 -1
  8. package/dist/migrations.js.map +1 -1
  9. package/dist/modes/interactive/components/voice-panel.d.ts +56 -0
  10. package/dist/modes/interactive/components/voice-panel.d.ts.map +1 -0
  11. package/dist/modes/interactive/components/voice-panel.js +195 -0
  12. package/dist/modes/interactive/components/voice-panel.js.map +1 -0
  13. package/dist/modes/interactive/interactive-mode.d.ts +42 -3
  14. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  15. package/dist/modes/interactive/interactive-mode.js +260 -16
  16. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  17. package/dist/modes/interactive/voice-transcribe.d.ts +68 -1
  18. package/dist/modes/interactive/voice-transcribe.d.ts.map +1 -1
  19. package/dist/modes/interactive/voice-transcribe.js +163 -2
  20. package/dist/modes/interactive/voice-transcribe.js.map +1 -1
  21. package/dist/utils/tools-manager.d.ts +1 -1
  22. package/dist/utils/tools-manager.d.ts.map +1 -1
  23. package/dist/utils/tools-manager.js +23 -0
  24. package/dist/utils/tools-manager.js.map +1 -1
  25. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  26. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  27. package/examples/extensions/sandbox/package.json +1 -1
  28. package/examples/extensions/with-deps/package.json +1 -1
  29. package/package.json +4 -4
@@ -9,7 +9,7 @@ import * as path from "node:path";
9
9
  import { getProviders, } from "@kolisachint/hoocode-ai";
10
10
  import { CombinedAutocompleteProvider, Container, fuzzyFilter, getCapabilities, hyperlink, Loader, Markdown, matchesKey, ProcessTerminal, Spacer, setKeybindings, Text, TruncatedText, TUI, } from "@kolisachint/hoocode-tui";
11
11
  import { spawn, spawnSync } from "child_process";
12
- import { APP_NAME, APP_TITLE, getAgentDir, getAuthPath, getDocsPath, resolveVoicetoolsBin, VERSION, } from "../../config.js";
12
+ import { APP_NAME, APP_TITLE, getAgentDir, getAuthPath, getDocsPath, VERSION } from "../../config.js";
13
13
  import { parseSkillBlock } from "../../core/agent-session.js";
14
14
  import { FooterDataProvider } from "../../core/footer-data-provider.js";
15
15
  import { KeybindingsManager } from "../../core/keybindings.js";
@@ -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,12 @@ function isDeadTerminalError(error) {
84
85
  const code = error.code;
85
86
  return code !== undefined && DEAD_TERMINAL_ERROR_CODES.has(code);
86
87
  }
88
+ // Mirrors voicetools' own default trailing-silence window (see `--silence-ms` /
89
+ // VOICE_SILENCE_MS upstream). Only used to drive the cosmetic countdown in the
90
+ // panel; the actual cutoff decision is made by the binary, not this timer.
91
+ const VOICE_SILENCE_MS = 600;
92
+ const VOICE_UNAVAILABLE_MESSAGE = "Voice input failed: voicetools binary unavailable and could not be downloaded. " +
93
+ "Install it, set VOICETOOLS_BIN, or ensure a published release exists for this platform.";
87
94
  const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING = "Anthropic subscription auth: billed per token as extra usage, not plan limits.";
88
95
  function isAnthropicSubscriptionAuthKey(apiKey) {
89
96
  return typeof apiKey === "string" && apiKey.startsWith("sk-ant-oat");
@@ -116,6 +123,18 @@ export class InteractiveMode {
116
123
  editor;
117
124
  editorComponentFactory;
118
125
  voiceSession;
126
+ // Persistent `voicetools serve` process, once probed successfully. Stays alive
127
+ // (and warm) across captures for the life of the interactive session.
128
+ voiceDaemon;
129
+ // Sticky for the session: set once `serve` is confirmed unsupported (old binary)
130
+ // so later presses skip straight to the per-press `transcribe` fallback.
131
+ voiceDaemonUnsupported = false;
132
+ /** True while the voicetools binary is being resolved/downloaded before a session starts. */
133
+ voiceStarting = false;
134
+ /** True while a capture (daemon or legacy) is in flight. */
135
+ voiceActive = false;
136
+ /** The live multi-line status panel for the current capture (undefined when idle). */
137
+ voicePanel;
119
138
  autocompleteProvider;
120
139
  autocompleteProviderWrappers = [];
121
140
  fdPath;
@@ -2760,39 +2779,256 @@ export class InteractiveMode {
2760
2779
  * we update the previous status line instead of appending new ones to avoid log spam.
2761
2780
  */
2762
2781
  /**
2763
- * Toggle voice-to-text capture. First press spawns `voicetools`, streams
2764
- * decoded segments into the editor via bracketed paste, and reports state
2765
- * through the status line. Pressing the shortcut again stops early.
2782
+ * Toggle voice-to-text capture.
2783
+ *
2784
+ * Prefers a persistent `voicetools serve` daemon: the first press probes for
2785
+ * support and (if found) loads models once, showing a "warming up" spinner;
2786
+ * every later press reuses the already-warm daemon and jumps straight to
2787
+ * Listening. Binaries without `serve` support fall back to spawning
2788
+ * `voicetools transcribe` per press, same as before. Pressing the shortcut
2789
+ * again while listening (or while still warming up) cancels.
2766
2790
  */
2767
2791
  toggleVoiceTranscribe() {
2768
- if (this.voiceSession?.running) {
2769
- this.voiceSession.stop();
2792
+ if (this.voiceActive) {
2793
+ if (this.voiceDaemon?.isReady) {
2794
+ this.voiceDaemon.cancel();
2795
+ }
2796
+ else {
2797
+ this.voiceSession?.stop();
2798
+ }
2770
2799
  this.voiceSession = undefined;
2771
- this.showStatus("Voice input cancelled");
2800
+ this.voiceActive = false;
2801
+ this.resetVoiceUI();
2802
+ return;
2803
+ }
2804
+ if (this.voiceStarting) {
2805
+ // A second press while resolving/warming up: honour the cancel. Any
2806
+ // daemon that finishes loading afterwards is kept warm for next time.
2807
+ this.voiceStarting = false;
2808
+ this.resetVoiceUI();
2809
+ return;
2810
+ }
2811
+ if (this.voiceDaemonUnsupported) {
2812
+ this.voiceStarting = true;
2813
+ this.showVoiceWarming("Starting voice input...");
2814
+ void this.resolveVoiceBin()
2815
+ .then((bin) => {
2816
+ if (!this.voiceStarting)
2817
+ return;
2818
+ this.voiceStarting = false;
2819
+ if (!bin) {
2820
+ this.resetVoiceUI();
2821
+ this.showError(VOICE_UNAVAILABLE_MESSAGE);
2822
+ return;
2823
+ }
2824
+ this.beginLegacyVoiceCapture(bin);
2825
+ })
2826
+ .catch((err) => {
2827
+ this.voiceStarting = false;
2828
+ this.resetVoiceUI();
2829
+ this.showError(`Voice input failed: ${err instanceof Error ? err.message : String(err)}`);
2830
+ });
2831
+ return;
2832
+ }
2833
+ if (this.voiceDaemon?.isReady) {
2834
+ this.beginDaemonVoiceCapture();
2772
2835
  return;
2773
2836
  }
2774
- const statusLabels = {
2775
- recording: "Recording... speak now (press again to cancel)",
2776
- transcribing: "Transcribing...",
2777
- done: "Voice input done",
2837
+ // No daemon yet: resolve the binary, then probe for `serve` support by
2838
+ // spawning it. `VoiceDaemon.spawn` doubles as the probe: it resolves to a
2839
+ // live daemon once READY arrives, to "unsupported" if the process exits
2840
+ // with no output at all (an old binary rejecting the unrecognized `serve`
2841
+ // subcommand), or to "error" if it printed a real ERROR first (e.g. no
2842
+ // model installed yet) — already surfaced via onError, so that case skips
2843
+ // the legacy fallback (it would just hit the same error) but leaves
2844
+ // daemon mode available to retry on the next press.
2845
+ this.voiceStarting = true;
2846
+ this.showVoiceWarming("Warming up voice input...");
2847
+ void this.resolveVoiceBin()
2848
+ .then(async (bin) => {
2849
+ if (!bin) {
2850
+ this.voiceStarting = false;
2851
+ this.resetVoiceUI();
2852
+ this.showError(VOICE_UNAVAILABLE_MESSAGE);
2853
+ return;
2854
+ }
2855
+ const result = await VoiceDaemon.spawn(bin, this.buildVoiceDaemonHandlers());
2856
+ if (!result.ok) {
2857
+ if (result.reason === "unsupported") {
2858
+ this.voiceDaemonUnsupported = true;
2859
+ if (!this.voiceStarting) {
2860
+ this.resetVoiceUI();
2861
+ return;
2862
+ }
2863
+ this.voiceStarting = false;
2864
+ this.beginLegacyVoiceCapture(bin);
2865
+ return;
2866
+ }
2867
+ this.voiceStarting = false;
2868
+ this.resetVoiceUI();
2869
+ return;
2870
+ }
2871
+ this.voiceDaemon = result.daemon;
2872
+ if (!this.voiceStarting) {
2873
+ // Cancelled while warming up: keep the loaded daemon warm for next time.
2874
+ this.resetVoiceUI();
2875
+ return;
2876
+ }
2877
+ this.voiceStarting = false;
2878
+ this.beginDaemonVoiceCapture();
2879
+ })
2880
+ .catch((err) => {
2881
+ this.voiceStarting = false;
2882
+ this.resetVoiceUI();
2883
+ this.showError(`Voice input failed: ${err instanceof Error ? err.message : String(err)}`);
2884
+ });
2885
+ }
2886
+ /**
2887
+ * Build the (stable, reused-across-captures) handlers for the daemon.
2888
+ *
2889
+ * Every handler bails out once `voiceActive` is false: CANCEL is a soft
2890
+ * request (unlike the legacy path's `proc.kill()`, it doesn't sever the
2891
+ * pipe), so the daemon can still have a trailing PARTIAL/FINAL/DONE for the
2892
+ * just-cancelled capture in flight when the user presses cancel. Without
2893
+ * this guard that stale text would land in the editor after the panel had
2894
+ * already collapsed.
2895
+ *
2896
+ * v0.1.4 serve streams `PARTIAL <full growing hypothesis>` (live preview,
2897
+ * never committed) and ends with a single `FINAL <complete text>` (the one
2898
+ * commit to the editor). `SEGMENT` is still handled for the legacy
2899
+ * transcribe path and any binary that streams committed chunks directly.
2900
+ */
2901
+ buildVoiceDaemonHandlers() {
2902
+ return {
2903
+ onSegment: (text) => {
2904
+ if (!this.voiceActive)
2905
+ return;
2906
+ this.commitVoiceText(text);
2907
+ },
2908
+ onPartial: (text) => {
2909
+ if (!this.voiceActive)
2910
+ return;
2911
+ this.voicePanel?.setPartial(text);
2912
+ },
2913
+ onFinal: (text) => {
2914
+ if (!this.voiceActive)
2915
+ return;
2916
+ this.commitVoiceText(text);
2917
+ },
2918
+ onStatus: (status) => {
2919
+ if (!this.voiceActive)
2920
+ return;
2921
+ if (status === "done") {
2922
+ this.voiceActive = false;
2923
+ this.resetVoiceUI();
2924
+ return;
2925
+ }
2926
+ if (status === "transcribing") {
2927
+ this.voicePanel?.setTranscribing();
2928
+ }
2929
+ else if (status === "listening") {
2930
+ this.voicePanel?.startListening();
2931
+ }
2932
+ },
2933
+ onLevel: (rms) => {
2934
+ if (!this.voiceActive)
2935
+ return;
2936
+ this.voicePanel?.pushLevel(rms);
2937
+ },
2938
+ onPhase: (phase) => {
2939
+ if (!this.voiceActive)
2940
+ return;
2941
+ if (phase === "silence") {
2942
+ this.voicePanel?.beginSilence(VOICE_SILENCE_MS);
2943
+ }
2944
+ else {
2945
+ this.voicePanel?.endSilence();
2946
+ }
2947
+ },
2948
+ onError: (message) => {
2949
+ this.voiceActive = false;
2950
+ this.resetVoiceUI();
2951
+ this.showError(`Voice input failed: ${message}`);
2952
+ },
2953
+ onCrash: (message) => {
2954
+ this.voiceActive = false;
2955
+ this.voiceDaemon = undefined;
2956
+ this.resetVoiceUI();
2957
+ this.showError(`Voice input daemon crashed: ${message}. It will restart on next use.`);
2958
+ },
2778
2959
  };
2779
- this.voiceSession = startVoiceTranscribe(resolveVoicetoolsBin(), {
2960
+ }
2961
+ /** Inject decoded text into the editor via bracketed paste (with a trailing space). */
2962
+ commitVoiceText(text) {
2963
+ this.editor.handleInput(`\x1b[200~${text} \x1b[201~`);
2964
+ }
2965
+ beginDaemonVoiceCapture() {
2966
+ if (!this.voiceDaemon?.isReady)
2967
+ return;
2968
+ this.voiceActive = true;
2969
+ this.showVoicePanel().startListening();
2970
+ this.voiceDaemon.startCapture();
2971
+ }
2972
+ beginLegacyVoiceCapture(bin) {
2973
+ this.voiceActive = true;
2974
+ const panel = this.showVoicePanel();
2975
+ panel.startListening();
2976
+ this.voiceSession = startVoiceTranscribe(bin, {
2780
2977
  onStatus: (status) => {
2781
- this.showStatus(statusLabels[status] ?? status);
2782
2978
  if (status === "done") {
2979
+ this.voiceActive = false;
2783
2980
  this.voiceSession = undefined;
2981
+ this.resetVoiceUI();
2982
+ return;
2784
2983
  }
2984
+ // Old binaries emit no LEVEL/PARTIAL, so the panel shows a spinner
2985
+ // for the batch phases; committed words still stream into the editor.
2986
+ if (status === "transcribing")
2987
+ panel.setTranscribing();
2785
2988
  },
2786
2989
  onSegment: (text) => {
2787
- // Inject via bracketed paste so the editor treats it as pasted text.
2788
- this.editor.handleInput(`\x1b[200~${text} \x1b[201~`);
2990
+ this.commitVoiceText(text);
2789
2991
  },
2790
2992
  onError: (message) => {
2993
+ this.voiceActive = false;
2791
2994
  this.voiceSession = undefined;
2995
+ this.resetVoiceUI();
2792
2996
  this.showError(`Voice input failed: ${message}`);
2793
2997
  },
2794
2998
  });
2795
2999
  }
3000
+ /** Resolve the `voicetools` binary path. Prefers an explicit VOICETOOLS_BIN
3001
+ * override, otherwise resolves via the managed tools manager (bin dir / PATH /
3002
+ * download from the published release). Returns undefined when unavailable.
3003
+ */
3004
+ async resolveVoiceBin() {
3005
+ const override = process.env.VOICETOOLS_BIN?.trim();
3006
+ if (override)
3007
+ return override;
3008
+ return ensureTool("voicetools", true);
3009
+ }
3010
+ /** Create (or reuse) the voice panel and mount it in the status container. */
3011
+ showVoicePanel() {
3012
+ if (!this.voicePanel) {
3013
+ this.statusContainer.clear();
3014
+ this.voicePanel = new VoicePanel(this.ui, keyHint("app.input.voiceTranscribe", "cancel"));
3015
+ this.statusContainer.addChild(this.voicePanel);
3016
+ }
3017
+ this.ui.requestRender();
3018
+ return this.voicePanel;
3019
+ }
3020
+ showVoiceWarming(message) {
3021
+ this.showVoicePanel().setWarming(message);
3022
+ }
3023
+ /** Collapse the voice panel back to nothing (idle) and stop its animation. */
3024
+ resetVoiceUI() {
3025
+ if (this.voicePanel) {
3026
+ this.voicePanel.dispose();
3027
+ this.voicePanel = undefined;
3028
+ }
3029
+ this.statusContainer.clear();
3030
+ this.ui.requestRender();
3031
+ }
2796
3032
  showStatus(message) {
2797
3033
  const children = this.chatContainer.children;
2798
3034
  const last = children.length > 0 ? children[children.length - 1] : undefined;
@@ -4471,6 +4707,14 @@ export class InteractiveMode {
4471
4707
  this.voiceSession.stop();
4472
4708
  this.voiceSession = undefined;
4473
4709
  }
4710
+ this.voiceDaemon?.shutdown();
4711
+ this.voiceDaemon = undefined;
4712
+ if (this.voicePanel) {
4713
+ this.voicePanel.dispose();
4714
+ this.voicePanel = undefined;
4715
+ }
4716
+ this.voiceActive = false;
4717
+ this.voiceStarting = false;
4474
4718
  if (this.settingsManager.getShowTerminalProgress()) {
4475
4719
  this.ui.terminal.setProgress(false);
4476
4720
  }